package runtime import ( "context" "errors" "fmt" "log/slog" "path" "path/filepath" "reflect" "regexp" "slices" "strings" "time" "uuid" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" "github.com/docker/docker-agent/pkg/agent" "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/compaction" "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/httpclient" "github.com/docker/docker-agent/pkg/model/provider" "github.com/docker/docker-agent/pkg/modelsdev" "github.com/docker/docker-agent/pkg/runtime/toolexec" "github.com/docker/docker-agent/pkg/session" "github.com/docker/docker-agent/pkg/telemetry/genai" "github.com/docker/docker-agent/pkg/tools" bgagent "github.com/docker/docker-agent/pkg/tools/builtin/agent" "github.com/docker/docker-agent/pkg/tools/builtin/backgroundjobs" "github.com/docker/docker-agent/pkg/tools/builtin/plan" "github.com/docker/docker-agent/pkg/tools/builtin/sessioncontext" "github.com/docker/docker-agent/pkg/userconfig" "github.com/docker/docker-agent/pkg/workspacemedia" ) // registerDefaultTools wires up the built-in tool handlers (delegation, // background agents, model switching) into the runtime's tool dispatch map. func (r *LocalRuntime) registerDefaultTools() { r.toolMap[sessioncontext.ToolNameListSessions] = r.handleListSessions r.toolMap[sessioncontext.ToolNameReadSession] = r.handleReadSession r.bgAgents.RegisterHandlers(func(name string, fn func(context.Context, *session.Session, tools.ToolCall) (*tools.ToolCallResult, error)) { r.toolMap[name] = func(ctx context.Context, sess *session.Session, tc tools.ToolCall, _ EventSink, _ tools.Runtime) (*tools.ToolCallResult, error) { return fn(ctx, sess, tc) } }) } // appendSteerAndEmit adds a steer message to the session and emits the corresponding event. func (r *LocalRuntime) appendSteerAndEmit(sess *session.Session, sm QueuedMessage, events EventSink) { msg := session.UserMessage(sm.Content, sm.MultiContent...) msg.Message.MessageID = uuid.NewV4().String() pos := sess.AddMessage(msg) events.Emit(userMessageEvent(msg.Message, sess.ID, pos)) } // drainAndEmitSteered drains all messages from the steer queue and injects // them into the session as individual user messages. When multiple messages // are drained, a "\n" is appended to the content of every non-last message. // Some chat templates concatenate consecutive user messages without a // separator before tokenisation, which would cause trailing/leading word // fragments from adjacent messages to be glued together. The "\n" prevents // this without merging the messages into one. // // It also snapshots the message count before any messages are added and // returns it alongside the drained flag so the caller can pass it to // compactIfNeeded without a separate len(sess.GetAllMessages()) call. // // NOTE: the appended \n is persisted in the session message and included in // UserMessageEvent. This is a deliberate trade-off: because the runtime passes // chat.Message slices directly to the provider, this is the only injection // point that doesn't require restructuring. TUI consumers may see a trailing // newline on non-last steered messages in multi-drain batches. // // After appending the drained messages it fires the // user_steering_messages_submit hook (the steering-queue analogue of // user_prompt_submit), passing the drained message text. The hook may // block the run (steerResult.stop) or contribute a transient system // message (steerResult.contextMsgs) that the caller threads into the // steered turn only — never persisted, exactly like user_prompt_submit. // // Returns drained=true with messageCountBefore set when any messages // were drained and emitted; otherwise drained=false. func (r *LocalRuntime) drainAndEmitSteered(ctx context.Context, sess *session.Session, a *agent.Agent, events EventSink) steerResult { // A routed invocation keeps later input queued until its boundary, so // the steered text cannot change the assessment already made. if sess.Routed() { return steerResult{} } return r.drainSteeredQueue(ctx, sess, a, events) } // drainSteeredQueue unconditionally moves queued steering messages into the session. func (r *LocalRuntime) drainSteeredQueue(ctx context.Context, sess *session.Session, a *agent.Agent, events EventSink) steerResult { steered := r.steerQueue.Drain(ctx) if len(steered) == 0 { return steerResult{} } messageCountBefore := sess.OwnMessageCount() contents := make([]string, 0, len(steered)) for i, sm := range steered { contents = append(contents, sm.Content) if i < len(steered)-1 { sm = appendNewlineToQueuedMessage(sm) } r.appendSteerAndEmit(sess, sm, events) } stop, stopMsg, ctxMsgs := r.executeUserSteeringMessagesSubmitHooks(ctx, sess, a, contents, events) return steerResult{ drained: true, messageCountBefore: messageCountBefore, stop: stop, stopMsg: stopMsg, contextMsgs: ctxMsgs, } } // drainSteeredQueueAtBoundary drains the steering queue where an invocation // ends; routed conversations only ever drain here. func (r *LocalRuntime) drainSteeredQueueAtBoundary(ctx context.Context, sess *session.Session, a *agent.Agent, ls *loopState, events EventSink) steerResult { if ls.route != nil { return r.drainSteeredQueue(ctx, sess, a, events) } return r.drainAndEmitSteered(ctx, sess, a, events) } // steerResult is the outcome of a drainAndEmitSteered call: whether any // messages were drained, the pre-drain message count (for // compactIfNeeded), and the user_steering_messages_submit hook verdict // (a terminating stop and/or a transient context message to thread into // the steered turn). type steerResult struct { drained bool messageCountBefore int stop bool stopMsg string contextMsgs []chat.Message } // appendNewlineToQueuedMessage returns sm with "\n" appended to its text // content; never mutates the caller's slice contents. // For plain-text messages Content is extended. For multi-content messages // only the last part is considered: if it is a text part, "\n" is appended // to it in a shallow copy of the slice. If the last part is not text type // (e.g. image), sm is returned unchanged — non-text parts carry their own // provider envelope that acts as a separator. func appendNewlineToQueuedMessage(sm QueuedMessage) QueuedMessage { if len(sm.MultiContent) == 0 { sm.Content += "\n" return sm } // Only act if the last part is a text part. last := len(sm.MultiContent) - 1 if sm.MultiContent[last].Type != chat.MessagePartTypeText { return sm } // Shallow-copy the slice so we don't mutate the original. parts := slices.Clone(sm.MultiContent) parts[last].Text += "\n" sm.MultiContent = parts return sm } // emitHookDrivenShutdown fans out the standard Error / // notification(level=error) / on_error stanzas when a hook // (post_tool_use or before_llm_call) signals run termination. func (r *LocalRuntime) emitHookDrivenShutdown( ctx context.Context, a *agent.Agent, sess *session.Session, message string, events EventSink, ) { if message == "" { // aggregate() always populates Result.Message on a deny // verdict; the fallback covers any future hook that returns // block without a reason. message = "Agent terminated by a hook." } events.Emit(ErrorWithCodeForSession(sess.ID, ErrorCodeHookBlocked, message)) r.notifyError(ctx, a, sess.ID, message) } // finalizeEventChannel performs cleanup at the end of a RunStream goroutine: // emits the StreamStopped event, fires hooks, records telemetry, restores the // previous elicitation channel, and closes the events channel. // // reason is one of the turnEndReason* constants and classifies how the // stream ended (e.g. "normal", "error", "canceled"). It is surfaced in // the StreamStoppedEvent so external consumers (boards, dashboards) can // distinguish between successful completion, crashes, and user-initiated // stops without reverse-engineering reconnect failures. // // Ordering (decided in #3074): StreamStopped is emitted before the session-end // hooks and telemetry run, not after, so consumers react immediately (the TUI // stops its spinner and drains its queued messages) instead of waiting behind // potentially slow user-configured session_end hooks. This is safe because // those hooks dispatch with a nil event sink and never emit onto this channel, // so StreamStopped stays the last event a consumer observes. The authoritative // "all cleanup done" signal is the channel close (done last, in // restoreAndClose) that terminates a `for range`, not the StreamStopped event. // // Delivery: StreamStopped is delivered with a bounded blocking send (see // [boundedChannelSink]) rather than a plain non-blocking one. A consumer // still draining the channel — including one that already got Esc or // otherwise cancelled its context, since the TUI keeps draining until close — // reliably receives it. It is dropped only if nothing accepts it within // streamStoppedTimeout, i.e. the consumer has genuinely abandoned the channel // (#4136 fixed the non-blocking drop that caused this; #3070 is why the send // is bounded rather than unbounded). Consumers must still rely on the channel // close, not on receiving StreamStopped, as the one guaranteed terminal // signal. func (r *LocalRuntime) finalizeEventChannel(ctx context.Context, sess *session.Session, reason string, finishReason chat.FinishReason, prevElicitationCh, events chan Event) { a := r.resolveSessionAgent(sess) if ctx.Err() != nil && reason == "" { reason = turnEndReasonCanceled } // Bounded, not unbounded: an abandoned consumer must not hang teardown // forever (#3070), but a live one draining past cancellation must still // get this event (#4136) — so this never selects on ctx.Done(). stopped := StreamStopped(sess.ID, a.Name(), reason).(*StreamStoppedEvent) stopped.FinishReason = finishReason bounded(&channelSink{ch: events}, r.streamStoppedTimeout()).Emit(stopped) // Execute session end hooks with a context that won't be cancelled so // cleanup hooks run even when the stream was interrupted (e.g. Ctrl+C). r.executeSessionEndHooks(context.WithoutCancel(ctx), sess, a) // on_user_input means "the agent is now waiting for the user". Only a // root interactive stream hands control back to a user when it ends; // sub-session and non-interactive teardowns (background agents, MCP // serve, A2A, evals) have nobody to wait for and must not fire it (#4004). if !sess.IsSubSession() && !sess.NonInteractive { r.executeOnUserInputHooks(ctx, a, sess.ID, "stream stopped") } r.telemetry.RecordSessionEnd(ctx) r.elicitation.restoreAndClose(events, prevElicitationCh) } // streamStoppedTimeout returns the bounded-delivery deadline for the // StreamStopped emit in finalizeEventChannel, falling back to // defaultStreamStoppedDeliveryTimeout when unset (e.g. a *LocalRuntime built // directly as a struct literal in tests, bypassing NewLocalRuntime). func (r *LocalRuntime) streamStoppedTimeout() time.Duration { if r.streamStoppedDeliveryTimeout > 0 { return r.streamStoppedDeliveryTimeout } return defaultStreamStoppedDeliveryTimeout } // RunStream starts the agent's interaction loop and returns a channel of events. // The returned channel is closed when the loop terminates (success, error, or // context cancellation). Each iteration: sends messages to the model, streams // the response, executes any tool calls, and loops until the model signals stop // or the iteration limit is reached. func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-chan Event { return r.runStream(ctx, sess, defaultIdleStreamRetryPolicy()) } func (r *LocalRuntime) runStream(ctx context.Context, sess *session.Session, idleRetryPolicy idleStreamRetryPolicy) <-chan Event { slog.DebugContext(ctx, "Starting runtime stream", "agent", r.currentAgentName(), "session_id", sess.ID) events := make(chan Event, defaultEventChannelCapacity) rootStream := !sess.IsSubSession() if rootStream { r.activeRootStreams.Add(1) // Install a fresh budget for this run. Sub-sessions deliberately // skip this: they share the root's tracker so a fan-out spends // against one wallet rather than one allowance per child. r.ensureBudget() } // Register before the run goroutine starts so the session is listed in // the /context team view (and targetable for explicit compaction) for // the whole lifetime of its stream. entry := r.registerLiveSessionWithIdleRetry(sess, idleRetryPolicy) go func() { if rootStream { defer r.activeRootStreams.Add(-1) } r.runStreamLoop(ctx, sess, entry, events) }() return r.observe(ctx, sess, events) } // runStreamLoop is the body of RunStream. Pulled out of the anonymous // goroutine so it has a real name in stack traces and is easier to navigate // in editors. func (r *LocalRuntime) runStreamLoop(ctx context.Context, sess *session.Session, liveEntry *liveSessionEntry, events chan Event) { sink := &channelSink{ch: events} // Seed the cagent session ID at the run-loop boundary so any // gateway-bound HTTP call originating from this loop can correlate // back to the originating session. Plumbing happens in // pkg/httpclient/userAgentTransport, gated on `X-Cagent-Forward`. ctx = httpclient.ContextWithSessionID(ctx, sess.ID) r.telemetry.RecordSessionStart(ctx, r.currentAgentName(), sess.ID) // Seed `gen_ai.conversation.id` into baggage at the session // boundary. Every span the runtime, providers, MCP client, RAG, // sandbox, evaluation, hooks, and (downstream) any subprocess // or remote service create from here on will pick it up // automatically without per-helper plumbing — and the value // rides over W3C `baggage` so it crosses MCP / sandbox / // HTTP boundaries too. ctx = genai.WithConversationID(ctx, sess.ID) // A non-interactive session (background_agents via runCollecting, MCP // serve, A2A, evals) has no live UI that can answer an OAuth elicitation, // and runCollecting drops events rather than forwarding them. If a remote // MCP toolset needs first-time OAuth, blocking on an elicitation that // nobody can answer hangs the sub-agent forever (issue #3200): the // connection context is detached with context.WithoutCancel, so not even // cancellation can unblock it. Mark the context so toolset Start() fails // fast with an AuthorizationRequiredError — the same fast-fail the startup // tool probe uses (see EmitStartupInfo) — instead of eliciting. A real user // authorizes such servers from an interactive turn (or transfer_task, which // keeps NonInteractive=false and forwards the dialog to the TUI). if sess.NonInteractive { ctx = tools.WithoutInteractivePrompts(ctx) } // runtime.session is the root span for one stream. gen_ai.* keys // are emitted alongside the legacy `agent` / `session.id` keys // so existing dashboards keep matching while spec-aware tooling // can filter by `gen_ai.conversation.id` and // `cagent.agent.name`. Legacy keys drop out under // OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental. sessionAttrs := []attribute.KeyValue{ attribute.String(genai.AttrConversationID, sess.ID), attribute.String(genai.AttrAgentNameRuntime, r.currentAgentName()), } if genai.EmitLegacyAttributes() { sessionAttrs = append(sessionAttrs, attribute.String("agent", r.currentAgentName()), attribute.String("session.id", sess.ID), ) } ctx, sessionSpan := r.startSpan(ctx, "runtime.session", trace.WithAttributes(sessionAttrs...)) defer sessionSpan.End() // Swap in this stream's events channel for elicitation and save the // previous one so it can be restored on teardown. This allows nested // RunStream calls to temporarily own elicitation without losing the // parent's channel. prevElicitationCh := r.elicitation.swap(events) // streamReason records the exit reason from the final turn so // finalizeEventChannel can surface it in the StreamStoppedEvent. // It is updated by each turn via runTurn (passed by pointer). // // Register the cleanup defer immediately after the swap so every exit // path finalizes, including the early returns below (tool setup // failure, a user_prompt_submit hook signalling termination). Without // this, those early returns leak the events channel: observe's // forwarder goroutine never exits, a `for range RunStream(...)` // consumer hangs forever, and the elicitation bridge is left pointing // at this dead stream's channel. var streamReason string var streamFinishReason chat.FinishReason defer func() { r.finalizeEventChannel(ctx, sess, streamReason, streamFinishReason, prevElicitationCh, events) }() // Unregister from the live-session registry and execute any accepted // explicit-compaction request before the stream lifecycle closes. // Registered after the finalize defer so it runs first (LIFO): an // accepted request is processed before StreamStopped is emitted and // the events channel closes, so it can never be stranded. defer r.finishLiveSession(ctx, liveEntry) // Subscribe this stream to shared-plan change notifications for its // whole lifecycle. Registered once here rather than per iteration, and // the deferred release runs (LIFO) before finalizeEventChannel closes // the events channel — on every exit path, including errors and // cancellation — so no subscription outlives its sink. defer r.subscribePlanChanges(sess, sink)() defer r.subscribeToolsetEvents(sess, sink)() // Routed conversations restart at the entry agent and let before_agent_run // pick the agent that actually runs, before anything is prepared for it. route := r.beginRouting(sess) a := r.resolveSessionAgent(sess) var stop *routeStop if route != nil { a, stop = r.activateAgent(ctx, sess, a, route, sink) } else { stop = r.routingUnsupported(route, a) } if stop != nil { streamReason = r.stopRouting(ctx, sess, a, stop, sink) return } // session_start fires once per RunStream. Its AdditionalContext // (typically the AddEnvironmentInfo env block) is held as transient // extras and threaded into every model call below — never persisted, // to keep the visible transcript clean and the user message tail // stable. sessionStart := r.executeSessionStartHooks(ctx, sess, a, sink) ls := &loopState{ maxIterations: sess.MaxIterations, idleRetry: liveEntry.idleRetry, sessionStartMsgs: sessionStart.messages, sessionStartLegacyMsgs: sessionStart.legacyMessages(), sessionStartSources: sessionStart.sources, route: route, } // Emit team information sink.Emit(TeamInfo(r.agentDetailsFromTeam(ctx), a.Name())) // Surface the run budget from the first frame, before any model call, // so the configured ceilings are visible immediately rather than only // after the first priced turn. A run that fails on its very first call // (e.g. a bad API key) still shows its budget was active. if b := r.currentBudget(); b != nil { sink.Emit(BudgetUsage(sess.ID, a.Name(), b.snapshot())) } ctx = tools.WithHandlerScope(ctx, tools.HandlerScope{ Elicitation: r.elicitationHandler, Sampling: r.samplingHandler, SamplingWithTools: r.samplingWithToolsHandler, OAuthSuccess: func() { sink.Emit(Authorization(tools.ElicitationActionAccept, r.resolveSessionAgent(sess).Name())) }, }) r.emitAgentWarnings(a, sink) for _, name := range r.team.AgentNames() { if configured, err := r.team.Agent(name); err == nil { r.configureToolsetHandlers(configured) } } agentTools, err := r.getTools(ctx, sess, a, sessionSpan, sink, true) if err != nil { sink.Emit(ErrorWithCodeForSession(sess.ID, ErrorCodeToolFailed, fmt.Sprintf("failed to get tools: %v", err))) return } // Record the catalogue size on the session span — answers "how // many tools could this turn actually use?" without having to // walk into per-toolset spans. Stamped after exclusion filters // so the count matches what was offered to the model. sessionSpan.SetAttributes(attribute.Int("cagent.agent.tools.count", len(agentTools))) sink.Emit(ToolsetInfo(len(agentTools), false, a.Name())) messages := sess.GetMessagesWithoutInstructionContext(a) // Sub-sessions (transferred tasks, background agents, skill // sub-sessions) carry a synthesised "Please proceed." message that // no human authored. SendUserMessage is the same flag the runtime // uses to gate the UserMessageEvent, which is exactly the right // signal here too: "a real user prompt is at the tail of the session". if sess.SendUserMessage && len(messages) > 0 { lastMsg := messages[len(messages)-1] sink.Emit(userMessageEvent(lastMsg, sess.ID, sess.ItemCount()-1)) // user_prompt_submit fires once per real user message, after // session_start and before the first model call. if lastMsg.Role == chat.MessageRoleUser { stop, msg, ctxMsgs := r.executeUserPromptSubmitHooks(ctx, sess, a, lastMsg.Content, sink) if stop { slog.WarnContext(ctx, "user_prompt_submit hook signalled run termination", "agent", a.Name(), "session_id", sess.ID, "reason", msg) r.emitHookDrivenShutdown(ctx, a, sess, msg, sink) return } ls.userPromptMsgs = ctxMsgs } } sink.Emit(StreamStarted(sess.ID, a.Name())) if a.HasHarness() { streamReason = r.runHarnessAgent(ctx, sess, a, sink) return } // Response cache lookup. On a hit, replay the stored answer and // skip the model entirely. The matching storage half is // implemented as the cache_response stop-hook builtin (see // runtime/cache.go and getHooksExecutor). if r.tryReplayCachedResponse(ctx, sess, a, sink) { if ls.route == nil { return } // A replayed answer is a completion like any other: it may still route onward. outcome, stop := r.routeCompletion(ctx, sess, a, sess.GetLastAssistantMessageContent(), ls, sink) switch outcome { case routeStopped: streamReason = r.stopRouting(ctx, sess, a, stop, sink) return case routeFinished: return case routeContinued: } } // Initialize consecutive duplicate tool call detector. // Polling and wait tools are expected to be called repeatedly with // identical arguments while a background task is in progress. Exempt // them so they never trigger the loop-termination path. loopThreshold := sess.MaxConsecutiveToolCalls if loopThreshold == 0 { loopThreshold = 5 // default: always active } ls.loopDetector = toolexec.NewLoopDetector(loopThreshold, bgagent.ToolNameViewBackgroundAgent, bgagent.ToolNameListBackgroundAgents, bgagent.ToolNameWaitBackgroundAgents, backgroundjobs.ToolNameViewBackgroundJob, ) for { // Pause the loop here if /pause has been toggled on. Any in-flight // LLM request and its tool calls have already completed. Emit a // RuntimePaused event right before blocking so the TUI can flip its // indicator from "Pausing…" to "Paused". if r.isPaused() { sink.Emit(Paused(sess.ID, a.Name())) if err := r.waitIfPaused(ctx); err != nil { return } } // Execute any explicitly requested (/context) compaction for this // session now, at the iteration boundary: no model turn is in flight, // so the compaction snapshot cannot race with messages being appended // between the snapshot and ApplyCompaction. r.runQueuedCompaction(ctx, liveEntry) a = r.resolveSessionAgent(sess) if ls.route != nil { a, stop = r.activateAgent(ctx, sess, a, ls.route, sink) } else { stop = r.routingUnsupported(ls.route, a) } if stop != nil { streamReason = r.stopRouting(ctx, sess, a, stop, sink) return } // Clear per-tool model override on agent switch so it doesn't // leak from one agent's toolset into another agent's turn. Also // reset prevTurnMadeToolCalls so a new agent's empty first turn is // judged on its own merits, not silenced by the previous agent's // tool activity. if a.Name() != ls.prevAgentName { ls.toolModelOverride = "" ls.prevTurnMadeToolCalls = false ls.structuredOutputReminders = 0 ls.prevAgentName = a.Name() } r.emitAgentWarnings(a, sink) agentTools, err := r.getTools(ctx, sess, a, sessionSpan, sink, true) if err != nil { sink.Emit(ErrorWithCodeForSession(sess.ID, ErrorCodeToolFailed, fmt.Sprintf("failed to get tools: %v", err))) return } // Emit updated tool count. After a ToolListChanged MCP notification // the cache is invalidated, so getTools above re-fetches from the // server and may return a different count. sink.Emit(ToolsetInfo(len(agentTools), false, a.Name())) // Check iteration limit newMax, decision := r.enforceMaxIterations(ctx, sess, a, ls.iteration, ls.maxIterations, sink) if decision == iterationStop { streamReason = StreamStopReasonMaxIterations if ctx.Err() != nil { streamReason = turnEndReasonCanceled } return } ls.maxIterations = newMax // Check the run budget. Placed next to the iteration cap because // both are "should this run keep going" questions answered at the // turn boundary, before a model call is paid for. if r.enforceBudget(ctx, sess, a, sink) == iterationStop { streamReason = turnEndReasonBudgetExceeded return } ls.iteration++ // Exit immediately if the stream context has been cancelled (e.g., Ctrl+C) if err := ctx.Err(); err != nil { slog.DebugContext(ctx, "Runtime stream context cancelled, stopping loop", "agent", a.Name(), "session_id", sess.ID) return } slog.DebugContext(ctx, "Starting conversation loop iteration", "agent", a.Name()) model := a.Model(ctx) // Per-tool model routing: use a cheaper model for this turn // if the previous tool calls specified one, then reset. if ls.toolModelOverride != "" { if overrideModel, err := r.resolveModelRef(ctx, ls.toolModelOverride); err != nil { slog.WarnContext(ctx, "Failed to resolve per-tool model override; using agent default", "model_override", ls.toolModelOverride, "error", err) } else { slog.InfoContext(ctx, "Using per-tool model override for this turn", "agent", a.Name(), "override", overrideModel.ID().String(), "primary", model.ID().String()) model = overrideModel } ls.toolModelOverride = "" } modelID := model.ID() // Notify sidebar of the model for this turn. For rule-based // routing, the actual routed model is emitted from within the // stream once the first chunk arrives. sink.Emit(AgentInfo(a.Name(), modelID.String(), a.Description(), a.WelcomeMessage())) slog.DebugContext(ctx, "Using agent", "agent", a.Name(), "model", modelID.String()) slog.DebugContext(ctx, "Getting model definition", "model_id", modelID.String()) m, err := r.modelsStore.GetModel(ctx, modelID) if err != nil { slog.DebugContext(ctx, "Failed to get model definition", "error", err) } // We can only compact if we know the context limit. // resolveContextLimit prefers provider_opts.context_size when set // (some providers — notably Docker Model Runner — use it to size // the actual inference context), then tries provider discovery and // the models.dev catalogue. The lookup above is reused inside resolveContextLimit // only when context_size isn't supplied; we keep the explicit call // here because m is also passed to [computeMessageCost] for // per-turn cost computation. // resolveContextLimit yields the primary model's window; effectiveContextLimit // caps it to the dedicated compaction model's (smaller) window when one is // configured, so the session operates within a budget the summary call can // always ingest. The capped value drives both the proactive compaction // trigger and the UI context gauge (issue #3241); it equals the primary // window unless a smaller compaction model is configured. contextLimit := r.effectiveContextLimit(ctx, a, r.resolveContextLimit(ctx, model, modelID)) inputTokens, outputTokens := sess.Usage() if contextLimit > 0 && r.sessionCompactionEnabled(a) && compaction.ShouldCompact(inputTokens, outputTokens, 0, contextLimit, a.CompactionThreshold()) { r.compactWithReason(ctx, sess, "", compactionReasonThreshold, sink) } // Drain steer messages queued while idle or before the first model call // (covers idle-window and first-turn-miss races). if sr := r.drainAndEmitSteered(ctx, sess, a, sink); sr.drained { if sr.stop { slog.WarnContext(ctx, "user_steering_messages_submit hook signalled run termination", "agent", a.Name(), "session_id", sess.ID, "reason", sr.stopMsg) r.emitHookDrivenShutdown(ctx, a, sess, sr.stopMsg, sink) return } ls.userPromptMsgs = sr.contextMsgs r.compactIfNeeded(ctx, sess, a, contextLimit, sr.messageCountBefore, sink) } // Proactive compaction may have exhausted the budget since the first check. if r.enforceBudget(ctx, sess, a, sink) == iterationStop { streamReason = turnEndReasonBudgetExceeded return } // Everything from turn_start onwards is wrapped in a closure so a // single deferred turn_end hook fires on every exit path: a normal // stop, a follow-up continue, an error, a hook-driven shutdown, the // loop-detector tripping, ctx cancellation, even a panic. The // closure returns the loop control directive and the reason string // reported via [hooks.Input.Reason]; the deferred dispatch then runs // AFTER the closure body has assigned both, so callers see the same // reason the runtime took. ctrl drives the outer for-loop's // continue-or-exit decision. ctrl := r.runTurn(ctx, sess, a, m, model, modelID, contextLimit, sessionSpan, agentTools, ls, sink) streamReason = ls.exitReason switch ctrl { case turnContinue: continue case turnExit: streamFinishReason = ls.finishReason return } } } // turnControl is what [LocalRuntime.runTurn] reports back to the outer // run-stream loop: continue to the next iteration, or exit the loop // entirely. break and return are equivalent here because the loop is // the last statement in runStreamLoop, so we collapse them into one. type turnControl int const ( // turnContinue — outer loop should re-iterate (e.g. follow-up, // drained steered, retry after stream error, more tool calls). turnContinue turnControl = iota // turnExit — outer loop should stop and let runStreamLoop’s // deferred cleanup run (normal stop, error, hook-blocked, // loop-detected, ctx cancelled). turnExit ) // loopState bundles the mutable per-RunStream state that persists across // iterations. Previously these were individual local variables in // runStreamLoop and pointer parameters of runTurn; grouping them in a // struct keeps the function signatures small and makes it trivial to add // new per-stream tracking (cost ceiling, token budget, turn timing) // without touching any signature. type loopState struct { iteration int maxIterations int overflowCompactions int toolModelOverride string prevAgentName string loopDetector *toolexec.LoopDetector sessionStartMsgs []chat.Message sessionStartLegacyMsgs []chat.Message sessionStartSources []session.InstructionSource userPromptMsgs []chat.Message exitReason string finishReason chat.FinishReason // structuredOutputReminders counts the transient reminders injected // after a tool-mode turn stopped without calling the internal output // tool. Bounded by maxStructuredOutputReminders; reset on agent switch // so it never carries across agents. structuredOutputReminders int // prevTurnMadeToolCalls reports whether the immediately preceding // turn emitted tool calls. Used to classify an empty trailing turn: // a clean stop right after tool work is benign (the model already // did its job and has nothing left to say — common at the tail of a // fork-skill sequence), not the rate-limit / token-cap case the // empty-response warning would otherwise imply. Reset on agent switch // so it never carries across agents. prevTurnMadeToolCalls bool // idleRetry is shared by every turn and fallback attempt in this child run. idleRetry *idleStreamRetryAllowance // route is non-nil only for hook-routed conversations. route *routeState } // emptyTurnWarning classifies an empty assistant turn (no content, no tool // calls) and returns the user-facing warning message, or "" when the turn is // benign and should stay silent. Known cases: // - benign: a clean stop (finish_reason "stop") right after a tool-call turn // — the model already did its work and had nothing left to say (common at // the tail of a fork-skill sequence, e.g. commit / open-pr). The real // answer is the previous assistant message, so a UI warning would be noise. // Only "stop" qualifies: a "length" finish after tool calls means the reply // was truncated by the output token limit and must still warn. // - reasoning-only: thinking-mode models (e.g. Qwen3 via // openai_chatcompletions) that stream only reasoning tokens and then stop // or hit the output token limit, leaving visible content empty (see #3145). // - otherwise: an empty stream from a rate-limited or token-capped provider. // // Refusals are handled separately by the caller and never reach here. func emptyTurnWarning(res streamResult, prevTurnMadeToolCalls bool, modelID string, reason chat.FinishReason) string { switch { case reason == chat.FinishReasonStop && prevTurnMadeToolCalls: return "" case strings.TrimSpace(res.ReasoningContent) != "": return fmt.Sprintf( "Model %s produced only reasoning and no reply (stop reason: %s). "+ "Thinking-mode models can emit reasoning tokens without a final answer; "+ "the reasoning is not used as the response.", modelID, reason, ) default: return fmt.Sprintf( "Model %s returned an empty response (stop reason: %s). "+ "This usually means the provider rate-limited the request or the output token limit was reached.", modelID, reason, ) } } // runTurn performs one iteration of the run-stream loop, from // turn_start onwards. Wrapping the body in its own function exists for // one reason: a deferred call can fire turn_end on every exit path — a // normal stop, an error from handleStreamError, a hook-driven // shutdown, the loop detector, context cancellation, even a panic — // without sprinkling explicit dispatch calls at every return / break / // continue. endReason is captured by reference so each branch can set // it before falling out; the deferred call reads it AFTER the body has // assigned the final value. // // The outer loop owns persistent per-stream state via ls ([loopState]); // per-turn state that needs to survive into the next iteration // (overflowCompactions, toolModelOverride) is mutated through the // shared loopState pointer. func (r *LocalRuntime) runTurn( ctx context.Context, sess *session.Session, a *agent.Agent, m *modelsdev.Model, model provider.Provider, modelID modelsdev.ID, contextLimit int64, sessionSpan trace.Span, agentTools []tools.Tool, ls *loopState, events EventSink, ) turnControl { ls.finishReason = "" // turnStart bounds this turn's active time, attributed to the agent // below so the run budget can show how long each agent spent working. turnStart := r.now() streamAttrs := []attribute.KeyValue{ attribute.String(genai.AttrConversationID, sess.ID), attribute.String(genai.AttrAgentNameRuntime, a.Name()), } if genai.EmitLegacyAttributes() { streamAttrs = append(streamAttrs, attribute.String("agent", a.Name()), attribute.String("session.id", sess.ID), ) } streamCtx, streamSpan := r.startSpan(ctx, "runtime.stream", trace.WithAttributes(streamAttrs...)) // streamSpan ends inline at the natural points (success path before // recordAssistantMessage, error path after handleStreamError) so its // duration tracks the model call only, not the whole iteration. The // boolean prevents a double-End on paths that already closed it. spanEnded := false endStreamSpan := func() { if !spanEnded { streamSpan.End() spanEnded = true } } defer endStreamSpan() // endReason is set by every exit branch below and read by the // deferred turn_end dispatch. Default = normal so a clean fall- // through (model produced output, more tool calls, no hook // blocked) reports "continue" or "normal" depending on which // branch ran last. Branches overwrite this before returning. endReason := turnEndReasonNormal defer func() { if ctxErr := ctx.Err(); ctxErr != nil && endReason == turnEndReasonNormal { // Context cancellation is detected after the fact: a // branch that returned early because of ctx.Err overrides // the default, but a panic-recovered branch may not have // had the chance, so re-check here. endReason = turnEndReasonCanceled } // Use a non-cancellable context so turn_end runs even when // the stream was interrupted (Ctrl+C, parent cancellation), // matching the same guarantee session_end has at the // finalizeEventChannel level. r.executeTurnEndHooks(context.WithoutCancel(ctx), sess, a, endReason, events) ls.exitReason = endReason }() // Run turn_start hooks before assembly so dynamic context can be diffed // against what the model already knows. Changes extend the conversation; // they never rewrite the frozen instruction prefix. turnStartMsgs := r.executeTurnStartHooks(ctx, sess, a, events) // Pending tool-mode structured-output reminder rides with the transient // system extras: threaded per call, never persisted as a user message. reminderMsgs := ls.structuredOutputReminderMessages() legacyExtras := slices.Concat(ls.sessionStartLegacyMsgs, ls.userPromptMsgs, turnStartMsgs.legacyMessages(), reminderMsgs) sources := instructionSources(ls.sessionStartMsgs, ls.userPromptMsgs, turnStartMsgs, ls.sessionStartSources...) sources = append(sources, instructionSource("runtime/structured-output", "structured-output reminder", reminderMsgs)) messages, err := r.messagesWithDynamicContext(ctx, sess, a, sources, legacyExtras) if err != nil { if ctx.Err() != nil { endReason = turnEndReasonCanceled return turnExit } r.emitHookDrivenShutdown(ctx, a, sess, err.Error(), events) endReason = turnEndReasonHookBlocked return turnExit } slog.DebugContext(ctx, "Retrieved messages for processing", "agent", a.Name(), "message_count", len(messages)) // before_llm_call hooks fire just before the model is invoked. // A terminating verdict (e.g. from the max_iterations builtin) // stops the run loop here, before any tokens are spent. Hooks // may also rewrite the outgoing messages by returning // HookSpecificOutput.UpdatedMessages — the redact_secrets // builtin uses this to scrub secrets from chat content before // the LLM ever sees it. The rewrite happens BEFORE the // runtime's Go-only message transforms so a hook that drops a // message (e.g. a custom "strip system reminders") doesn't get // silently overridden by a transform later in the chain. stop, msg, rewritten := r.executeBeforeLLMCallHooks(ctx, sess, a, modelID.String(), ls.iteration, messages) if stop { slog.WarnContext(ctx, "before_llm_call hook signalled run termination", "agent", a.Name(), "session_id", sess.ID, "reason", msg) r.emitHookDrivenShutdown(ctx, a, sess, msg, events) endStreamSpan() endReason = turnEndReasonHookBlocked return turnExit } if rewritten != nil { messages = rewritten } // Runtime message transforms run inside fallback.execute so each attempt // uses the capabilities of the provider that will receive it. // Try primary model with fallback chain if configured. The idle retry // admission callback belongs to this loop invocation so a denial can stop // this exact session before generic model-error handling runs. agentTools = r.toolDeferrals.MarkAt(sess.ID, lastToolCallID(messages), agentTools) admitIdleRetry := func() error { if r.enforceBudget(ctx, sess, a, events) == iterationStop { return budgetAdmissionError{} } return nil } res, usedModel, err := r.fallback.execute(streamCtx, a, model, messages, agentTools, sess, events, ls.idleRetry, admitIdleRetry) if _, ok := errors.AsType[budgetAdmissionError](err); ok { endStreamSpan() endReason = turnEndReasonBudgetExceeded return turnExit } if err != nil { outcome := r.handleStreamError(ctx, sess, a, err, contextLimit, &ls.overflowCompactions, streamSpan, events) endStreamSpan() endReason = turnEndReasonError if outcome == streamErrorRetry { return turnContinue } return turnExit } if usedModel != nil { agentTools = toolsForProvider(ctx, usedModel, agentTools) if usedModel.ID() != modelID { slog.InfoContext(ctx, "Used fallback model", "agent", a.Name(), "primary", modelID.String(), "used", usedModel.ID().String()) modelID = usedModel.ID() m, err = r.modelsStore.GetModel(ctx, modelID) if err != nil { slog.DebugContext(ctx, "Failed to get fallback model definition", "model_id", modelID.String(), "error", err) m = nil } events.Emit(AgentInfo(a.Name(), modelID.String(), a.Description(), a.WelcomeMessage())) } // Fallbacks may share an ID but have different endpoint pricing overrides. m = applyConfigCost(m, modelID, usedModel.BaseConfig().ModelConfig.Cost) } // A successful model call resets the overflow compaction counter. ls.overflowCompactions = 0 // Compute the per-turn cost once, here, so the exact same value // reaches telemetry, the after_llm_call hook payload and the recorded // assistant message — the hook's cost is therefore guaranteed to // equal the cost the session bills for this turn. It is nil when // the turn cannot be priced (no usage, or a model with no pricing // table); see computeMessageCost. msgCost := computeMessageCost(res.Usage, m) if res.Usage != nil { modelName := "unknown" if m != nil { modelName = m.Name } var cost float64 if msgCost != nil { cost = *msgCost } r.telemetry.RecordTokenUsage(ctx, modelName, res.Usage.PromptTokens(), res.Usage.OutputTokens, cost) } // Fold this turn into the run budget from the same computed value, so // what the ceiling counts can never disagree with what the session // bills or what after_llm_call reports. Sub-sessions reach the root // run's tracker here, which is how delegated spend lands on the // parent's budget. The turn's duration is attributed to this agent. r.recordBudget(sess, a, res.Usage, msgCost, r.now().Sub(turnStart), events) // after_llm_call hooks fire on success only; failed calls // fire on_error above. The assistant text content is passed // via stop_response, matching the stop event's payload, so // handlers can reuse the same parsing. Usage and Cost carry the // per-turn billing data for sidecar cost ledgers. r.executeAfterLLMCallHooks(ctx, sess, a, modelID.String(), res.Content, res.Usage, msgCost) streamSpan.SetAttributes( attribute.Int("tool.calls", len(res.Calls)), attribute.Int("content.length", len(res.Content)), attribute.Bool("stopped", res.Stopped), ) endStreamSpan() slog.DebugContext(ctx, "Stream processed", "agent", a.Name(), "tool_calls", len(res.Calls), "content_length", len(res.Content), "stopped", res.Stopped) // Surface refusals (e.g. Anthropic safety classifiers): the API returns a // successful, often empty response that would otherwise look like the model // silently said nothing. emptyTurn := strings.TrimSpace(res.Content) == "" && len(res.Calls) == 0 switch { case res.FinishReason == chat.FinishReasonRefusal: slog.WarnContext(ctx, "Model refused to respond", "agent", a.Name(), "model", modelID.String(), "session_id", sess.ID) events.Emit(Warning(fmt.Sprintf("Model %s refused to respond (stop reason: refusal).", modelID.String()), a.Name())) case emptyTurn && len(res.Media) > 0: slog.DebugContext(ctx, "Media-only assistant turn", "agent", a.Name(), "model", modelID.String(), "media_items", len(res.Media), "session_id", sess.ID) case emptyTurn: // Retained usage records carry no visible reply; explain the empty turn. reason := res.FinishReason if reason == "" { reason = chat.FinishReasonNull } warning := emptyTurnWarning(res, ls.prevTurnMadeToolCalls, modelID.String(), reason) if warning == "" { // Benign trailing stop after tool work: log at debug for // traceability without alarming the user or noising up WARN logs. slog.DebugContext(ctx, "Empty trailing turn after tool calls (benign natural stop)", "agent", a.Name(), "model", modelID.String(), "finish_reason", string(reason), "session_id", sess.ID) } else { slog.WarnContext(ctx, "Empty assistant turn", "agent", a.Name(), "model", modelID.String(), "finish_reason", string(reason), "reasoning_length", len(res.ReasoningContent), "session_id", sess.ID) events.Emit(Warning(warning, a.Name())) } } msgUsage := r.recordAssistantMessage(ctx, sess, a, res, agentTools, modelID.String(), msgCost, events) usage := SessionUsage(sess, contextLimit, a.CompactionThreshold()) usage.LastMessage = msgUsage events.Emit(NewTokenUsageEvent(sess.ID, a.Name(), usage)) if res.ProviderState != nil && res.ProviderState.CacheDiagnostics != nil { d := res.ProviderState.CacheDiagnostics if d.MissedInputTokens > 0 { events.Emit(Warning(fmt.Sprintf("Prompt cache miss: %s (%d input tokens).", d.Reason, d.MissedInputTokens), a.Name())) } } if shouldWarnOnCacheMiss(sess, msgUsage) { events.Emit(Warning("This agent turn did not use the prompt cache.", a.Name())) } // Record the message count before tool calls so we can // measure how much content was added by tool results. messageCountBeforeTools := sess.OwnMessageCount() // Intercept internal structured-output calls before dispatch: they bypass // approval and the user's tool hooks, and an exclusive valid call // finalizes the turn (res is rewritten so the stop path below runs on // the validated JSON). dispatchCalls, soFinalized := r.handleStructuredOutputCalls(ctx, sess, a, &res, agentTools, modelID.String(), events) stopRun, stopMsg := r.processToolCalls(ctx, sess, a, dispatchCalls, agentTools, events) if stopRun && r.enforceBudget(ctx, sess, a, events) == iterationStop { endReason = turnEndReasonBudgetExceeded return turnExit } // Re-probe toolsets after tool calls: an install/setup tool call may // have made a previously-unavailable LSP or MCP connectable. reprobe() // calls ensureToolSetsAreStarted, emits recovery notices, and updates // the TUI tool-count immediately. // // The new tools are picked up by the next iteration's getTools() call // at the top of this loop, so the model sees them on its very next // response — within the same user turn, without requiring a new user // message. reprobe's return value is intentionally discarded here; // the top-of-loop getTools() is the authoritative source. if len(res.Calls) > 0 { r.reprobe(ctx, sess, a, agentTools, sessionSpan, events) } // Check for degenerate tool call loops if ls.loopDetector.Record(res.Calls) { toolName := "unknown" if len(res.Calls) > 0 { toolName = res.Calls[0].Function.Name } consecutive := ls.loopDetector.Consecutive() slog.WarnContext(ctx, "Repetitive tool call loop detected", "agent", a.Name(), "tool", toolName, "consecutive", consecutive, "session_id", sess.ID) errMsg := fmt.Sprintf( "Agent terminated: detected %d consecutive identical calls to %s. "+ "This indicates a degenerate loop where the model is not making progress.", consecutive, toolName, ) // Mark the session span as Error so loop-termination shows up // in trace status / error-rate dashboards instead of blending // in with normal completions. sessionSpan.SetAttributes( attribute.String("error.type", "loop_detected"), attribute.String("cagent.session.terminated_by", "loop_detector"), attribute.Int("cagent.loop.consecutive_calls", consecutive), ) sessionSpan.SetStatus(codes.Error, errMsg) events.Emit(ErrorWithCodeForSession(sess.ID, ErrorCodeLoopDetected, errMsg)) r.notifyError(ctx, a, sess.ID, errMsg) ls.loopDetector.Reset() endReason = turnEndReasonLoopDetected return turnExit } // post_tool_use hook signalled run termination via a deny // verdict (decision="block" / continue=false / exit 2). // User-authored hooks can use this to stop the run; the // runtime fans out the standard Error / notification / // on_error stanzas before exiting. if stopRun { slog.WarnContext(ctx, "post_tool_use hook signalled run termination", "agent", a.Name(), "session_id", sess.ID, "reason", stopMsg) r.emitHookDrivenShutdown(ctx, a, sess, stopMsg, events) endReason = turnEndReasonHookBlocked return turnExit } // Record whether this turn made tool calls so the next iteration can // classify a trailing empty turn as a benign post-tool stop rather than // a rate-limit / token-cap event. Set after processToolCalls so it // reflects the turn just completed. ls.prevTurnMadeToolCalls = len(res.Calls) > 0 // Record per-toolset model override for the next LLM turn. ls.toolModelOverride = toolexec.ResolveModelOverride(res.Calls, agentTools) // Drain steer messages that arrived during tool calls. if sr := r.drainAndEmitSteered(ctx, sess, a, events); sr.drained { if sr.stop { slog.WarnContext(ctx, "user_steering_messages_submit hook signalled run termination", "agent", a.Name(), "session_id", sess.ID, "reason", sr.stopMsg) r.emitHookDrivenShutdown(ctx, a, sess, sr.stopMsg, events) endReason = turnEndReasonHookBlocked return turnExit } ls.userPromptMsgs = sr.contextMsgs r.compactIfNeeded(ctx, sess, a, contextLimit, messageCountBeforeTools, events) endReason = turnEndReasonSteered return turnContinue } if res.Stopped { // Tool-mode structured output never accepts a plain-text stop as the // final answer: remind the model (bounded) or fail with a coded error. if structuredOutputEnabled(sess, a) { if !soFinalized { ctrl, reason := r.structuredOutputStop(ctx, sess, a, ls, events) endReason = reason return ctrl } // Accepted result: give a potential next turn (follow-up, // steered continuation) a fresh reminder budget. ls.structuredOutputReminders = 0 } if a.StructuredOutput() == nil && res.Content != "" && len(res.Media) == 0 && hasExplicitImageGenerationIntent(sess.GetLastUserMessageContent()) { events.Emit(Warning(missingGeneratedImageWarning, a.Name())) } slog.DebugContext(ctx, "Conversation stopped", "agent", a.Name()) r.executeStopHooks(ctx, sess, a, res.Content, events) // --- ROUTED COMPLETION: after_agent_complete or forced handoff --- // Routed conversations decide continuation here, for every // successful completion, with session-local agent switching. if ls.route != nil { outcome, stop := r.routeCompletion(ctx, sess, a, res.Content, ls, events) switch outcome { case routeStopped: endReason = r.stopRouting(ctx, sess, a, stop, events) return turnExit case routeContinued: endReason = turnEndReasonContinue return turnContinue case routeFinished: } } else if next := a.ForceHandoff(); next != nil && (sess.AgentName == "" || sess.AllowsAgentHandoffs()) { // --- FORCED HANDOFF: deterministic routing on natural stop --- // When the agent's config names a force_handoff target, the // runtime intercepts the finish state and routes the conversation // to that agent without involving the LLM. Hard-pinned background // tasks stay put; skill forks route handoffs within their own session. r.applyForceHandoff(ctx, sess, a, next) endReason = turnEndReasonForceHandoff return turnContinue } // Re-check steer queue: closes the race between the mid-loop drain and this stop. // Routed conversations defer it to this boundary and start a new invocation. if sr := r.drainSteeredQueueAtBoundary(ctx, sess, a, ls, events); sr.drained { if sr.stop { slog.WarnContext(ctx, "user_steering_messages_submit hook signalled run termination", "agent", a.Name(), "session_id", sess.ID, "reason", sr.stopMsg) r.emitHookDrivenShutdown(ctx, a, sess, sr.stopMsg, events) endReason = turnEndReasonHookBlocked return turnExit } ls.userPromptMsgs = sr.contextMsgs r.restartRouting(sess, ls) r.compactIfNeeded(ctx, sess, a, contextLimit, messageCountBeforeTools, events) endReason = turnEndReasonSteered return turnContinue } // --- FOLLOW-UP: end-of-turn injection --- // Pop exactly one follow-up message. Unlike steered // messages, follow-ups are plain user messages that start // a new turn — the model sees them as fresh input, not a // mid-stream interruption. Each follow-up gets a full // undivided agent turn. if followUp, ok := r.followUpQueue.Dequeue(ctx); ok { userMsg := session.UserMessage(followUp.Content, followUp.MultiContent...) userMsg.Message.MessageID = uuid.NewV4().String() pos := sess.AddMessage(userMsg) events.Emit(userMessageEvent(userMsg.Message, sess.ID, pos)) stop, msg, ctxMsgs := r.executeUserFollowupSubmitHooks(ctx, sess, a, followUp.Content, events) if stop { slog.WarnContext(ctx, "user_followup_submit hook signalled run termination", "agent", a.Name(), "session_id", sess.ID, "reason", msg) r.emitHookDrivenShutdown(ctx, a, sess, msg, events) endReason = turnEndReasonHookBlocked return turnExit } ls.userPromptMsgs = ctxMsgs r.restartRouting(sess, ls) r.compactIfNeeded(ctx, sess, a, contextLimit, messageCountBeforeTools, events) endReason = turnEndReasonContinue return turnContinue // re-enter the loop for a new turn } endReason = turnEndReasonNormal ls.finishReason = res.FinishReason if soFinalized { ls.finishReason = chat.FinishReasonStop } return turnExit } r.compactIfNeeded(ctx, sess, a, contextLimit, messageCountBeforeTools, events) endReason = turnEndReasonContinue return turnContinue } // Run executes the agent loop synchronously and returns the final session // messages. This is a convenience wrapper around RunStream for non-streaming // callers. func (r *LocalRuntime) Run(ctx context.Context, sess *session.Session) ([]session.Message, error) { ctx, cancel := context.WithCancel(ctx) events := r.RunStream(ctx, sess) // Cancel before draining so error returns cannot strand the producer. defer func() { cancel() for range events { } }() for event := range events { if errEvent, ok := event.(*ErrorEvent); ok { return nil, fmt.Errorf("%s", errEvent.Error) } } return sess.GetAllMessages(), nil } // applyConfigCost overlays a config-declared price table (USD per 1M tokens) // onto the catalogue entry, returning m untouched when there is no override. // It never mutates m: the store caches entries shared across sessions. When // the model is uncatalogued (m == nil) a minimal entry is synthesized so the // call is still priced — this is the escape hatch for custom endpoints that // models.dev cannot describe. func applyConfigCost(m *modelsdev.Model, id modelsdev.ID, cost *latest.CostConfig) *modelsdev.Model { if cost == nil { return m } var out modelsdev.Model if m != nil { out = *m } else { out.Name = id.Model } out.Cost = &modelsdev.Cost{ Input: cost.Input, Output: cost.Output, CacheRead: cost.CacheRead, CacheWrite: cost.CacheWrite, } return &out } // computeMessageCost prices the whole call at the tier selected by its prompt size. // Nil means unpriced; a non-nil zero means free. Messages and hooks share this value. func computeMessageCost(usage *chat.Usage, m *modelsdev.Model) *float64 { if usage == nil || m == nil || m.Cost == nil { return nil } rates := m.Cost.RatesFor(usage.PromptTokens()) return new((float64(usage.InputTokens)*rates.Input + float64(usage.OutputTokens)*rates.Output + float64(usage.CachedInputTokens)*rates.CacheRead + float64(usage.CacheWriteTokens)*rates.CacheWrite) / 1e6) } func shouldWarnOnCacheMiss(sess *session.Session, usage *MessageUsage) bool { if !userconfig.Get().CacheMissWarningsEnabled() || usage == nil || usage.CachedInputTokens > 0 { return false } if usage.InputTokens == 0 && usage.CacheWriteTokens == 0 { return false } assistantMessages := 0 for _, message := range sess.OwnMessages() { if message.Message.Role != chat.MessageRoleAssistant { continue } assistantMessages++ if assistantMessages > 1 { return true } } return false } // recordAssistantMessage adds the model's response to the session and returns // per-message usage information for the token-usage event. Empty responses with // usage are kept for accounting; session prompt assembly filters them out. // cost is the precomputed per-turn cost (see computeMessageCost); nil records // as 0, matching the previous "no pricing data" behaviour. func (r *LocalRuntime) recordAssistantMessage( ctx context.Context, sess *session.Session, a *agent.Agent, res streamResult, agentTools []tools.Tool, modelID string, cost *float64, events EventSink, ) *MessageUsage { if strings.TrimSpace(res.Content) == "" && len(res.Calls) == 0 && len(res.Media) == 0 { if res.Usage == nil { slog.DebugContext(ctx, "Skipping empty assistant message without usage", "agent", a.Name()) return nil } res.Content = "" } // Sanitize tool call names before persisting. A model may hallucinate // attribute-style syntax into the name field (e.g. `view_file" path="..."`); // strict providers like AWS Bedrock reject names outside [a-zA-Z0-9_-]{1,64} // with a non-retriable ValidationException when the poisoned name is replayed // in conversation history. calls := res.Calls for i, tc := range calls { if !validToolNameRe.MatchString(tc.Function.Name) { safe := sanitizeToolCallName(tc.Function.Name) slog.WarnContext(ctx, "Sanitizing malformed tool call name", "agent", a.Name(), "original", tc.Function.Name, "sanitized", safe, ) calls[i].Function.Name = safe } } // Resolve tool definitions for the tool calls. var toolDefs []tools.Tool if len(calls) > 0 { toolMap := make(map[string]tools.Tool, len(agentTools)) for _, t := range agentTools { toolMap[t.Name] = t } for _, call := range calls { if def, ok := toolMap[call.Function.Name]; ok { toolDefs = append(toolDefs, def) } } } // The per-turn cost was computed once in runTurn and threaded in; // nil means the response could not be priced and records as 0, // preserving the previous "no pricing data" behaviour. When the model // is absent from the catalogue (or carries no price table) the cost is // silently 0 even though tokens were spent; warn so the otherwise- // invisible "uncatalogued model bills $0" leak is at least observable // in logs and any spend guardrail built on top of it. var messageCost float64 if cost != nil { messageCost = *cost } else if usageHasTokens(res.Usage) { slog.WarnContext(ctx, "Model is missing from the pricing catalogue; recording $0 cost despite token usage", "agent", a.Name(), "model", modelID, "input_tokens", res.Usage.InputTokens, "output_tokens", res.Usage.OutputTokens, "cached_input_tokens", res.Usage.CachedInputTokens, "cache_write_tokens", res.Usage.CacheWriteTokens) } messageModel := modelID assistantMessage := chat.Message{ MessageID: res.MessageID, Role: chat.MessageRoleAssistant, Content: res.Content, ReasoningContent: res.ReasoningContent, ThinkingSignature: res.ThinkingSignature, ThoughtSignature: res.ThoughtSignature, OpenAIResponse: res.OpenAIResponse, ToolCalls: calls, ToolDefinitions: toolDefs, CreatedAt: r.now().Format(time.RFC3339), Usage: res.Usage, Model: messageModel, Cost: messageCost, FinishReason: res.FinishReason, } // Sealed after name sanitization so the hash reflects what is persisted. assistantMessage.AttachProviderState(res.ProviderState) if len(res.Media) > 0 { mediaParts := r.materializeGeneratedMedia(ctx, sess, res.Media, a.Name(), events) if len(mediaParts) > 0 && strings.TrimSpace(res.Content) != "" { // Providers that treat MultiContent as authoritative once it is // non-empty (e.g. pkg/model/provider/oaistream, which reads ONLY // MultiContent's text-type parts and ignores .Content entirely // in that case) would otherwise silently drop the assistant's // text the moment a document part is present alongside it. assistantMessage.MultiContent = append(assistantMessage.MultiContent, chat.MessagePart{ Type: chat.MessagePartTypeText, Text: res.Content, }) } assistantMessage.MultiContent = append(assistantMessage.MultiContent, mediaParts...) } addAgentMessage(sess, a, &assistantMessage, events) slog.DebugContext(ctx, "Added assistant message to session", "agent", a.Name(), "total_messages", sess.AllMessageCount()) // Build per-message usage for the event. if res.Usage == nil { return nil } msgUsage := &MessageUsage{ Usage: *res.Usage, Cost: messageCost, Model: messageModel, FinishReason: res.FinishReason, } return msgUsage } // validToolNameRe matches the subset of tool names providers like AWS Bedrock // accept (pattern [a-zA-Z0-9_-]+, 1–64 chars). Names that fall outside this // set are sanitized by sanitizeToolCallName before being persisted. var validToolNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) // sanitizeToolCallName returns a provider-safe version of a model-emitted tool // call name by keeping only the leading [a-zA-Z0-9_-]+ run and capping at 64 // chars. A model may hallucinate attribute-style syntax into the name field // (e.g. `view_file" path="foo.php" ...`); truncating at the first illegal // character recovers the intended name and prevents the poisoned string from // being replayed to strict providers. Falls back to "unknown_tool" when the // entire name is invalid. func sanitizeToolCallName(name string) string { end := strings.IndexFunc(name, func(r rune) bool { return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' && r != '-' }) if end >= 0 { name = name[:end] } if len(name) > 64 { name = name[:64] } if name == "" { return "unknown_tool" } return name } // materializeGeneratedMedia writes each streamed [chat.MediaDelta] into the // owning session's workspace (the effective WorkingDir resolved via // [session.ResolveWorkingDir]) through [workspacemedia.Write] and returns // the corresponding document parts, so the persisted assistant message // keeps only a relative, owner-qualified workspace reference // ([chat.ArtifactRootWorkspace]) rather than raw bytes. sess.ID becomes the // reference's permanent owner (see chat.DocumentSource) — it never changes // even if this message is later copied into a branched or forked session. // Each successful write is also recorded in the session store's // generated-media manifest ([session.GeneratedMediaManifest]), the trust // anchor a resolver must consult before reading a workspace path back. // // The requested filename is the prompt-directed path when one exists // ([chat.MediaDelta.RequestedPath], populated by media-file marker pairing // in handleStream, or — for a turn whose single blob no marker named — by // deterministic explicit-filename extraction from the triggering user // message, see [applyUserPromptRequestedPath]), otherwise the sanitized // provider display name, otherwise a generic "generated-N"; the writer owns MIME/extension // correction and collision suffixing, and the part persists the exact final // path it returns. A corrected extension additionally surfaces a bounded // user-visible notice naming the final path. A prompt-directed path that // escapes the workspace (absolute, "..", or "~"-rooted) is redirected into // the workspace root under the sanitized basename with a bounded warning. // // When no workspace root is available (no provenance anywhere in the parent // chain, or a malformed stored value) every item fails with the same // per-item warning contract as a write failure — there is deliberately no // data-dir fallback, so generated files never land outside the workspace. // // A materialization failure drops that one media item, logs the detailed // error (including the workspace root) to the debug log only, and emits a // runtime [WarningEvent] carrying nothing but safe display metadata — the // exact 1-based failed item index and total batch count, the sanitized MIME // type, the sanitized provider-supplied name (or [fallbackDisplayName] // when that name is empty, whitespace-only, or missing — never omitted, // exactly like the strip_generated_media.go placeholder), and a fixed // classified reason from [mediaSaveFailureReason] (a retry-with-debug // hint when the cause is unclassified, never raw error text) — so the failure // is observable to the user/caller without leaking the absolute workspace // path or a raw OS error (which could contain that path) into a surface a // user might paste into a bug report or share screen. Both the name AND the // MIME type are provider-supplied, untrusted strings — sanitizeMimeType // (shared with strip_generated_media.go's placeholder text) strips control // characters and newlines the same way chat.SanitizeDisplayName does for // the name, applies the same [chat.MaxSanitizedFieldBytes] field bound, and // falls back to [fallbackMimeType] for empty/invalid input. Every formatted // warning/notice is additionally capped at [maxPlaceholderOrWarningBytes]. // Only the sanitized MIME type is ever persisted into the resulting // [chat.Document]. One item's failure must not affect a sibling that saves // successfully in the same reply, and must not lose the (already generated) // accompanying text either. func (r *LocalRuntime) materializeGeneratedMedia(ctx context.Context, sess *session.Session, media []chat.MediaDelta, agentName string, events EventSink) []chat.MessagePart { // Runs after marker pairing (handleStream already filled RequestedPath // for marker-named blobs) and before any write, so marker precedence and // the untrusted-path pipeline below apply unchanged. applyUserPromptRequestedPath(media, sess.GetLastUserMessageContent()) root, rootErr := session.ResolveWorkingDir(ctx, sess, r.sessionLookup()) if rootErr != nil { slog.DebugContext(ctx, "No workspace root for generated media; dropping every media item, keeping the rest of the turn", "agent", agentName, "session_id", sess.ID, "error", rootErr) } else { // Seed the resolver cache so live inline rendering of this turn's // media does not have to re-resolve the root from the store. r.generatedFiles.setRoot(sess.ID, root) } parts := make([]chat.MessagePart, 0, len(media)) for i, m := range media { safeName := chat.SanitizeDisplayName(m.Name) safeMimeType := sanitizeMimeType(m.MimeType) warnItemFailed := func(err error) { slog.DebugContext(ctx, "Failed to materialize generated media into the workspace; dropping it, keeping the rest of the turn", "agent", agentName, "session_id", sess.ID, "workspace_root", root, "mime_type", m.MimeType, "index", i+1, "error", err) if events == nil { return } displayName := safeName if displayName == "" { displayName = fallbackDisplayName } warning := fmt.Sprintf("Failed to save generated media item %d/%d (%s, %s). %s", i+1, len(media), safeMimeType, displayName, mediaSaveFailureReason(err)) events.Emit(Warning(chat.TruncateUTF8Bytes(warning, maxPlaceholderOrWarningBytes), agentName)) } if rootErr != nil { warnItemFailed(rootErr) continue } res, err := r.writeGeneratedMedia(generatedMediaItem{ workspaceRoot: root, requestedPath: m.RequestedPath, providerName: safeName, genericName: fmt.Sprintf("generated-%d", i+1), data: m.Data, mimeType: m.MimeType, agentName: agentName, index: i + 1, total: len(media), }, events) if err != nil { warnItemFailed(err) continue } manifestRecorded := true if err := r.recordGeneratedFile(ctx, sess.ID, chat.ArtifactRootWorkspace, res.RelPath, safeMimeType); err != nil { manifestRecorded = false // Keep the saved file, but warn that missing manifest authorization prevents display. slog.DebugContext(ctx, "Failed to record generated media in the manifest; the file was written but may not display inline", "agent", agentName, "session_id", sess.ID, "rel_path", res.RelPath, "error", err) if events != nil { warning := fmt.Sprintf("Saved generated media %s but could not record it for display; it may not render inline. %s", res.RelPath, retryWithDebugAdvice) events.Emit(Warning(chat.TruncateUTF8Bytes(warning, maxPlaceholderOrWarningBytes), agentName)) } } if manifestRecorded { if err := r.recordGeneratedBlob(ctx, sess.ID, res.RelPath, m.Data); err != nil { slog.DebugContext(ctx, "Failed to store portable generated media; keeping the workspace file", "agent", agentName, "session_id", sess.ID, "rel_path", res.RelPath, "error", err) if events != nil { warning := generatedBlobWarning(res.RelPath) events.Emit(Warning(chat.TruncateUTF8Bytes(warning, maxPlaceholderOrWarningBytes), agentName)) } } } if res.ExtensionCorrected && events != nil { notice := fmt.Sprintf("Saved generated media as %s: the requested extension %q does not match the returned %s data", res.RelPath, res.RequestedExtension, safeMimeType) events.Emit(Warning(chat.TruncateUTF8Bytes(notice, maxPlaceholderOrWarningBytes), agentName)) } parts = append(parts, chat.MessagePart{ Type: chat.MessagePartTypeDocument, Document: &chat.Document{ Name: path.Base(res.RelPath), MimeType: safeMimeType, Size: m.Size, Source: chat.DocumentSource{ ArtifactPath: res.RelPath, ArtifactRoot: chat.ArtifactRootWorkspace, ArtifactOwnerSessionID: sess.ID, }, }, }) } return parts } // sessionLookup adapts the runtime's session store to [session.Lookup] for // parent-chain WorkingDir resolution; nil when no store is configured. func (r *LocalRuntime) sessionLookup() session.Lookup { if r.sessionStore == nil { return nil } return r.sessionStore.GetSession } // recordGeneratedFile writes one manifest record after a successful write. // Materialization is the only writer of the manifest; resolvers always read // it back so authorization reflects current store state. func (r *LocalRuntime) recordGeneratedFile(ctx context.Context, sessionID string, root chat.ArtifactRootKind, finalPath, mimeType string) error { manifest, ok := r.sessionStore.(session.GeneratedMediaManifest) if !ok { return fmt.Errorf("session store %T does not implement the generated-media manifest", r.sessionStore) } file := session.GeneratedFile{ SessionID: sessionID, RelPath: finalPath, Root: root, MimeType: mimeType, CreatedAt: r.now(), } if err := manifest.AddGeneratedFile(ctx, file); err != nil { return err } return nil } func (r *LocalRuntime) recordGeneratedBlob(ctx context.Context, sessionID, finalPath string, data []byte) error { blobs, ok := r.sessionStore.(session.GeneratedMediaBlobStore) if !ok { return fmt.Errorf("session store %T does not implement generated-media blob storage", r.sessionStore) } return blobs.AddGeneratedBlob(ctx, sessionID, finalPath, data) } func generatedBlobWarning(finalPath string) string { return fmt.Sprintf("Saved generated media %s in the workspace, but could not keep a portable copy with the session. %s", finalPath, retryWithDebugAdvice) } // workspacemediaWrite is [workspacemedia.Write] behind a package-level // indirection so tests can inject a deterministic failure for one item in a // batch [LocalRuntime.materializeGeneratedMedia] call. Production code must // never reassign this; only *_test.go files do, always restoring it via // t.Cleanup. var workspacemediaWrite = workspacemedia.Write // usageHasTokens reports whether any billable tokens were recorded for a turn. // Used to suppress the missing-price warning for empty/no-op turns. func usageHasTokens(usage *chat.Usage) bool { if usage == nil { return false } return usage.InputTokens > 0 || usage.OutputTokens > 0 || usage.CachedInputTokens > 0 || usage.CacheWriteTokens > 0 } // compactIfNeeded estimates the token impact of tool results added since // messageCountBefore and triggers proactive compaction when the estimated // total crosses the agent's compaction threshold (90% of the context window // by default). This prevents sending an oversized request on the next // iteration. func (r *LocalRuntime) compactIfNeeded( ctx context.Context, sess *session.Session, a *agent.Agent, contextLimit int64, messageCountBefore int, events EventSink, ) { // contextLimit is the effective budget (primary window, capped to a smaller // dedicated compaction model's window when configured) computed by // runStreamLoop, so this site fires consistently with the pre-turn trigger. if !r.sessionCompactionEnabled(a) || contextLimit <= 0 { return } // Estimate only over the session's own new messages: sub-session // content recorded during tool calls (transfer_task and friends) // never enters this session's prompt, so counting it here would // attribute phantom tokens to a small parent conversation and // trigger a compaction that wipes it (see issue #2871). // // The estimator is calibrated against the provider-reported usage // already recorded on this session's assistant messages, so the // heuristic guess for the fresh tool results tracks the provider's // actual tokenizer instead of a fixed chars-per-token ratio. ownMessages := sess.OwnMessages() estimator := compaction.NewEstimator(func(yield func(*chat.Message) bool) { for i := range ownMessages { if !yield(&ownMessages[i].Message) { return } } }) newMessages := ownMessages[messageCountBefore:] var addedTokens int64 for i := range newMessages { addedTokens += estimator.EstimateMessageTokens(&newMessages[i].Message) } inputTokens, outputTokens := sess.Usage() if !compaction.ShouldCompact(inputTokens, outputTokens, addedTokens, contextLimit, a.CompactionThreshold()) { return } slog.InfoContext(ctx, "Proactive compaction: tool results pushed estimated context past the compaction threshold", "agent", a.Name(), "input_tokens", inputTokens, "output_tokens", outputTokens, "added_estimated_tokens", addedTokens, "estimator_scale", estimator.Scale(), "estimated_total", inputTokens+outputTokens+addedTokens, "context_limit", contextLimit, ) r.compactWithReason(ctx, sess, "", compactionReasonThreshold, events) } // getTools executes tool retrieval with automatic OAuth handling and applies // the session's tool-visibility rules: exclusion filters, the skill // sub-session allow-list and extra toolsets, then the internal tool-mode // structured-output tool. The internal tool is appended after every session // filter so a skill allow-list can never strip active enforcement, and it is // omitted entirely for sessions that disable structured output (fork-mode // skill children). // emitLifecycleEvents controls whether MCPInitStarted/Finished are emitted; // pass false when calling from reprobe to avoid spurious TUI spinner flicker. func (r *LocalRuntime) getTools(ctx context.Context, sess *session.Session, a *agent.Agent, sessionSpan trace.Span, events EventSink, emitLifecycleEvents bool) ([]tools.Tool, error) { if emitLifecycleEvents && len(a.ToolSets()) > 0 { events.Emit(MCPInitStarted(a.Name())) defer func() { events.Emit(MCPInitFinished(a.Name())) }() } // Catalog tools ride through the same session filters below. agentTools, err := listAgentTools(ctx, a) if err == nil { agentTools = filterExcludedTools(agentTools, sess.ExcludedTools) agentTools = r.skillSubSessionTools(ctx, sess, a, agentTools) // Tool-mode structured output rides on the same error path: a name // collision with the reserved internal tool or an uncompilable schema // must fail the turn loudly, not mask one of the two tools. agentTools, err = appendStructuredOutputTool(agentTools, sess, a) } if err != nil { slog.ErrorContext(ctx, "Failed to get agent tools", "agent", a.Name(), "error", err) sessionSpan.RecordError(err) sessionSpan.SetStatus(codes.Error, "failed to get tools") r.telemetry.RecordError(ctx, err.Error()) return nil, err } slog.DebugContext(ctx, "Retrieved agent tools", "agent", a.Name(), "tool_count", len(agentTools)) return agentTools, nil } // configureToolsetHandlers installs stable request-scoped dispatchers. func (r *LocalRuntime) configureToolsetHandlers(a *agent.Agent) { for _, toolset := range a.ToolSets() { tools.ConfigureHandlers(toolset, tools.ScopedElicitationHandler, tools.SamplingScopeHandler, tools.SamplingWithToolsScopeHandler, nil, r.managedOAuth, r.unmanagedOAuthRedirectURI, ) } } // subscribePlanChanges subscribes this stream's event sink to every plan // ChangeNotifier reachable from the team's toolsets (plus the session's // skill-provided extra toolsets) so an open /plans browser refreshes live, // and returns a release function that unsubscribes them all. Called once per // stream from runStreamLoop — not per iteration — and released before the // events channel closes, so subscriptions never leak past the stream. // Notifier instances are deduplicated: the registry-created plan toolset is // a process-wide singleton shared by all agents, and one mutation must emit // one event per stream, not one per agent. // // The emitted event carries no agent attribution: the shared toolset // executes mutations for whichever session's agent is calling, so this // subscriber cannot know the mutator (see plan.Change). An empty agent name // follows the AgentContext convention for events without a meaningful agent; // consumers read the plan's Author field for collaborative attribution. // // The fan-out is deliberately process-global: the registry plan toolset is // one process-wide notifier, so every active local runtime stream subscribed // to it — API/SSE streams of other sessions included — receives a // PlanChangedEvent for any session's mutation. The payload identifies the // plan by name (plus scope, action, and version), never the mutating // session; inventing a session attribution here would be false, and the // global reach is what keeps every open /plans view live. // // Non-blocking sink like the RAG forwarder: the callback fires from // another session's tool call, and a blocking send into this stream's // events channel could hang that session's tool call. func (r *LocalRuntime) subscribePlanChanges(sess *session.Session, events EventSink) (release func()) { sink := nonBlocking(events) forward := func(c plan.Change) { sink.Emit(PlanChanged("shared", c.Name, c.Action, c.Revision, "")) } toolsets := slices.Clone(sess.ExtraToolSets) for _, name := range r.team.AgentNames() { if a, err := r.team.Agent(name); err == nil { toolsets = append(toolsets, a.ToolSets()...) } } seen := make(map[notifierIdentity]struct{}) var unsubs []func() for _, ts := range toolsets { for _, notifier := range tools.FindAll[plan.ChangeNotifier](ts) { if key, identifiable := notifierDedupKey(notifier); identifiable { if _, dup := seen[key]; dup { continue } seen[key] = struct{}{} } unsubs = append(unsubs, notifier.SubscribeChanges(forward)) } } return func() { for _, unsub := range unsubs { unsub() } } } // notifierIdentity keys subscribePlanChanges' dedup map: dynamic type plus // pointer, so two pointers of different types sharing an address (an outer // struct and its first field) stay distinct. type notifierIdentity struct { typ reflect.Type ptr uintptr } // notifierDedupKey derives a dedup identity for n, reporting ok=false when // none is safe. Interface map keys hash the dynamic value, which panics at // runtime for non-comparable implementations (e.g. a value struct with a // func field) — and even a comparable struct type panics when an interface // field holds a non-comparable value. Pointer identity is the only // universally hash-safe choice, and it covers the case dedup exists for: // the registry-created *plan.ToolSet singleton shared by every agent. // Non-pointer implementations skip dedup — a duplicate subscription is // benign, a panic is not. func notifierDedupKey(n plan.ChangeNotifier) (notifierIdentity, bool) { v := reflect.ValueOf(n) if v.Kind() != reflect.Pointer { return notifierIdentity{}, false } return notifierIdentity{typ: v.Type(), ptr: v.Pointer()}, true } // emitAgentWarnings drains and emits any pending toolset warnings as // persistent TUI notifications. Failures ("start failed", "list failed") // are surfaced so the user can act on them; recoveries are intentionally // not emitted — "X is now available" reads as a spurious warning right // after the user completes an OAuth dance, and adds no signal for other // recoveries either. func (r *LocalRuntime) emitAgentWarnings(a *agent.Agent, events EventSink) { warnings := a.DrainWarnings() if len(warnings) == 0 { return } slog.Warn("Tool setup partially failed; continuing", "agent", a.Name(), "warnings", warnings) events.Emit(Warning(formatToolWarning(a, warnings), a.Name())) } func formatToolWarning(a *agent.Agent, warnings []string) string { var builder strings.Builder fmt.Fprintf(&builder, "Some toolsets failed to initialize for agent '%s'.\n\nDetails:\n\n", a.Name()) for _, warning := range warnings { fmt.Fprintf(&builder, "- %s\n", warning) } return strings.TrimSuffix(builder.String(), "\n") } func lastToolCallID(messages []chat.Message) string { for _, message := range slices.Backward(messages) { if message.Role == chat.MessageRoleTool { return message.ToolCallID } } return "" } // filterExcludedTools removes tools whose names appear in the excluded list. // This is used by skill sub-sessions to prevent recursive run_skill calls. func filterExcludedTools(agentTools []tools.Tool, excluded []string) []tools.Tool { if len(excluded) == 0 { return agentTools } excludeSet := make(map[string]bool, len(excluded)) for _, name := range excluded { excludeSet[name] = true } filtered := make([]tools.Tool, 0, len(agentTools)) for _, t := range agentTools { if !excludeSet[t.Name] { filtered = append(filtered, t) } } return filtered } // filterAllowedTools keeps only tools whose names match an entry in allowed // (filepath.Match-style glob, falling back to an exact match). An empty // allow-list imposes no restriction. Used by fork-mode skill sub-sessions // that declare an allowed-tools list. func filterAllowedTools(agentTools []tools.Tool, allowed []string) []tools.Tool { if len(allowed) == 0 { return agentTools } filtered := make([]tools.Tool, 0, len(agentTools)) for _, t := range agentTools { if toolNameMatchesAny(t.Name, allowed) { filtered = append(filtered, t) } } return filtered } // toolNameMatchesAny reports whether name matches any of the patterns. Each // pattern is tried as a filepath.Match glob; a malformed pattern falls back // to an exact string comparison. func toolNameMatchesAny(name string, patterns []string) bool { for _, p := range patterns { if p == name { return true } if ok, err := filepath.Match(p, name); err == nil && ok { return true } } return false } // skillSubSessionTools augments the agent's tools for a fork-mode skill // sub-session: it applies the skill's allowed-tools allow-list to the // inherited agent tools, then appends the tools from the skill's assistive // toolsets (which bypass the allow-list — the skill explicitly asked for // them). It is a no-op for ordinary sessions that set neither field. func (r *LocalRuntime) skillSubSessionTools(ctx context.Context, sess *session.Session, a *agent.Agent, agentTools []tools.Tool) []tools.Tool { if len(sess.AllowedTools) == 0 && len(sess.ExtraToolSets) == 0 { return agentTools } agentTools = filterAllowedTools(agentTools, sess.AllowedTools) for _, ts := range sess.ExtraToolSets { tools.ConfigureHandlers(ts, tools.ScopedElicitationHandler, tools.SamplingScopeHandler, tools.SamplingWithToolsScopeHandler, nil, r.managedOAuth, r.unmanagedOAuthRedirectURI, ) if startable, ok := tools.As[tools.Startable](ts); ok { if err := startable.Start(ctx); err != nil { slog.WarnContext(ctx, "Skill toolset failed to start; skipping", "agent", a.Name(), "toolset", tools.DescribeToolSet(ts), "error", err) continue } } extra, err := ts.Tools(ctx) if err != nil { slog.WarnContext(ctx, "Skill toolset listing failed; skipping", "agent", a.Name(), "toolset", tools.DescribeToolSet(ts), "error", err) continue } agentTools = append(agentTools, extra...) } return agentTools } // reprobe re-runs ensureToolSetsAreStarted after a batch of tool calls. // If new tools became available (by name-set diff), it emits a ToolsetInfo // event to update the TUI immediately. The new tools will be picked up by // the next iteration's getTools() call at the top of the loop. // // reprobe deliberately does NOT return the new tool list: the top-of-loop // getTools() is the single authoritative source for agentTools each iteration. func (r *LocalRuntime) reprobe( ctx context.Context, sess *session.Session, a *agent.Agent, currentTools []tools.Tool, sessionSpan trace.Span, events EventSink, ) { updated, err := r.getTools(ctx, sess, a, sessionSpan, events, false) if err != nil { slog.WarnContext(ctx, "reprobe: getTools failed", "agent", a.Name(), "error", err) return } // Emit any pending warnings that getTools just generated. r.emitAgentWarnings(a, events) // Compute added tools by comparing name-sets (not just counts), so we // correctly handle a toolset that replaced one tool with another. prev := make(map[string]struct{}, len(currentTools)) for _, t := range currentTools { prev[t.Name] = struct{}{} } var added []string for _, t := range updated { if _, exists := prev[t.Name]; !exists { added = append(added, t.Name) } } if len(added) == 0 { return } slog.InfoContext(ctx, "New tools available after toolset re-probe", "agent", a.Name(), "added", added) // Emit updated tool count to the TUI immediately. events.Emit(ToolsetInfo(len(updated), false, a.Name())) }