Files
xinmi/internal/agent/tools/output_budget_test.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

110 lines
4.0 KiB
Go

package tools
import (
"context"
"encoding/json"
"strings"
"testing"
"unicode/utf8"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// budgetProbeTool records the budget it observed and returns a caller-supplied
// output, so registry-level plumbing can be asserted end to end.
type budgetProbeTool struct {
observedBudget int
output string
}
func (b *budgetProbeTool) Name() string { return "budget_probe" }
func (b *budgetProbeTool) Description() string { return "probe" }
func (b *budgetProbeTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (b *budgetProbeTool) Execute(ctx context.Context, _ json.RawMessage) (*types.ToolResult, error) {
b.observedBudget = OutputBudget(ctx)
return &types.ToolResult{Success: true, Output: b.output}, nil
}
func TestOutputBudgetDefaultsWhenUnset(t *testing.T) {
var missingCtx context.Context
assert.Equal(t, DefaultMaxToolOutput, OutputBudget(context.Background()))
assert.Equal(t, DefaultMaxToolOutput, OutputBudget(missingCtx))
assert.Equal(t, DefaultMaxToolOutput, OutputBudget(WithOutputBudget(context.Background(), 0)))
}
// The registry owns the output ceiling, so a tool that wants to shape its own
// result has no other way to learn what it is.
func TestExecuteToolPublishesConfiguredBudget(t *testing.T) {
registry := NewToolRegistry()
registry.SetMaxToolOutputSize(1234)
probe := &budgetProbeTool{output: "ok"}
registry.RegisterTool(probe)
_, err := registry.ExecuteTool(context.Background(), "budget_probe", json.RawMessage(`{}`))
require.NoError(t, err)
assert.Equal(t, 1234, probe.observedBudget)
}
// The ceiling is documented as a rune count. Comparing bytes here used to let
// CJK output through untouched, because 16000 runes of Chinese are ~48000
// bytes: the byte check fired, then TruncateToolOutput saw a rune count under
// the limit and returned the blob unchanged.
func TestExecuteToolTruncatesCJKOutputByRuneCount(t *testing.T) {
registry := NewToolRegistry()
registry.SetMaxToolOutputSize(1000)
registry.RegisterTool(&budgetProbeTool{output: strings.Repeat("你", 5000)})
result, err := registry.ExecuteTool(context.Background(), "budget_probe", json.RawMessage(`{}`))
require.NoError(t, err)
require.NotNil(t, result)
assert.Contains(t, result.Output, "output truncated")
assert.LessOrEqual(t, utf8.RuneCountInString(result.Output), 1000)
}
func TestExecuteToolLeavesOutputWithinBudgetUntouched(t *testing.T) {
registry := NewToolRegistry()
registry.SetMaxToolOutputSize(1000)
output := strings.Repeat("你", 900)
registry.RegisterTool(&budgetProbeTool{output: output})
result, err := registry.ExecuteTool(context.Background(), "budget_probe", json.RawMessage(`{}`))
require.NoError(t, err)
assert.Equal(t, output, result.Output)
}
func TestSplitBudgetFairly(t *testing.T) {
t.Run("everything fits", func(t *testing.T) {
assert.Equal(t, []int{10, 20, 30}, splitBudgetFairly(100, []int{10, 20, 30}))
})
t.Run("small entries keep full size and donate slack", func(t *testing.T) {
// An equal split would be 30 each, but the 5-rune entry only needs 5,
// so its 25 spare runes are redistributed to the two large entries.
caps := splitBudgetFairly(90, []int{5, 1000, 1000})
assert.Equal(t, 5, caps[0])
assert.Equal(t, caps[1], caps[2])
assert.Greater(t, caps[1], 30)
assert.LessOrEqual(t, caps[0]+caps[1]+caps[2], 90)
})
t.Run("never exceeds total or per-entry size", func(t *testing.T) {
sizes := []int{700, 20, 5000, 120}
caps := splitBudgetFairly(1000, sizes)
sum := 0
for i, c := range caps {
assert.LessOrEqual(t, c, sizes[i], "cap must not exceed the entry size")
sum += c
}
assert.LessOrEqual(t, sum, 1000)
})
t.Run("degenerate inputs", func(t *testing.T) {
assert.Empty(t, splitBudgetFairly(100, nil))
assert.Equal(t, []int{0, 0}, splitBudgetFairly(0, []int{10, 10}))
assert.Equal(t, []int{0, 0}, splitBudgetFairly(-5, []int{10, 10}))
})
}