1100 lines
34 KiB
Go
1100 lines
34 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/ollama/ollama/api"
|
|
"github.com/ollama/ollama/internal/modelref"
|
|
)
|
|
|
|
type ChatClient interface {
|
|
Chat(context.Context, *api.ChatRequest, api.ChatResponseFunc) error
|
|
}
|
|
|
|
type Session struct {
|
|
Client ChatClient
|
|
EventSinks []EventSink
|
|
Tools *Registry
|
|
Skills *SkillCatalog
|
|
DisableTools bool
|
|
ApprovalPrompter ApprovalPrompter
|
|
ApprovalState *ApprovalState
|
|
WorkingDir string
|
|
Compactor Compactor
|
|
}
|
|
|
|
type RunOptions struct {
|
|
ChatID string
|
|
Model string
|
|
SystemPrompt string
|
|
Messages []api.Message
|
|
NewMessages []api.Message
|
|
Format string
|
|
Options map[string]any
|
|
Think *api.ThinkValue
|
|
KeepAlive *api.Duration
|
|
// SkillName loads a catalog skill as an ordered synthetic tool call/result
|
|
// before the first model request for this run.
|
|
SkillName string
|
|
// MaxToolRounds limits consecutive model/tool cycles. A positive value is
|
|
// an explicit limit. Zero selects the model-specific default: local models
|
|
// use the default guard and cloud models are unlimited. A negative value
|
|
// disables the guard for tests or special callers.
|
|
MaxToolRounds int
|
|
}
|
|
|
|
type RunResult struct {
|
|
Messages []api.Message
|
|
Latest api.ChatResponse
|
|
WorkingDir string
|
|
}
|
|
|
|
const (
|
|
defaultMaxToolRounds = 100
|
|
maxToolResultRunes = 60000
|
|
smallContextToolResultRunes = 6000
|
|
tinyContextToolResultRunes = 3200
|
|
smallContextToolResultTokenWindow = 8192
|
|
tinyContextToolResultTokenWindow = 4096
|
|
toolTruncationMarkerReserveTokens = 64
|
|
toolOutputFullOmissionPrefix = "[tool output truncated: output omitted because the context is full;"
|
|
)
|
|
|
|
type toolOutputOverflow struct {
|
|
toolName string
|
|
toolCallID string
|
|
content string
|
|
}
|
|
|
|
type toolBatchResult struct {
|
|
messages []api.Message
|
|
stop toolExecutionStop
|
|
overflows []toolOutputOverflow
|
|
}
|
|
|
|
// toolExecutionStop is the batch-level outcome for a group of tool calls,
|
|
// distinct from per-call Event.Status values. The values overlap with
|
|
// runFinish.status ("denied", "canceled") because a denied or canceled
|
|
// batch also terminates the run with the matching status.
|
|
type toolExecutionStop string
|
|
|
|
const (
|
|
toolExecutionDenied toolExecutionStop = "denied"
|
|
toolExecutionCanceled toolExecutionStop = "canceled"
|
|
)
|
|
|
|
const toolExecutionDisabledMessage = "Tool execution disabled."
|
|
|
|
type runPhase int
|
|
|
|
const (
|
|
runPhaseModel runPhase = iota
|
|
runPhaseTools
|
|
runPhaseCompact
|
|
runPhaseDone
|
|
)
|
|
|
|
type runState struct {
|
|
runID string
|
|
opts RunOptions
|
|
|
|
phase runPhase
|
|
|
|
messages []api.Message
|
|
latest api.ChatResponse
|
|
|
|
assistant api.Message
|
|
pendingToolCalls []api.ToolCall
|
|
canceled bool
|
|
|
|
toolBatch *toolBatchResult
|
|
|
|
consecutiveModelErrors int
|
|
toolRounds int
|
|
maxToolRounds int
|
|
compactionSkipNotified bool
|
|
|
|
finish runFinish
|
|
}
|
|
|
|
type runFinish struct {
|
|
status RunStatus
|
|
ignoreCanceled bool
|
|
err error
|
|
}
|
|
|
|
func (st *runState) finishDone() {
|
|
st.finish = runFinish{status: RunStatusDone}
|
|
st.phase = runPhaseDone
|
|
}
|
|
|
|
func (st *runState) finishDenied() {
|
|
st.finish = runFinish{status: RunStatusDenied}
|
|
st.phase = runPhaseDone
|
|
}
|
|
|
|
func (st *runState) finishCanceled() {
|
|
st.finish = runFinish{status: RunStatusCanceled, ignoreCanceled: true}
|
|
st.phase = runPhaseDone
|
|
}
|
|
|
|
func (st *runState) finishError(err error) {
|
|
st.finish = runFinish{err: err}
|
|
st.phase = runPhaseDone
|
|
}
|
|
|
|
func (s *Session) Run(ctx context.Context, opts RunOptions) (*RunResult, error) {
|
|
if err := s.validateRun(opts); err != nil {
|
|
return nil, err
|
|
}
|
|
if s.ApprovalState == nil {
|
|
s.ApprovalState = &ApprovalState{}
|
|
}
|
|
runID := uuid.NewString()
|
|
messages, err := s.buildRunMessages(ctx, runID, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
activatedSkill, err := s.activateSkill(ctx, runID, opts)
|
|
if err != nil {
|
|
s.emit(newErrorEvent(newEventMetadata(runID, opts), err.Error()))
|
|
return nil, err
|
|
}
|
|
if len(activatedSkill) > 0 {
|
|
messages = append(messages, activatedSkill...)
|
|
if err := s.checkPreflightPromptBudget(opts, messages); err != nil {
|
|
s.emit(newErrorEvent(newEventMetadata(runID, opts), err.Error()))
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
st := runState{
|
|
runID: runID,
|
|
opts: opts,
|
|
phase: runPhaseModel,
|
|
messages: messages,
|
|
maxToolRounds: resolvedMaxToolRounds(opts.Model, opts.MaxToolRounds),
|
|
}
|
|
for {
|
|
switch st.phase {
|
|
case runPhaseModel:
|
|
if err := s.runModelStep(ctx, &st); err != nil {
|
|
return nil, err
|
|
}
|
|
case runPhaseTools:
|
|
if err := s.runToolStep(ctx, &st); err != nil {
|
|
return nil, err
|
|
}
|
|
case runPhaseCompact:
|
|
if err := s.runCompactionStep(ctx, &st); err != nil {
|
|
return nil, err
|
|
}
|
|
case runPhaseDone:
|
|
return s.finishRun(ctx, &st)
|
|
}
|
|
}
|
|
}
|
|
|
|
// validateRun checks the preconditions for a run.
|
|
func (s *Session) validateRun(opts RunOptions) error {
|
|
if s == nil {
|
|
return errors.New("nil session")
|
|
}
|
|
if s.Client == nil {
|
|
return errors.New("agent session requires a chat client")
|
|
}
|
|
if opts.Model == "" {
|
|
return errors.New("agent session requires a model")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildRunMessages sanitizes the provided message history, runs the preflight
|
|
// prompt-budget check, and returns the initial message list for the run. It
|
|
// emits an EventError and returns it if the preflight check fails.
|
|
func (s *Session) buildRunMessages(ctx context.Context, runID string, opts RunOptions) ([]api.Message, error) {
|
|
messages := make([]api.Message, 0, len(opts.Messages)+len(opts.NewMessages))
|
|
for _, msg := range opts.Messages {
|
|
messages = append(messages, sanitizeMessageForRun(msg))
|
|
}
|
|
for _, msg := range opts.NewMessages {
|
|
msg = sanitizeMessageForRun(msg)
|
|
messages = append(messages, msg)
|
|
}
|
|
|
|
if err := s.checkPreflightPromptBudget(opts, messages); err != nil {
|
|
s.emit(newErrorEvent(newEventMetadata(runID, opts), err.Error()))
|
|
return nil, err
|
|
}
|
|
return messages, nil
|
|
}
|
|
|
|
func (s *Session) runModelStep(ctx context.Context, st *runState) error {
|
|
opts := st.opts
|
|
meta := newEventMetadata(st.runID, opts)
|
|
|
|
assistant, pendingToolCalls, canceled, err := s.chatRound(ctx, st.runID, opts, st.messages, &st.latest)
|
|
if err != nil {
|
|
var statusErr api.StatusError
|
|
if errors.As(err, &statusErr) && statusErr.StatusCode >= 500 && st.consecutiveModelErrors < 2 {
|
|
st.consecutiveModelErrors++
|
|
st.messages = append(st.messages, api.Message{
|
|
Role: "user",
|
|
Content: fmt.Sprintf("Your previous response caused an error: %s\n\nPlease try again with a valid response.", statusErr.ErrorMessage),
|
|
})
|
|
return nil
|
|
}
|
|
s.emit(newErrorEvent(meta, err.Error()))
|
|
return err
|
|
}
|
|
st.consecutiveModelErrors = 0
|
|
st.assistant = assistant
|
|
st.pendingToolCalls = pendingToolCalls
|
|
st.canceled = canceled
|
|
|
|
if !messageEmpty(assistant) {
|
|
st.messages = append(st.messages, assistant)
|
|
}
|
|
|
|
if len(pendingToolCalls) == 0 {
|
|
st.toolBatch = nil
|
|
st.phase = runPhaseCompact
|
|
return nil
|
|
}
|
|
|
|
if canceled {
|
|
skipped, skipErr := s.skipToolCalls(ctx, st.runID, opts, pendingToolCalls, "Tool execution skipped because the run was canceled.")
|
|
if skipErr != nil {
|
|
s.emit(newErrorEvent(meta, skipErr.Error()))
|
|
return skipErr
|
|
}
|
|
st.messages = append(st.messages, skipped...)
|
|
st.finishCanceled()
|
|
return nil
|
|
}
|
|
|
|
if s.DisableTools {
|
|
batch, skipErr := s.disabledToolCalls(ctx, st.runID, opts, st.messages, pendingToolCalls)
|
|
if skipErr != nil {
|
|
s.emit(newErrorEvent(meta, skipErr.Error()))
|
|
return skipErr
|
|
}
|
|
st.messages = append(st.messages, batch.messages...)
|
|
st.toolBatch = &batch
|
|
st.phase = runPhaseCompact
|
|
return nil
|
|
}
|
|
|
|
if s.Tools == nil {
|
|
st.finishDone()
|
|
return nil
|
|
}
|
|
|
|
if st.maxToolRounds >= 0 && st.toolRounds >= st.maxToolRounds {
|
|
content := fmt.Sprintf("Tool execution skipped because the max tool-round limit of %d was reached. Send another message to continue.", st.maxToolRounds)
|
|
toolMessages, skipErr := s.skipToolCalls(ctx, st.runID, opts, pendingToolCalls, content)
|
|
if skipErr != nil {
|
|
s.emit(newErrorEvent(meta, skipErr.Error()))
|
|
return skipErr
|
|
}
|
|
st.messages = append(st.messages, toolMessages...)
|
|
err := fmt.Errorf("tool round limit reached after %d rounds; send another message to continue", st.maxToolRounds)
|
|
s.emit(newErrorEvent(meta, err.Error()))
|
|
st.finishError(err)
|
|
return nil
|
|
}
|
|
|
|
st.phase = runPhaseTools
|
|
return nil
|
|
}
|
|
|
|
func (s *Session) runToolStep(ctx context.Context, st *runState) error {
|
|
batch, err := s.executeToolCalls(ctx, st.runID, st.opts, st.messages, st.pendingToolCalls)
|
|
if err != nil {
|
|
s.emit(newErrorEvent(newEventMetadata(st.runID, st.opts), err.Error()))
|
|
return err
|
|
}
|
|
|
|
st.messages = append(st.messages, batch.messages...)
|
|
st.toolBatch = &batch
|
|
st.phase = runPhaseCompact
|
|
return nil
|
|
}
|
|
|
|
func (s *Session) runCompactionStep(ctx context.Context, st *runState) error {
|
|
opts := st.opts
|
|
meta := newEventMetadata(st.runID, opts)
|
|
var err error
|
|
if st.toolBatch != nil && len(st.toolBatch.overflows) > 0 {
|
|
st.messages, st.compactionSkipNotified, err = s.compactForToolOutputOverflow(ctx, st.runID, opts, st.messages, st.latest, st.assistant, st.toolBatch.messages, st.toolBatch.overflows, st.compactionSkipNotified)
|
|
} else {
|
|
st.messages, st.compactionSkipNotified, err = s.maybeCompact(ctx, st.runID, opts, st.messages, st.latest, st.compactionSkipNotified)
|
|
}
|
|
if err != nil {
|
|
s.emit(newErrorEvent(meta, err.Error()))
|
|
st.finishError(err)
|
|
return nil
|
|
}
|
|
|
|
if st.toolBatch == nil {
|
|
if st.canceled {
|
|
st.finishCanceled()
|
|
} else {
|
|
st.finishDone()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
switch st.toolBatch.stop {
|
|
case toolExecutionDenied:
|
|
st.finishDenied()
|
|
case toolExecutionCanceled:
|
|
st.finishCanceled()
|
|
default:
|
|
st.toolRounds++
|
|
st.assistant = api.Message{}
|
|
st.pendingToolCalls = nil
|
|
st.toolBatch = nil
|
|
st.phase = runPhaseModel
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Session) finishRun(ctx context.Context, st *runState) (*RunResult, error) {
|
|
if st.finish.status != "" {
|
|
event := newRunFinished(newEventMetadata(st.runID, st.opts), st.finish.status)
|
|
var err error
|
|
if st.finish.ignoreCanceled {
|
|
err = s.emitIgnoringCanceled(ctx, event)
|
|
} else {
|
|
err = s.emit(event)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &RunResult{Messages: st.messages, Latest: st.latest, WorkingDir: s.WorkingDir}, st.finish.err
|
|
}
|
|
|
|
func (s *Session) chatRound(ctx context.Context, runID string, opts RunOptions, messages []api.Message, latest *api.ChatResponse) (api.Message, []api.ToolCall, bool, error) {
|
|
meta := newEventMetadata(runID, opts)
|
|
var tools api.Tools
|
|
if !s.DisableTools {
|
|
tools = s.availableTools()
|
|
}
|
|
req := buildChatRequest(opts, messages, tools)
|
|
|
|
assistant := api.Message{Role: "assistant"}
|
|
var pendingToolCalls []api.ToolCall
|
|
|
|
err := s.Client.Chat(ctx, &req, func(response api.ChatResponse) error {
|
|
if response.Message.Role != "" {
|
|
assistant.Role = response.Message.Role
|
|
}
|
|
|
|
if messageEmpty(response.Message) {
|
|
*latest = response
|
|
return nil
|
|
}
|
|
|
|
if response.Message.Thinking != "" {
|
|
assistant.Thinking += response.Message.Thinking
|
|
if err := s.emit(newThinkingDelta(meta, response.Message.Thinking)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if response.Message.Content != "" {
|
|
assistant.Content += response.Message.Content
|
|
if err := s.emit(newMessageDelta(meta, response.Message.Content)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if len(response.Message.ToolCalls) > 0 {
|
|
assistant.ToolCalls = append(assistant.ToolCalls, response.Message.ToolCalls...)
|
|
pendingToolCalls = append(pendingToolCalls, response.Message.ToolCalls...)
|
|
if err := s.emit(newToolCallDetected(meta, response.Message.ToolCalls)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
*latest = response
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
if isContextCanceledError(ctx, err) {
|
|
return assistant, pendingToolCalls, true, nil
|
|
}
|
|
return assistant, pendingToolCalls, false, err
|
|
}
|
|
|
|
return assistant, pendingToolCalls, false, nil
|
|
}
|
|
|
|
func buildChatRequest(opts RunOptions, messages []api.Message, tools api.Tools) api.ChatRequest {
|
|
requestMessages := sanitizeMessagesForRequest(messages)
|
|
if strings.TrimSpace(opts.SystemPrompt) != "" {
|
|
withSystem := make([]api.Message, 0, len(requestMessages)+1)
|
|
withSystem = append(withSystem, api.Message{Role: "system", Content: opts.SystemPrompt})
|
|
requestMessages = append(withSystem, requestMessages...)
|
|
}
|
|
|
|
format := opts.Format
|
|
if format == "json" {
|
|
format = `"` + format + `"`
|
|
}
|
|
|
|
req := api.ChatRequest{
|
|
Model: opts.Model,
|
|
Messages: requestMessages,
|
|
Format: json.RawMessage(format),
|
|
Options: opts.Options,
|
|
Think: opts.Think,
|
|
}
|
|
if opts.KeepAlive != nil {
|
|
req.KeepAlive = opts.KeepAlive
|
|
}
|
|
if len(tools) > 0 {
|
|
req.Tools = tools
|
|
}
|
|
return req
|
|
}
|
|
|
|
func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOptions, messages []api.Message, calls []api.ToolCall) (toolBatchResult, error) {
|
|
meta := newEventMetadata(runID, opts)
|
|
batch := toolBatchResult{
|
|
messages: make([]api.Message, 0, len(calls)),
|
|
}
|
|
projectedMessages := append([]api.Message(nil), messages...)
|
|
// Pre-compute the full-history token estimate once per batch instead of
|
|
// re-marshaling the entire history for each tool call. Per-call deltas
|
|
// (tool messages already appended this batch) are tracked in batchTokens
|
|
// and added to historyTokens for a lightweight running total.
|
|
historyTokens := s.estimateRunPromptTokens(opts, messages)
|
|
batchTokens := 0
|
|
|
|
type plannedToolCall struct {
|
|
call api.ToolCall
|
|
tool Tool
|
|
toolName string
|
|
args map[string]any
|
|
workingDir string
|
|
}
|
|
plans := make([]plannedToolCall, 0, len(calls))
|
|
batchWorkingDir := s.currentWorkingDir()
|
|
approvalReq := ApprovalRequest{WorkingDir: batchWorkingDir}
|
|
for _, call := range calls {
|
|
toolName := call.Function.Name
|
|
args := call.Function.Arguments.ToMap()
|
|
tool, ok := s.Tools.Get(toolName)
|
|
plans = append(plans, plannedToolCall{
|
|
call: call,
|
|
tool: tool,
|
|
toolName: toolName,
|
|
args: args,
|
|
workingDir: batchWorkingDir,
|
|
})
|
|
if ok && s.needsApproval(tool, toolName, args) {
|
|
approvalReq.AddToolCall(call.ID, toolName, toolApprovalScope(tool, toolName, args), args)
|
|
}
|
|
}
|
|
|
|
if len(approvalReq.Calls) > 0 {
|
|
approvalResult, err := s.authorizeToolCalls(ctx, approvalReq)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
skipped, skipErr := s.skipToolCalls(ctx, runID, opts, calls, "Tool execution skipped because the run was canceled.")
|
|
if skipErr != nil {
|
|
return toolBatchResult{}, skipErr
|
|
}
|
|
batch.messages = append(batch.messages, skipped...)
|
|
batch.stop = toolExecutionCanceled
|
|
return batch, nil
|
|
}
|
|
return toolBatchResult{}, err
|
|
}
|
|
if !approvalResult.Allow {
|
|
content := approvalResult.Reason
|
|
if content == "" {
|
|
content = "Tool execution denied."
|
|
}
|
|
for _, plan := range plans {
|
|
msg := s.toolMessageForContext(plan.toolName, plan.call.ID, content, opts, historyTokens+batchTokens)
|
|
batch.messages = append(batch.messages, msg)
|
|
projectedMessages = append(projectedMessages, msg)
|
|
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
|
deniedContent := msg.Content
|
|
if emitErr := s.emit(newToolFinished(meta, "denied", plan.call.ID, plan.toolName, "", plan.args, deniedContent, deniedContent)); emitErr != nil {
|
|
return toolBatchResult{}, emitErr
|
|
}
|
|
}
|
|
batch.stop = toolExecutionDenied
|
|
return batch, nil
|
|
}
|
|
}
|
|
|
|
for i, plan := range plans {
|
|
call := plan.call
|
|
toolName := plan.toolName
|
|
args := plan.args
|
|
if ctx.Err() != nil {
|
|
skipped, skipErr := s.skipToolCalls(ctx, runID, opts, calls[i:], "Tool execution skipped because the run was canceled.")
|
|
if skipErr != nil {
|
|
return toolBatchResult{}, skipErr
|
|
}
|
|
batch.messages = append(batch.messages, skipped...)
|
|
batch.stop = toolExecutionCanceled
|
|
return batch, nil
|
|
}
|
|
if plan.tool == nil {
|
|
content := fmt.Sprintf("Error: unknown tool: %s", toolName)
|
|
msg := s.toolMessageForContext(toolName, call.ID, content, opts, historyTokens+batchTokens)
|
|
batch.messages = append(batch.messages, msg)
|
|
projectedMessages = append(projectedMessages, msg)
|
|
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
|
content = msg.Content
|
|
if toolOutputFullyOmitted(content) {
|
|
batch.overflows = append(batch.overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: fmt.Sprintf("Error: unknown tool: %s", toolName)})
|
|
}
|
|
if emitErr := s.emit(newToolFinished(meta, "failed", call.ID, toolName, "", args, content, fmt.Sprintf("unknown tool: %s", toolName))); emitErr != nil {
|
|
return toolBatchResult{}, emitErr
|
|
}
|
|
continue
|
|
}
|
|
|
|
if err := s.emit(newToolStarted(meta, call.ID, toolName, plan.workingDir, args)); err != nil {
|
|
return toolBatchResult{}, err
|
|
}
|
|
|
|
result, err := s.Tools.Execute(ctx, ToolContext{WorkingDir: plan.workingDir}, call)
|
|
if err != nil {
|
|
rawContent := fmt.Sprintf("Error: %v", err)
|
|
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, historyTokens+batchTokens)
|
|
batch.messages = append(batch.messages, msg)
|
|
projectedMessages = append(projectedMessages, msg)
|
|
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
|
content := msg.Content
|
|
if toolOutputFullyOmitted(content) {
|
|
batch.overflows = append(batch.overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: rawContent})
|
|
}
|
|
if emitErr := s.emitIgnoringCanceled(ctx, newToolFinished(meta, "failed", call.ID, toolName, "", args, content, err.Error())); emitErr != nil {
|
|
return toolBatchResult{}, emitErr
|
|
}
|
|
if ctx.Err() != nil {
|
|
skipped, skipErr := s.skipToolCalls(ctx, runID, opts, calls[i+1:], "Tool execution skipped because the run was canceled.")
|
|
if skipErr != nil {
|
|
return toolBatchResult{}, skipErr
|
|
}
|
|
batch.messages = append(batch.messages, skipped...)
|
|
batch.stop = toolExecutionCanceled
|
|
return batch, nil
|
|
}
|
|
continue
|
|
}
|
|
|
|
eventWorkingDir := plan.workingDir
|
|
if s.applyToolWorkingDir(result.WorkingDir) {
|
|
eventWorkingDir = s.WorkingDir
|
|
}
|
|
rawContent := result.Content
|
|
|
|
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, historyTokens+batchTokens)
|
|
batch.messages = append(batch.messages, msg)
|
|
projectedMessages = append(projectedMessages, msg)
|
|
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
|
content := msg.Content
|
|
|
|
if toolOutputFullyOmitted(content) {
|
|
batch.overflows = append(batch.overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: rawContent})
|
|
}
|
|
if err := s.emitIgnoringCanceled(ctx, newToolFinished(meta, "done", call.ID, toolName, eventWorkingDir, args, content, "")); err != nil {
|
|
return toolBatchResult{}, err
|
|
}
|
|
if ctx.Err() != nil {
|
|
skipped, skipErr := s.skipToolCalls(ctx, runID, opts, calls[i+1:], "Tool execution skipped because the run was canceled.")
|
|
if skipErr != nil {
|
|
return toolBatchResult{}, skipErr
|
|
}
|
|
batch.messages = append(batch.messages, skipped...)
|
|
batch.stop = toolExecutionCanceled
|
|
return batch, nil
|
|
}
|
|
}
|
|
return batch, nil
|
|
}
|
|
|
|
func (s *Session) disabledToolCalls(ctx context.Context, runID string, opts RunOptions, messages []api.Message, calls []api.ToolCall) (toolBatchResult, error) {
|
|
meta := newEventMetadata(runID, opts)
|
|
batch := toolBatchResult{
|
|
messages: make([]api.Message, 0, len(calls)),
|
|
}
|
|
projectedMessages := append([]api.Message(nil), messages...)
|
|
historyTokens := s.estimateRunPromptTokens(opts, messages)
|
|
batchTokens := 0
|
|
for _, call := range calls {
|
|
toolName := call.Function.Name
|
|
args := call.Function.Arguments.ToMap()
|
|
msg := s.toolMessageForContext(toolName, call.ID, toolExecutionDisabledMessage, opts, historyTokens+batchTokens)
|
|
batch.messages = append(batch.messages, msg)
|
|
projectedMessages = append(projectedMessages, msg)
|
|
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
|
if emitErr := s.emitIgnoringCanceled(ctx, newToolFinished(meta, "disabled", call.ID, toolName, "", args, msg.Content, msg.Content)); emitErr != nil {
|
|
return toolBatchResult{}, emitErr
|
|
}
|
|
}
|
|
return batch, nil
|
|
}
|
|
|
|
func (s *Session) skipToolCalls(ctx context.Context, runID string, opts RunOptions, calls []api.ToolCall, content string) ([]api.Message, error) {
|
|
meta := newEventMetadata(runID, opts)
|
|
toolMessages := make([]api.Message, 0, len(calls))
|
|
for _, call := range calls {
|
|
toolName := call.Function.Name
|
|
args := call.Function.Arguments.ToMap()
|
|
msg := toolMessage(toolName, call.ID, content)
|
|
toolMessages = append(toolMessages, msg)
|
|
if emitErr := s.emitIgnoringCanceled(ctx, newToolFinished(meta, "skipped", call.ID, toolName, "", args, msg.Content, msg.Content)); emitErr != nil {
|
|
return nil, emitErr
|
|
}
|
|
}
|
|
return toolMessages, nil
|
|
}
|
|
|
|
func (s *Session) currentWorkingDir() string {
|
|
if s.WorkingDir != "" {
|
|
return s.WorkingDir
|
|
}
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
s.WorkingDir = wd
|
|
return s.WorkingDir
|
|
}
|
|
|
|
func (s *Session) applyToolWorkingDir(next string) bool {
|
|
next = strings.TrimSpace(next)
|
|
if next == "" {
|
|
return false
|
|
}
|
|
current := s.currentWorkingDir()
|
|
nextAbs, err := canonicalSessionPath(next)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if current == nextAbs {
|
|
return false
|
|
}
|
|
s.WorkingDir = nextAbs
|
|
return true
|
|
}
|
|
|
|
func canonicalSessionPath(path string) (string, error) {
|
|
abs, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
resolved, err := filepath.EvalSymlinks(abs)
|
|
if err == nil {
|
|
return resolved, nil
|
|
}
|
|
return abs, nil
|
|
}
|
|
|
|
func isContextCanceledError(ctx context.Context, err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, context.Canceled) {
|
|
return true
|
|
}
|
|
return ctx != nil && errors.Is(ctx.Err(), context.Canceled) && strings.Contains(err.Error(), "context canceled")
|
|
}
|
|
|
|
func (s *Session) maybeCompact(ctx context.Context, runID string, opts RunOptions, messages []api.Message, latest api.ChatResponse, skipNotified bool) ([]api.Message, bool, error) {
|
|
if s.Compactor == nil {
|
|
return messages, skipNotified, nil
|
|
}
|
|
req := s.compactionRequest(runID, opts, messages, latest)
|
|
trigger := s.autoCompactionTrigger(req)
|
|
if trigger != "" {
|
|
s.emitCompactionStarted(runID, opts, trigger)
|
|
}
|
|
result, err := s.Compactor.MaybeCompact(ctx, req)
|
|
if err != nil {
|
|
if result.Due && !skipNotified {
|
|
if trigger == "" {
|
|
trigger = CompactionTriggerError
|
|
}
|
|
s.emitCompactionSkipped(runID, opts, trigger, result.Reason)
|
|
skipNotified = true
|
|
}
|
|
return messages, skipNotified, nil
|
|
}
|
|
if !result.Compacted {
|
|
if result.Due && !skipNotified {
|
|
if trigger == "" {
|
|
trigger = CompactionTriggerDue
|
|
}
|
|
s.emitCompactionSkipped(runID, opts, trigger, result.Reason)
|
|
skipNotified = true
|
|
}
|
|
return messages, skipNotified, nil
|
|
}
|
|
s.emitCompacted(runID, opts, result.Messages, trigger, result.Summary)
|
|
if err := s.checkPostCompactionPromptBudget(opts, result.Messages); err != nil {
|
|
return result.Messages, skipNotified, err
|
|
}
|
|
return result.Messages, skipNotified, nil
|
|
}
|
|
|
|
func (s *Session) compactForToolOutputOverflow(ctx context.Context, runID string, opts RunOptions, messages []api.Message, latest api.ChatResponse, assistant api.Message, toolMessages []api.Message, overflows []toolOutputOverflow, skipNotified bool) ([]api.Message, bool, error) {
|
|
if s.Compactor == nil {
|
|
return messages, skipNotified, nil
|
|
}
|
|
|
|
keepUserTurns := 0
|
|
req := s.compactionRequest(runID, opts, messages, latest)
|
|
req.Force = true
|
|
req.KeepUserTurns = &keepUserTurns
|
|
s.emitCompactionStarted(runID, opts, CompactionTriggerToolOutput)
|
|
|
|
result, err := s.Compactor.MaybeCompact(ctx, req)
|
|
if err != nil {
|
|
if result.Due && !skipNotified {
|
|
s.emitCompactionSkipped(runID, opts, CompactionTriggerToolOutput, result.Reason)
|
|
skipNotified = true
|
|
}
|
|
return messages, skipNotified, nil
|
|
}
|
|
if !result.Compacted {
|
|
if result.Due && !skipNotified {
|
|
s.emitCompactionSkipped(runID, opts, CompactionTriggerToolOutput, result.Reason)
|
|
skipNotified = true
|
|
}
|
|
return messages, skipNotified, nil
|
|
}
|
|
|
|
overflowByID := make(map[string]toolOutputOverflow, len(overflows))
|
|
for _, overflow := range overflows {
|
|
overflowByID[overflow.toolCallID] = overflow
|
|
}
|
|
|
|
compacted := append([]api.Message(nil), result.Messages...)
|
|
if !messageEmpty(assistant) {
|
|
compacted = append(compacted, assistant)
|
|
}
|
|
|
|
historyTokens := s.estimateRunPromptTokens(opts, compacted)
|
|
batchTokens := 0
|
|
for _, msg := range toolMessages {
|
|
content := msg.Content
|
|
toolName := msg.ToolName
|
|
if overflow, ok := overflowByID[msg.ToolCallID]; ok {
|
|
content = overflow.content
|
|
if overflow.toolName != "" {
|
|
toolName = overflow.toolName
|
|
}
|
|
}
|
|
refit := s.toolMessageForPostCompactionContext(toolName, msg.ToolCallID, content, opts, historyTokens+batchTokens)
|
|
compacted = append(compacted, refit)
|
|
batchTokens += estimateMessagesTokens([]api.Message{refit})
|
|
}
|
|
|
|
s.emitCompacted(runID, opts, compacted, CompactionTriggerToolOutput, result.Summary)
|
|
if err := s.checkPostCompactionPromptBudget(opts, compacted); err != nil {
|
|
return compacted, skipNotified, err
|
|
}
|
|
return compacted, skipNotified, nil
|
|
}
|
|
|
|
func (s *Session) compactionRequest(runID string, opts RunOptions, messages []api.Message, latest api.ChatResponse) CompactionRequest {
|
|
meta := newEventMetadata(runID, opts)
|
|
return CompactionRequest{
|
|
ChatID: opts.ChatID,
|
|
Model: opts.Model,
|
|
SystemPrompt: opts.SystemPrompt,
|
|
Messages: messages,
|
|
Tools: s.availableTools(),
|
|
Format: opts.Format,
|
|
Latest: latest,
|
|
Options: opts.Options,
|
|
KeepAlive: opts.KeepAlive,
|
|
Think: opts.Think,
|
|
ContinueTask: true,
|
|
Progress: func(progress CompactionProgress) {
|
|
_ = s.emit(newCompactionProgress(meta, progress.Tokens))
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *Session) emitCompactionStarted(runID string, opts RunOptions, trigger CompactionTrigger) {
|
|
_ = s.emit(newCompactionStarted(newEventMetadata(runID, opts), trigger))
|
|
}
|
|
|
|
func (s *Session) emitCompactionSkipped(runID string, opts RunOptions, trigger CompactionTrigger, reason string) {
|
|
_ = s.emit(newCompactionSkipped(newEventMetadata(runID, opts), trigger, CompactionSkippedMessage(reason)))
|
|
}
|
|
|
|
func (s *Session) emitCompacted(runID string, opts RunOptions, messages []api.Message, trigger CompactionTrigger, summary string) {
|
|
_ = s.emit(newCompacted(newEventMetadata(runID, opts), messages, trigger, summary))
|
|
}
|
|
|
|
func (s *Session) autoCompactionTrigger(req CompactionRequest) CompactionTrigger {
|
|
if s.Compactor == nil {
|
|
return ""
|
|
}
|
|
trigger, should := s.Compactor.ShouldCompact(req)
|
|
if should {
|
|
return CompactionTrigger(trigger)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func CompactionSkippedMessage(reason string) string {
|
|
reason = strings.TrimSpace(reason)
|
|
if reason == "" {
|
|
reason = "compaction could not run"
|
|
}
|
|
return reason
|
|
}
|
|
|
|
func resolvedMaxToolRounds(model string, value int) int {
|
|
if value != 0 {
|
|
return value
|
|
}
|
|
if modelref.HasExplicitCloudSource(model) {
|
|
return -1
|
|
}
|
|
return defaultMaxToolRounds
|
|
}
|
|
|
|
// toolMessageWithBudget sizes a tool result message to fit within a token
|
|
// budget (compaction threshold or context window). baseTokens is the
|
|
// pre-computed estimate of everything before this message; budgetTokens is
|
|
// the ceiling. If the message already fits, it is returned with only the
|
|
// small-context rune cap applied.
|
|
func (s *Session) toolMessageWithBudget(toolName, toolCallID, content string, opts RunOptions, baseTokens, budgetTokens int) api.Message {
|
|
maxRunes := maxToolResultRunes
|
|
if limit := smallContextToolResultLimitRunes(s.contextWindowTokens(opts)); limit > 0 {
|
|
maxRunes = min(maxRunes, limit)
|
|
}
|
|
|
|
if budgetTokens <= 0 {
|
|
return toolMessageWithLimit(toolName, toolCallID, content, maxRunes)
|
|
}
|
|
|
|
msg := toolMessageWithLimit(toolName, toolCallID, content, maxRunes)
|
|
projectedTokens := baseTokens + estimateMessagesTokens([]api.Message{msg})
|
|
if projectedTokens < budgetTokens {
|
|
return msg
|
|
}
|
|
|
|
overheadTokens := estimateMessagesTokens([]api.Message{{
|
|
Role: "tool",
|
|
ToolName: toolName,
|
|
ToolCallID: toolCallID,
|
|
}})
|
|
// Keep oversized tool output below the budget before it is appended to
|
|
// history. This is especially important for <=8k contexts: the next step
|
|
// must still have enough room to compact and continue the same user
|
|
// request instead of asking the user to prompt again.
|
|
availableRunes := (budgetTokens - baseTokens - overheadTokens - toolTruncationMarkerReserveTokens) * 4
|
|
maxRunes = min(maxRunes, max(0, availableRunes))
|
|
msg.Content = truncateToolResultContentTo(content, maxRunes)
|
|
return msg
|
|
}
|
|
|
|
func (s *Session) toolMessageForContext(toolName, toolCallID, content string, opts RunOptions, baseTokens int) api.Message {
|
|
return s.toolMessageWithBudget(toolName, toolCallID, content, opts, baseTokens, s.compactionThresholdTokens(opts))
|
|
}
|
|
|
|
func (s *Session) toolMessageForPostCompactionContext(toolName, toolCallID, content string, opts RunOptions, baseTokens int) api.Message {
|
|
return s.toolMessageWithBudget(toolName, toolCallID, content, opts, baseTokens, s.contextWindowTokens(opts))
|
|
}
|
|
|
|
func toolMessageWithLimit(toolName, toolCallID, content string, maxRunes int) api.Message {
|
|
return api.Message{
|
|
Role: "tool",
|
|
Content: truncateToolResultContentTo(content, maxRunes),
|
|
ToolName: toolName,
|
|
ToolCallID: toolCallID,
|
|
}
|
|
}
|
|
|
|
func smallContextToolResultLimitRunes(contextWindow int) int {
|
|
switch {
|
|
case contextWindow > 0 && contextWindow <= tinyContextToolResultTokenWindow:
|
|
return tinyContextToolResultRunes
|
|
case contextWindow > 0 && contextWindow <= smallContextToolResultTokenWindow:
|
|
return smallContextToolResultRunes
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func (s *Session) availableTools() api.Tools {
|
|
if s == nil || s.Tools == nil {
|
|
return nil
|
|
}
|
|
return s.Tools.Tools()
|
|
}
|
|
|
|
func (s *Session) compactionThresholdTokens(opts RunOptions) int {
|
|
contextWindow := s.contextWindowTokens(opts)
|
|
if contextWindow <= 0 {
|
|
return 0
|
|
}
|
|
|
|
configuredThreshold := 0.0
|
|
if s.Compactor != nil {
|
|
configuredThreshold = s.Compactor.Threshold()
|
|
}
|
|
|
|
threshold := int(float64(contextWindow) * ResolveCompactionThreshold(configuredThreshold))
|
|
if threshold <= 0 {
|
|
return 0
|
|
}
|
|
return threshold
|
|
}
|
|
|
|
func (s *Session) contextWindowTokens(opts RunOptions) int {
|
|
if s.Compactor == nil {
|
|
return 0
|
|
}
|
|
return s.Compactor.ContextWindowTokens(opts.Options)
|
|
}
|
|
|
|
func toolMessage(toolName, toolCallID, content string) api.Message {
|
|
return toolMessageWithLimit(toolName, toolCallID, content, maxToolResultRunes)
|
|
}
|
|
|
|
func sanitizeMessageForRun(msg api.Message) api.Message {
|
|
if msg.Role == "tool" {
|
|
msg.Content = truncateToolResultContent(msg.Content)
|
|
}
|
|
return msg
|
|
}
|
|
|
|
func sanitizeMessagesForRequest(messages []api.Message) []api.Message {
|
|
if len(messages) == 0 {
|
|
return nil
|
|
}
|
|
sanitized := make([]api.Message, len(messages))
|
|
for i, msg := range messages {
|
|
sanitized[i] = sanitizeMessageForRun(msg)
|
|
}
|
|
return sanitized
|
|
}
|
|
|
|
func truncateToolResultContent(content string) string {
|
|
return truncateToolResultContentTo(content, maxToolResultRunes)
|
|
}
|
|
|
|
func truncateToolResultContentTo(content string, maxRunes int) string {
|
|
return Truncate(content, TruncateConfig{
|
|
MaxRunes: maxRunes,
|
|
HeadTail: true,
|
|
HeadPct: 75,
|
|
Label: "tool output",
|
|
Hint: "Use a narrower command, line range, or search query if more detail is needed.",
|
|
FullOmissionPrefix: toolOutputFullOmissionPrefix,
|
|
})
|
|
}
|
|
|
|
// TruncateConfig configures content truncation via Truncate.
|
|
type TruncateConfig struct {
|
|
MaxRunes int // rune limit; <= 0 means full omission
|
|
HeadTail bool // true = head + tail split; false = head only
|
|
HeadPct int // percentage of MaxRunes for head (e.g. 75); tail gets the rest
|
|
Label string // e.g. "tool output", "summary", "stdout"
|
|
Hint string // guidance text appended to marker (optional)
|
|
FullOmissionPrefix string // marker prefix when MaxRunes <= 0
|
|
}
|
|
|
|
// Truncate truncates content to at most cfg.MaxRunes runes. When HeadTail is
|
|
// true, it preserves the first HeadPct% and last (100-HeadPct)% of the budget
|
|
// with a marker between; otherwise it keeps only the head. MaxRunes <= 0
|
|
// triggers full omission using FullOmissionPrefix. All token counts in
|
|
// markers use ApproximateTokens.
|
|
func Truncate(content string, cfg TruncateConfig) string {
|
|
runes := []rune(content)
|
|
total := len(runes)
|
|
|
|
if cfg.MaxRunes <= 0 {
|
|
return fmt.Sprintf("%s omitted ~%d tokens.%s]", cfg.FullOmissionPrefix, ApproximateTokens(total), truncHint(cfg.Hint))
|
|
}
|
|
if total <= cfg.MaxRunes {
|
|
return content
|
|
}
|
|
|
|
if !cfg.HeadTail {
|
|
head := cfg.MaxRunes
|
|
omitted := total - head
|
|
return string(runes[:head]) + TruncMarker(cfg.Label, head, 0, omitted, false, cfg.Hint)
|
|
}
|
|
|
|
head := cfg.MaxRunes * cfg.HeadPct / 100
|
|
tail := cfg.MaxRunes - head
|
|
omitted := total - head - tail
|
|
return string(runes[:head]) + TruncMarker(cfg.Label, head, tail, omitted, true, cfg.Hint) + string(runes[len(runes)-tail:])
|
|
}
|
|
|
|
func truncHint(hint string) string {
|
|
hint = strings.TrimSpace(hint)
|
|
if hint == "" {
|
|
return ""
|
|
}
|
|
if !strings.HasSuffix(hint, ".") {
|
|
hint += "."
|
|
}
|
|
return " " + hint
|
|
}
|
|
|
|
// TruncMarker formats a truncation marker with consistent wording. head and
|
|
// tail are rune counts; omitted is the count of runes removed. headTail
|
|
// selects the head+tail vs head-only format. hint is optional guidance text.
|
|
func TruncMarker(label string, head, tail, omitted int, headTail bool, hint string) string {
|
|
var b strings.Builder
|
|
b.WriteString("\n\n[")
|
|
b.WriteString(label)
|
|
b.WriteString(" truncated: ")
|
|
if headTail {
|
|
fmt.Fprintf(&b, "showing first ~%d tokens and last ~%d tokens; ", ApproximateTokens(head), ApproximateTokens(tail))
|
|
} else {
|
|
fmt.Fprintf(&b, "showing first ~%d tokens; ", ApproximateTokens(head))
|
|
}
|
|
fmt.Fprintf(&b, "omitted ~%d tokens.%s]", ApproximateTokens(omitted), truncHint(hint))
|
|
if headTail {
|
|
b.WriteString("\n\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func toolOutputFullyOmitted(content string) bool {
|
|
return strings.HasPrefix(content, toolOutputFullOmissionPrefix)
|
|
}
|
|
|
|
// ApproximateTokens estimates token count from a character/byte count using
|
|
// the standard ~4 chars-per-token heuristic. It is intentionally rough; all
|
|
// callers use it only for sizing/truncation decisions, not billing.
|
|
func ApproximateTokens(n int) int {
|
|
if n <= 0 {
|
|
return 0
|
|
}
|
|
return max(1, (n+3)/4)
|
|
}
|
|
|
|
func messageEmpty(msg api.Message) bool {
|
|
return msg.Content == "" && msg.Thinking == "" && len(msg.ToolCalls) == 0
|
|
}
|