feat: upstream core loop migration (resolveMessageRoute, commands.Executor, Run/processMessage)

PR-A: Bus/Channels prep
- channels.Manager.InvokeTypingStop(): explicit typing stop for defer in Run()
- loopExt.reloadFunc + AgentLoop.SetReloadFunc(): hook for /reload command
- AgentLoop.resolveMessageRoute(): extract routing logic from processMessage()
- resolveScopeKey(): helper to resolve session key with pre-set override
- AgentLoop.selectCandidates(): per-turn model candidate selection with Router

PR-B: handleCommand integration
- buildCommandsRuntime() wires agent state into commands.Runtime
- handleCommand() rewritten to use commands.Executor for upstream commands
  (/show, /list, /switch, /check, /clear, /reload, /start, /help)
- Fork-specific commands (/session, /skills, /plan, /heartbeat) as fallback
- Signature: (ctx, msg, agent, sessionKey) for runtime wiring

PR-C: Run/processMessage/runLLMIteration
- Run(): ConsumeInbound() → InboundChan() select pattern
- llmWorkerNormal: defer InvokeTypingStop + activeRequests WaitGroup
- runLLMIteration: selectCandidates() once per turn (sticky), TickTTL()
- maybeSummarize: use agent's configurable thresholds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-20 19:50:16 +09:00
parent 876247fe6e
commit 7a2498de51
8 changed files with 242 additions and 208 deletions

View file

