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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue