mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-04 10:02:15 +00:00
2058 lines
85 KiB
Go
2058 lines
85 KiB
Go
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()))
|
||
}
|