@ -347,17 +347,17 @@ func (al *AgentLoop) Run(ctx context.Context) error {
<-workerDone <-workerDone
}() }()
inbound := al.bus.InboundChan()
for al.running.Load() { for al.running.Load() {
var msg bus.InboundMessage
select { select {
case <-ctx.Done(): case <-ctx.Done():
return nil return nil
default: case m, ok := <-inbound:
} if !ok {
return nil
msg, ok := al.bus.ConsumeInbound(ctx) }
msg = m
if !ok {
continue
} }
// Echo commands sent from the Mini App so the user can see what was sent. // Echo commands sent from the Mini App so the user can see what was sent.
@ -375,8 +375,8 @@ func (al *AgentLoop) Run(ctx context.Context) error {
} }
// Fast path: handle slash commands immediately without blocking the LLM worker. // Fast path: handle slash commands immediately without blocking the LLM worker.
defaultAgent := al.registry.GetDefaultAgent()
if response, handled := al.handleCommand(ctx, msg); handled { if response, handled := al.handleCommand(ctx, msg, defaultAgent, msg.SessionKey); handled {
if response != "" { if response != "" {
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: msg.Channel, Channel: msg.Channel,
@ -496,6 +496,14 @@ func (al *AgentLoop) llmWorker(ctx context.Context, queue <-chan bus.InboundMess
// llmWorkerNormal processes a single non-PDF message. // llmWorkerNormal processes a single non-PDF message.
func (al *AgentLoop) llmWorkerNormal(ctx context.Context, msg bus.InboundMessage) { func (al *AgentLoop) llmWorkerNormal(ctx context.Context, msg bus.InboundMessage) {
al.activeRequests.Add(1)
defer al.activeRequests.Done()
// Ensure typing indicator is stopped when processing completes.
if al.channelManager != nil {
defer al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID)
}
// Reset per-round message-tool state so a previous round's // Reset per-round message-tool state so a previous round's
// tool-sent flag does not suppress this round's response. // tool-sent flag does not suppress this round's response.
if defaultAgent := al.registry.GetDefaultAgent(); defaultAgent != nil { if defaultAgent := al.registry.GetDefaultAgent(); defaultAgent != nil {
@ -1041,6 +1049,54 @@ func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, cha
}) })
} }
// resolveMessageRoute resolves the agent and routing info for an inbound message.
// It looks up the agent registry to determine which agent handles the message
// and resets the message tool context for the new round.
func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) {
registry := al.GetRegistry()
route := registry.ResolveRoute(routing.RouteInput{
Channel: msg.Channel,
AccountID: msg.Metadata[metadataKeyAccountID],
Peer: extractPeer(msg),
ParentPeer: extractParentPeer(msg),
GuildID: msg.Metadata[metadataKeyGuildID],
TeamID: msg.Metadata[metadataKeyTeamID],
})
agent, ok := registry.GetAgent(route.AgentID)
if !ok {
agent = registry.GetDefaultAgent()
}
if agent == nil {
return route, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
}
// Reset message-tool state for this round
if tool, ok := agent.Tools.Get("message"); ok {
if mt, ok := tool.(tools.ContextualTool); ok {
mt.SetContext(msg.Channel, msg.ChatID)
}
}
logger.InfoCF("agent", "Routed message",
map[string]any{
"agent_id": agent.ID,
"session_key": route.SessionKey,
"matched_by": route.MatchedBy,
})
return route, agent, nil
}
// resolveScopeKey returns the session key to use: honors a pre-set key (from
// ProcessDirect/cron) over the route-resolved key.
func resolveScopeKey(route routing.ResolvedRoute, msgSessionKey string) string {
if msgSessionKey != "" {
return msgSessionKey
}
return route.SessionKey
}
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
// Add message preview to log (show full content for error messages) // Add message preview to log (show full content for error messages)
var logContent string var logContent string
@ -1091,62 +1147,18 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
// Expand fork-specific /skill and /plan commands // Expand fork-specific /skill and /plan commands
expansionCompact := al.expandForkCommands(&msg) expansionCompact := al.expandForkCommands(&msg)
// Check for commands // Check for commands (using default agent, before routing)
if response, handled := al.handleCommand(ctx, msg, al.registry.GetDefaultAgent(), msg.SessionKey); handled {
if response, handled := al.handleCommand(ctx, msg); handled {
return response, nil return response, nil
} }
// Route to determine agent and session key // Route to determine agent and session key
route, agent, err := al.resolveMessageRoute(msg)
registry := al.GetRegistry() if err != nil {
route := registry.ResolveRoute(routing.RouteInput{ return "", err
Channel: msg.Channel,
AccountID: msg.Metadata["account_id"],
Peer: extractPeer(msg),
ParentPeer: extractParentPeer(msg),
GuildID: msg.Metadata["guild_id"],
TeamID: msg.Metadata["team_id"],
})
agent, ok := registry.GetAgent(route.AgentID)
if !ok {
agent = registry.GetDefaultAgent()
}
if agent == nil {
return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
} }
// Reset message-tool state for this round so we don't skip publishing due to a previous round. sessionKey := resolveScopeKey(route, msg.SessionKey)
if tool, ok := agent.Tools.Get("message"); ok {
if mt, ok := tool.(tools.ContextualTool); ok {
mt.SetContext(msg.Channel, msg.ChatID)
}
}
// Use routed session key, but honor ANY pre-set session key (for ProcessDirect/cron)
sessionKey := route.SessionKey
if msg.SessionKey != "" {
sessionKey = msg.SessionKey
}
logger.InfoCF("agent", "Routed message",
map[string]any{
"agent_id": agent.ID,
"session_key": sessionKey,
"matched_by": route.MatchedBy,
})
return al.runAgentLoop(ctx, agent, processOptions{ return al.runAgentLoop(ctx, agent, processOptions{
SessionKey: sessionKey, SessionKey: sessionKey,

View file

@ -8,164 +8,136 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/commands"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/stats" "github.com/sipeed/picoclaw/pkg/stats"
) )
func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) { // buildCommandsRuntime constructs a commands.Runtime wired to the current
content := strings.TrimSpace(msg.Content) // agent and loop state. This is the upstream pattern for providing runtime
// dependencies to command handlers.
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, sessionKey string) *commands.Runtime {
return &commands.Runtime{
Config: al.GetConfig(),
GetModelInfo: func() (string, string) {
if agent == nil {
return "unknown", "unknown"
}
prov, _ := providers.ExtractProtocol(agent.Model)
return agent.Model, prov
},
ListAgentIDs: func() []string {
return al.GetRegistry().ListAgentIDs()
},
ListDefinitions: func() []commands.Definition {
return al.cmdRegistry.Definitions()
},
GetEnabledChannels: func() []string {
if al.channelManager == nil {
return nil
}
return al.channelManager.GetEnabledChannels()
},
SwitchModel: func(value string) (string, error) {
if agent == nil {
return "", fmt.Errorf("no default agent configured")
}
old := agent.Model
agent.Model = value
return old, nil
},
SwitchChannel: func(value string) error {
if al.channelManager == nil {
return fmt.Errorf("channel manager not initialized")
}
if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
return fmt.Errorf("channel '%s' not found or not enabled", value)
}
return nil
},
ClearHistory: func() error {
if agent == nil || sessionKey == "" {
return fmt.Errorf("no active session")
}
agent.Sessions.SetHistory(sessionKey, nil)
agent.Sessions.SetSummary(sessionKey, "")
return agent.Sessions.Save(sessionKey)
},
ReloadConfig: func() error {
if al.reloadFunc != nil {
return al.reloadFunc()
}
return fmt.Errorf("reload not available")
},
}
}
if !strings.HasPrefix(content, "/") { // handleCommand processes slash commands. It first tries the upstream
// commands.Executor (for /show, /list, /switch, /check, /clear, /reload, etc.),
// then falls back to fork-specific commands (/session, /skills, /plan, /heartbeat).
func (al *AgentLoop) handleCommand(
ctx context.Context,
msg bus.InboundMessage,
agent *AgentInstance,
sessionKey string,
) (string, bool) {
content := strings.TrimSpace(msg.Content)
if !commands.HasCommandPrefix(content) {
return "", false return "", false
} }
parts := strings.Fields(content) // Build a reply collector — the Executor calls req.Reply with the response.
var response string
replyFn := func(text string) error {
response = text
return nil
}
rt := al.buildCommandsRuntime(agent, sessionKey)
exec := commands.NewExecutor(al.cmdRegistry, rt)
result := exec.Execute(ctx, commands.Request{
Channel: msg.Channel,
ChatID: msg.ChatID,
SenderID: msg.SenderID,
Text: content,
Reply: replyFn,
})
if result.Outcome == commands.OutcomeHandled {
if result.Err != nil {
return fmt.Sprintf("Command error: %v", result.Err), true
}
return response, true
}
// Fallback: fork-specific commands not in the upstream registry
parts := strings.Fields(content)
if len(parts) == 0 { if len(parts) == 0 {
return "", false return "", false
} }
cmd := parts[0] cmd := parts[0]
args := parts[1:] args := parts[1:]
switch cmd { switch cmd {
case "/show":
if len(args) < 1 {
return "Usage: /show [model|channel|agents]", true
}
switch args[0] {
case "model":
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
return "No default agent configured", true
}
return fmt.Sprintf("Current model: %s", defaultAgent.Model), true
case "channel":
return fmt.Sprintf("Current channel: %s", msg.Channel), true
case "agents":
agentIDs := al.registry.ListAgentIDs()
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
default:
return fmt.Sprintf("Unknown show target: %s", args[0]), true
}
case "/list":
if len(args) < 1 {
return "Usage: /list [models|channels|agents]", true
}
switch args[0] {
case "models":
return "Available models: configured in config.json per agent", true
case "channels":
if al.channelManager == nil {
return "Channel manager not initialized", true
}
channels := al.channelManager.GetEnabledChannels()
if len(channels) == 0 {
return "No channels enabled", true
}
return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true
case "agents":
agentIDs := al.registry.ListAgentIDs()
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
default:
return fmt.Sprintf("Unknown list target: %s", args[0]), true
}
case "/switch":
if len(args) < 3 || args[1] != "to" {
return "Usage: /switch [model|channel] to <name>", true
}
target := args[0]
value := args[2]
switch target {
case "model":
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
return "No default agent configured", true
}
oldModel := defaultAgent.Model
defaultAgent.Model = value
return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true
case "channel":
if al.channelManager == nil {
return "Channel manager not initialized", true
}
if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
return fmt.Sprintf("Channel '%s' not found or not enabled", value), true
}
return fmt.Sprintf("Switched target channel to %s", value), true
default:
return fmt.Sprintf("Unknown switch target: %s", target), true
}
case "/session": case "/session":
return al.handleSessionCommand(args, msg.SessionKey), true return al.handleSessionCommand(args, msg.SessionKey), true
case "/skills": case "/skills":
return al.handleSkillsCommand(), true return al.handleSkillsCommand(), true
case "/plan": case "/plan":
resp, handled := al.handlePlanCommand(args, msg.SessionKey) resp, handled := al.handlePlanCommand(args, msg.SessionKey)
if handled { if handled {
al.notifyStateChange() al.notifyStateChange()
} }
return resp, handled return resp, handled
case "/heartbeat": case "/heartbeat":
resp, handled := al.handleHeartbeatCommand(args, msg) resp, handled := al.handleHeartbeatCommand(args, msg)
if handled { if handled {
al.notifyStateChange() al.notifyStateChange()
} }
return resp, handled return resp, handled
} }

