Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions internal/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleTool,
Content: toolResult.Output,
Content: toolResult.ModelOutput(),
ToolCallID: toolResult.ToolCallID,
IsError: toolResult.Status == tools.StatusError,
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
Expand Down Expand Up @@ -721,7 +721,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
messages = append(messages, toolImageMessages...)
result.FinalAnswer = toolResult.Output
result.FinalAnswer = toolResult.ModelOutput()
result.StopReason = stopReason
result.Messages = copyMessages(messages)
return result, nil
Expand All @@ -734,7 +734,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// aren't fixed by reformatting the call, so a "match this schema" hint
// would misdirect the model toward JSON shape or blocked behavior.
retriableFailure := isRetriableToolError(toolResult)
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.Output)
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.ModelOutput())
posture.observeToolOutcome(outcome, toolResult)
if outcome.Stop {
// The assistant message advertised EVERY collected tool call, but
Expand All @@ -750,7 +750,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
return result, nil
}
if outcome.InjectHint && failureHint == "" {
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.ModelOutput())
}

// Collect the files this successful mutating tool changed. Self-correct
Expand Down Expand Up @@ -922,6 +922,21 @@ func recordOutputBudgetTrace(recorder *trace.Recorder, result ToolResult) {
if recorder == nil || result.Meta["output_budget_category"] == "" {
return
}
if result.Outcome.Finalized() {
diagnostics := result.Outcome.Diagnostics
recorder.EmitOutputBudget(trace.OutputBudgetEvent{
Tool: result.Name,
Category: diagnostics.Category,
OriginalBytes: diagnostics.OriginalBytes,
RetainedBytes: diagnostics.ModelBytes,
EstimatedOriginalTokens: diagnostics.EstimatedOriginalTokens,
EstimatedRetainedTokens: diagnostics.EstimatedModelTokens,
Truncated: diagnostics.Truncated,
Reason: diagnostics.Reason,
SpillCreated: result.Outcome.Artifact != nil,
})
return
}
parseInt := func(key string) int {
value, _ := strconv.Atoi(result.Meta[key])
return value
Expand Down Expand Up @@ -1457,14 +1472,15 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Output: result.ModelOutput(),
Truncated: result.Truncated,
Meta: result.Meta,
Images: result.Images,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
Display: result.Display,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
LoadedTools: loadedToolsFromResult(result.Meta),
// A tool may signal a mid-run model escalation by carrying the target id
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
Expand Down Expand Up @@ -2061,13 +2077,14 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.Output,
Output: result.ModelOutput(),
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
ChangeSummaries: result.ChangeSummaries,
Display: result.Display,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
}
}
return ToolResult{
Expand Down
19 changes: 19 additions & 0 deletions internal/agent/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ type ToolResult struct {
// command execution; callers must not schedule per-file work from them.
ChangeSummaries []execution.Change
Display tools.Display
Outcome tools.ToolOutcome
// DenialReason categorizes why a tool call was blocked (empty when it ran).
// It lets a surface distinguish the cause precisely instead of parsing Output.
DenialReason DenialCategory
Expand All @@ -99,6 +100,24 @@ type ToolResult struct {
RequestedModel string
}

// ModelOutput returns the bounded provider-facing result while preserving
// compatibility with synthetic and restored results created before outcomes
// were finalized.
func (result ToolResult) ModelOutput() string {
if result.Outcome.Finalized() {
return result.Outcome.ModelView
}
return result.Output
}

// HumanDisplay returns the presentation intended for interactive surfaces.
func (result ToolResult) HumanDisplay() tools.Display {
if result.Outcome.Finalized() {
return result.Outcome.HumanView
}
return result.Display
}

// DenialCategory classifies why a tool call was blocked before it executed.
type DenialCategory string

Expand Down
16 changes: 9 additions & 7 deletions internal/cli/exec_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,15 @@ func (writer *execEventWriter) checkpoint(event sessions.Event) {
}

func (writer *execEventWriter) toolResult(result agent.ToolResult) {
modelOutput := result.ModelOutput()
display := result.HumanDisplay()
if writer.format == execOutputJSON {
payload := map[string]any{
"type": "tool_result",
"tool_call_id": result.ToolCallID,
"name": result.Name,
"status": string(result.Status),
"output": result.Output,
"output": modelOutput,
}
if len(result.Meta) > 0 {
payload["meta"] = result.Meta
Expand All @@ -162,14 +164,14 @@ func (writer *execEventWriter) toolResult(result agent.ToolResult) {
if len(result.ChangedFiles) > 0 {
payload["changed_files"] = result.ChangedFiles
}
if result.Display.Summary != "" || result.Display.Kind != "" {
payload["display"] = map[string]string{"summary": result.Display.Summary, "kind": result.Display.Kind}
if display.Summary != "" || display.Kind != "" {
payload["display"] = map[string]string{"summary": display.Summary, "kind": display.Kind}
}
writer.writeJSON(payload)
return
}
if writer.format == execOutputStreamJSON {
output, surfaceTruncated := truncateForStreamJSONOutput(result.Output)
output, surfaceTruncated := truncateForStreamJSONOutput(modelOutput)
truncated := result.Truncated || surfaceTruncated
event := streamjson.Event{
Type: streamjson.EventToolResult,
Expand All @@ -186,13 +188,13 @@ func (writer *execEventWriter) toolResult(result agent.ToolResult) {
redacted := true
event.Redacted = &redacted
}
if result.Display.Summary != "" || result.Display.Kind != "" {
event.Display = &streamjson.Display{Summary: result.Display.Summary, Kind: result.Display.Kind}
if display.Summary != "" || display.Kind != "" {
event.Display = &streamjson.Display{Summary: display.Summary, Kind: display.Kind}
}
writer.writeStreamJSON(event)
return
}
writer.writeStderr("[result] " + truncateForStatus(result.Output) + "\n")
writer.writeStderr("[result] " + truncateForStatus(modelOutput) + "\n")
}

func (writer *execEventWriter) permission(event agent.PermissionEvent) {
Expand Down
7 changes: 5 additions & 2 deletions internal/tools/output_boundary.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,18 @@ func selfManagedOutputBudget(toolName string, args map[string]any) outputBudget
// newly combined result so hooks cannot bypass the established safety ceiling.
func (registry *Registry) RebudgetAfterHook(toolName string, args map[string]any, result Result) Result {
result = scrubResultSecrets(result)
boundaryOutput := result.Output
tool, _ := registry.Get(toolName)
if _, ok := tool.(selfBudgeting); ok {
// Match the primary registry boundary: self-managed tools keep their
// call-specific capture/output budget instead of being tightened or
// loosened to the generic registry ceiling after hook feedback.
return applySelfManagedOutputBudget(tool, toolName, args, result)
result = applySelfManagedOutputBudget(tool, toolName, args, result)
return finalizeToolOutcome(result, boundaryOutput)
}
result = applyRegistryOutputBudget(tool, toolName, args, result)
return enforceOutputCeiling(toolName, result)
result = enforceOutputCeiling(toolName, result)
return finalizeToolOutcome(result, boundaryOutput)
}

func registryOutputBudget(toolName string) outputBudget {
Expand Down
2 changes: 2 additions & 0 deletions internal/tools/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,15 @@ func (registry *Registry) RunWithOptions(ctx context.Context, name string, args
var ok bool
defer func() {
result = scrubResultSecrets(result)
boundaryOutput := result.Output
result = reduceCommandOutput(name, args, result)
if selfManagedOutput {
result = applySelfManagedOutputBudget(tool, name, args, result)
} else {
result = applyRegistryOutputBudget(tool, name, args, result)
result = enforceOutputCeiling(name, result)
}
result = finalizeToolOutcome(result, boundaryOutput)
if commitFileObservation {
result = registry.CommitFileObservation(result, options.FileTracker)
}
Expand Down
115 changes: 115 additions & 0 deletions internal/tools/tool_outcome.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package tools

import (
"encoding/json"
"strings"
)

const humanOutcomePreviewBytes = 8 * 1024

type serializedToolOutcome struct {
ModelView string `json:"modelView,omitempty"`
HumanView Display `json:"humanView,omitempty"`
Artifact *ToolArtifact `json:"artifact,omitempty"`
Diagnostics OutcomeDiagnostics `json:"diagnostics,omitempty"`
Finalized bool `json:"finalized,omitempty"`
}

// MarshalJSON preserves the private finalized marker without making outcome
// state mutable to callers.
func (outcome ToolOutcome) MarshalJSON() ([]byte, error) {
return json.Marshal(serializedToolOutcome{
ModelView: outcome.ModelView,
HumanView: outcome.HumanView,
Artifact: outcome.Artifact,
Diagnostics: outcome.Diagnostics,
Finalized: outcome.finalized,
})
}

// UnmarshalJSON restores the finalized marker used to select canonical views.
func (outcome *ToolOutcome) UnmarshalJSON(data []byte) error {
var serialized serializedToolOutcome
if err := json.Unmarshal(data, &serialized); err != nil {
return err
}
outcome.ModelView = serialized.ModelView
outcome.HumanView = serialized.HumanView
outcome.Artifact = serialized.Artifact
outcome.Diagnostics = serialized.Diagnostics
outcome.finalized = serialized.Finalized
return nil
}

// finalizeToolOutcome is the single seam between tool execution and its three
// consumers: provider context, human presentation, and recoverable artifacts.
// boundaryOutput must already be redacted. It is the text seen immediately
// before command reduction and semantic budgeting.
func finalizeToolOutcome(result Result, boundaryOutput string) Result {
previous := result.Outcome
human := result.Display
if human.Preview == "" && result.Meta["command_output_reduced"] == "true" {
human.Preview = boundedHumanOutcomePreview(boundaryOutput, result.Meta["spill_path"])
}
// A tool-provided success preview (for example, a prospective file diff)
// must not hide the actual error. Command reduction is different: its
// preview is the redacted execution output containing that same failure plus
// the evidence omitted from the model view.
if result.Status == StatusError && result.Meta["command_output_reduced"] != "true" {
human.Preview = ""
}

originalBytes := len(boundaryOutput)
originalTokens := estimateOutputTokens(boundaryOutput)
if previous.Finalized() {
originalBytes = previous.Diagnostics.OriginalBytes
originalTokens = previous.Diagnostics.EstimatedOriginalTokens
}
modelBytes := len(result.Output)
modelTokens := estimateOutputTokens(result.Output)

var artifact *ToolArtifact
if previous.Finalized() {
artifact = previous.Artifact
}
if path := strings.TrimSpace(result.Meta["spill_path"]); path != "" {
if artifact == nil || artifact.Path != path {
artifact = &ToolArtifact{
Path: path,
CompleteAtBoundary: result.Meta["command_output_reduced"] == "true" || result.Meta[outputBudgetSpillCreatedMeta] == "true",
}
}
}

result.Display = human
result.Outcome = ToolOutcome{
ModelView: result.Output,
HumanView: human,
Artifact: artifact,
Diagnostics: OutcomeDiagnostics{
Category: result.Meta[outputBudgetCategoryMeta],
OriginalBytes: originalBytes,
ModelBytes: modelBytes,
EstimatedOriginalTokens: originalTokens,
EstimatedModelTokens: modelTokens,
Truncated: result.Truncated,
Redacted: result.Redacted,
Reason: result.Meta["truncation_reason"],
},
finalized: true,
}
return result
}

func boundedHumanOutcomePreview(output string, artifactPath string) string {
if len(output) <= humanOutcomePreviewBytes {
return output
}
marker := "\n[zero] human preview shortened"
if strings.TrimSpace(artifactPath) != "" {
marker += "; exact output: " + artifactPath
}
marker += "\n"
contentBudget := max(0, humanOutcomePreviewBytes-len(marker))
return utf8Prefix(output, contentBudget*3/5) + marker + utf8Suffix(output, contentBudget*2/5)
}
Loading
Loading