mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
2086 lines
66 KiB
Go
2086 lines
66 KiB
Go
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
|
|
}
|