mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-04 18:30:09 +00:00
feat(runtime): cap oversized tool results
This commit is contained in:
21 files changed
+872
-8
No files matched your search
@@ -643,6 +643,11 @@
|
||||
"description": "Maximum number of tokens to keep from old tool call arguments and results. Older tool calls beyond this budget will have their content replaced with a placeholder. Tokens are approximated as len/4. Truncation is disabled by default; set a positive value to enable it. Set to -1 to disable truncation (unlimited tool content).",
|
||||
"minimum": -1
|
||||
},
|
||||
"max_tool_result_tokens": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of tokens to keep from each tool result when it is added to the session. Oversized results are truncated middle-out: the head and tail are kept and the removed middle is replaced with a truncation marker. Textual documents attached to the result share the same budget. Tokens are approximated as len/4. The cap is disabled by default; set a positive value to enable it. 0 and -1 both leave tool results unbounded.",
|
||||
"minimum": -1
|
||||
},
|
||||
"num_history_items": {
|
||||
"type": "integer",
|
||||
"description": "Number of history items to keep",
|
||||
|
||||
@@ -1042,6 +1042,7 @@ func (f *runExecFlags) buildSessionOpts(agt *agent.Agent, req runtime.CreateSess
|
||||
session.WithMaxIterations(agt.MaxIterations()),
|
||||
session.WithMaxConsecutiveToolCalls(agt.MaxConsecutiveToolCalls()),
|
||||
session.WithMaxOldToolCallTokens(agt.MaxOldToolCallTokens()),
|
||||
session.WithMaxToolResultTokens(agt.MaxToolResultTokens()),
|
||||
session.WithToolsApproved(req.ToolsApproved),
|
||||
session.WithHideToolResults(req.HideToolResults),
|
||||
session.WithWorkingDir(req.WorkingDir),
|
||||
|
||||
@@ -34,6 +34,7 @@ agents:
|
||||
max_iterations: int # Optional: max tool-calling loops
|
||||
max_consecutive_tool_calls: int # Optional: max identical consecutive tool calls
|
||||
max_old_tool_call_tokens: int # Optional: token budget for old tool call content (disabled unless positive)
|
||||
max_tool_result_tokens: int # Optional: per-tool-result token cap with middle-out truncation (disabled unless positive)
|
||||
num_history_items: int # Optional: limit conversation history
|
||||
session_compaction: boolean # Optional: disable automatic session compaction (default: true)
|
||||
compaction_threshold: float # Optional: context-window fraction that triggers auto-compaction (0–1, default: 0.9)
|
||||
@@ -97,6 +98,7 @@ agents:
|
||||
| `max_iterations` | int | ✗ | Maximum number of tool-calling loops. Default: unlimited (0). Set this to prevent infinite loops. |
|
||||
| `max_consecutive_tool_calls` | int | ✗ | Maximum consecutive identical tool calls before the agent is terminated, preventing degenerate loops. Default: `5`. |
|
||||
| `max_old_tool_call_tokens` | int | ✗ | Maximum number of tokens to keep from old tool call arguments and results. Older tool calls beyond this budget have their content replaced with a placeholder, saving context space. Tokens are approximated as `len/4`. Truncation is disabled by default; set a positive value to enable it. Set to `-1` to disable truncation (unlimited). |
|
||||
| `max_tool_result_tokens` | int | ✗ | Maximum number of tokens to keep from each tool result when it is added to the session. Oversized results are truncated middle-out: the head and tail are kept and the removed middle is replaced with a truncation marker. Textual documents attached to the result share the same budget. Tokens are approximated as `len/4`. The cap is disabled by default; set a positive value to enable it. `0` and `-1` both leave tool results unbounded. |
|
||||
| `num_history_items` | int | ✗ | Limit the number of conversation history messages sent to the model. Useful for managing context window size with long conversations. Default: unlimited (all messages sent). |
|
||||
| `session_compaction` | boolean | ✗ | When `false`, disables automatic session compaction for this agent: neither the proactive threshold trigger nor the post-overflow auto-recovery runs. The manual `/compact` command remains available. Default: `true`. |
|
||||
| `compaction_threshold` | float | ✗ | Fraction of the model's context window at which proactive auto-compaction triggers. Must be greater than `0` and at most `1`. A `compaction_threshold` set on the agent's model takes precedence. Default: `0.9`. See [Compaction Threshold](../models/index.md#delegating-session-compaction). |
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Max Tool Result Tokens Example
|
||||
#
|
||||
# This example demonstrates `max_tool_result_tokens`, an opt-in cap applied
|
||||
# to every textual tool result the moment it enters the session.
|
||||
#
|
||||
# Tools like shell or filesystem can return enormous outputs (a full build
|
||||
# log, a large file dump) that permanently bloat the conversation. With
|
||||
# `max_tool_result_tokens` set to a positive value, any tool result larger
|
||||
# than the budget is truncated middle-out: the beginning and the end — usually
|
||||
# the most informative parts — are kept, and the removed middle is replaced
|
||||
# with a clear truncation marker. Tokens are approximated as bytes/4.
|
||||
#
|
||||
# The cap is disabled by default (unset or 0); -1 also disables it. It
|
||||
# complements `max_old_tool_call_tokens`, which replaces *old* results
|
||||
# wholesale once a session-wide budget is exhausted: this cap instead bounds
|
||||
# *each* result as it is recorded, so even the freshest result never exceeds
|
||||
# the limit.
|
||||
|
||||
agents:
|
||||
root:
|
||||
model: openai/gpt-5-mini
|
||||
description: An assistant whose tool results are capped at ~2000 tokens each.
|
||||
instruction: |
|
||||
You are a helpful assistant with shell access. Use it to inspect the
|
||||
system when asked. Very large command outputs are truncated in the
|
||||
middle — the beginning and end are always preserved, and a marker shows
|
||||
where content was removed.
|
||||
# Keep at most ~2000 approximate tokens (~8000 bytes) per tool result.
|
||||
max_tool_result_tokens: 2000
|
||||
toolsets:
|
||||
- type: shell
|
||||
@@ -86,6 +86,7 @@ func runDockerAgent(ctx agent.InvocationContext, t *team.Team, agentName string,
|
||||
session.WithMaxIterations(a.MaxIterations()),
|
||||
session.WithMaxConsecutiveToolCalls(a.MaxConsecutiveToolCalls()),
|
||||
session.WithMaxOldToolCallTokens(a.MaxOldToolCallTokens()),
|
||||
session.WithMaxToolResultTokens(a.MaxToolResultTokens()),
|
||||
session.WithToolsApproved(true),
|
||||
session.WithNonInteractive(true),
|
||||
session.WithWorkingDir(workingDir),
|
||||
|
||||
@@ -211,6 +211,7 @@ func (a *Agent) NewSession(ctx context.Context, params acp.NewSessionRequest) (a
|
||||
session.WithMaxIterations(defaultAgent.MaxIterations()),
|
||||
session.WithMaxConsecutiveToolCalls(defaultAgent.MaxConsecutiveToolCalls()),
|
||||
session.WithMaxOldToolCallTokens(defaultAgent.MaxOldToolCallTokens()),
|
||||
session.WithMaxToolResultTokens(defaultAgent.MaxToolResultTokens()),
|
||||
session.WithWorkingDir(workingDir),
|
||||
)
|
||||
sess.Title = "ACP Session " + sess.ID
|
||||
|
||||
@@ -46,6 +46,7 @@ type Agent struct {
|
||||
maxIterations int
|
||||
maxConsecutiveToolCalls int
|
||||
maxOldToolCallTokens int
|
||||
maxToolResultTokens int
|
||||
numHistoryItems int
|
||||
addPromptFiles []string
|
||||
tools []tools.Tool
|
||||
@@ -130,6 +131,10 @@ func (a *Agent) MaxOldToolCallTokens() int {
|
||||
return a.maxOldToolCallTokens
|
||||
}
|
||||
|
||||
func (a *Agent) MaxToolResultTokens() int {
|
||||
return a.maxToolResultTokens
|
||||
}
|
||||
|
||||
func (a *Agent) NumHistoryItems() int {
|
||||
return a.numHistoryItems
|
||||
}
|
||||
|
||||
@@ -216,6 +216,15 @@ func WithMaxOldToolCallTokens(n int) Opt {
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxToolResultTokens sets the per-tool-result token cap applied when a
|
||||
// tool result enters a session. Positive values enable middle-out truncation;
|
||||
// 0 and -1 disable the cap (unbounded tool results).
|
||||
func WithMaxToolResultTokens(n int) Opt {
|
||||
return func(a *Agent) {
|
||||
a.maxToolResultTokens = n
|
||||
}
|
||||
}
|
||||
|
||||
func WithNumHistoryItems(numHistoryItems int) Opt {
|
||||
return func(a *Agent) {
|
||||
a.numHistoryItems = numHistoryItems
|
||||
|
||||
@@ -552,7 +552,13 @@ type AgentConfig struct {
|
||||
MaxIterations int `json:"max_iterations,omitempty"`
|
||||
MaxConsecutiveToolCalls int `json:"max_consecutive_tool_calls,omitempty"`
|
||||
MaxOldToolCallTokens int `json:"max_old_tool_call_tokens,omitempty"`
|
||||
NumHistoryItems int `json:"num_history_items,omitempty"`
|
||||
// MaxToolResultTokens caps each textual tool result when it enters the
|
||||
// session. Oversized results are middle-out truncated (head and tail kept,
|
||||
// middle replaced with a marker); textual documents attached to the result
|
||||
// share the same budget. Tokens are approximated as len/4.
|
||||
// Disabled by default; only positive values enable the cap.
|
||||
MaxToolResultTokens int `json:"max_tool_result_tokens,omitempty"`
|
||||
NumHistoryItems int `json:"num_history_items,omitempty"`
|
||||
// SessionCompaction toggles automatic session compaction for this agent:
|
||||
// the proactive threshold trigger and the post-overflow auto-recovery.
|
||||
// Manual /compact stays available regardless. Pointer (tri-state) so
|
||||
|
||||
@@ -206,6 +206,7 @@ func CreateToolHandler(t *team.Team, agentName string) func(context.Context, *mc
|
||||
session.WithMaxIterations(ag.MaxIterations()),
|
||||
session.WithMaxConsecutiveToolCalls(ag.MaxConsecutiveToolCalls()),
|
||||
session.WithMaxOldToolCallTokens(ag.MaxOldToolCallTokens()),
|
||||
session.WithMaxToolResultTokens(ag.MaxToolResultTokens()),
|
||||
session.WithUserMessage(input.Message),
|
||||
session.WithToolsApproved(true),
|
||||
session.WithNonInteractive(true),
|
||||
|
||||
@@ -174,6 +174,7 @@ func newSubSession(parent *session.Session, cfg SubSessionConfig, childAgent *ag
|
||||
session.WithMaxIterations(childAgent.MaxIterations()),
|
||||
session.WithMaxConsecutiveToolCalls(childAgent.MaxConsecutiveToolCalls()),
|
||||
session.WithMaxOldToolCallTokens(childAgent.MaxOldToolCallTokens()),
|
||||
session.WithMaxToolResultTokens(childAgent.MaxToolResultTokens()),
|
||||
session.WithTitle(cfg.Title),
|
||||
session.WithToolsApproved(cfg.ToolsApproved),
|
||||
session.WithNonInteractive(cfg.NonInteractive),
|
||||
|
||||
@@ -356,6 +356,7 @@ func (sm *SessionManager) CreateSession(ctx context.Context, sessionTemplate *se
|
||||
session.WithMaxIterations(sessionTemplate.MaxIterations),
|
||||
session.WithMaxConsecutiveToolCalls(sessionTemplate.MaxConsecutiveToolCalls),
|
||||
session.WithMaxOldToolCallTokens(sessionTemplate.MaxOldToolCallTokens),
|
||||
session.WithMaxToolResultTokens(sessionTemplate.MaxToolResultTokens),
|
||||
session.WithToolsApproved(sessionTemplate.ToolsApproved),
|
||||
)
|
||||
|
||||
@@ -1050,6 +1051,7 @@ func (sm *SessionManager) runtimeForSession(ctx context.Context, sess *session.S
|
||||
sess.MaxIterations = agt.MaxIterations()
|
||||
sess.MaxConsecutiveToolCalls = agt.MaxConsecutiveToolCalls()
|
||||
sess.MaxOldToolCallTokens = agt.MaxOldToolCallTokens()
|
||||
sess.MaxToolResultTokens = agt.MaxToolResultTokens()
|
||||
|
||||
modelSwitcherCfg := &runtime.ModelSwitcherConfig{
|
||||
Models: loadResult.Models,
|
||||
@@ -1443,6 +1445,7 @@ func (sm *SessionManager) SetSessionAgentModel(ctx context.Context, sessionID, m
|
||||
MaxIterations: sess.MaxIterations,
|
||||
MaxConsecutiveToolCalls: sess.MaxConsecutiveToolCalls,
|
||||
MaxOldToolCallTokens: sess.MaxOldToolCallTokens,
|
||||
MaxToolResultTokens: sess.MaxToolResultTokens,
|
||||
InputTokens: sess.InputTokens,
|
||||
OutputTokens: sess.OutputTokens,
|
||||
Cost: sess.Cost,
|
||||
|
||||
@@ -2,6 +2,8 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/docker/docker-agent/pkg/agent"
|
||||
"github.com/docker/docker-agent/pkg/api"
|
||||
"github.com/docker/docker-agent/pkg/chat"
|
||||
"github.com/docker/docker-agent/pkg/concurrent"
|
||||
@@ -579,3 +582,72 @@ func TestUserMessageOrdinalToItemIndex(t *testing.T) {
|
||||
_, err = userMessageOrdinalToItemIndex(sess, 99)
|
||||
require.ErrorIs(t, err, ErrForkOutOfRange)
|
||||
}
|
||||
|
||||
// TestAddMessage_SQLitePersistedToolResultCappedOnReload pins the read-time
|
||||
// backstop for the generic API path: SessionManager.AddMessage persists a
|
||||
// tool result through the store without Session.AddMessage's ingest-time
|
||||
// cap, so a session reloaded from SQLite carries the unbounded payload.
|
||||
// Once the runtime stamps the agent's MaxToolResultTokens onto the session
|
||||
// (runtimeForSession), GetMessages — what actually reaches a provider —
|
||||
// must bound the result while the persisted history stays as stored.
|
||||
func TestAddMessage_SQLitePersistedToolResultCappedOnReload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := t.Context()
|
||||
store, err := session.NewSQLiteSessionStore(ctx, filepath.Join(t.TempDir(), "sessions.db"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
|
||||
sess := session.New(session.WithUserMessage("run big-tool"))
|
||||
sess.AddMessage(session.NewAgentMessage("root", &chat.Message{
|
||||
Role: chat.MessageRoleAssistant,
|
||||
ToolCalls: []tools.ToolCall{
|
||||
{ID: "tc", Function: tools.FunctionCall{Name: "big_tool", Arguments: "{}"}},
|
||||
},
|
||||
}))
|
||||
require.NoError(t, store.AddSession(ctx, sess))
|
||||
|
||||
sm := NewSessionManager(ctx, config.Sources{}, store, 0, &config.RuntimeConfig{})
|
||||
oversized := "HEAD" + strings.Repeat("x", 100_000) + "TAIL"
|
||||
require.NoError(t, sm.AddMessage(ctx, sess.ID, session.NewAgentMessage("root", &chat.Message{
|
||||
Role: chat.MessageRoleTool,
|
||||
ToolCallID: "tc",
|
||||
Content: oversized,
|
||||
})))
|
||||
|
||||
reloaded, err := store.GetSession(ctx, sess.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, oversized, storedToolResultContent(t, reloaded, "tc"),
|
||||
"persistence must keep the raw result; only what reaches the provider is capped")
|
||||
|
||||
// runtimeForSession copies the agent's cap onto the session before a run.
|
||||
agt := agent.New("root", "test instruction", agent.WithMaxToolResultTokens(100))
|
||||
reloaded.MaxToolResultTokens = agt.MaxToolResultTokens()
|
||||
|
||||
var got string
|
||||
for _, msg := range reloaded.GetMessages(agt) {
|
||||
if msg.Role == chat.MessageRoleTool && msg.ToolCallID == "tc" {
|
||||
got = msg.Content
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, got, "tool result must survive sanitizeToolCalls")
|
||||
assert.LessOrEqual(t, len(got)/4, 100, "reloaded result must be bounded by the cap")
|
||||
assert.True(t, strings.HasPrefix(got, "HEAD"), "head must survive middle-out truncation")
|
||||
assert.True(t, strings.HasSuffix(got, "TAIL"), "tail must survive middle-out truncation")
|
||||
|
||||
assert.Equal(t, oversized, storedToolResultContent(t, reloaded, "tc"),
|
||||
"GetMessages must not rewrite the in-memory history either")
|
||||
}
|
||||
|
||||
func storedToolResultContent(t *testing.T, sess *session.Session, toolCallID string) string {
|
||||
t.Helper()
|
||||
|
||||
for _, msg := range sess.GetAllMessages() {
|
||||
if msg.Message.Role == chat.MessageRoleTool && msg.Message.ToolCallID == toolCallID {
|
||||
return msg.Message.Content
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "tool result not found", "tool_call_id=%s", toolCallID)
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/docker/docker-agent/pkg/chat"
|
||||
"github.com/docker/docker-agent/pkg/tools"
|
||||
)
|
||||
|
||||
func addToolResult(s *Session, toolCallID, content string) {
|
||||
s.AddMessage(NewAgentMessage("test-agent", &chat.Message{
|
||||
Role: chat.MessageRoleTool,
|
||||
ToolCallID: toolCallID,
|
||||
Content: content,
|
||||
}))
|
||||
}
|
||||
|
||||
// storedToolResult returns the tool-result message as retained by the
|
||||
// session, bypassing GetMessages so the tests observe ingestion-time state.
|
||||
func storedToolResult(t *testing.T, s *Session, toolCallID string) chat.Message {
|
||||
t.Helper()
|
||||
|
||||
for _, item := range s.Messages {
|
||||
if item.IsMessage() && item.Message.Message.ToolCallID == toolCallID {
|
||||
return item.Message.Message
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "tool result not found", "tool_call_id=%s", toolCallID)
|
||||
return chat.Message{}
|
||||
}
|
||||
|
||||
func TestAddMessageMaxToolResultTokensDefaultPreservesOversizedResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
oversized := strings.Repeat("x", 100_000)
|
||||
|
||||
s := New()
|
||||
addToolResult(s, "tc", oversized)
|
||||
|
||||
assert.Equal(t, oversized, storedToolResult(t, s, "tc").Content)
|
||||
}
|
||||
|
||||
func TestAddMessageMaxToolResultTokensNegativeDisablesCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
oversized := strings.Repeat("x", 100_000)
|
||||
|
||||
s := New(WithMaxToolResultTokens(-1))
|
||||
addToolResult(s, "tc", oversized)
|
||||
|
||||
assert.Equal(t, oversized, storedToolResult(t, s, "tc").Content)
|
||||
}
|
||||
|
||||
func TestAddMessageMaxToolResultTokensCapsAtIngestion(t *testing.T) {
|
||||
t.Parallel()
|
||||
oversized := strings.Repeat("x", 100_000)
|
||||
|
||||
s := New(WithMaxToolResultTokens(100))
|
||||
addToolResult(s, "tc", oversized)
|
||||
|
||||
got := storedToolResult(t, s, "tc").Content
|
||||
assert.Contains(t, got, toolResultTruncationMarker)
|
||||
assert.LessOrEqual(t, approximateTokens(got), 100)
|
||||
}
|
||||
|
||||
func TestAddMessageMaxToolResultTokensKeepsHeadAndTail(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := "HEAD-MARKER " + strings.Repeat("h", 500) +
|
||||
strings.Repeat("m", 4000) +
|
||||
strings.Repeat("t", 500) + " TAIL-MARKER"
|
||||
|
||||
s := New(WithMaxToolResultTokens(256)) // 1024-byte budget
|
||||
addToolResult(s, "tc", content)
|
||||
|
||||
got := storedToolResult(t, s, "tc").Content
|
||||
assert.True(t, strings.HasPrefix(got, "HEAD-MARKER "), "head must survive")
|
||||
assert.True(t, strings.HasSuffix(got, " TAIL-MARKER"), "tail must survive")
|
||||
assert.Contains(t, got, toolResultTruncationMarker)
|
||||
assert.NotContains(t, got, "mmm", "middle must be removed")
|
||||
assert.LessOrEqual(t, approximateTokens(got), 256, "marker must count within the cap")
|
||||
}
|
||||
|
||||
func TestAddMessageMaxToolResultTokensBoundaryUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
exact := strings.Repeat("e", 100) // exactly 25 tokens at len/4
|
||||
below := strings.Repeat("b", 99)
|
||||
|
||||
s := New(WithMaxToolResultTokens(25))
|
||||
addToolResult(s, "exact", exact)
|
||||
addToolResult(s, "below", below)
|
||||
|
||||
assert.Equal(t, exact, storedToolResult(t, s, "exact").Content)
|
||||
assert.Equal(t, below, storedToolResult(t, s, "below").Content)
|
||||
}
|
||||
|
||||
func TestAddMessageMaxToolResultTokensPreservesUTF8(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 3-byte runes make the raw cut points land mid-rune.
|
||||
content := strings.Repeat("€", 4000)
|
||||
|
||||
s := New(WithMaxToolResultTokens(64))
|
||||
addToolResult(s, "tc", content)
|
||||
|
||||
got := storedToolResult(t, s, "tc").Content
|
||||
assert.True(t, utf8.ValidString(got), "truncated content must stay valid UTF-8")
|
||||
assert.Contains(t, got, toolResultTruncationMarker)
|
||||
assert.True(t, strings.HasPrefix(got, "€"), "head must keep whole runes")
|
||||
assert.True(t, strings.HasSuffix(got, "€"), "tail must keep whole runes")
|
||||
}
|
||||
|
||||
func TestAddMessageMaxToolResultTokensLeavesNonToolMessagesAlone(t *testing.T) {
|
||||
t.Parallel()
|
||||
oversized := strings.Repeat("x", 100_000)
|
||||
|
||||
s := New(WithMaxToolResultTokens(10))
|
||||
s.AddMessage(UserMessage(oversized))
|
||||
s.AddMessage(NewAgentMessage("test-agent", &chat.Message{
|
||||
Role: chat.MessageRoleAssistant,
|
||||
Content: oversized,
|
||||
}))
|
||||
|
||||
for _, item := range s.Messages {
|
||||
require.True(t, item.IsMessage())
|
||||
assert.Equal(t, oversized, item.Message.Message.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddMessageMaxToolResultTokensSyncsMultiContentText(t *testing.T) {
|
||||
t.Parallel()
|
||||
oversized := strings.Repeat("x", 100_000)
|
||||
images := []tools.MediaContent{{Data: "aGk=", MimeType: "image/png"}}
|
||||
|
||||
s := New(WithMaxToolResultTokens(100))
|
||||
s.AddMessage(NewAgentMessage("test-agent", &chat.Message{
|
||||
Role: chat.MessageRoleTool,
|
||||
ToolCallID: "tc",
|
||||
Content: oversized,
|
||||
MultiContent: chat.BuildToolResultMultiContent(oversized, images, nil),
|
||||
}))
|
||||
|
||||
got := storedToolResult(t, s, "tc")
|
||||
require.Len(t, got.MultiContent, 2)
|
||||
assert.Equal(t, chat.MessagePartTypeText, got.MultiContent[0].Type)
|
||||
assert.Equal(t, got.Content, got.MultiContent[0].Text, "text part must stay in sync with Content")
|
||||
assert.Contains(t, got.MultiContent[0].Text, toolResultTruncationMarker)
|
||||
assert.Equal(t, chat.MessagePartTypeImageURL, got.MultiContent[1].Type, "non-text parts must be untouched")
|
||||
}
|
||||
|
||||
func TestAddMessageMaxToolResultTokensSyncsOneDuplicateAndBoundsIdenticalExtraText(t *testing.T) {
|
||||
t.Parallel()
|
||||
oversized := strings.Repeat("x", 100_000)
|
||||
|
||||
s := New(WithMaxToolResultTokens(100))
|
||||
s.AddMessage(NewAgentMessage("test-agent", &chat.Message{
|
||||
Role: chat.MessageRoleTool,
|
||||
ToolCallID: "tc",
|
||||
Content: oversized,
|
||||
MultiContent: []chat.MessagePart{
|
||||
{Type: chat.MessagePartTypeText, Text: oversized}, // Content duplicate
|
||||
{Type: chat.MessagePartTypeText, Text: oversized}, // Distinct identical payload
|
||||
},
|
||||
}))
|
||||
|
||||
got := storedToolResult(t, s, "tc")
|
||||
require.Len(t, got.MultiContent, 2)
|
||||
assert.Equal(t, got.Content, got.MultiContent[0].Text, "first matching text part must stay in sync with Content")
|
||||
assert.Empty(t, got.MultiContent[1].Text, "distinct identical text must still consume the exhausted budget")
|
||||
assert.LessOrEqual(t, toolResultTextTokens(got), 100)
|
||||
}
|
||||
|
||||
// toolResultTextTokens approximates the aggregate textual tokens a provider
|
||||
// can receive from a stored tool result: Content counted once (a text part
|
||||
// equal to Content is its duplicate) plus distinct text parts and
|
||||
// inline-text documents.
|
||||
func toolResultTextTokens(msg chat.Message) int {
|
||||
total := len(msg.Content)
|
||||
contentDuplicateSeen := false
|
||||
for _, part := range msg.MultiContent {
|
||||
switch {
|
||||
case part.Type == chat.MessagePartTypeText && !contentDuplicateSeen && part.Text == msg.Content:
|
||||
contentDuplicateSeen = true
|
||||
case part.Type == chat.MessagePartTypeText:
|
||||
total += len(part.Text)
|
||||
case part.Type == chat.MessagePartTypeDocument && part.Document != nil:
|
||||
total += len(part.Document.Source.InlineText)
|
||||
}
|
||||
}
|
||||
return total / 4
|
||||
}
|
||||
|
||||
func TestAddMessageMaxToolResultTokensCapsInlineTextDocument(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := "short tool output"
|
||||
docText := strings.Repeat("€", 50_000)
|
||||
documents := []tools.DocumentContent{
|
||||
{Name: "big.md", MimeType: "text/markdown", Text: docText},
|
||||
{Name: "blob.pdf", MimeType: "application/pdf", Data: "UERGREFUQQ=="}, // "PDFDATA"
|
||||
}
|
||||
|
||||
s := New(WithMaxToolResultTokens(100)) // 400-byte budget
|
||||
s.AddMessage(NewAgentMessage("test-agent", &chat.Message{
|
||||
Role: chat.MessageRoleTool,
|
||||
ToolCallID: "tc",
|
||||
Content: content,
|
||||
MultiContent: chat.BuildToolResultMultiContent(content, nil, documents),
|
||||
}))
|
||||
|
||||
got := storedToolResult(t, s, "tc")
|
||||
assert.Equal(t, content, got.Content, "content within budget must stay intact")
|
||||
require.Len(t, got.MultiContent, 3)
|
||||
|
||||
doc := got.MultiContent[1].Document
|
||||
require.NotNil(t, doc)
|
||||
assert.Equal(t, "big.md", doc.Name, "document metadata must be preserved")
|
||||
assert.Equal(t, "text/markdown", doc.MimeType, "document metadata must be preserved")
|
||||
assert.Contains(t, doc.Source.InlineText, toolResultTruncationMarker)
|
||||
assert.True(t, utf8.ValidString(doc.Source.InlineText), "truncated document must stay valid UTF-8")
|
||||
assert.Equal(t, int64(len(doc.Source.InlineText)), doc.Size, "size must track the kept text")
|
||||
|
||||
binary := got.MultiContent[2].Document
|
||||
require.NotNil(t, binary)
|
||||
assert.Equal(t, []byte("PDFDATA"), binary.Source.InlineData, "binary documents must be untouched")
|
||||
|
||||
assert.LessOrEqual(t, toolResultTextTokens(got), 100)
|
||||
}
|
||||
|
||||
// TestAddMessageMaxToolResultTokensDoesNotMutateCallerDocument pins the
|
||||
// copy-on-write contract: truncation must replace the stored part's Document
|
||||
// with a copy, leaving a *chat.Document the caller still holds untouched.
|
||||
func TestAddMessageMaxToolResultTokensDoesNotMutateCallerDocument(t *testing.T) {
|
||||
t.Parallel()
|
||||
docText := strings.Repeat("d", 100_000)
|
||||
external := &chat.Document{
|
||||
Name: "big.md",
|
||||
MimeType: "text/markdown",
|
||||
Size: int64(len(docText)),
|
||||
Source: chat.DocumentSource{InlineText: docText},
|
||||
}
|
||||
|
||||
s := New(WithMaxToolResultTokens(100))
|
||||
s.AddMessage(NewAgentMessage("test-agent", &chat.Message{
|
||||
Role: chat.MessageRoleTool,
|
||||
ToolCallID: "tc",
|
||||
Content: "short tool output",
|
||||
MultiContent: []chat.MessagePart{
|
||||
{Type: chat.MessagePartTypeText, Text: "short tool output"},
|
||||
{Type: chat.MessagePartTypeDocument, Document: external},
|
||||
},
|
||||
}))
|
||||
|
||||
got := storedToolResult(t, s, "tc")
|
||||
require.Len(t, got.MultiContent, 2)
|
||||
stored := got.MultiContent[1].Document
|
||||
require.NotNil(t, stored)
|
||||
require.NotSame(t, external, stored, "truncation must copy the document, not mutate the caller's")
|
||||
assert.Contains(t, stored.Source.InlineText, toolResultTruncationMarker)
|
||||
assert.Less(t, len(stored.Source.InlineText), len(docText))
|
||||
assert.Equal(t, int64(len(stored.Source.InlineText)), stored.Size, "stored size must track the kept text")
|
||||
|
||||
assert.Equal(t, docText, external.Source.InlineText, "caller's document text must stay untouched")
|
||||
assert.Equal(t, int64(len(docText)), external.Size, "caller's document size must stay untouched")
|
||||
}
|
||||
|
||||
// TestAddMessageMaxToolResultTokensBoundsAggregateAcrossDocuments proves the
|
||||
// cap is a result-wide budget: content plus six oversized documents would
|
||||
// reach ~7x the cap if each payload were capped independently.
|
||||
func TestAddMessageMaxToolResultTokensBoundsAggregateAcrossDocuments(t *testing.T) {
|
||||
t.Parallel()
|
||||
oversized := strings.Repeat("x", 100_000)
|
||||
content := strings.Repeat("c", 100)
|
||||
documents := make([]tools.DocumentContent, 6)
|
||||
for i := range documents {
|
||||
documents[i] = tools.DocumentContent{
|
||||
Name: fmt.Sprintf("doc-%d.txt", i),
|
||||
MimeType: "text/plain",
|
||||
Text: oversized,
|
||||
}
|
||||
}
|
||||
images := []tools.MediaContent{{Data: "aGk=", MimeType: "image/png"}}
|
||||
|
||||
s := New(WithMaxToolResultTokens(100)) // 400-byte budget
|
||||
s.AddMessage(NewAgentMessage("test-agent", &chat.Message{
|
||||
Role: chat.MessageRoleTool,
|
||||
ToolCallID: "tc",
|
||||
Content: content,
|
||||
MultiContent: chat.BuildToolResultMultiContent(content, images, documents),
|
||||
}))
|
||||
|
||||
got := storedToolResult(t, s, "tc")
|
||||
assert.LessOrEqual(t, toolResultTextTokens(got), 100, "aggregate must never exceed the per-result cap")
|
||||
assert.Equal(t, content, got.Content, "content within budget must stay intact")
|
||||
|
||||
require.Len(t, got.MultiContent, 8) // text + 6 documents + image
|
||||
assert.Equal(t, got.Content, got.MultiContent[0].Text, "duplicate text part must stay in sync")
|
||||
for i := 1; i <= 6; i++ {
|
||||
doc := got.MultiContent[i].Document
|
||||
require.NotNil(t, doc)
|
||||
assert.Equal(t, fmt.Sprintf("doc-%d.txt", i-1), doc.Name, "document metadata must be preserved")
|
||||
assert.Equal(t, "text/plain", doc.MimeType, "document metadata must be preserved")
|
||||
assert.True(t, utf8.ValidString(doc.Source.InlineText))
|
||||
assert.Less(t, len(doc.Source.InlineText), len(oversized))
|
||||
}
|
||||
assert.Contains(t, got.MultiContent[1].Document.Source.InlineText, toolResultTruncationMarker,
|
||||
"first document gets the budget left over from content")
|
||||
assert.Empty(t, got.MultiContent[6].Document.Source.InlineText,
|
||||
"documents past an exhausted budget keep metadata but no text")
|
||||
assert.Equal(t, chat.MessagePartTypeImageURL, got.MultiContent[7].Type, "non-text parts must be untouched")
|
||||
}
|
||||
|
||||
func TestTruncateMiddleOutTinyCaps(t *testing.T) {
|
||||
t.Parallel()
|
||||
contents := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{"ascii", strings.Repeat("abcdefgh", 50)},
|
||||
{"three-byte runes", strings.Repeat("€", 150)},
|
||||
{"four-byte runes", strings.Repeat("🌍", 100)},
|
||||
{"mixed width runes", strings.Repeat("a€🌍", 50)},
|
||||
}
|
||||
for _, tt := range contents {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
for maxTokens := 1; maxTokens <= 16; maxTokens++ {
|
||||
got := truncateMiddleOut(tt.content, maxTokens)
|
||||
assert.LessOrEqual(t, approximateTokens(got), maxTokens, "cap exceeded for maxTokens=%d", maxTokens)
|
||||
assert.Less(t, len(got), len(tt.content), "truncation must shrink for maxTokens=%d", maxTokens)
|
||||
assert.True(t, utf8.ValidString(got), "invalid UTF-8 for maxTokens=%d", maxTokens)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: tiny caps used to emit the 47-byte marker around two kept
|
||||
// bytes, producing results far larger than both the cap and the input.
|
||||
func TestTruncateMiddleOutNeverGrowsShortInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
inputs := []string{
|
||||
"abcdefghi", // 9 bytes of ASCII
|
||||
"🌍🌍", // 8 bytes, 4-byte runes
|
||||
strings.Repeat("€", 4), // 12 bytes, 3-byte runes
|
||||
}
|
||||
for _, content := range inputs {
|
||||
for maxTokens := 1; maxTokens <= 12; maxTokens++ {
|
||||
got := truncateMiddleOut(content, maxTokens)
|
||||
assert.LessOrEqual(t, len(got), len(content), "content=%q maxTokens=%d", content, maxTokens)
|
||||
assert.LessOrEqual(t, approximateTokens(got), maxTokens, "content=%q maxTokens=%d", content, maxTokens)
|
||||
assert.True(t, utf8.ValidString(got), "content=%q maxTokens=%d", content, maxTokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateMiddleOutFullMarkerWhenBudgetAllows(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := "HEAD" + strings.Repeat("m", 1000) + "TAIL"
|
||||
|
||||
// 13 tokens (52 bytes) is the smallest ASCII budget fitting the full
|
||||
// marker plus at least one byte of head and tail.
|
||||
got := truncateMiddleOut(content, 13)
|
||||
assert.Contains(t, got, toolResultTruncationMarker)
|
||||
assert.True(t, strings.HasPrefix(got, "HEA"), "head must survive")
|
||||
assert.True(t, strings.HasSuffix(got, "IL"), "tail must survive")
|
||||
assert.LessOrEqual(t, approximateTokens(got), 13)
|
||||
|
||||
got = truncateMiddleOut(content, 64)
|
||||
assert.Contains(t, got, toolResultTruncationMarker)
|
||||
assert.True(t, strings.HasPrefix(got, "HEAD"), "head must survive")
|
||||
assert.True(t, strings.HasSuffix(got, "TAIL"), "tail must survive")
|
||||
assert.LessOrEqual(t, approximateTokens(got), 64)
|
||||
}
|
||||
|
||||
func TestTruncateMiddleOutCompactFallbackBelowMarkerBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := strings.Repeat("abcdefgh", 50)
|
||||
|
||||
// 12 tokens (48 bytes) cannot fit the 47-byte marker plus a complete rune
|
||||
// on each side, so the compact ellipsis keeps real content instead.
|
||||
got := truncateMiddleOut(content, 12)
|
||||
assert.NotContains(t, got, toolResultTruncationMarker)
|
||||
assert.Contains(t, got, "...")
|
||||
assert.True(t, strings.HasPrefix(got, "abcdefgh"), "head must survive")
|
||||
assert.True(t, strings.HasSuffix(got, "abcdefgh"), "tail must survive")
|
||||
assert.LessOrEqual(t, approximateTokens(got), 12)
|
||||
}
|
||||
@@ -87,6 +87,7 @@ func (s *Session) Clone() *Session {
|
||||
MaxIterations: s.MaxIterations,
|
||||
MaxConsecutiveToolCalls: s.MaxConsecutiveToolCalls,
|
||||
MaxOldToolCallTokens: s.MaxOldToolCallTokens,
|
||||
MaxToolResultTokens: s.MaxToolResultTokens,
|
||||
Starred: s.Starred,
|
||||
InputTokens: s.InputTokens,
|
||||
OutputTokens: s.OutputTokens,
|
||||
@@ -181,14 +182,15 @@ func copySessionMetadata(dst, src *Session, title string) {
|
||||
dst.WorkingDir = src.WorkingDir
|
||||
dst.SendUserMessage = src.SendUserMessage
|
||||
dst.MaxIterations = src.MaxIterations
|
||||
// MaxConsecutiveToolCalls and MaxOldToolCallTokens are safety / context
|
||||
// rails that may be configured deliberately by the user or operator
|
||||
// (consecutive-tool-call cutoff, old-tool-call truncation budget).
|
||||
// Dropping them on a fork or branch would silently make the new
|
||||
// session behave differently from its parent. Clone() preserves both,
|
||||
// so do the same here.
|
||||
// MaxConsecutiveToolCalls, MaxOldToolCallTokens and MaxToolResultTokens
|
||||
// are safety / context rails that may be configured deliberately by the
|
||||
// user or operator (consecutive-tool-call cutoff, old-tool-call
|
||||
// truncation budget, per-tool-result cap). Dropping them on a fork or
|
||||
// branch would silently make the new session behave differently from its
|
||||
// parent. Clone() preserves them all, so do the same here.
|
||||
dst.MaxConsecutiveToolCalls = src.MaxConsecutiveToolCalls
|
||||
dst.MaxOldToolCallTokens = src.MaxOldToolCallTokens
|
||||
dst.MaxToolResultTokens = src.MaxToolResultTokens
|
||||
dst.Starred = src.Starred
|
||||
dst.Permissions = clonePermissionsConfig(src.Permissions)
|
||||
dst.AgentModelOverrides = cloneStringMap(src.AgentModelOverrides)
|
||||
|
||||
@@ -440,13 +440,15 @@ func TestForkSession(t *testing.T) {
|
||||
// be silently zeroed out, making the fork behave differently from
|
||||
// the parent (tool-call cutoff and old-tool-call truncation budget
|
||||
// would reset to "use built-in default") despite the user having
|
||||
// configured them deliberately.
|
||||
// configured them deliberately. MaxToolResultTokens rides the same
|
||||
// rail.
|
||||
parent := &Session{
|
||||
Title: "Parent Title",
|
||||
Messages: []Item{NewMessageItem(UserMessage("hi"))},
|
||||
MaxIterations: 42,
|
||||
MaxConsecutiveToolCalls: 17,
|
||||
MaxOldToolCallTokens: 9000,
|
||||
MaxToolResultTokens: 4000,
|
||||
}
|
||||
|
||||
forked, err := ForkSession(parent, 1)
|
||||
@@ -454,5 +456,6 @@ func TestForkSession(t *testing.T) {
|
||||
assert.Equal(t, 42, forked.MaxIterations)
|
||||
assert.Equal(t, 17, forked.MaxConsecutiveToolCalls)
|
||||
assert.Equal(t, 9000, forked.MaxOldToolCallTokens)
|
||||
assert.Equal(t, 4000, forked.MaxToolResultTokens)
|
||||
})
|
||||
}
|
||||
@@ -29,6 +29,7 @@ func TestClone_CopiesScalarFields(t *testing.T) {
|
||||
MaxIterations: 7,
|
||||
MaxConsecutiveToolCalls: 3,
|
||||
MaxOldToolCallTokens: 99,
|
||||
MaxToolResultTokens: 44,
|
||||
Starred: true,
|
||||
InputTokens: 11,
|
||||
OutputTokens: 22,
|
||||
@@ -56,6 +57,7 @@ func TestClone_CopiesScalarFields(t *testing.T) {
|
||||
assert.Equal(t, 7, clone.MaxIterations)
|
||||
assert.Equal(t, 3, clone.MaxConsecutiveToolCalls)
|
||||
assert.Equal(t, 99, clone.MaxOldToolCallTokens)
|
||||
assert.Equal(t, 44, clone.MaxToolResultTokens)
|
||||
assert.True(t, clone.Starred)
|
||||
assert.Equal(t, int64(11), clone.InputTokens)
|
||||
assert.Equal(t, int64(22), clone.OutputTokens)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/docker/docker-agent/pkg/agent"
|
||||
"github.com/docker/docker-agent/pkg/chat"
|
||||
"github.com/docker/docker-agent/pkg/tools"
|
||||
)
|
||||
|
||||
// reloadedToolExchangeItems builds a history that never passed through
|
||||
// AddMessage — like a session reconstructed from persistence (the generic
|
||||
// API/SQLite path stores messages directly) — ending in a tool result with
|
||||
// the given payloads. The assistant tool_use matches the result so
|
||||
// sanitizeToolCalls retains it.
|
||||
func reloadedToolExchangeItems(toolCallID, content string, multiContent []chat.MessagePart) []Item {
|
||||
return []Item{
|
||||
NewMessageItem(UserMessage("run " + toolCallID)),
|
||||
NewMessageItem(NewAgentMessage("test-agent", &chat.Message{
|
||||
Role: chat.MessageRoleAssistant,
|
||||
ToolCalls: []tools.ToolCall{
|
||||
{
|
||||
ID: toolCallID,
|
||||
Function: tools.FunctionCall{
|
||||
Name: "test_tool",
|
||||
Arguments: `{"input":"` + toolCallID + `"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
NewMessageItem(NewAgentMessage("test-agent", &chat.Message{
|
||||
Role: chat.MessageRoleTool,
|
||||
ToolCallID: toolCallID,
|
||||
Content: content,
|
||||
MultiContent: multiContent,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
func toolResultMessage(t *testing.T, messages []chat.Message, toolCallID string) chat.Message {
|
||||
t.Helper()
|
||||
|
||||
for _, msg := range messages {
|
||||
if msg.Role == chat.MessageRoleTool && msg.ToolCallID == toolCallID {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "tool result not found", "tool_call_id=%s", toolCallID)
|
||||
return chat.Message{}
|
||||
}
|
||||
|
||||
// TestGetMessagesMaxToolResultTokensCapsReloadedResult pins the read-time
|
||||
// backstop: a tool result that entered the history without AddMessage (e.g.
|
||||
// persisted by the API/SQLite path and reloaded) must still reach the
|
||||
// provider bounded — Content, its duplicate text part, and inline-text
|
||||
// documents alike — while the stored history stays untouched.
|
||||
func TestGetMessagesMaxToolResultTokensCapsReloadedResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
oversized := strings.Repeat("x", 100_000)
|
||||
docText := strings.Repeat("€", 50_000)
|
||||
documents := []tools.DocumentContent{
|
||||
{Name: "big.md", MimeType: "text/markdown", Text: docText},
|
||||
}
|
||||
|
||||
s := New(
|
||||
WithMessages(reloadedToolExchangeItems("tc", oversized, chat.BuildToolResultMultiContent(oversized, nil, documents))),
|
||||
WithMaxToolResultTokens(100),
|
||||
)
|
||||
|
||||
messages := s.GetMessages(agent.New("test-agent", "test instruction"))
|
||||
|
||||
got := toolResultMessage(t, messages, "tc")
|
||||
assert.Contains(t, got.Content, toolResultTruncationMarker)
|
||||
assert.LessOrEqual(t, toolResultTextTokens(got), 100, "aggregate must never exceed the per-result cap")
|
||||
require.Len(t, got.MultiContent, 2)
|
||||
assert.Equal(t, got.Content, got.MultiContent[0].Text, "duplicate text part must stay in sync with Content")
|
||||
doc := got.MultiContent[1].Document
|
||||
require.NotNil(t, doc)
|
||||
assert.Equal(t, "big.md", doc.Name, "document metadata must be preserved")
|
||||
assert.Less(t, len(doc.Source.InlineText), len(docText))
|
||||
assert.True(t, utf8.ValidString(doc.Source.InlineText), "truncated document must stay valid UTF-8")
|
||||
assert.Equal(t, int64(len(doc.Source.InlineText)), doc.Size, "size must track the kept text")
|
||||
|
||||
// Reading must not rewrite history: the stored message keeps the
|
||||
// unbounded payloads.
|
||||
stored := storedToolResult(t, s, "tc")
|
||||
assert.Equal(t, oversized, stored.Content)
|
||||
require.Len(t, stored.MultiContent, 2)
|
||||
assert.Equal(t, oversized, stored.MultiContent[0].Text)
|
||||
require.NotNil(t, stored.MultiContent[1].Document)
|
||||
assert.Equal(t, docText, stored.MultiContent[1].Document.Source.InlineText)
|
||||
}
|
||||
|
||||
func TestGetMessagesMaxToolResultTokensDefaultPreservesReloadedResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
oversized := strings.Repeat("x", 100_000)
|
||||
|
||||
s := New(WithMessages(reloadedToolExchangeItems("tc", oversized, nil)))
|
||||
|
||||
messages := s.GetMessages(agent.New("test-agent", "test instruction"))
|
||||
|
||||
assert.Equal(t, oversized, toolResultMessage(t, messages, "tc").Content)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -23,6 +24,10 @@ func defaultNewID() string { return uuid.New().String() }
|
||||
const (
|
||||
// toolContentPlaceholder is the text used to replace truncated tool content
|
||||
toolContentPlaceholder = "[content truncated]"
|
||||
|
||||
// toolResultTruncationMarker replaces the removed middle of a tool result
|
||||
// that exceeds MaxToolResultTokens.
|
||||
toolResultTruncationMarker = "\n[...tool result truncated: middle omitted...]\n"
|
||||
)
|
||||
|
||||
// SafetyPolicy is the per-session safety preference. It is data only:
|
||||
@@ -186,6 +191,19 @@ type Session struct {
|
||||
// 0 (unset) and -1 both disable truncation (unlimited tool content).
|
||||
MaxOldToolCallTokens int `json:"max_old_tool_call_tokens,omitempty"`
|
||||
|
||||
// MaxToolResultTokens is the maximum number of tokens to keep from each
|
||||
// textual tool result at ingestion. Oversized results are middle-out
|
||||
// truncated by AddMessage: the head and tail are kept and the removed
|
||||
// middle is replaced with toolResultTruncationMarker. The budget covers
|
||||
// every textual payload of the result — Content and inline-text document
|
||||
// parts share it rather than each claiming a full cap. Tokens are
|
||||
// approximated by approximateTokens (len/4). The cap is enabled only when
|
||||
// this is positive; 0 (unset) and -1 both disable it (unbounded results).
|
||||
// GetMessages reapplies the same cap to the copies it returns as a
|
||||
// backstop for results that entered the history without passing through
|
||||
// AddMessage (e.g. persisted via the API/SQLite path and reloaded).
|
||||
MaxToolResultTokens int `json:"max_tool_result_tokens,omitempty"`
|
||||
|
||||
// Starred indicates if this session has been starred by the user
|
||||
Starred bool `json:"starred"`
|
||||
|
||||
@@ -565,6 +583,9 @@ func cloneSchemaValue(v any) any {
|
||||
func (s *Session) AddMessage(msg *Message) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if msg != nil {
|
||||
capToolResultContent(&msg.Message, s.MaxToolResultTokens)
|
||||
}
|
||||
s.Messages = append(s.Messages, NewMessageItem(msg))
|
||||
}
|
||||
|
||||
@@ -825,6 +846,15 @@ func WithMaxOldToolCallTokens(n int) Opt {
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxToolResultTokens sets the per-tool-result token cap applied at
|
||||
// ingestion. Positive values enable middle-out truncation; 0 and -1 disable
|
||||
// the cap (unbounded tool results).
|
||||
func WithMaxToolResultTokens(n int) Opt {
|
||||
return func(s *Session) {
|
||||
s.MaxToolResultTokens = n
|
||||
}
|
||||
}
|
||||
|
||||
func WithWorkingDir(workingDir string) Opt {
|
||||
return func(s *Session) {
|
||||
s.WorkingDir = workingDir
|
||||
@@ -1357,6 +1387,17 @@ func (s *Session) GetMessages(a *agent.Agent, extraSystemMessages ...chat.Messag
|
||||
messages = normalizeMessageContent(messages)
|
||||
messages = sanitizeToolCalls(messages)
|
||||
|
||||
// Read-time backstop: tool results that entered the history without
|
||||
// passing through AddMessage (e.g. persisted via the API/SQLite path and
|
||||
// reloaded) may still be unbounded, so reapply the cap to the assembled
|
||||
// messages just before they reach a provider. These are snapshot copies
|
||||
// (snapshotItems), so the stored history is never mutated by a read.
|
||||
if s.MaxToolResultTokens > 0 {
|
||||
for i := range messages {
|
||||
capToolResultContent(&messages[i], s.MaxToolResultTokens)
|
||||
}
|
||||
}
|
||||
|
||||
systemCount := 0
|
||||
conversationCount := 0
|
||||
for i := range messages {
|
||||
@@ -1624,3 +1665,161 @@ func truncateOldToolContent(messages []chat.Message, maxTokens int) []chat.Messa
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// capToolResultContent applies the opt-in MaxToolResultTokens middle-out cap
|
||||
// to a tool-result message, mutating msg in place. A single result-wide
|
||||
// budget covers every textual payload a provider can receive from the
|
||||
// message — Content plus MultiContent text parts and inline-text documents —
|
||||
// so a result carrying many documents stays bounded instead of claiming one
|
||||
// full cap per document. The budget is consumed in order — Content first,
|
||||
// then MultiContent parts left to right — so payloads past an exhausted
|
||||
// budget keep their metadata but lose their text (no proportional sharing).
|
||||
// The text part duplicating Content (see chat.BuildToolResultMultiContent)
|
||||
// is rewritten in lockstep and charged only once so providers cannot receive
|
||||
// the unbounded copy. Document names, MIME types, and non-text parts are
|
||||
// preserved; a truncated document's Size is updated to match the kept text.
|
||||
//
|
||||
// AddMessage applies the cap at ingestion (the primary path); GetMessages
|
||||
// reapplies it to its local copies as a read-time backstop for results that
|
||||
// entered the history without passing through AddMessage.
|
||||
func capToolResultContent(msg *chat.Message, maxTokens int) {
|
||||
if maxTokens <= 0 || msg.Role != chat.MessageRoleTool {
|
||||
return
|
||||
}
|
||||
// Results whose aggregate textual payload approximates within the cap
|
||||
// stay untouched — this also makes the cap idempotent, so the read-time
|
||||
// reapply is a no-op for results already capped at ingestion. Past this
|
||||
// branch maxTokens*4 < aggregate bytes, so the byte conversions below
|
||||
// cannot overflow.
|
||||
if toolResultTextualBytes(msg)/4 <= maxTokens {
|
||||
return
|
||||
}
|
||||
|
||||
remaining := maxTokens * 4
|
||||
|
||||
original := msg.Content
|
||||
msg.Content = truncateMiddleOut(original, maxTokens)
|
||||
// Content may keep up to 3 bytes of len/4 rounding slack beyond the byte
|
||||
// budget; the aggregate still approximates within the cap.
|
||||
remaining = max(remaining-len(msg.Content), 0)
|
||||
|
||||
fit := func(text string) string {
|
||||
kept := truncateMiddleOutBytes(text, remaining)
|
||||
remaining -= len(kept)
|
||||
return kept
|
||||
}
|
||||
contentDuplicateSynced := false
|
||||
for i := range msg.MultiContent {
|
||||
part := &msg.MultiContent[i]
|
||||
switch {
|
||||
case part.Type == chat.MessagePartTypeText && !contentDuplicateSynced && part.Text == original:
|
||||
// The first matching text part is the duplicate emitted by
|
||||
// BuildToolResultMultiContent. Rewrite it in lockstep and charge
|
||||
// it once via Content above; later identical parts are distinct.
|
||||
part.Text = msg.Content
|
||||
contentDuplicateSynced = true
|
||||
case part.Type == chat.MessagePartTypeText:
|
||||
part.Text = fit(part.Text)
|
||||
case part.Type == chat.MessagePartTypeDocument && part.Document != nil && part.Document.Source.InlineText != "":
|
||||
kept := fit(part.Document.Source.InlineText)
|
||||
if kept == part.Document.Source.InlineText {
|
||||
continue
|
||||
}
|
||||
// Copy-on-write so a Document shared with the caller is not
|
||||
// mutated behind its back.
|
||||
doc := *part.Document
|
||||
doc.Source.InlineText = kept
|
||||
doc.Size = int64(len(kept))
|
||||
part.Document = &doc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toolResultTextualBytes sums the bytes of every textual payload a provider
|
||||
// can receive from a tool-result message: Content, MultiContent text parts,
|
||||
// and inline-text documents. The first text part equal to Content is the
|
||||
// duplicate emitted by chat.BuildToolResultMultiContent — providers send
|
||||
// either Content or the parts, never both — so it is counted once. Any later
|
||||
// identical text parts are distinct payloads and still consume budget.
|
||||
func toolResultTextualBytes(msg *chat.Message) int {
|
||||
total := len(msg.Content)
|
||||
contentDuplicateSeen := false
|
||||
for i := range msg.MultiContent {
|
||||
part := &msg.MultiContent[i]
|
||||
switch part.Type {
|
||||
case chat.MessagePartTypeText:
|
||||
if !contentDuplicateSeen && part.Text == msg.Content {
|
||||
contentDuplicateSeen = true
|
||||
} else {
|
||||
total += len(part.Text)
|
||||
}
|
||||
case chat.MessagePartTypeDocument:
|
||||
if part.Document != nil {
|
||||
total += len(part.Document.Source.InlineText)
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// truncateMiddleOut caps content at maxTokens approximate tokens
|
||||
// (approximateTokens, len/4) by keeping the head and the tail and dropping
|
||||
// the middle. Content already within the cap is returned unchanged.
|
||||
func truncateMiddleOut(content string, maxTokens int) string {
|
||||
if maxTokens <= 0 || approximateTokens(content) <= maxTokens {
|
||||
return content
|
||||
}
|
||||
// Reaching here implies maxTokens < len(content)/4, so the byte budget
|
||||
// cannot overflow.
|
||||
return truncateMiddleOutBytes(content, maxTokens*4)
|
||||
}
|
||||
|
||||
// truncateMiddleOutBytes keeps content within budget bytes by preserving the
|
||||
// head and the tail and dropping the middle. Content within the budget is
|
||||
// returned unchanged; a truncated result is always strictly smaller than the
|
||||
// input, stays valid UTF-8 (cut points snap to rune starts), and flags the
|
||||
// removed middle as clearly as the budget allows:
|
||||
//
|
||||
// 1. toolResultTruncationMarker between head and tail, when the budget fits
|
||||
// the marker plus at least one complete rune on each side;
|
||||
// 2. a bare "..." between whatever head/tail still fits, for budgets too
|
||||
// small for the full marker;
|
||||
// 3. the empty string when not even "..." fits — any shorter fragment would
|
||||
// read as complete output rather than as an elision.
|
||||
func truncateMiddleOutBytes(content string, budget int) string {
|
||||
if len(content) <= budget {
|
||||
return content
|
||||
}
|
||||
|
||||
if keep := budget - len(toolResultTruncationMarker); keep >= 2 {
|
||||
head, tailStart := middleOutCut(content, keep)
|
||||
if head > 0 && tailStart < len(content) {
|
||||
return content[:head] + toolResultTruncationMarker + content[tailStart:]
|
||||
}
|
||||
}
|
||||
|
||||
const ellipsis = "..."
|
||||
keep := budget - len(ellipsis)
|
||||
if keep < 0 {
|
||||
return ""
|
||||
}
|
||||
head, tailStart := middleOutCut(content, keep)
|
||||
return content[:head] + ellipsis + content[tailStart:]
|
||||
}
|
||||
|
||||
// middleOutCut splits a keep-byte allowance between a head prefix and a tail
|
||||
// suffix of content, snapping both cut points to rune starts so the kept
|
||||
// pieces stay valid UTF-8. Snapping only shrinks the pieces, so
|
||||
// head + len(content) - tailStart never exceeds keep (either piece may snap
|
||||
// to empty). Callers must ensure 0 <= keep < len(content).
|
||||
func middleOutCut(content string, keep int) (head, tailStart int) {
|
||||
head = (keep + 1) / 2
|
||||
for head > 0 && !utf8.RuneStart(content[head]) {
|
||||
head--
|
||||
}
|
||||
tailStart = len(content) - keep/2
|
||||
for tailStart < len(content) && !utf8.RuneStart(content[tailStart]) {
|
||||
tailStart++
|
||||
}
|
||||
return head, tailStart
|
||||
}
|
||||
@@ -267,6 +267,7 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c
|
||||
agent.WithMaxIterations(agentConfig.MaxIterations),
|
||||
agent.WithMaxConsecutiveToolCalls(agentConfig.MaxConsecutiveToolCalls),
|
||||
agent.WithMaxOldToolCallTokens(agentConfig.MaxOldToolCallTokens),
|
||||
agent.WithMaxToolResultTokens(agentConfig.MaxToolResultTokens),
|
||||
agent.WithNumHistoryItems(agentConfig.NumHistoryItems),
|
||||
agent.WithSessionCompaction(agentConfig.SessionCompactionEnabled()),
|
||||
agent.WithCommands(expander.ExpandCommands(ctx, agentConfig.Commands)),
|
||||
|
||||
@@ -910,6 +910,27 @@ func TestLoadRetainsAgentConfig(t *testing.T) {
|
||||
assert.False(t, ok, "unknown agent reports no retained config")
|
||||
}
|
||||
|
||||
// TestLoadPropagatesMaxToolResultTokens verifies the opt-in per-tool-result
|
||||
// cap travels from the YAML config to the built agent, where session
|
||||
// constructors read it via agent.MaxToolResultTokens().
|
||||
func TestLoadPropagatesMaxToolResultTokens(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "dummy")
|
||||
|
||||
data := []byte(`agents:
|
||||
root:
|
||||
model: openai/gpt-4o
|
||||
instruction: test
|
||||
max_tool_result_tokens: 512
|
||||
`)
|
||||
|
||||
team, err := Load(t.Context(), config.NewBytesSource("cap.yaml", data), &config.RuntimeConfig{}, withTestProviderRegistry()...)
|
||||
require.NoError(t, err)
|
||||
|
||||
agt, err := team.Agent("root")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 512, agt.MaxToolResultTokens())
|
||||
}
|
||||
|
||||
// TestLoadWithConfig_GlobalProviders covers user-level custom providers
|
||||
// (seeded into the runtime config from the user config file): they must
|
||||
// resolve inline `provider/model` references in any agent config, while
|
||||
|
||||
Reference in new issue
Block a user