feat(runtime): cap oversized tool results

This commit is contained in:
Sayt-0 committed 2026-07-10 13:41:33 +02:00
1 parent 2eafbb375a
commit 7ca74ebd43
21 files changed
+872 -8

No files matched your search

+5
View File
@@ -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",
+1
View File
@@ -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),
+2
View File
@@ -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). |
+31
View File
@@ -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
+1
View File
@@ -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),
+1
View File
@@ -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
+5
View File
@@ -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
}
+9
View File
@@ -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
+7 -1
View File
@@ -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
+1
View File
@@ -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),
+1
View File
@@ -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),
+3
View File
@@ -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,
+72
View File
@@ -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)
}
+8 -6
View File
@@ -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)
+4 -1
View File
@@ -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)
})
}
+2
View File
@@ -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)
}
+199
View File
@@ -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
}
+1
View File
@@ -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)),
+21
View File
@@ -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