package chatloop import ( "cmp" "context" "database/sql" "encoding/base64" "encoding/json" "errors" "fmt" "maps" "slices" "strconv" "strings" "sync" "time" "unicode" "charm.land/fantasy" fantasyanthropic "charm.land/fantasy/providers/anthropic" fantasyopenai "charm.land/fantasy/providers/openai" fantasyopenaicompat "charm.land/fantasy/providers/openaicompat" fantasyopenrouter "charm.land/fantasy/providers/openrouter" fantasyvercel "charm.land/fantasy/providers/vercel" "github.com/google/uuid" "go.opentelemetry.io/otel/attribute" "golang.org/x/xerrors" "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/x/chatd/chatdebug" "github.com/coder/coder/v2/coderd/x/chatd/chaterror" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/chatsanitize" "github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/coderd/x/chatfiles" "github.com/coder/coder/v2/codersdk" "github.com/coder/quartz" ) const ( // DefaultStreamSilenceTimeout bounds how long an individual // model attempt may go without receiving a stream part before // the attempt is canceled and retried. DefaultStreamSilenceTimeout = 10 * time.Minute // StreamSilenceTimeoutDisabled turns the silence guard off. StreamSilenceTimeoutDisabled time.Duration = -1 streamSilenceGuardTimerTag = "streamSilenceGuard" ) var ( // ErrContentFiltered is returned when the provider's safety // classifiers blocked the response and the model produced no // content, e.g. Anthropic's stop_reason "refusal". ErrContentFiltered = xerrors.New("response blocked by provider content filter") // ErrNoModelOutput is returned when a stream ends without visible content or // tool calls and its finish reason indicates an incomplete response. ErrNoModelOutput = xerrors.New("model stream finished without output") errStreamSilenceTimeout = xerrors.New( "chat stream was silent for longer than the configured timeout", ) ) // PersistedStep is the unit the persistence layer splits into role-separated // database messages. Content mixes assistant blocks (text, reasoning, tool // calls) and tool result blocks from one completed or interrupted agent step. type PersistedStep struct { Content []fantasy.Content Usage fantasy.Usage ContextLimit sql.NullInt64 // Runtime is the wall-clock duration from opening to consuming the // model stream. Runtime time.Duration // ProviderResponseID is the response ID the model endpoint reported, if // any. Through the AI Gateway, Anthropic Messages responses carry the // gateway's interception ID instead of the upstream message ID. ProviderResponseID string // BatchRuntime is the union of billed local-tool execution intervals. // Parallel calls count once and serial calls count from their own start. BatchRuntime time.Duration // BatchBilledCalls counts the executed calls whose intervals produced // BatchRuntime. Audit metadata for the batch usage record. BatchBilledCalls int // ToolCallCreatedAt maps tool-call IDs to the time // the model emitted each tool call. Applied by the // persistence layer to set CreatedAt on persisted // tool-call ChatMessageParts. ToolCallCreatedAt map[string]time.Time // ToolResultCreatedAt maps tool-call IDs to the time // each tool result was produced (or interrupted). // Applied by the persistence layer to set CreatedAt // on persisted tool-result ChatMessageParts. ToolResultCreatedAt map[string]time.Time // ReasoningStartedAt and ReasoningCompletedAt are parallel // slices indexed by the occurrence order of reasoning // content in Content. The persistence layer walks reasoning // parts in order and applies these timestamps to the // corresponding ChatMessageParts so the frontend can render // reasoning duration. Reasoning parts have no provider-side // stable ID, so order is the only correlation we have. ReasoningStartedAt []time.Time ReasoningCompletedAt []time.Time } // GenerateAssistantOptions configures one assistant model call. type GenerateAssistantOptions struct { Model fantasy.LanguageModel // ErrorProvider labels user-facing errors with the configured provider // identity (e.g. "bedrock"). It differs from Model.Provider(), which // reflects the fantasy transport client and is "anthropic" for Bedrock // routed through aibridge. Metrics and prompt preparation keep using // Model.Provider(). When empty, Model.Provider() is used. ErrorProvider string Messages []fantasy.Message Tools []fantasy.AgentTool ActiveTools []string ProviderTools []ProviderTool StreamSilenceTimeout time.Duration Clock quartz.Clock ContextLimitFallback int64 // CallTemplate is copied before GenerateAssistant attaches the prompt and // tools. CallTemplate fantasy.Call PublishMessagePart func(codersdk.ChatMessageRole, codersdk.ChatMessagePart) // OnModelStreamStart runs immediately before the provider stream is // opened, at the instant PersistedStep.Runtime starts measuring. It // lets callers record the billable window's start out of band, so an // interrupted attempt bills the same window a completed step reports. OnModelStreamStart func() Logger slog.Logger Metrics *Metrics Stages *StageTracer // StageModel should match the model identity on provider_attempt stages. StageModel StageModel } // AssistantOutcome is the durable assistant-side result from one model call. type AssistantOutcome struct { Step PersistedStep ToolCalls []fantasy.ToolCallContent FinishReason fantasy.FinishReason } // ExecuteLocalToolsOptions configures one local tool execution batch. type ExecuteLocalToolsOptions struct { Tools []fantasy.AgentTool ActiveTools []string AllowInactiveTools map[string]bool ProviderTools []ProviderTool ToolCalls []fantasy.ToolCallContent // ObservedToolCalls optionally carries the step's full assistant // tool-call batch, including calls denied before execution, so // step observers account for denied siblings that derivation will // still count. Defaults to ToolCalls. ObservedToolCalls []fantasy.ToolCallContent ExclusiveToolNames map[string]bool BuiltinToolNames map[string]bool ModelProvider string ModelName string // ContextLimit is the model's context window in tokens. It is used // to derive a per-result byte budget so a single oversized tool // result cannot overflow the prompt. Zero means unknown, in which // case a default budget applies. ContextLimit int64 // ToolNameAliases maps a non-advertised tool name to the canonical // tool it dispatches to. Used for backward compatibility when a tool // is renamed but old chat histories still reference the old name. ToolNameAliases map[string]string // UnbilledToolNames lists called tool names excluded from the batch // window. Include deprecated aliases. UnbilledToolNames map[string]bool // BillingRecorder observes each local call's start and completion // for interrupt billing. Serial calls may start after concurrent // siblings settle, so interrupts bill actual starts and skip calls // that never run. Optional. BillingRecorder ToolBillingRecorder // ToolCallContext returns the context to run tc with. Optional. ToolCallContext func(ctx context.Context, tc fantasy.ToolCallContent) context.Context Stages *StageTracer StageModel StageModel PublishMessagePart func(codersdk.ChatMessageRole, codersdk.ChatMessagePart) Logger slog.Logger Metrics *Metrics Clock quartz.Clock } // ToolBillingRecorder records live start and completion timestamps for // local tool calls. Interrupt billing uses these so a cancel can bill // work that already started and skip calls that never ran. // dispatchIndex identifies the dispatch-order occurrence. // RecordComplete may run from multiple tool goroutines; implementations // must be concurrency-safe. type ToolBillingRecorder interface { RecordStart(dispatchIndex int, startedAt time.Time) RecordComplete(dispatchIndex int, completedAt time.Time) } // GenerateCompactionOptions configures one context compaction call. type GenerateCompactionOptions struct { Model fantasy.LanguageModel Messages []fantasy.Message ThresholdPercent int32 ContextLimit int64 ContextLimitFallback int64 SummaryPrompt string SummaryHint string SystemSummaryPrefix string StepUsage fantasy.Usage // Force skips the threshold gate (including the threshold=100 // disable and the zero-usage early return). Set for manual, // user-requested compactions. Force bool // Source labels what triggered the compaction. Defaults to // CompactionSourceAutomatic when empty. Source CompactionSource DebugSvc *chatdebug.Service ChatID uuid.UUID HistoryTipMessageID int64 ToolCallID string ToolName string // ResolvedProvider, ResolvedModel, and ModelConfigID identify the // summary model, which can differ from the chat model when a // compaction override is configured. Debug runs record these. ResolvedProvider string ResolvedModel string ModelConfigID uuid.UUID // SummaryContextLimit is the summary model's window. When positive, it // bounds the summary cap instead of the trigger's limit. SummaryContextLimit int64 // ChatContextLimit is the chat model's window, recorded for chat usage // display. ChatContextLimit int64 // SummaryCall is copied before GenerateCompaction attaches the summary // prompt and prepared tool definitions. SummaryCall fantasy.Call // ToolDefinitions is copied from the parent generation request so the // summary call uses the exact same ordered definitions. ToolDefinitions []fantasy.Tool PublishMessagePart func(codersdk.ChatMessageRole, codersdk.ChatMessagePart) // Clock measures the summary call duration. Required. Clock quartz.Clock // StreamSilenceTimeout bounds how long the summary stream may go // without yielding a part. Defaults to defaultStreamSilenceTimeout. StreamSilenceTimeout time.Duration // OnModelStreamStart runs immediately before the summary model call, // at the instant CompactionResult.Runtime starts measuring. OnModelStreamStart func() } // ProviderTool pairs a provider-native tool definition with an // optional local executor. When Runner is nil the tool is fully // provider-executed (e.g. web search). When Runner is non-nil // the definition is sent to the API but execution is handled // locally (e.g. computer use). type ProviderTool struct { Definition fantasy.Tool Runner fantasy.AgentTool // ResultProviderMetadata extracts provider-specific metadata from successful // local runner responses. The chat loop attaches returned metadata to the tool // result sent back to the model. OpenAI computer-use uses this to request // original screenshot detail for image results. ResultProviderMetadata func(response fantasy.ToolResponse) fantasy.ProviderMetadata } // stepResult holds the accumulated output of a single streaming // step. Since we own the stream consumer, all content is tracked // directly here, no shadow draft state needed. type stepResult struct { content []fantasy.Content usage fantasy.Usage providerMetadata fantasy.ProviderMetadata providerResponseID string finishReason fantasy.FinishReason toolCalls []fantasy.ToolCallContent toolCallCreatedAt map[string]time.Time toolResultCreatedAt map[string]time.Time reasoningStartedAt []time.Time reasoningCompletedAt []time.Time // unfinishedToolCalls holds local calls whose input started streaming // but never completed into a ToolCall. unfinishedToolCalls []fantasy.ToolCallContent } // reasoningState accumulates reasoning content and provider // metadata while the stream is in flight. type reasoningState struct { text string options fantasy.ProviderMetadata startedAt time.Time } type textState struct { text string options fantasy.ProviderMetadata narration bool } // GenerateAssistant performs one assistant model stream and returns the // durable assistant-side content. It does not execute tools, retry, or persist. func GenerateAssistant(ctx context.Context, opts GenerateAssistantOptions) (_ AssistantOutcome, retErr error) { if opts.Model == nil { return AssistantOutcome{}, xerrors.New("chat model is required") } if opts.StreamSilenceTimeout == 0 { opts.StreamSilenceTimeout = DefaultStreamSilenceTimeout } if opts.Clock == nil { opts.Clock = quartz.NewReal() } if opts.Metrics == nil { opts.Metrics = NopMetrics() } publishMessagePart := func(role codersdk.ChatMessageRole, part codersdk.ChatMessagePart) { if opts.PublishMessagePart != nil { opts.PublishMessagePart(role, part) } } provider := opts.Model.Provider() modelName := opts.Model.Model() // errorProvider labels user-facing errors with the configured provider; // see GenerateAssistantOptions.ErrorProvider. The transport provider is // kept for prompt preparation, Anthropic history sanitization, and the // metric labels below. errorProvider := cmp.Or(opts.ErrorProvider, provider) prepared, err := prepareMessagesForRequest(ctx, opts.Model, opts.Logger, opts.Messages, provider, modelName, 0, 1) if err != nil { return AssistantOutcome{}, xerrors.Errorf("prepare prompt: %w", err) } opts.Metrics.MessageCount.WithLabelValues(provider, modelName).Observe(float64(len(prepared))) opts.Metrics.PromptSizeBytes.WithLabelValues(provider, modelName).Observe(float64(EstimatePromptSize(prepared))) opts.Metrics.StepsTotal.WithLabelValues(provider, modelName).Inc() call := opts.CallTemplate call.Prompt = prepared call.Tools = BuildToolDefinitions(opts.Tools, opts.ActiveTools, opts.ProviderTools) stepStart := opts.Clock.Now() if opts.OnModelStreamStart != nil { opts.OnModelStreamStart() } stepCtx := chatdebug.ReuseStep(ctx) streamCtx, streamSpan := opts.Stages.Start(stepCtx, StageStream) streamSpan.SetModel(opts.StageModel) attempt, streamErr := guardedStream( streamCtx, provider, modelName, opts.Clock, opts.StreamSilenceTimeout, func(attemptCtx context.Context) (fantasy.StreamResponse, error) { return opts.Model.Stream(attemptCtx, call) }, opts.Metrics, opts.Stages, opts.StageModel, ) if streamErr != nil { wrappedErr := wrapProviderStreamError(errorProvider, streamErr) streamSpan.End(wrappedErr) classified := chaterror.Classify(wrappedErr).WithProvider(errorProvider) if classified.Retryable { opts.Metrics.RecordStreamRetry(provider, modelName, classified) } return AssistantOutcome{}, wrappedErr } // release closes the time_to_first_token window, so it must run // before the stream stage ends. defer func() { attempt.release() streamSpan.End(retErr) }() result, processErr := processStepStream(attempt.stream, opts.Clock, publishMessagePart) if err := attempt.finish(processErr); err != nil { wrappedErr := wrapProviderStreamError(errorProvider, err) classified := chaterror.Classify(wrappedErr).WithProvider(errorProvider) if classified.Retryable { opts.Metrics.RecordStreamRetry(provider, modelName, classified) } return AssistantOutcome{}, wrappedErr } contextLimit := extractContextLimitWithFallback(result.providerMetadata, opts.ContextLimitFallback) result.content = chatsanitize.SanitizeAnthropicProviderToolStepContent( ctx, opts.Logger, provider, modelName, "assistant_helper", 0, result.finishReason, result.content, ) if result.finishReason == fantasy.FinishReasonLength { resolveOutputLimitToolCalls(&result, call.MaxOutputTokens, opts.Clock, publishMessagePart) } // A content-filter finish without user-visible output means the // provider's safety classifiers blocked the whole response (e.g. // Anthropic stop_reason "refusal"). The refusal can arrive after // reasoning has already streamed, so reasoning alone must not // count as output. if result.finishReason == fantasy.FinishReasonContentFilter && !hasUserVisibleContent(result.content) { return AssistantOutcome{}, contentFilterError(errorProvider, result.providerMetadata) } // Treat discarded responses as retryable so an empty turn is not persisted. if silentNoOutputFinish(result.finishReason) && !hasUserVisibleContent(result.content) && len(result.toolCalls) == 0 { noOutputErr := noModelOutputError(errorProvider, result.finishReason) opts.Metrics.RecordStreamRetry(provider, modelName, chaterror.Classify(noOutputErr)) return AssistantOutcome{}, noOutputErr } step := PersistedStep{ Content: result.content, Usage: result.usage, ContextLimit: contextLimit, Runtime: opts.Clock.Since(stepStart), ProviderResponseID: result.providerResponseID, ToolCallCreatedAt: result.toolCallCreatedAt, ToolResultCreatedAt: result.toolResultCreatedAt, ReasoningStartedAt: result.reasoningStartedAt, ReasoningCompletedAt: result.reasoningCompletedAt, } return AssistantOutcome{ Step: step, ToolCalls: append([]fantasy.ToolCallContent(nil), result.toolCalls...), FinishReason: result.finishReason, }, nil } // resolveOutputLimitToolCalls gives every call whose input the output token // limit cut off an error result, so the model learns why the call failed // instead of retrying the same oversized input or silently losing the call. func resolveOutputLimitToolCalls( result *stepResult, maxOutputTokens *int64, clock quartz.Clock, publishMessagePart func(codersdk.ChatMessageRole, codersdk.ChatMessagePart), ) { limit := "the output token limit" if maxOutputTokens != nil && *maxOutputTokens > 0 { limit = fmt.Sprintf("the output token limit (%d tokens)", *maxOutputTokens) } message := "This tool call was not executed because the response reached " + limit + " while writing its input. Split the content into smaller tool calls." appendResult := func(toolCall fantasy.ToolCallContent) { toolResult := fantasy.ToolResultContent{ ToolCallID: toolCall.ToolCallID, ToolName: toolCall.ToolName, Result: fantasy.ToolResultOutputContentError{Error: xerrors.New(message)}, } result.content = append(result.content, toolResult) now := clockNow(clock) if result.toolResultCreatedAt == nil { result.toolResultCreatedAt = make(map[string]time.Time) } result.toolResultCreatedAt[toolCall.ToolCallID] = now part := chatprompt.PartFromContent(toolResult) part.CreatedAt = &now publishMessagePart(codersdk.ChatMessageRoleTool, part) } var truncated []fantasy.ToolCallContent for _, block := range result.content { toolCall, ok := fantasy.AsContentType[fantasy.ToolCallContent](block) if ok && !toolCall.ProviderExecuted && !json.Valid([]byte(toolCall.Input)) { truncated = append(truncated, toolCall) } } for _, toolCall := range truncated { appendResult(toolCall) } for _, toolCall := range result.unfinishedToolCalls { result.content = append(result.content, toolCall) now := clockNow(clock) if result.toolCallCreatedAt == nil { result.toolCallCreatedAt = make(map[string]time.Time) } result.toolCallCreatedAt[toolCall.ToolCallID] = now part := chatprompt.PartFromContent(toolCall) part.CreatedAt = &now publishMessagePart(codersdk.ChatMessageRoleAssistant, part) appendResult(toolCall) } } func wrapProviderStreamError(provider string, err error) error { if err == nil { return nil } classified := chaterror.Classify(err).WithProvider(provider) if !classified.Retryable && classified.StatusCode == 0 && errors.Is(err, context.Canceled) { wrapped := errors.Join(chaterror.ErrProviderTransportReset, err) reclassified := chaterror.Classify(wrapped).WithProvider(provider) if reclassified.Retryable { classified = reclassified err = wrapped } } return xerrors.Errorf("stream response: %w", chaterror.WithClassification(err, classified)) } // hasUserVisibleContent ignores reasoning and blank text because neither can // complete a user-facing response. func hasUserVisibleContent(content []fantasy.Content) bool { for _, part := range content { switch value := part.(type) { case fantasy.ReasoningContent, *fantasy.ReasoningContent: case fantasy.TextContent: if strings.TrimSpace(value.Text) != "" { return true } case *fantasy.TextContent: if value != nil && strings.TrimSpace(value.Text) != "" { return true } default: return true } } return false } func silentNoOutputFinish(reason fantasy.FinishReason) bool { switch reason { case fantasy.FinishReasonUnknown, fantasy.FinishReasonError, fantasy.FinishReasonOther, fantasy.FinishReasonToolCalls: return true default: return false } } func noModelOutputError(provider string, reason fantasy.FinishReason) error { classified := chaterror.ClassifiedError{ Message: "The model ended its response without producing any output.", Detail: "finish reason: " + string(reason), Kind: codersdk.ChatErrorKindGeneric, Provider: provider, Retryable: true, } return chaterror.WithClassification(ErrNoModelOutput, classified) } func contentFilterError(provider string, metadata fantasy.ProviderMetadata) error { classified := chaterror.ClassifiedError{ Kind: codersdk.ChatErrorKindContentFilter, Provider: provider, Retryable: false, } if refusal := fantasyanthropic.GetRefusalMetadata(metadata); refusal != nil { classified.Message = chaterror.ContentFilterMessage(provider, refusal.Category) classified.Detail = strings.TrimSpace(refusal.Explanation) } return chaterror.WithClassification(ErrContentFiltered, classified) } // ExecuteLocalTools runs local tool calls and returns durable tool results. It // does not retry or persist. func ExecuteLocalTools(ctx context.Context, opts ExecuteLocalToolsOptions) (PersistedStep, error) { if opts.Metrics == nil { opts.Metrics = NopMetrics() } provider := opts.ModelProvider if provider == "" { provider = "unknown" } modelName := opts.ModelName if modelName == "" { modelName = "unknown" } publishMessagePart := func(role codersdk.ChatMessageRole, part codersdk.ChatMessagePart) { if opts.PublishMessagePart != nil { opts.PublishMessagePart(role, part) } } // Expose the publisher on the execution context so tools that stream // intermediate output (e.g. the advisor tool) can publish parts // without capturing the publisher at construction time. ctx = WithMessagePartPublisher(ctx, opts.PublishMessagePart) if ctx.Err() != nil { return PersistedStep{}, ctx.Err() } localCalls := make([]fantasy.ToolCallContent, 0, len(opts.ToolCalls)) for _, tc := range opts.ToolCalls { if !tc.ProviderExecuted { localCalls = append(localCalls, tc) } } if len(localCalls) == 0 { return PersistedStep{}, nil } var result stepResult policyResults, exclusiveViolation := applyExclusiveToolPolicy( localCalls, opts.ExclusiveToolNames, opts.Metrics, provider, modelName, ) if exclusiveViolation { now := clockNow(opts.Clock) for _, tr := range policyResults { recordToolResultTimestamp(&result, tr.ToolCallID, now) publishToolAttachments(ctx, opts.Logger, tr, now, publishMessagePart) ssePart := chatprompt.PartFromContentWithLogger(ctx, opts.Logger, tr) ssePart.CreatedAt = &now publishMessagePart(codersdk.ChatMessageRoleTool, ssePart) result.content = append(result.content, tr) } if ctx.Err() != nil { return PersistedStep{}, ctx.Err() } return PersistedStep{ Content: result.content, ToolResultCreatedAt: result.toolResultCreatedAt, }, nil } maxResultBytes := ToolResultByteBudget(opts.ContextLimit) batchStart := clockNow(opts.Clock) toolExecutions := executeTools( ctx, opts.Clock, opts.Tools, opts.ActiveTools, opts.AllowInactiveTools, opts.ProviderTools, localCalls, opts.ObservedToolCalls, opts.Metrics, opts.Logger, provider, modelName, opts.BuiltinToolNames, maxResultBytes, opts.ToolNameAliases, batchStart, opts.BillingRecorder, opts.ToolCallContext, opts.Stages, opts.StageModel, ) for _, execution := range toolExecutions { tr := execution.content completedAt := execution.interval.End recordToolResultTimestamp(&result, tr.ToolCallID, completedAt) publishToolAttachments(ctx, opts.Logger, tr, completedAt, publishMessagePart) ssePart := chatprompt.PartFromContentWithLogger(ctx, opts.Logger, tr) ssePart.CreatedAt = &completedAt publishMessagePart(codersdk.ChatMessageRoleTool, ssePart) result.content = append(result.content, tr) } if ctx.Err() != nil { return PersistedStep{}, ctx.Err() } billedIntervals := billableBatchIntervals(toolExecutions, opts.UnbilledToolNames) return PersistedStep{ Content: result.content, ToolResultCreatedAt: result.toolResultCreatedAt, BatchRuntime: BilledIntervalsDuration(billedIntervals), BatchBilledCalls: len(billedIntervals), }, nil } // billableBatchIntervals returns the billed execution intervals. // Unbilled tools and calls without both stamps do not count. func billableBatchIntervals( executions []toolExecutionResult, unbilledToolNames map[string]bool, ) []BilledInterval { intervals := make([]BilledInterval, 0, len(executions)) for _, execution := range executions { if unbilledToolNames[execution.content.ToolName] || execution.interval.Start.IsZero() || execution.interval.End.IsZero() { continue } intervals = append(intervals, execution.interval) } return intervals } // BilledInterval is one billed tool call's execution window. type BilledInterval struct { Start time.Time End time.Time } // BilledIntervalsDuration returns the union duration of valid intervals. // Overlaps count once, gaps do not, and inverted intervals are ignored. // Committed and interrupted batches share this helper. func BilledIntervalsDuration(intervals []BilledInterval) time.Duration { valid := slices.DeleteFunc(slices.Clone(intervals), func(iv BilledInterval) bool { return iv.End.Before(iv.Start) }) if len(valid) == 0 { return 0 } slices.SortFunc(valid, func(a, b BilledInterval) int { return a.Start.Compare(b.Start) }) curStart, curEnd := valid[0].Start, valid[0].End var total time.Duration for _, iv := range valid[1:] { if iv.Start.After(curEnd) { total += curEnd.Sub(curStart) curStart, curEnd = iv.Start, iv.End continue } if iv.End.After(curEnd) { curEnd = iv.End } } return total + curEnd.Sub(curStart) } // prepareMessagesForRequest applies the prompt preparation pipeline used // immediately before sending messages to a provider. It returns the // possibly updated canonical messages and an independent provider-ready // prompt. When preparation fails, the prompt result is nil and err is the // terminal prompt-preparation failure. func prepareMessagesForRequest( ctx context.Context, model fantasy.LanguageModel, logger slog.Logger, messages []fantasy.Message, provider string, modelName string, step int, totalSteps int, ) ([]fantasy.Message, error) { // Copy messages so provider-specific caching mutations don't leak // back to the canonical message slice. prompt := slices.Clone(messages) prompt, sanitizeStats := chatsanitize.SanitizeAnthropicProviderToolHistory(provider, prompt) chatsanitize.LogAnthropicProviderToolSanitization( ctx, logger, "pre_request", provider, modelName, sanitizeStats, slog.F("step_index", step), slog.F("total_steps", totalSteps), ) prompt, err := chatsanitize.ApplyAnthropicProviderToolGuard( ctx, logger, provider, modelName, prompt, ) if err != nil { err = chaterror.WithClassification( xerrors.Errorf("apply anthropic provider tool guard: %w", err), chaterror.ClassifiedError{ Message: "The chat continuation failed due to an internal state mismatch. This is not a configuration or billing issue. Start a new chat to continue.", Detail: "Anthropic replay diagnostic: match=provider_tool_guard_postcondition_failed.", Kind: codersdk.ChatErrorKindGeneric, Provider: provider, Retryable: false, }, ) return nil, err } if applyPromptCaching := promptCachingStrategyFor(model, modelName); applyPromptCaching != nil { applyPromptCaching(prompt) } return prompt, nil } // guardedAttempt owns an attempt-scoped context and silence guard // around a provider stream. release is idempotent and frees the // attempt-scoped timer/context. finish canonicalizes silence timeout // errors before the retry loop classifies them. type guardedAttempt struct { ctx context.Context stream fantasy.StreamResponse release func() finish func(error) error } // streamSilenceGuard arbitrates whether an attempt times out while // waiting for the next stream part. Exactly one outcome wins: the // timer cancels the attempt, or release disarms the timer. type streamSilenceGuard struct { mu sync.Mutex timer *quartz.Timer cancel context.CancelCauseFunc timeout time.Duration settled bool } func newStreamSilenceGuard( clock quartz.Clock, timeout time.Duration, cancel context.CancelCauseFunc, ) *streamSilenceGuard { guard := &streamSilenceGuard{ cancel: cancel, timeout: timeout, } if timeout < 0 { return guard } guard.timer = clock.AfterFunc( timeout, guard.onTimeout, streamSilenceGuardTimerTag, ) return guard } func (g *streamSilenceGuard) settle() bool { g.mu.Lock() defer g.mu.Unlock() if g.settled { return false } g.settled = true return true } func (g *streamSilenceGuard) onTimeout() { if !g.settle() { return } g.cancel(errStreamSilenceTimeout) } func (g *streamSilenceGuard) Reset() { g.mu.Lock() defer g.mu.Unlock() if g.settled || g.timer == nil { return } g.timer.Reset(g.timeout, streamSilenceGuardTimerTag) } func (g *streamSilenceGuard) Disarm() { if !g.settle() || g.timer == nil { return } g.timer.Stop() } func classifyStreamSilenceTimeout( attemptCtx context.Context, provider string, err error, ) error { if !errors.Is(context.Cause(attemptCtx), errStreamSilenceTimeout) { return err } if err == nil { err = errStreamSilenceTimeout } return chaterror.WithClassification(err, chaterror.ClassifiedError{ Kind: codersdk.ChatErrorKindStreamSilenceTimeout, Provider: provider, Retryable: true, }) } type streamWatchdogKey struct{} // WithStreamWatchdog returns a context whose guarded streams call kick // with the silence timeout whenever the guard arms or resets. func WithStreamWatchdog(ctx context.Context, kick func(silence time.Duration)) context.Context { return context.WithValue(ctx, streamWatchdogKey{}, kick) } var errNoFirstToken = xerrors.New("stream ended before the first token") func guardedStream( parent context.Context, provider, model string, clock quartz.Clock, timeout time.Duration, openStream func(context.Context) (fantasy.StreamResponse, error), metrics *Metrics, stages *StageTracer, stageModel StageModel, ) (guardedAttempt, error) { attemptCtx, cancelAttempt := context.WithCancelCause(parent) kick, _ := parent.Value(streamWatchdogKey{}).(func(time.Duration)) if kick == nil { kick = func(time.Duration) {} } guard := newStreamSilenceGuard(clock, timeout, cancelAttempt) kick(timeout) // A nil tracer still times the window for TTFTSeconds. if stages == nil { stages = NewStageTracer(nil, nil, WithClock(clock)) } _, ttftSpan := stages.Start(parent, StageTimeToFirstToken) ttftSpan.SetModel(stageModel) var ttftOnce sync.Once // A silence guard cancellation surfaces as a context error, so it is // replaced with the classified timeout. finishTTFT := func(err error) { ttftOnce.Do(func() { if err != nil { if errors.Is(context.Cause(attemptCtx), errStreamSilenceTimeout) { err = classifyStreamSilenceTimeout(attemptCtx, provider, nil) } ttftSpan.EndWithoutObservation(err) return } elapsed := ttftSpan.End(nil) metrics.TTFTSeconds.WithLabelValues(provider, model).Observe(elapsed.Seconds()) }) } var releaseOnce sync.Once release := func() { releaseOnce.Do(func() { guard.Disarm() cancelAttempt(nil) finishTTFT(errNoFirstToken) }) } stream, err := openStream(attemptCtx) if err != nil { err = classifyStreamSilenceTimeout(attemptCtx, provider, err) finishTTFT(err) release() return guardedAttempt{}, err } return guardedAttempt{ ctx: attemptCtx, stream: fantasy.StreamResponse(func(yield func(fantasy.StreamPart) bool) { for part := range stream { guard.Reset() kick(timeout) switch part.Type { case fantasy.StreamPartTypeError: finishTTFT(part.Error) case fantasy.StreamPartTypeWarnings, fantasy.StreamPartTypeFinish: // Neither is model output, so the window stays open. default: // Start markers such as text_start count as output. finishTTFT(nil) } if !yield(part) { return } } }), release: release, finish: func(err error) error { return classifyStreamSilenceTimeout(attemptCtx, provider, err) }, }, nil } // clockNow returns the clock's current time normalized the same // way as dbtime.Now so persisted timestamps are Postgres-safe. func clockNow(clock quartz.Clock) time.Time { return dbtime.Time(clock.Now().UTC()) } // processStepStream consumes a fantasy StreamResponse and // accumulates all content into a stepResult. Callbacks fire // inline and their errors propagate directly. func processStepStream( stream fantasy.StreamResponse, clock quartz.Clock, publishMessagePart func(codersdk.ChatMessageRole, codersdk.ChatMessagePart), ) (stepResult, error) { var result stepResult providerExecutedCalls := make(map[string]bool) activeTextContent := make(map[string]textState) activeReasoningContent := make(map[string]reasoningState) // Track tool names by ID for input delta publishing. toolNames := make(map[string]string) var startedToolInputIDs []string for part := range stream { switch part.Type { case fantasy.StreamPartTypeTextStart: // OpenAI labels narration when the text starts, so every delta // can carry the label before the text ends. activeTextContent[part.ID] = textState{ options: part.ProviderMetadata, narration: chatprompt.IsNarration(part.ProviderMetadata), } case fantasy.StreamPartTypeTextDelta: textPart := codersdk.ChatMessageText(part.Delta) if active, exists := activeTextContent[part.ID]; exists { active.text += part.Delta activeTextContent[part.ID] = active textPart.Narration = active.narration } publishMessagePart(codersdk.ChatMessageRoleAssistant, textPart) case fantasy.StreamPartTypeTextEnd: if active, exists := activeTextContent[part.ID]; exists { if len(part.ProviderMetadata) > 0 { active.options = part.ProviderMetadata } result.content = append(result.content, fantasy.TextContent{ Text: active.text, ProviderMetadata: active.options, }) delete(activeTextContent, part.ID) } case fantasy.StreamPartTypeReasoningStart: activeReasoningContent[part.ID] = reasoningState{ text: part.Delta, options: part.ProviderMetadata, startedAt: clockNow(clock), } case fantasy.StreamPartTypeReasoningDelta: reasoningPart := codersdk.ChatMessageReasoning(part.Delta) if active, exists := activeReasoningContent[part.ID]; exists { active.text += part.Delta if len(part.ProviderMetadata) > 0 { active.options = part.ProviderMetadata } activeReasoningContent[part.ID] = active if !active.startedAt.IsZero() { startedAt := active.startedAt reasoningPart.CreatedAt = &startedAt } } publishMessagePart(codersdk.ChatMessageRoleAssistant, reasoningPart) case fantasy.StreamPartTypeReasoningEnd: if active, exists := activeReasoningContent[part.ID]; exists { if len(part.ProviderMetadata) > 0 { active.options = part.ProviderMetadata } content := fantasy.ReasoningContent{ Text: active.text, ProviderMetadata: active.options, } result.content = append(result.content, content) result.reasoningStartedAt = append(result.reasoningStartedAt, active.startedAt) result.reasoningCompletedAt = append(result.reasoningCompletedAt, clockNow(clock)) delete(activeReasoningContent, part.ID) } case fantasy.StreamPartTypeToolInputStart: if _, exists := providerExecutedCalls[part.ID]; !exists { startedToolInputIDs = append(startedToolInputIDs, part.ID) } providerExecutedCalls[part.ID] = part.ProviderExecuted if strings.TrimSpace(part.ToolCallName) != "" { toolNames[part.ID] = part.ToolCallName } case fantasy.StreamPartTypeToolInputDelta: providerExecuted := providerExecutedCalls[part.ID] toolName := toolNames[part.ID] publishMessagePart(codersdk.ChatMessageRoleAssistant, codersdk.ChatMessagePart{ Type: codersdk.ChatMessagePartTypeToolCall, ToolCallID: part.ID, ToolName: toolName, ArgsDelta: part.Delta, ProviderExecuted: providerExecuted, }) case fantasy.StreamPartTypeToolInputEnd: // No callback needed; the full tool call arrives in // StreamPartTypeToolCall. case fantasy.StreamPartTypeToolCall: tc := fantasy.ToolCallContent{ ToolCallID: part.ID, ToolName: part.ToolCallName, Input: part.ToolCallInput, ProviderExecuted: part.ProviderExecuted, ProviderMetadata: part.ProviderMetadata, } result.toolCalls = append(result.toolCalls, tc) result.content = append(result.content, tc) if strings.TrimSpace(part.ToolCallName) != "" { toolNames[part.ID] = part.ToolCallName } // Clean up active tool call tracking. delete(providerExecutedCalls, part.ID) // Record when the model emitted this tool call // so the persisted part carries an accurate // timestamp for duration computation. now := clockNow(clock) if result.toolCallCreatedAt == nil { result.toolCallCreatedAt = make(map[string]time.Time) } result.toolCallCreatedAt[part.ID] = now ssePart := chatprompt.PartFromContent(tc) ssePart.CreatedAt = &now publishMessagePart( codersdk.ChatMessageRoleAssistant, ssePart, ) case fantasy.StreamPartTypeSource: sourceContent := fantasy.SourceContent{ SourceType: part.SourceType, ID: part.ID, URL: part.URL, Title: part.Title, ProviderMetadata: part.ProviderMetadata, } result.content = append(result.content, sourceContent) publishMessagePart( codersdk.ChatMessageRoleAssistant, chatprompt.PartFromContent(sourceContent), ) case fantasy.StreamPartTypeToolResult: // Provider-executed tool results (e.g. web search) // are emitted by the provider and added directly // to the step content for multi-turn round-tripping. // This mirrors fantasy's agent.go accumulation logic. if part.ProviderExecuted { tr := fantasy.ToolResultContent{ ToolCallID: part.ID, ToolName: part.ToolCallName, ProviderExecuted: part.ProviderExecuted, ProviderMetadata: part.ProviderMetadata, } result.content = append(result.content, tr) now := clockNow(clock) if result.toolResultCreatedAt == nil { result.toolResultCreatedAt = make(map[string]time.Time) } result.toolResultCreatedAt[part.ID] = now ssePart := chatprompt.PartFromContent(tr) ssePart.CreatedAt = &now publishMessagePart( codersdk.ChatMessageRoleTool, ssePart, ) } case fantasy.StreamPartTypeFinish: result.usage = part.Usage result.finishReason = part.FinishReason result.providerMetadata = part.ProviderMetadata result.providerResponseID = providerResponseID(part) case fantasy.StreamPartTypeError: return result, part.Error } } for _, id := range startedToolInputIDs { providerExecuted, unfinished := providerExecutedCalls[id] if !unfinished || providerExecuted { continue } result.unfinishedToolCalls = append(result.unfinishedToolCalls, fantasy.ToolCallContent{ ToolCallID: id, ToolName: toolNames[id], }) } return result, nil } // providerResponseID returns the response ID from a finish part. // Anthropic and Bedrock report the message ID as the part ID, and the OpenAI // Responses API reports it in provider metadata. Chat Completions providers // report neither. func providerResponseID(part fantasy.StreamPart) string { if part.ID != "" { return part.ID } for _, metadata := range part.ProviderMetadata { if responses, ok := metadata.(*fantasyopenai.ResponsesProviderMetadata); ok && responses != nil { return responses.ResponseID } } return "" } type toolExecutionResult struct { content fantasy.ToolResultContent interval BilledInterval } // executeTools runs non-serial calls concurrently, then SerialToolCalls in // call order. Results are returned in original order after all tools finish. // recorder, if set, receives live start and completion timestamps. func executeTools( ctx context.Context, clock quartz.Clock, allTools []fantasy.AgentTool, activeTools []string, allowInactiveTools map[string]bool, providerTools []ProviderTool, toolCalls []fantasy.ToolCallContent, observedToolCalls []fantasy.ToolCallContent, metrics *Metrics, logger slog.Logger, provider, model string, builtinToolNames map[string]bool, maxResultBytes int, toolNameAliases map[string]string, batchStart time.Time, recorder ToolBillingRecorder, toolCallContext func(context.Context, fantasy.ToolCallContent) context.Context, stages *StageTracer, stageModel StageModel, ) []toolExecutionResult { if len(toolCalls) == 0 { return nil } // Filter out provider-executed tool calls. These were // handled server-side by the LLM provider (e.g., web // search) and their results are already in the stream // content. localToolCalls := make([]fantasy.ToolCallContent, 0, len(toolCalls)) for _, tc := range toolCalls { if !tc.ProviderExecuted { localToolCalls = append(localToolCalls, tc) } } if len(localToolCalls) == 0 { return nil } toolMap := make(map[string]fantasy.AgentTool, len(allTools)) for _, t := range allTools { toolMap[t.Info().Name] = t } providerRunnerNames := make(map[string]struct{}, len(providerTools)) resultProviderMetadata := make( map[string]func(fantasy.ToolResponse) fantasy.ProviderMetadata, len(providerTools), ) // Include runners from provider tools so locally-executed // provider tools (e.g. computer use) can be dispatched. for _, pt := range providerTools { if pt.Runner == nil { continue } name := pt.Runner.Info().Name toolMap[name] = pt.Runner providerRunnerNames[name] = struct{}{} if pt.ResultProviderMetadata != nil { resultProviderMetadata[name] = pt.ResultProviderMetadata } } observed := observedToolCalls if observed == nil { observed = localToolCalls } notifyStepToolCallObservers(toolMap, toolNameAliases, observed) executions := make([]toolExecutionResult, len(localToolCalls)) runCall := func(i int, tc fantasy.ToolCallContent) { // Started only if the tool runs, so rejected calls record no stage. var toolSpan *StageSpan startRun := func(ctx context.Context) context.Context { var toolCtx context.Context toolCtx, toolSpan = stages.Start(ctx, StageToolCall, attribute.String(AttrToolName, tc.ToolName), ) toolSpan.SetModel(stageModel) return toolCtx } var execErr error defer func() { if r := recover(); r != nil { execErr = xerrors.Errorf("tool panicked: %v", r) executions[i].content = fantasy.ToolResultContent{ ToolCallID: tc.ToolCallID, ToolName: tc.ToolName, Result: fantasy.ToolResultOutputContentError{ Error: execErr, }, } } // Record when this tool completed (or panicked). // Captured per call so parallel tools get // accurate individual completion times. completedAt := clockNow(clock) executions[i].interval.End = completedAt if recorder != nil { recorder.RecordComplete(i, completedAt) } toolSpan.End(execErr) }() callCtx := ctx if toolCallContext != nil { callCtx = toolCallContext(ctx, tc) } executions[i].content, execErr = executeSingleTool( callCtx, toolMap, tc, metrics, logger, provider, model, builtinToolNames, activeTools, allowInactiveTools, providerRunnerNames, resultProviderMetadata, maxResultBytes, toolNameAliases, startRun, ) } // SerialToolCalls run in call order after concurrent siblings settle, so // order-sensitive state observes final sibling outcomes. var serialIndexes []int var wg sync.WaitGroup for i, tc := range localToolCalls { if isSerialToolCall(toolMap, toolNameAliases, tc.ToolName) { serialIndexes = append(serialIndexes, i) continue } executions[i].interval.Start = batchStart if recorder != nil { recorder.RecordStart(i, batchStart) } wg.Add(1) go func() { defer wg.Done() runCall(i, tc) }() } wg.Wait() // Reconcile concurrent results before serial tools inspect shared state. settled := make([]fantasy.ToolResultContent, 0, len(executions)) for i := range executions { if !slices.Contains(serialIndexes, i) { settled = append(settled, executions[i].content) } } notifyStepToolResultObservers(toolMap, toolNameAliases, localToolCalls, observed, settled) for _, i := range serialIndexes { // Stamp serial calls at launch, not batch start. startedAt := clockNow(clock) executions[i].interval.Start = startedAt if recorder != nil { recorder.RecordStart(i, startedAt) } runCall(i, localToolCalls[i]) } return executions } // applyExclusiveToolPolicy checks whether toolCalls violate the // exclusive-tool policy declared by exclusiveToolNames. When a // violation is detected it synthesizes deterministic policy-error // results for every tool call and records size/error metrics so the // exclusivity failure mode is visible to operators. Returns // (results, true) on violation; (nil, false) otherwise. func applyExclusiveToolPolicy( toolCalls []fantasy.ToolCallContent, exclusiveToolNames map[string]bool, metrics *Metrics, provider, model string, ) ([]fantasy.ToolResultContent, bool) { blockingToolName, ok := firstExclusiveToolName(toolCalls, exclusiveToolNames) if !ok { return nil, false } results := exclusiveToolPolicyResults(toolCalls, exclusiveToolNames, blockingToolName) for _, tr := range results { recordToolResultMetrics(metrics, provider, model, tr) } return results, true } // recordToolResultMetrics observes tool result size and increments // tool_errors_total when the result carries an error output. Mirrors // the metric-recording defer in executeSingleTool so that synthetic // results (e.g. exclusive-tool policy errors) contribute to operator // visibility. func recordToolResultMetrics(metrics *Metrics, provider, model string, tr fantasy.ToolResultContent) { if metrics == nil { return } label := tr.ToolName if label == "" { label = "unknown" } metrics.ToolResultSizeBytes.WithLabelValues(provider, model, label).Observe( float64(ToolResultSize(tr)), ) if _, ok := tr.Result.(fantasy.ToolResultOutputContentError); ok { metrics.RecordToolError(provider, model, label) } } func firstExclusiveToolName( toolCalls []fantasy.ToolCallContent, exclusiveToolNames map[string]bool, ) (string, bool) { if len(toolCalls) <= 1 || len(exclusiveToolNames) == 0 { return "", false } for _, tc := range toolCalls { if exclusiveToolNames[tc.ToolName] { return tc.ToolName, true } } return "", false } func exclusiveToolPolicyResults( toolCalls []fantasy.ToolCallContent, exclusiveToolNames map[string]bool, blockingToolName string, ) []fantasy.ToolResultContent { results := make([]fantasy.ToolResultContent, len(toolCalls)) for i, tc := range toolCalls { message := exclusiveToolSkippedErrorMessage(blockingToolName) if exclusiveToolNames[tc.ToolName] { message = exclusiveToolMustRunAloneErrorMessage(tc.ToolName) } results[i] = fantasy.ToolResultContent{ ToolCallID: tc.ToolCallID, ToolName: tc.ToolName, Result: fantasy.ToolResultOutputContentError{ Error: xerrors.New(message), }, } } return results } func exclusiveToolMustRunAloneErrorMessage(toolName string) string { return toolName + " must be called alone, without other tools in the same batch. If you need more information to feed into the " + toolName + " call, execute the other tools first, then retry with only the " + toolName + " call." } func exclusiveToolSkippedErrorMessage(toolName string) string { return "this tool was skipped because " + toolName + " must run alone in its batch. Retry your tool calls without " + toolName + ", or call " + toolName + " separately first." } // executeSingleTool executes one tool call and converts the // response into a ToolResultContent. The error is non-nil only when // tool.Run fails. startRun, if set, supplies the context the tool runs on. func executeSingleTool( ctx context.Context, toolMap map[string]fantasy.AgentTool, tc fantasy.ToolCallContent, metrics *Metrics, logger slog.Logger, provider, model string, builtinToolNames map[string]bool, activeTools []string, allowInactiveTools map[string]bool, providerRunnerNames map[string]struct{}, resultProviderMetadata map[string]func(fantasy.ToolResponse) fantasy.ProviderMetadata, maxResultBytes int, toolNameAliases map[string]string, startRun func(context.Context) context.Context, ) (fantasy.ToolResultContent, error) { result := fantasy.ToolResultContent{ ToolCallID: tc.ToolCallID, ToolName: tc.ToolName, ProviderExecuted: false, } defer func() { metricLabel := tc.ToolName if metricLabel == "" { metricLabel = "unknown" } metrics.ToolResultSizeBytes.WithLabelValues(provider, model, metricLabel).Observe( float64(ToolResultSize(result)), ) if _, ok := result.Result.(fantasy.ToolResultOutputContentError); ok { metrics.RecordToolError(provider, model, metricLabel) } }() // Resolve backward-compatible tool aliases (for example a renamed // tool whose old name still appears in chat history) to the canonical // tool before the active-tool and dispatch lookups. resolvedName := tc.ToolName if alias, ok := toolNameAliases[tc.ToolName]; ok { resolvedName = alias } _, isProviderRunner := providerRunnerNames[resolvedName] if !isProviderRunner && !isToolActive(resolvedName, activeTools) && !allowInactiveTools[resolvedName] { result.Result = fantasy.ToolResultOutputContentError{ Error: xerrors.New("Tool not active in this turn: " + resolvedName), } return result, nil } tool, exists := toolMap[resolvedName] if !exists { result.Result = fantasy.ToolResultOutputContentError{ Error: xerrors.New("Tool not found: " + resolvedName), } return result, nil } logger.Debug(ctx, "tool execution", slog.F("tool_name", tc.ToolName), slog.F("resolved_tool_name", resolvedName), slog.F("tool_call_id", tc.ToolCallID), slog.F("builtin", builtinToolNames[resolvedName]), slog.F("is_provider_runner", isProviderRunner), ) if startRun != nil { ctx = startRun(ctx) } resp, err := tool.Run(ctx, fantasy.ToolCall{ ID: tc.ToolCallID, Name: resolvedName, Input: tc.Input, }) if err != nil { result.Result = fantasy.ToolResultOutputContentError{ Error: err, } result.ClientMetadata = resp.Metadata logger.Error(ctx, "tool execution failed", slog.F("tool_name", tc.ToolName), slog.F("tool_call_id", tc.ToolCallID), slog.Error(err), ) return result, err } result.ClientMetadata = resp.Metadata // Bound text so one tool result cannot overflow the model's context window. // Media limits are applied separately by normalizeToolMedia. content := resp.Content if truncated, didTruncate := TruncateToolResultText(content, maxResultBytes); didTruncate { metrics.RecordToolResultTruncated(provider, model, tc.ToolName) logger.Warn(ctx, "tool result truncated to fit model context", slog.F("tool_name", tc.ToolName), slog.F("tool_call_id", tc.ToolCallID), slog.F("original_bytes", len(content)), slog.F("max_bytes", maxResultBytes), ) content = truncated } switch { case resp.IsError: result.Result = fantasy.ToolResultOutputContentError{ Error: xerrors.New(content), } logger.Info(ctx, "tool returned error result", slog.F("tool_name", tc.ToolName), slog.F("tool_call_id", tc.ToolCallID), slog.F("tool_error", content), ) case resp.Type == "image" || resp.Type == "media": text := strings.ToValidUTF8(content, "\uFFFD") if note, rejected := normalizeToolMedia(&resp); rejected { logger.Warn(ctx, "tool result media rejected, keeping text only", slog.F("tool_name", tc.ToolName), slog.F("tool_call_id", tc.ToolCallID), slog.F("media_type", resp.MediaType), slog.F("media_bytes", len(resp.Data)), ) if text != "" { text += "\n" } result.Result = fantasy.ToolResultOutputContentText{Text: text + note} break } result.Result = fantasy.ToolResultOutputContentMedia{ Data: base64.StdEncoding.EncodeToString(resp.Data), MediaType: resp.MediaType, Text: text, } default: result.Result = fantasy.ToolResultOutputContentText{ Text: strings.ToValidUTF8(content, "\uFFFD"), } } if _, isError := result.Result.(fantasy.ToolResultOutputContentError); isError { return result, nil } if len(result.ProviderMetadata) == 0 { if callback := resultProviderMetadata[tc.ToolName]; callback != nil { metadata := callback(resp) if len(metadata) > 0 { result.ProviderMetadata = metadata } } } return result, nil } // normalizeToolMedia bounds persisted payloads and corrects image MIME types // by signature, not full image decoding. Transport limits apply at prompt build. func normalizeToolMedia(resp *fantasy.ToolResponse) (string, bool) { if len(resp.Data) > codersdk.MaxChatFileSizeBytes { return fmt.Sprintf( "[%s content omitted: %d bytes exceeds the %d byte tool media limit]", resp.MediaType, len(resp.Data), codersdk.MaxChatFileSizeBytes, ), true } if !strings.HasPrefix(chatfiles.BaseMediaType(resp.MediaType), "image/") { return "", false } detected := chatfiles.DetectMediaType(resp.Data) if !strings.HasPrefix(detected, "image/") { return fmt.Sprintf( "[image omitted: payload declared as %s is %s]", resp.MediaType, detected, ), true } resp.MediaType = detected return "", false } func isToolActive(name string, activeTools []string) bool { return len(activeTools) == 0 || slices.Contains(activeTools, name) } // serialToolCaller is implemented by tools whose calls within one step // must execute in tool-call order because they claim from shared state. type serialToolCaller interface{ SerialToolCalls() bool } // stepToolCallObserver is implemented by tools that need to see every // tool-call name in the step before any call executes, for example so // find_tools can charge same-step direct calls against its budget. type stepToolCallObserver interface{ ObserveStepToolCalls(names []string) } // notifyStepToolCallObservers passes the step's resolved tool-call // names to each distinct called tool that observes them. func notifyStepToolCallObservers(toolMap map[string]fantasy.AgentTool, toolNameAliases map[string]string, calls []fantasy.ToolCallContent) { names := make([]string, 0, len(calls)) for _, tc := range calls { name := tc.ToolName if alias, ok := toolNameAliases[name]; ok { name = alias } names = append(names, name) } notified := make(map[string]struct{}, len(names)) for _, name := range names { if _, dup := notified[name]; dup { continue } notified[name] = struct{}{} tool, ok := toolMap[name] if !ok { continue } if observer, ok := tool.(stepToolCallObserver); ok { observer.ObserveStepToolCalls(names) } } } // stepToolResultObserver is implemented by tools that need the step's // per-call execution outcomes, for example so find_tools can refund // budget it reserved for a direct call whose execution errored. names // and errored are parallel slices in the observed tool-call order; // outcomes are kept per call because one tool can be called several // times in a step with different results. type stepToolResultObserver interface { ObserveStepToolResults(names []string, errored []bool) } // notifyStepToolResultObservers passes the settled sibling outcomes to // each distinct called tool that observes them, per call in observed // order. Observed calls missing from the executed batch were rejected // before execution (for example malformed JSON partitioned into // synthetic denials) and settle as errored, since their persisted // results always carry IsError. Serial calls have not run yet, so // their own outcomes are reported as not errored; observers only need // the concurrent siblings they share state with. func notifyStepToolResultObservers(toolMap map[string]fantasy.AgentTool, toolNameAliases map[string]string, calls, observed []fantasy.ToolCallContent, settled []fantasy.ToolResultContent) { resolve := func(name string) string { if alias, ok := toolNameAliases[name]; ok { return alias } return name } erroredByID := make(map[string]bool, len(settled)) for _, tr := range settled { _, isErr := tr.Result.(fantasy.ToolResultOutputContentError) erroredByID[tr.ToolCallID] = isErr } executedIDs := make(map[string]struct{}, len(calls)) for _, tc := range calls { executedIDs[tc.ToolCallID] = struct{}{} } names := make([]string, 0, len(observed)) errored := make([]bool, 0, len(observed)) for _, tc := range observed { names = append(names, resolve(tc.ToolName)) if isErr, ok := erroredByID[tc.ToolCallID]; ok { errored = append(errored, isErr) continue } _, executed := executedIDs[tc.ToolCallID] errored = append(errored, !executed) } notified := make(map[string]struct{}, len(calls)) for _, tc := range calls { name := tc.ToolName if alias, ok := toolNameAliases[name]; ok { name = alias } if _, dup := notified[name]; dup { continue } notified[name] = struct{}{} tool, ok := toolMap[name] if !ok { continue } if observer, ok := tool.(stepToolResultObserver); ok { observer.ObserveStepToolResults(names, errored) } } } func isSerialToolCall(toolMap map[string]fantasy.AgentTool, toolNameAliases map[string]string, name string) bool { if alias, ok := toolNameAliases[name]; ok { name = alias } tool, ok := toolMap[name] if !ok { return false } serial, ok := tool.(serialToolCaller) return ok && serial.SerialToolCalls() } // promptCachingStrategy marks prompt cache breakpoints on a prepared // prompt in the shape the model's fantasy client serializes. type promptCachingStrategy func(messages []fantasy.Message) // promptCachingStrategyFor returns the breakpoint marker for Claude models // on transports that forward Anthropic prompt caching, or nil. func promptCachingStrategyFor(model fantasy.LanguageModel, modelName string) promptCachingStrategy { if model == nil { return nil } switch model.Provider() { case fantasyanthropic.Name: return addAnthropicPromptCaching case fantasyopenrouter.Name, fantasyvercel.Name: // Both clients translate Anthropic-keyed cache options into // cache_control on the OpenAI-compatible wire. if chatprovider.IsAnthropicFamilyModelID(modelName) { return addAnthropicPromptCaching } case fantasyopenaicompat.Name: // The client only knows ContentExtraFields, and the AI Gateway // re-applies client-sent cache_control when it rebuilds the // upstream body, so the marker reaches OpenRouter-style upstreams. if chatprovider.IsAnthropicFamilyModelID(modelName) { return addOpenAICompatPromptCaching } } return nil } // addAnthropicPromptCaching mutates messages in-place, setting // ProviderOptions for Anthropic prompt caching on the last system // message and the final two messages. func addAnthropicPromptCaching(messages []fantasy.Message) { for i := range messages { messages[i].ProviderOptions = nil } providerOption := fantasy.ProviderOptions{ fantasyanthropic.Name: &fantasyanthropic.ProviderCacheControlOptions{ CacheControl: fantasyanthropic.CacheControl{Type: "ephemeral"}, }, } lastSystemRoleIdx := -1 systemMessageUpdated := false for i, msg := range messages { if msg.Role == fantasy.MessageRoleSystem { lastSystemRoleIdx = i } else if !systemMessageUpdated && lastSystemRoleIdx >= 0 { messages[lastSystemRoleIdx].ProviderOptions = providerOption systemMessageUpdated = true } if i > len(messages)-3 { messages[i].ProviderOptions = providerOption } } } // addOpenAICompatPromptCaching marks breakpoints on the last text part // of the last system message and of the final two text-bearing user or // assistant messages; tool messages have no text block on the // OpenAI-compatible wire. Markers are part-level because message-level // ContentExtraFields shares a key with openaicompat.ProviderOptions. func addOpenAICompatPromptCaching(messages []fantasy.Message) { marker := &fantasyopenaicompat.ContentExtraFields{ Fields: map[string]any{"cache_control": map[string]string{"type": "ephemeral"}}, } lastSystemIdx := -1 for i := range messages { // Content slices are shared with the canonical messages. messages[i].Content = slices.Clone(messages[i].Content) if messages[i].Role == fantasy.MessageRoleSystem { lastSystemIdx = i } } if lastSystemIdx >= 0 { markLastTextPart(messages[lastSystemIdx].Content, marker) } remaining := 2 for i := len(messages) - 1; i >= 0 && remaining > 0; i-- { if messages[i].Role != fantasy.MessageRoleUser && messages[i].Role != fantasy.MessageRoleAssistant { continue } if markLastTextPart(messages[i].Content, marker) { remaining-- } } } func markLastTextPart(content []fantasy.MessagePart, marker fantasy.ProviderOptionsData) bool { for j := len(content) - 1; j >= 0; j-- { textPart, ok := content[j].(fantasy.TextPart) if !ok || strings.TrimSpace(textPart.Text) == "" { continue } // The options map is shared with the canonical part; never mutate it. options := maps.Clone(textPart.ProviderOptions) if options == nil { options = fantasy.ProviderOptions{} } options[fantasyopenaicompat.Name] = marker textPart.ProviderOptions = options content[j] = textPart return true } return false } // recordToolResultTimestamp lazily initializes the // toolResultCreatedAt map on the stepResult and records // the completion timestamp for the given tool-call ID. func recordToolResultTimestamp(result *stepResult, toolCallID string, ts time.Time) { if result.toolResultCreatedAt == nil { result.toolResultCreatedAt = make(map[string]time.Time) } result.toolResultCreatedAt[toolCallID] = ts } func publishToolAttachments( ctx context.Context, logger slog.Logger, tr fantasy.ToolResultContent, createdAt time.Time, publishMessagePart func(codersdk.ChatMessageRole, codersdk.ChatMessagePart), ) { attachments, err := chattool.AttachmentsFromMetadata(tr.ClientMetadata) if err != nil { logger.Warn(ctx, "skipping malformed tool attachment metadata", slog.F("tool_name", tr.ToolName), slog.F("tool_call_id", tr.ToolCallID), slog.Error(err), ) return } for _, attachment := range attachments { filePart := codersdk.ChatMessageFile( attachment.FileID, attachment.MediaType, attachment.Name, ) filePart.CreatedAt = &createdAt publishMessagePart(codersdk.ChatMessageRoleAssistant, filePart) } } func extractContextLimit(metadata fantasy.ProviderMetadata) sql.NullInt64 { if len(metadata) == 0 { return sql.NullInt64{} } encoded, err := json.Marshal(metadata) if err != nil || len(encoded) == 0 { return sql.NullInt64{} } var payload any if err := json.Unmarshal(encoded, &payload); err != nil { return sql.NullInt64{} } limit, ok := findContextLimitValue(payload) if !ok { return sql.NullInt64{} } return sql.NullInt64{ Int64: limit, Valid: true, } } func extractContextLimitWithFallback(metadata fantasy.ProviderMetadata, fallback int64) sql.NullInt64 { contextLimit := extractContextLimit(metadata) if contextLimit.Valid || fallback <= 0 { return contextLimit } return sql.NullInt64{ Int64: fallback, Valid: true, } } func findContextLimitValue(value any) (int64, bool) { var ( limit int64 found bool ) collectContextLimitValues(value, func(candidate int64) { if !found || candidate > limit { limit = candidate found = true } }) return limit, found } func collectContextLimitValues(value any, onValue func(int64)) { switch typed := value.(type) { case map[string]any: for key, child := range typed { if isContextLimitKey(key) { if numeric, ok := numericContextLimitValue(child); ok { onValue(numeric) } } collectContextLimitValues(child, onValue) } case []any: for _, child := range typed { collectContextLimitValues(child, onValue) } } } func isContextLimitKey(key string) bool { normalized := normalizeMetadataKey(key) if normalized == "" { return false } switch normalized { case "contextlimit", "contextwindow", "contextlength", "maxcontext", "maxcontexttokens", "maxinputtokens", "maxinputtoken", "inputtokenlimit": return true } words := metadataKeyWords(key) if !slices.Contains(words, "context") { return false } if slices.Contains(words, "limit") { return true } if slices.Contains(words, "window") { return slices.Contains(words, "size") || slices.Contains(words, "max") } if slices.Contains(words, "length") { return slices.Contains(words, "max") } return (slices.Contains(words, "token") || slices.Contains(words, "tokens")) && (slices.Contains(words, "max") || slices.Contains(words, "limit")) } func normalizeMetadataKey(key string) string { var b strings.Builder b.Grow(len(key)) for _, r := range key { switch { case r >= 'a' && r <= 'z': _, _ = b.WriteRune(r) case r >= 'A' && r <= 'Z': _, _ = b.WriteRune(r + ('a' - 'A')) case r >= '0' && r <= '9': _, _ = b.WriteRune(r) } } return b.String() } func metadataKeyWords(key string) []string { words := make([]string, 0, 4) var current strings.Builder flush := func() { if current.Len() == 0 { return } words = append(words, current.String()) current.Reset() } var prev rune var hasPrev bool for _, r := range key { if !unicode.IsLetter(r) { flush() hasPrev = false continue } if hasPrev && unicode.IsUpper(r) && unicode.IsLower(prev) { flush() } _, _ = current.WriteRune(unicode.ToLower(r)) prev = r hasPrev = true } flush() return words } func numericContextLimitValue(value any) (int64, bool) { switch typed := value.(type) { case float64: casted := int64(typed) if typed > 0 && float64(casted) == typed { return casted, true } case string: parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) if err == nil { return positiveInt64(parsed) } } return 0, false } func positiveInt64(value int64) (int64, bool) { if value <= 0 { return 0, false } return value, true }