View file

@ -39,8 +39,12 @@ type loopExt struct {
activeTasks sync.Map // sessionKey → *activeTask activeTasks sync.Map // sessionKey → *activeTask
activeRequests sync.WaitGroup // tracks in-flight LLM worker requests
done chan struct{} // closed by Close() to stop background goroutines done chan struct{} // closed by Close() to stop background goroutines
reloadFunc func() error // upstream compat: called by buildCommandsRuntime
saveConfig func(*config.Config) error saveConfig func(*config.Config) error
onHeartbeatThreadUpdate func(int) onHeartbeatThreadUpdate func(int)
@ -131,6 +135,12 @@ func (al *AgentLoop) pruneMediaCache() {
} }
} }
// SetReloadFunc registers a callback to reload config from disk.
// Used by buildCommandsRuntime to wire the /reload command.
func (al *AgentLoop) SetReloadFunc(fn func() error) {
al.reloadFunc = fn
}
// SetConfigSaver registers a callback to persist config changes. // SetConfigSaver registers a callback to persist config changes.
func (al *AgentLoop) SetConfigSaver(fn func(*config.Config) error) { func (al *AgentLoop) SetConfigSaver(fn func(*config.Config) error) {
al.saveConfig = fn al.saveConfig = fn

View file

@ -405,7 +405,7 @@ func TestPlanCommand_ShowNoPlan(t *testing.T) {
defer cleanup() defer cleanup()
response, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan"}) response, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan"}, al.registry.GetDefaultAgent(), "")
if !handled { if !handled {
t.Fatal("expected /plan to be handled") t.Fatal("expected /plan to be handled")
@ -488,7 +488,7 @@ func TestHeartbeatCommandThreadHerePersistsConfig(t *testing.T) {
ChatID: "-100500/42", ChatID: "-100500/42",
} }
resp, handled := al.handleCommand(context.Background(), msg) resp, handled := al.handleCommand(context.Background(), msg, al.registry.GetDefaultAgent(), "")
if !handled { if !handled {
t.Fatal("expected /heartbeat command to be handled") t.Fatal("expected /heartbeat command to be handled")
@ -528,7 +528,7 @@ func TestHeartbeatCommandThreadOff(t *testing.T) {
Channel: "telegram", Channel: "telegram",
ChatID: "-100500/42", ChatID: "-100500/42",
}) }, al.registry.GetDefaultAgent(), "")
if !handled { if !handled {
t.Fatal("expected /heartbeat command to be handled") t.Fatal("expected /heartbeat command to be handled")
@ -548,7 +548,7 @@ func TestPlanCommand_StartNewPlan(t *testing.T) {
defer cleanup() defer cleanup()
_, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Set up monitoring"}) _, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Set up monitoring"}, al.registry.GetDefaultAgent(), "")
if handled { if handled {
t.Fatal("expected /plan <task> NOT to be handled (should fall through to LLM)") t.Fatal("expected /plan <task> NOT to be handled (should fall through to LLM)")
@ -588,7 +588,7 @@ func TestPlanCommand_StartBlockedByExisting(t *testing.T) {
al.expandPlanCommand(bus.InboundMessage{Content: "/plan First task"}) al.expandPlanCommand(bus.InboundMessage{Content: "/plan First task"})
response, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Second task"}) response, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Second task"}, al.registry.GetDefaultAgent(), "")
if !handled { if !handled {
t.Fatal("expected second /plan to be handled (blocked)") t.Fatal("expected second /plan to be handled (blocked)")
@ -606,7 +606,7 @@ func TestPlanCommand_Clear(t *testing.T) {
al.expandPlanCommand(bus.InboundMessage{Content: "/plan Test task"}) al.expandPlanCommand(bus.InboundMessage{Content: "/plan Test task"})
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan clear"}) response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan clear"}, al.registry.GetDefaultAgent(), "")
if !strings.Contains(response, "Plan cleared") { if !strings.Contains(response, "Plan cleared") {
t.Errorf("expected 'Plan cleared', got %q", response) t.Errorf("expected 'Plan cleared', got %q", response)
@ -624,7 +624,7 @@ func TestPlanCommand_ClearNoPlan(t *testing.T) {
defer cleanup() defer cleanup()
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan clear"}) response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan clear"}, al.registry.GetDefaultAgent(), "")
if !strings.Contains(response, "No active plan") { if !strings.Contains(response, "No active plan") {
t.Errorf("expected 'No active plan', got %q", response) t.Errorf("expected 'No active plan', got %q", response)
@ -642,7 +642,7 @@ func TestPlanCommand_Start(t *testing.T) {
_ = agent.ContextBuilder.WriteMemory(plan) _ = agent.ContextBuilder.WriteMemory(plan)
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}, al.registry.GetDefaultAgent(), "")
if !strings.Contains(response, "approved") { if !strings.Contains(response, "approved") {
t.Errorf("expected 'approved', got %q", response) t.Errorf("expected 'approved', got %q", response)
@ -668,7 +668,7 @@ func TestPlanCommand_StartFromReview(t *testing.T) {
_ = agent.ContextBuilder.WriteMemory(plan) _ = agent.ContextBuilder.WriteMemory(plan)
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}, al.registry.GetDefaultAgent(), "")
if !strings.Contains(response, "approved") { if !strings.Contains(response, "approved") {
t.Errorf("expected 'approved', got %q", response) t.Errorf("expected 'approved', got %q", response)
@ -690,7 +690,7 @@ func TestPlanCommand_StartNoPhases(t *testing.T) {
al.expandPlanCommand(bus.InboundMessage{Content: "/plan Test task"}) al.expandPlanCommand(bus.InboundMessage{Content: "/plan Test task"})
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}, al.registry.GetDefaultAgent(), "")
if !strings.Contains(response, "no phases") { if !strings.Contains(response, "no phases") {
t.Errorf("expected 'no phases' error, got %q", response) t.Errorf("expected 'no phases' error, got %q", response)
@ -718,11 +718,11 @@ func TestPlanCommand_StartAlreadyExecuting(t *testing.T) {
_ = agent.ContextBuilder.WriteMemory(plan) _ = agent.ContextBuilder.WriteMemory(plan)
al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}, al.registry.GetDefaultAgent(), "")
al.planStartPending = false al.planStartPending = false
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}, al.registry.GetDefaultAgent(), "")
if !strings.Contains(response, "already executing") { if !strings.Contains(response, "already executing") {
t.Errorf("expected 'already executing', got %q", response) t.Errorf("expected 'already executing', got %q", response)
@ -768,7 +768,7 @@ Test context
agent.ContextBuilder.WriteMemory(plan) agent.ContextBuilder.WriteMemory(plan)
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan done 1"}) response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan done 1"}, al.registry.GetDefaultAgent(), "")
if !strings.Contains(response, "Marked step 1") { if !strings.Contains(response, "Marked step 1") {
t.Errorf("expected confirmation, got %q", response) t.Errorf("expected confirmation, got %q", response)
@ -782,7 +782,7 @@ func TestPlanCommand_DoneInvalidStep(t *testing.T) {
al.expandPlanCommand(bus.InboundMessage{Content: "/plan Test task"}) al.expandPlanCommand(bus.InboundMessage{Content: "/plan Test task"})
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan done abc"}) response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan done abc"}, al.registry.GetDefaultAgent(), "")
if !strings.Contains(response, "positive integer") { if !strings.Contains(response, "positive integer") {
t.Errorf("expected step validation error, got %q", response) t.Errorf("expected step validation error, got %q", response)
@ -822,7 +822,7 @@ Test context
agent.ContextBuilder.WriteMemory(plan) agent.ContextBuilder.WriteMemory(plan)
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan add New step here"}) response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan add New step here"}, al.registry.GetDefaultAgent(), "")
if !strings.Contains(response, "Added step") { if !strings.Contains(response, "Added step") {
t.Errorf("expected 'Added step', got %q", response) t.Errorf("expected 'Added step', got %q", response)
@ -874,7 +874,7 @@ Test
agent.ContextBuilder.WriteMemory(plan) agent.ContextBuilder.WriteMemory(plan)
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan next"}) response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan next"}, al.registry.GetDefaultAgent(), "")
if !strings.Contains(response, "phase 2") { if !strings.Contains(response, "phase 2") {
t.Errorf("expected 'phase 2', got %q", response) t.Errorf("expected 'phase 2', got %q", response)
@ -920,7 +920,7 @@ Production server
agent.ContextBuilder.WriteMemory(plan) agent.ContextBuilder.WriteMemory(plan)
response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan"}) response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan"}, al.registry.GetDefaultAgent(), "")
if !strings.Contains(response, "Deploy app") { if !strings.Contains(response, "Deploy app") {
t.Errorf("expected task name in display, got %q", response) t.Errorf("expected task name in display, got %q", response)
@ -2549,7 +2549,7 @@ func TestPlanCommand_StartClear(t *testing.T) {
Content: "/plan start clear", Content: "/plan start clear",
SessionKey: "test-session", SessionKey: "test-session",
}) }, al.registry.GetDefaultAgent(), "")
if !handled { if !handled {
t.Fatal("expected /plan start clear to be handled") t.Fatal("expected /plan start clear to be handled")
@ -2615,7 +2615,7 @@ func TestPlanCommand_StartWithoutClear_PreservesHistory(t *testing.T) {
Content: "/plan start", Content: "/plan start",
SessionKey: "test-session", SessionKey: "test-session",
}) }, al.registry.GetDefaultAgent(), "")
if strings.Contains(response, "clean history") { if strings.Contains(response, "clean history") {
t.Errorf("did not expect 'clean history' in response, got %q", response) t.Errorf("did not expect 'clean history' in response, got %q", response)

View file

@ -768,6 +768,26 @@ func (al *AgentLoop) redirectMessageToolForTask(agent *AgentInstance, task *acti
}) })
} }
// selectCandidates resolves the model candidates for a turn. Called once per
// turn (sticky) so the model doesn't change mid-iteration. Plan model hooks
// may further override per-iteration via hooks.SelectModel().
//
// Uses the agent's pre-resolved Candidates/LightCandidates. When a Router is
// configured, it scores the message and may select the light model tier.
func (al *AgentLoop) selectCandidates(
agent *AgentInstance,
userMsg string,
history []providers.Message,
) []providers.FallbackCandidate {
if agent.Router != nil && len(agent.LightCandidates) > 0 {
_, isLight, _ := agent.Router.SelectModel(userMsg, history, agent.Model)
if isLight {
return agent.LightCandidates
}
}
return agent.Candidates
}
// runLLMIteration executes the LLM call loop with tool handling using hooks. // runLLMIteration executes the LLM call loop with tool handling using hooks.
func (al *AgentLoop) runLLMIteration( func (al *AgentLoop) runLLMIteration(
ctx context.Context, ctx context.Context,
@ -779,6 +799,10 @@ func (al *AgentLoop) runLLMIteration(
) (string, int, error) { ) (string, int, error) {
hooks := al.buildHooks(agent, opts, task, planSnapshot) hooks := al.buildHooks(agent, opts, task, planSnapshot)
// Select candidates once per turn (sticky). Plan model hooks may
// override per-iteration via hooks.SelectModel().
turnCandidates := al.selectCandidates(agent, opts.UserMessage, messages)
iteration := 0 iteration := 0
var finalContent string var finalContent string
@ -799,8 +823,9 @@ func (al *AgentLoop) runLLMIteration(
// Build tool definitions // Build tool definitions
providerToolDefs := hooks.FilterTools(agent.Tools.ToProviderDefs()) providerToolDefs := hooks.FilterTools(agent.Tools.ToProviderDefs())
// Resolve model and candidates for this call // Resolve model and candidates for this call.
candidates := agent.Candidates // Default to turn-level candidates; hooks may override per-iteration.
candidates := turnCandidates
activeModel := agent.Model activeModel := agent.Model
if m, c := hooks.SelectModel(); m != "" { if m, c := hooks.SelectModel(); m != "" {
activeModel = m activeModel = m
@ -939,6 +964,9 @@ func (al *AgentLoop) runLLMIteration(
// Execute tool calls and collect results // Execute tool calls and collect results
lastBlocker := al.executeToolCalls(ctx, agent, normalizedToolCalls, &messages, opts, hooks, iteration) lastBlocker := al.executeToolCalls(ctx, agent, normalizedToolCalls, &messages, opts, hooks, iteration)
// Tick TTL-based tool expiry after execution
agent.Tools.TickTTL()
hooks.InjectReminders(iteration, &messages, lastBlocker) hooks.InjectReminders(iteration, &messages, lastBlocker)
hooks.RefreshSystemPrompt(messages) hooks.RefreshSystemPrompt(messages)
} }

View file

@ -97,9 +97,9 @@ func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, c
tokenEstimate := al.estimateTokens(newHistory) tokenEstimate := al.estimateTokens(newHistory)
threshold := agent.ContextWindow * 75 / 100 threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100
if len(newHistory) > 20 || tokenEstimate > threshold { if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold {
summarizeKey := agent.ID + ":" + sessionKey summarizeKey := agent.ID + ":" + sessionKey
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading { if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {

View file

@ -557,7 +557,7 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) {
Content: "/show channel", Content: "/show channel",
Peer: baseMsg.Peer, Peer: baseMsg.Peer,
}) })
if showResp != "Current channel: whatsapp" { if showResp != "Current Channel: whatsapp" {
t.Fatalf("unexpected /show reply: %q", showResp) t.Fatalf("unexpected /show reply: %q", showResp)
} }
if provider.calls != 0 { if provider.calls != 0 {
@ -641,7 +641,7 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
ID: "user1", ID: "user1",
}, },
}) })
if !strings.Contains(showResp, "Current model: after-switch") { if !strings.Contains(showResp, "Current Model: after-switch") {
t.Fatalf("unexpected /show model reply after switch: %q", showResp) t.Fatalf("unexpected /show model reply after switch: %q", showResp)
} }

View file

@ -805,6 +805,18 @@ func (m *Manager) runTTLJanitor(ctx context.Context) {
} }
} }
// InvokeTypingStop stops any active typing indicator for the given channel/chatID.
// Safe to call even if no typing indicator is active. Intended for use in defer
// after the LLM worker finishes processing a message.
func (m *Manager) InvokeTypingStop(channel, chatID string) {
key := channel + ":" + chatID
if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
if entry, ok := v.(typingEntry); ok && entry.stop != nil {
entry.stop()
}
}
}
func (m *Manager) GetChannel(name string) (Channel, bool) { func (m *Manager) GetChannel(name string) (Channel, bool) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()