feat(orch): define AgentState typed enum and report plan mode transitions
- Add pkg/orch/state.go with AgentState string type and constants: idle, waiting, toolcall, plan_interviewing, plan_review, plan_executing, plan_completed - Update AgentReporter interface and all implementations to use AgentState instead of raw strings - Add ReportStateChange calls in loop.go at plan review/executing/completed transitions - Update index.html statusText/glow for all 4 plan states (📋🔍▶️✅) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7d28d2abfa
commit
4cc6690fc2
8 changed files with 70 additions and 29 deletions
|
|
@ -1091,6 +1091,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
agent.Sessions.AddMessage(opts.SessionKey, "user", rejectionMsg)
|
agent.Sessions.AddMessage(opts.SessionKey, "user", rejectionMsg)
|
||||||
} else {
|
} else {
|
||||||
_ = agent.ContextBuilder.SetPlanStatus("review")
|
_ = agent.ContextBuilder.SetPlanStatus("review")
|
||||||
|
al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStatePlanReview, "")
|
||||||
if !constants.IsInternalChannel(opts.Channel) {
|
if !constants.IsInternalChannel(opts.Channel) {
|
||||||
planDisplay := agent.ContextBuilder.FormatPlanDisplay()
|
planDisplay := agent.ContextBuilder.FormatPlanDisplay()
|
||||||
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
|
|
@ -1112,6 +1113,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
_ = agent.ContextBuilder.SetCurrentPhase(total)
|
_ = agent.ContextBuilder.SetCurrentPhase(total)
|
||||||
if preStatus != "completed" {
|
if preStatus != "completed" {
|
||||||
_ = agent.ContextBuilder.SetPlanStatus("completed")
|
_ = agent.ContextBuilder.SetPlanStatus("completed")
|
||||||
|
al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStatePlanCompleted, "")
|
||||||
|
|
||||||
// Deactivate worktree on plan completion
|
// Deactivate worktree on plan completion
|
||||||
commitMsg := "plan: " + agent.ContextBuilder.Memory().GetPlanTaskName()
|
commitMsg := "plan: " + agent.ContextBuilder.Memory().GetPlanTaskName()
|
||||||
|
|
@ -2070,7 +2072,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Report waiting state to canvas before each LLM call.
|
// Report waiting state to canvas before each LLM call.
|
||||||
al.reporter().ReportStateChange(opts.SessionKey, "waiting", "")
|
al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateWaiting, "")
|
||||||
|
|
||||||
// Retry loop for context/token errors
|
// Retry loop for context/token errors
|
||||||
maxRetries := 2
|
maxRetries := 2
|
||||||
|
|
@ -2462,7 +2464,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Report toolcall state to canvas.
|
// Report toolcall state to canvas.
|
||||||
al.reporter().ReportStateChange(opts.SessionKey, "toolcall", tc.Name)
|
al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateToolCall, tc.Name)
|
||||||
|
|
||||||
toolStart := time.Now()
|
toolStart := time.Now()
|
||||||
toolCtx := ctx
|
toolCtx := ctx
|
||||||
|
|
@ -3362,6 +3364,7 @@ func (al *AgentLoop) handlePlanCommand(args []string, sessionKey string) (string
|
||||||
if err := agent.ContextBuilder.SetPlanStatus("executing"); err != nil {
|
if err := agent.ContextBuilder.SetPlanStatus("executing"); err != nil {
|
||||||
return fmt.Sprintf("Error: %v", err), true
|
return fmt.Sprintf("Error: %v", err), true
|
||||||
}
|
}
|
||||||
|
al.reporter().ReportStateChange(sessionKey, orch.AgentStatePlanExecuting, "")
|
||||||
al.planStartPending = true
|
al.planStartPending = true
|
||||||
clearHistory := len(args) > 1 && args[1] == "clear"
|
clearHistory := len(args) > 1 && args[1] == "clear"
|
||||||
al.planClearHistory = clearHistory
|
al.planClearHistory = clearHistory
|
||||||
|
|
|
||||||
|
|
@ -1849,10 +1849,14 @@ function _orchSyncBadge(id, state, alive) {
|
||||||
function _orchSetState(c, state, tool) {
|
function _orchSetState(c, state, tool) {
|
||||||
c.state=state; _orchSyncBadge(c.id, state, c.alive);
|
c.state=state; _orchSyncBadge(c.id, state, c.alive);
|
||||||
if (c === _orchConductor) {
|
if (c === _orchConductor) {
|
||||||
if (state==='waiting') c.statusText='🤔';
|
if (state==='waiting') c.statusText='🤔';
|
||||||
else if (state==='toolcall') c.statusText='⌨';
|
else if (state==='toolcall') c.statusText='⌨';
|
||||||
else if (state==='user_waiting') c.statusText='⏳';
|
else if (state==='user_waiting') c.statusText='⏳';
|
||||||
else c.statusText=null;
|
else if (state==='plan_interviewing') c.statusText='📋';
|
||||||
|
else if (state==='plan_review') c.statusText='🔍';
|
||||||
|
else if (state==='plan_executing') c.statusText='▶️';
|
||||||
|
else if (state==='plan_completed') c.statusText='✅';
|
||||||
|
else c.statusText=null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function _orchMoveTo(c, pos, cb) { c.target=pos; c._onArrive=cb||null; }
|
function _orchMoveTo(c, pos, cb) { c.target=pos; c._onArrive=cb||null; }
|
||||||
|
|
@ -1959,9 +1963,12 @@ function _orchDrawChar(c) {
|
||||||
} else if (c.state==='waiting'){
|
} else if (c.state==='waiting'){
|
||||||
orchCtx.fillStyle='rgba(96,165,250,0.25)'; orchCtx.beginPath();
|
orchCtx.fillStyle='rgba(96,165,250,0.25)'; orchCtx.beginPath();
|
||||||
orchCtx.arc(cx,cy,11,0,Math.PI*2); orchCtx.fill();
|
orchCtx.arc(cx,cy,11,0,Math.PI*2); orchCtx.fill();
|
||||||
} else if (c.state==='user_waiting'){
|
} else if (c.state==='user_waiting' || c.state==='plan_review'){
|
||||||
orchCtx.fillStyle='rgba(167,139,250,0.18)'; orchCtx.beginPath();
|
orchCtx.fillStyle='rgba(167,139,250,0.18)'; orchCtx.beginPath();
|
||||||
orchCtx.arc(cx,cy,10,0,Math.PI*2); orchCtx.fill();
|
orchCtx.arc(cx,cy,10,0,Math.PI*2); orchCtx.fill();
|
||||||
|
} else if (c.state==='plan_executing'){
|
||||||
|
orchCtx.fillStyle='rgba(74,222,128,0.18)'; orchCtx.beginPath();
|
||||||
|
orchCtx.arc(cx,cy,10,0,Math.PI*2); orchCtx.fill();
|
||||||
}
|
}
|
||||||
orchCtx.font='18px serif'; orchCtx.textAlign='center'; orchCtx.textBaseline='middle';
|
orchCtx.font='18px serif'; orchCtx.textAlign='center'; orchCtx.textBaseline='middle';
|
||||||
if (c.facing === -1) {
|
if (c.facing === -1) {
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,8 @@ func (b *Broadcaster) ReportSpawn(id, label, task string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReportStateChange implements AgentReporter.
|
// ReportStateChange implements AgentReporter.
|
||||||
func (b *Broadcaster) ReportStateChange(id, state, tool string) {
|
func (b *Broadcaster) ReportStateChange(id string, state AgentState, tool string) {
|
||||||
b.Publish(Event{Type: "agent_state", ID: id, State: state, Tool: tool})
|
b.Publish(Event{Type: "agent_state", ID: id, State: string(state), Tool: tool})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReportConversation implements AgentReporter.
|
// ReportConversation implements AgentReporter.
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,17 @@ package orch
|
||||||
// Both Broadcaster (real events) and noopReporter (disabled) implement this.
|
// Both Broadcaster (real events) and noopReporter (disabled) implement this.
|
||||||
type AgentReporter interface {
|
type AgentReporter interface {
|
||||||
ReportSpawn(id, label, task string)
|
ReportSpawn(id, label, task string)
|
||||||
ReportStateChange(id, state, tool string)
|
ReportStateChange(id string, state AgentState, tool string)
|
||||||
ReportConversation(from, to, text string)
|
ReportConversation(from, to, text string)
|
||||||
ReportGC(id, reason string)
|
ReportGC(id, reason string)
|
||||||
}
|
}
|
||||||
|
|
||||||
type noopReporter struct{}
|
type noopReporter struct{}
|
||||||
|
|
||||||
func (n *noopReporter) ReportSpawn(id, label, task string) {}
|
func (n *noopReporter) ReportSpawn(id, label, task string) {}
|
||||||
func (n *noopReporter) ReportStateChange(id, state, tool string) {}
|
func (n *noopReporter) ReportStateChange(id string, state AgentState, tool string) {}
|
||||||
func (n *noopReporter) ReportConversation(from, to, text string) {}
|
func (n *noopReporter) ReportConversation(from, to, text string) {}
|
||||||
func (n *noopReporter) ReportGC(id, reason string) {}
|
func (n *noopReporter) ReportGC(id, reason string) {}
|
||||||
|
|
||||||
// Noop is the AgentReporter to use when orchestration is disabled.
|
// Noop is the AgentReporter to use when orchestration is disabled.
|
||||||
// Allows nil-free code in callers.
|
// Allows nil-free code in callers.
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ var _ AgentReporter = (*Broadcaster)(nil)
|
||||||
// orchestration mode.
|
// orchestration mode.
|
||||||
func TestNoop_AllMethods_NoPanic(t *testing.T) {
|
func TestNoop_AllMethods_NoPanic(t *testing.T) {
|
||||||
Noop.ReportSpawn("id", "label", "task")
|
Noop.ReportSpawn("id", "label", "task")
|
||||||
Noop.ReportStateChange("id", "waiting", "")
|
Noop.ReportStateChange("id", AgentStateWaiting, "")
|
||||||
Noop.ReportStateChange("id", "toolcall", "bash")
|
Noop.ReportStateChange("id", AgentStateToolCall, "bash")
|
||||||
Noop.ReportConversation("conductor", "sub-1", "do something")
|
Noop.ReportConversation("conductor", "sub-1", "do something")
|
||||||
Noop.ReportGC("id", "completed")
|
Noop.ReportGC("id", "completed")
|
||||||
}
|
}
|
||||||
|
|
@ -49,9 +49,9 @@ func TestBroadcaster_ReportStateChange_MapsToAgentStateEvent(t *testing.T) {
|
||||||
b.ReportSpawn("agent-1", "coder", "implement it")
|
b.ReportSpawn("agent-1", "coder", "implement it")
|
||||||
<-sub.Ch // consume spawn
|
<-sub.Ch // consume spawn
|
||||||
|
|
||||||
b.ReportStateChange("agent-1", "toolcall", "bash")
|
b.ReportStateChange("agent-1", AgentStateToolCall, "bash")
|
||||||
ev := <-sub.Ch
|
ev := <-sub.Ch
|
||||||
if ev.Type != "agent_state" || ev.State != "toolcall" || ev.Tool != "bash" {
|
if ev.Type != "agent_state" || ev.State != string(AgentStateToolCall) || ev.Tool != "bash" {
|
||||||
t.Fatalf("unexpected event: %+v", ev)
|
t.Fatalf("unexpected event: %+v", ev)
|
||||||
}
|
}
|
||||||
snap := b.Snapshot()
|
snap := b.Snapshot()
|
||||||
|
|
|
||||||
31
pkg/orch/state.go
Normal file
31
pkg/orch/state.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
package orch
|
||||||
|
|
||||||
|
// AgentState is a typed string representing the lifecycle state of an agent session.
|
||||||
|
// Values are sent as-is over the WebSocket event stream to the Mini App canvas.
|
||||||
|
type AgentState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AgentStateIdle is the resting state, set automatically by Broadcaster on spawn.
|
||||||
|
AgentStateIdle AgentState = "idle"
|
||||||
|
|
||||||
|
// AgentStateWaiting means the agent is waiting for an LLM response.
|
||||||
|
AgentStateWaiting AgentState = "waiting"
|
||||||
|
|
||||||
|
// AgentStateToolCall means the agent is executing a tool.
|
||||||
|
// The tool name is carried in the Tool field of the event.
|
||||||
|
AgentStateToolCall AgentState = "toolcall"
|
||||||
|
|
||||||
|
// AgentStatePlanInterviewing means the conductor is in plan-mode interview phase,
|
||||||
|
// clarifying goals and constraints with the user.
|
||||||
|
AgentStatePlanInterviewing AgentState = "plan_interviewing"
|
||||||
|
|
||||||
|
// AgentStatePlanReview means the conductor has submitted a plan and is waiting
|
||||||
|
// for user approval before executing.
|
||||||
|
AgentStatePlanReview AgentState = "plan_review"
|
||||||
|
|
||||||
|
// AgentStatePlanExecuting means the conductor is executing an approved plan.
|
||||||
|
AgentStatePlanExecuting AgentState = "plan_executing"
|
||||||
|
|
||||||
|
// AgentStatePlanCompleted means all plan steps have been completed.
|
||||||
|
AgentStatePlanCompleted AgentState = "plan_completed"
|
||||||
|
)
|
||||||
|
|
@ -75,7 +75,7 @@ func RunToolLoop(
|
||||||
llmOpts = map[string]any{}
|
llmOpts = map[string]any{}
|
||||||
}
|
}
|
||||||
// 3. Call LLM (hook: waiting for response)
|
// 3. Call LLM (hook: waiting for response)
|
||||||
reporter.ReportStateChange(config.AgentID, "waiting", "")
|
reporter.ReportStateChange(config.AgentID, orch.AgentStateWaiting, "")
|
||||||
response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)
|
response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("toolloop", "LLM call failed",
|
logger.ErrorCF("toolloop", "LLM call failed",
|
||||||
|
|
@ -143,7 +143,7 @@ func RunToolLoop(
|
||||||
"tool": tc.Name,
|
"tool": tc.Name,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
})
|
})
|
||||||
reporter.ReportStateChange(config.AgentID, "toolcall", tc.Name)
|
reporter.ReportStateChange(config.AgentID, orch.AgentStateToolCall, tc.Name)
|
||||||
|
|
||||||
// Execute tool (no async callback for subagents - they run independently)
|
// Execute tool (no async callback for subagents - they run independently)
|
||||||
var toolResult *ToolResult
|
var toolResult *ToolResult
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,14 @@ type reporterSpy struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type spyCall struct {
|
type spyCall struct {
|
||||||
state string
|
state orch.AgentState
|
||||||
tool string
|
tool string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *reporterSpy) ReportSpawn(id, label, task string) {}
|
func (r *reporterSpy) ReportSpawn(id, label, task string) {}
|
||||||
func (r *reporterSpy) ReportConversation(from, to, text string) {}
|
func (r *reporterSpy) ReportConversation(from, to, text string) {}
|
||||||
func (r *reporterSpy) ReportGC(id, reason string) {}
|
func (r *reporterSpy) ReportGC(id, reason string) {}
|
||||||
func (r *reporterSpy) ReportStateChange(id, state, tool string) {
|
func (r *reporterSpy) ReportStateChange(id string, state orch.AgentState, tool string) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.calls = append(r.calls, spyCall{state, tool})
|
r.calls = append(r.calls, spyCall{state, tool})
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
|
@ -117,7 +117,7 @@ func TestToolLoop_Reporter_WaitingBeforeLLM(t *testing.T) {
|
||||||
if len(calls) == 0 {
|
if len(calls) == 0 {
|
||||||
t.Fatal("expected at least one ReportStateChange call")
|
t.Fatal("expected at least one ReportStateChange call")
|
||||||
}
|
}
|
||||||
if calls[0].state != "waiting" {
|
if calls[0].state != orch.AgentStateWaiting {
|
||||||
t.Fatalf("first call must be state=waiting, got %+v", calls[0])
|
t.Fatalf("first call must be state=waiting, got %+v", calls[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -152,13 +152,13 @@ func TestToolLoop_Reporter_ToolcallOrderedAfterWaiting(t *testing.T) {
|
||||||
if len(calls) < 3 {
|
if len(calls) < 3 {
|
||||||
t.Fatalf("expected at least 3 calls, got %d: %+v", len(calls), calls)
|
t.Fatalf("expected at least 3 calls, got %d: %+v", len(calls), calls)
|
||||||
}
|
}
|
||||||
if calls[0].state != "waiting" {
|
if calls[0].state != orch.AgentStateWaiting {
|
||||||
t.Fatalf("calls[0] must be waiting, got %+v", calls[0])
|
t.Fatalf("calls[0] must be waiting, got %+v", calls[0])
|
||||||
}
|
}
|
||||||
if calls[1].state != "toolcall" || calls[1].tool != "echo_tool" {
|
if calls[1].state != orch.AgentStateToolCall || calls[1].tool != "echo_tool" {
|
||||||
t.Fatalf("calls[1] must be toolcall(echo_tool), got %+v", calls[1])
|
t.Fatalf("calls[1] must be toolcall(echo_tool), got %+v", calls[1])
|
||||||
}
|
}
|
||||||
if calls[2].state != "waiting" {
|
if calls[2].state != orch.AgentStateWaiting {
|
||||||
t.Fatalf("calls[2] must be waiting (2nd LLM iteration), got %+v", calls[2])
|
t.Fatalf("calls[2] must be waiting (2nd LLM iteration), got %+v", calls[2])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue