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 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-24 18:51:53 +09:00
parent 3639231263
commit 251f5d63a5
5 changed files with 53 additions and 12 deletions

View file

@ -91,6 +91,7 @@ type AgentLoop struct {
activeTasks sync.Map // sessionKey → *activeTask activeTasks sync.Map // sessionKey → *activeTask
sessions *SessionTracker sessions *SessionTracker
lastSystemPrompt atomic.Value // string — last system prompt sent to LLM 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 OnStateChange func() // called on plan/session/skills mutations
OnUserMessage func() // called when a real user message is processed 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() { func (al *AgentLoop) notifyStateChange() {
al.promptDirty.Store(true)
if al.OnStateChange != nil { if al.OnStateChange != nil {
al.OnStateChange() 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 // Capture the finalized system prompt for Mini App inspection
if len(messages) > 0 { if len(messages) > 0 {
al.lastSystemPrompt.Store(messages[0].Content) al.lastSystemPrompt.Store(messages[0].Content)
al.promptDirty.Store(false)
} }
// 5. Run LLM iteration loop // 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, // 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. // 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 { func (al *AgentLoop) GetSystemPrompt() string {
if v := al.lastSystemPrompt.Load(); v != nil { if !al.promptDirty.Load() {
return v.(string) 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() agent := al.registry.GetDefaultAgent()
if agent == nil { if agent == nil {
return "" return ""
} }
return agent.ContextBuilder.BuildSystemPrompt() prompt := agent.ContextBuilder.BuildSystemPrompt()
al.lastSystemPrompt.Store(prompt)
al.promptDirty.Store(false)
return prompt
} }
// formatMessagesForLog formats messages for logging // formatMessagesForLog formats messages for logging

View file

@ -132,6 +132,15 @@ func (st *SessionTracker) ListActive() []SessionEntry {
return result 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. // GetPeerPurposes returns purposes of other active sessions targeting the same project.
// Used for lightweight coordination without context pollution. // Used for lightweight coordination without context pollution.
func (st *SessionTracker) GetPeerPurposes(sessionKey, projectPath string) []PeerInfo { func (st *SessionTracker) GetPeerPurposes(sessionKey, projectPath string) []PeerInfo {

View file

@ -786,7 +786,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
ch := h.notifier.Subscribe() ch := h.notifier.Subscribe()
defer h.notifier.Unsubscribe(ch) defer h.notifier.Unsubscribe(ch)
var lastPlan, lastSession, lastSkills, lastDev, lastContext []byte var lastPlan, lastSession, lastSkills, lastDev, lastContext, lastPrompt []byte
// Send initial state immediately // Send initial state immediately
sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan) 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, "skills", h.provider.ListSkills(), &lastSkills)
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev) sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext) sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext)
sendSSEIfChanged(w, flusher, "prompt", map[string]string{"prompt": h.provider.GetSystemPrompt()}, &lastPrompt)
for { for {
select { 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, "skills", h.provider.ListSkills(), &lastSkills)
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev) sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext) sendSSEIfChanged(w, flusher, "context", h.provider.GetContextInfo(), &lastContext)
sendSSEIfChanged(w, flusher, "prompt", map[string]string{"prompt": h.provider.GetSystemPrompt()}, &lastPrompt)
} }
} }
} }

View file

@ -397,8 +397,8 @@ func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) {
defer resp.Body.Close() defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
// Drain initial 5 events (plan, session, skills, dev, context) // Drain initial 6 events (plan, session, skills, dev, context, prompt)
drainEvents(t, scanner, 5, 2*time.Second) drainEvents(t, scanner, 6, 2*time.Second)
// Mutate state and notify — diff dedup should detect the change and send a new event // Mutate state and notify — diff dedup should detect the change and send a new event
provider.mutated.Store(true) provider.mutated.Store(true)
@ -426,8 +426,8 @@ func TestSSE_DiffDedupSuppressesDuplicate(t *testing.T) {
defer resp.Body.Close() defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
// Drain initial events (plan, session, skills, dev, context) // Drain initial events (plan, session, skills, dev, context, prompt)
drainEvents(t, scanner, 5, 2*time.Second) drainEvents(t, scanner, 6, 2*time.Second)
// Notify with unchanged data — should produce zero new event lines // Notify with unchanged data — should produce zero new event lines
notifier.Notify() notifier.Notify()
@ -1690,8 +1690,8 @@ func TestSSE_DevEventUpdatesOnActivateDeactivate(t *testing.T) {
defer resp.Body.Close() defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
// Drain initial 5 events // Drain initial 6 events (plan, session, skills, dev, context, prompt)
drainEvents(t, scanner, 5, 2*time.Second) drainEvents(t, scanner, 6, 2*time.Second)
// Activate — should trigger a dev event with active=true // Activate — should trigger a dev event with active=true
h.ActivateDevTarget(id) h.ActivateDevTarget(id)

View file

@ -1605,6 +1605,15 @@ function connectSSE() {
eventSource.addEventListener('context', function(e) { eventSource.addEventListener('context', function(e) {
try { renderContextFromData(JSON.parse(e.data)); } catch(err) {} 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() { eventSource.onerror = function() {
// Browser will auto-reconnect EventSource // Browser will auto-reconnect EventSource
}; };