From 251f5d63a539173ead3ceee79bdf1e16389a89d7 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:51:53 +0900 Subject: [PATCH] feat: rebuild system prompt in-loop after tool execution Tool execution can change workDir, MEMORY.md, and plan status, but messages[0] was never refreshed within the LLM iteration loop. This caused the next LLM call to use a stale system prompt, and the Mini App to display outdated state. - Rebuild messages[0] at the end of each tool-execution cycle in runLLMIteration, updating workDir from session TouchDir first - Add promptDirty atomic flag so GetSystemPrompt() rebuilds on demand when external state changes (notifyStateChange sets dirty=true) - Push prompt updates via SSE "prompt" event for live Mini App refresh - Add SessionTracker.GetTouchDir() for O(1) session directory lookup Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 31 ++++++++++++++++++++++++++----- pkg/agent/session_tracker.go | 9 +++++++++ pkg/miniapp/miniapp.go | 4 +++- pkg/miniapp/miniapp_test.go | 12 ++++++------ pkg/miniapp/static/index.html | 9 +++++++++ 5 files changed, 53 insertions(+), 12 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 8aa5581d6..75284b6dc 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -91,6 +91,7 @@ type AgentLoop struct { activeTasks sync.Map // sessionKey → *activeTask sessions *SessionTracker lastSystemPrompt atomic.Value // string — last system prompt sent to LLM + promptDirty atomic.Bool // true = rebuild needed on next GetSystemPrompt read OnStateChange func() // called on plan/session/skills mutations OnUserMessage func() // called when a real user message is processed } @@ -149,6 +150,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers } func (al *AgentLoop) notifyStateChange() { + al.promptDirty.Store(true) if al.OnStateChange != nil { al.OnStateChange() } @@ -918,6 +920,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt // Capture the finalized system prompt for Mini App inspection if len(messages) > 0 { al.lastSystemPrompt.Store(messages[0].Content) + al.promptDirty.Store(false) } // 5. Run LLM iteration loop @@ -2247,6 +2250,18 @@ func (al *AgentLoop) runLLMIteration( } } + // Refresh system prompt: tool execution may have changed workDir, + // memory, plan status, etc. Update messages[0] so the next LLM + // call sees the current state. + if touchDir := al.sessions.GetTouchDir(opts.SessionKey); touchDir != "" { + agent.ContextBuilder.SetWorkDir(filepath.Join(agent.Workspace, touchDir)) + } + if newPrompt := agent.ContextBuilder.BuildSystemPrompt(); len(messages) > 0 && messages[0].Content != newPrompt { + messages[0].Content = newPrompt + al.lastSystemPrompt.Store(newPrompt) + al.promptDirty.Store(false) + } + } // If max iterations exhausted with tool calls still pending, @@ -2483,17 +2498,23 @@ func (al *AgentLoop) GetContextInfo() (workDir, planWorkDir, workspace string, b } // GetSystemPrompt returns the system prompt last sent to the LLM. -// Falls back to building from current state if no LLM call has occurred yet. +// If the prompt is dirty (state changed since last capture), it rebuilds +// from current state. Falls back to building if no LLM call has occurred yet. func (al *AgentLoop) GetSystemPrompt() string { - if v := al.lastSystemPrompt.Load(); v != nil { - return v.(string) + if !al.promptDirty.Load() { + if v := al.lastSystemPrompt.Load(); v != nil { + return v.(string) + } } - // Fallback: no LLM call yet — build from current state + // Rebuild from current state agent := al.registry.GetDefaultAgent() if agent == nil { return "" } - return agent.ContextBuilder.BuildSystemPrompt() + prompt := agent.ContextBuilder.BuildSystemPrompt() + al.lastSystemPrompt.Store(prompt) + al.promptDirty.Store(false) + return prompt } // formatMessagesForLog formats messages for logging diff --git a/pkg/agent/session_tracker.go b/pkg/agent/session_tracker.go index 806348415..b842aa9fa 100644 --- a/pkg/agent/session_tracker.go +++ b/pkg/agent/session_tracker.go @@ -132,6 +132,15 @@ func (st *SessionTracker) ListActive() []SessionEntry { return result } +// GetTouchDir returns the TouchDir for a given session key, or "" if not found. +func (st *SessionTracker) GetTouchDir(sessionKey string) string { + val, ok := st.entries.Load(sessionKey) + if !ok { + return "" + } + return val.(*SessionEntry).TouchDir +} + // GetPeerPurposes returns purposes of other active sessions targeting the same project. // Used for lightweight coordination without context pollution. func (st *SessionTracker) GetPeerPurposes(sessionKey, projectPath string) []PeerInfo { diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index e04b8a281..6f1590d04 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -786,7 +786,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) { ch := h.notifier.Subscribe() defer h.notifier.Unsubscribe(ch) - var lastPlan, lastSession, lastSkills, lastDev, lastContext []byte + var lastPlan, lastSession, lastSkills, lastDev, lastContext, lastPrompt []byte // Send initial state immediately sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan) @@ -796,6 +796,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) { sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills) sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev) sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext) + sendSSEIfChanged(w, flusher, "prompt", map[string]string{"prompt": h.provider.GetSystemPrompt()}, &lastPrompt) for { select { @@ -811,6 +812,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) { sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills) sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev) sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext) + sendSSEIfChanged(w, flusher, "prompt", map[string]string{"prompt": h.provider.GetSystemPrompt()}, &lastPrompt) } } } diff --git a/pkg/miniapp/miniapp_test.go b/pkg/miniapp/miniapp_test.go index 642bc7918..4321fa84d 100644 --- a/pkg/miniapp/miniapp_test.go +++ b/pkg/miniapp/miniapp_test.go @@ -397,8 +397,8 @@ func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) { defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) - // Drain initial 5 events (plan, session, skills, dev, context) - drainEvents(t, scanner, 5, 2*time.Second) + // Drain initial 6 events (plan, session, skills, dev, context, prompt) + drainEvents(t, scanner, 6, 2*time.Second) // Mutate state and notify — diff dedup should detect the change and send a new event provider.mutated.Store(true) @@ -426,8 +426,8 @@ func TestSSE_DiffDedupSuppressesDuplicate(t *testing.T) { defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) - // Drain initial events (plan, session, skills, dev, context) - drainEvents(t, scanner, 5, 2*time.Second) + // Drain initial events (plan, session, skills, dev, context, prompt) + drainEvents(t, scanner, 6, 2*time.Second) // Notify with unchanged data — should produce zero new event lines notifier.Notify() @@ -1690,8 +1690,8 @@ func TestSSE_DevEventUpdatesOnActivateDeactivate(t *testing.T) { defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) - // Drain initial 5 events - drainEvents(t, scanner, 5, 2*time.Second) + // Drain initial 6 events (plan, session, skills, dev, context, prompt) + drainEvents(t, scanner, 6, 2*time.Second) // Activate — should trigger a dev event with active=true h.ActivateDevTarget(id) diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index 93f1c9e38..2ad862472 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -1605,6 +1605,15 @@ function connectSSE() { eventSource.addEventListener('context', function(e) { try { renderContextFromData(JSON.parse(e.data)); } catch(err) {} }); + eventSource.addEventListener('prompt', function(e) { + try { + var d = JSON.parse(e.data); + var view = document.getElementById('system-prompt-view'); + if (view && view.style.display !== 'none') { + view.textContent = d.prompt || '(empty)'; + } + } catch(err) {} + }); eventSource.onerror = function() { // Browser will auto-reconnect EventSource };