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:
parent
3639231263
commit
251f5d63a5
5 changed files with 53 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue