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 4f0384212d
commit 4d92b22867
5 changed files with 53 additions and 12 deletions

View file

@ -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 !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

View file

@ -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 {

View file

@ -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)
}
}
}

View file

@ -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)

View file

@ -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
};