Files
2026-07-17 10:32:22 -07:00

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
}