Files
zero/internal/cli/exec_writer.go
KRATOSandGnanam 69810d5ab0 Mid-run model escalation: escalate_model tool + --allow-escalation (#146)
* modelregistry: add UpgradeTargetID and Registry.UpgradeTarget escalation lookup

* modelregistry: seed escalation chains in catalog decoration

* modelregistry: verification gate for escalation upgrade map

* agent: add ModelSwitcher option and ToolResult.RequestedModel field

* agent: lift escalate_to_model meta into ToolResult.RequestedModel

* agent: perform at-most-one mid-run model switch in the turn loop

* tools: add SideEffectNone for control-only tools

* tools: add escalate_model control tool (metadata + args)

* cli: parse --allow-escalation exec flag

* cli: document --allow-escalation in exec help

* cli: register escalate_model tool under --allow-escalation

* cli: wire model switcher for exec escalation

* cli: guard byte-identical exec run without escalation flag

* cli: attribute post-switch usage to the escalated model

* tools: accept an explicit empty escalate_model reason

reason is an optional, informational-only arg, but the previous arg
parsing rejected an explicit reason:"" (non-empty-string check), failing
the whole escalation. Switch to stringArgWithEmpty so an absent OR empty
reason is accepted while a non-string value is still rejected. Add a test
covering both the empty and absent forms.

* agent: test (nil,nil) escalation switcher keeps original provider

When a ModelSwitcher returns (nil, nil) — no error, no replacement — the
loop's `else if newProvider != nil` guard already keeps the active
provider, so the run continues on the original model. Add an agent-layer
test that proves this (it panics if the nil guard is dropped), closing the
gap the adversarial review flagged.

Also note the deferred compaction limitation near the switch: the
compactor's context-window budget is fixed at run start and not updated on
a mid-run switch (needs a ModelSwitcher contract change to fix).

* cli: gate usage model attribution on the escalation flag and harden tests

FIX A (back-compat): the OnUsage closure wrote a "model" key into every
persisted EventUsage payload unconditionally, diverging a non-escalation
run from the byte-identical origin/main baseline. The model can only
change mid-run under --allow-escalation, so include the "model" key only
then. A new flag-OFF test asserts the persisted usage payload carries no
"model" key (mutation-proven).

Test hardening for the CLI escalation boundary:
- Wire test: each newProvider build returns a DISTINCT provider with its
  own turn counter; assert the source provider served exactly the
  escalation turn and the escalated provider served exactly the
  post-switch answer. Fails if `provider = newProvider` is dropped.
- Mixed usage attribution: emit usage pre- and post-switch and assert the
  first attributes to the original model, the second to the escalated
  target (the loop's ordering guarantee).
- Switch-error path: a failed rebuild is non-fatal and post-error usage
  stays attributed to the original model (currentModel unchanged).
- Top-tier decline end-to-end: a flag-on run that escalates while already
  top-tier performs no switch (newProvider built exactly once) and
  succeeds.

* modelregistry: validate UpgradeTargetID resolves at registry construction

* cli: update currentModel only when the switched provider is non-nil

* sandbox: make 'none' a first-class side effect (control-only tools)

---------

Co-authored-by: Gnanam <[email protected]>
2026-06-08 20:50:32 +05:30

436 lines
12 KiB
Go

package cli
import (
"encoding/json"
"io"
"strings"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
)
const streamJSONToolResultOutputLimit = 10 * 1024
type execRunMetadata struct {
Provider string
Model string
APIModel string
}
type execEventWriter struct {
stdout io.Writer
stderr io.Writer
format execOutputFormat
runID string
sessionID string
streamedText *strings.Builder
err error
}
func (writer *execEventWriter) runStart(cwd string, metadata execRunMetadata, permissionMode agent.PermissionMode) {
switch writer.format {
case execOutputJSON:
writer.writeJSON(map[string]any{
"type": "run_start",
"cwd": cwd,
"provider": metadata.Provider,
"model": metadata.Model,
"api_model": metadata.APIModel,
"permission_mode": string(permissionMode),
})
case execOutputStreamJSON:
writer.writeStreamJSON(streamjson.Event{
Type: streamjson.EventRunStart,
RunID: writer.runID,
SessionID: writer.sessionID,
Cwd: cwd,
Provider: metadata.Provider,
Model: metadata.Model,
APIModel: metadata.APIModel,
})
}
}
func (writer *execEventWriter) warning(message string) {
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{"type": "warning", "message": message})
return
}
if writer.format == execOutputStreamJSON {
writer.writeStreamJSON(streamjson.Event{Type: streamjson.EventWarning, RunID: writer.runID, Message: message})
return
}
writer.writeStderr("[zero] WARNING: " + message + "\n")
}
func (writer *execEventWriter) text(delta string) {
writer.streamedText.WriteString(delta)
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{"type": "text", "delta": delta})
return
}
if writer.format == execOutputStreamJSON {
writer.writeStreamJSON(streamjson.Event{Type: streamjson.EventText, RunID: writer.runID, Delta: delta})
return
}
writer.writeStdout(delta)
}
func (writer *execEventWriter) toolCall(call agent.ToolCall, registry *tools.Registry) {
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{
"type": "tool_call",
"id": call.ID,
"name": call.Name,
"arguments": call.Arguments,
})
return
}
if writer.format == execOutputStreamJSON {
writer.writeStreamJSON(streamjson.Event{
Type: streamjson.EventToolCall,
RunID: writer.runID,
ID: call.ID,
Name: call.Name,
Args: parseToolCallArgs(call.Arguments),
SideEffect: streamJSONSideEffect(call.Name, registry),
})
return
}
writer.writeStderr("[tool] " + call.Name + "\n")
}
func (writer *execEventWriter) checkpoint(event sessions.Event) {
var payload sessions.CheckpointPayload
if len(event.Payload) > 0 {
_ = json.Unmarshal(event.Payload, &payload)
}
files := make([]string, 0, len(payload.Files))
for _, f := range payload.Files {
files = append(files, f.Path)
}
switch writer.format {
case execOutputStreamJSON:
writer.writeStreamJSON(streamjson.Event{
Type: streamjson.EventCheckpoint,
RunID: writer.runID,
Checkpoint: &streamjson.CheckpointInfo{Sequence: event.Sequence, Tool: payload.Tool, Files: files},
})
case execOutputJSON:
writer.writeJSON(map[string]any{
"type": "checkpoint",
"sequence": event.Sequence,
"tool": payload.Tool,
"files": files,
})
}
// Plain text mode stays silent: checkpoints are background safety, not output.
}
func (writer *execEventWriter) toolResult(result agent.ToolResult) {
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,
}
if len(result.Meta) > 0 {
payload["meta"] = result.Meta
}
if result.Redacted {
payload["redacted"] = true
}
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}
}
writer.writeJSON(payload)
return
}
if writer.format == execOutputStreamJSON {
output, truncated := truncateForStreamJSONOutput(result.Output)
event := streamjson.Event{
Type: streamjson.EventToolResult,
RunID: writer.runID,
ID: result.ToolCallID,
Name: result.Name,
Status: string(result.Status),
Output: output,
Truncated: &truncated,
ChangedFiles: result.ChangedFiles,
Meta: result.Meta,
}
if result.Redacted {
redacted := true
event.Redacted = &redacted
}
if result.Display.Summary != "" || result.Display.Kind != "" {
event.Display = &streamjson.Display{Summary: result.Display.Summary, Kind: result.Display.Kind}
}
writer.writeStreamJSON(event)
return
}
writer.writeStderr("[result] " + truncateForStatus(result.Output) + "\n")
}
func (writer *execEventWriter) permission(event agent.PermissionEvent) {
if writer.format == execOutputJSON {
payload := map[string]any{
"type": "permission",
"tool_call_id": event.ToolCallID,
"name": event.ToolName,
"action": string(event.Action),
"permission": event.Permission,
"permission_granted": event.PermissionGranted,
"permission_mode": string(event.PermissionMode),
"autonomy": event.Autonomy,
"side_effect": event.SideEffect,
"reason": event.Reason,
"decision_reason": event.DecisionReason,
"risk": event.Risk,
}
if event.Violation != nil {
payload["violation"] = event.Violation
}
if event.GrantMatched {
payload["grant_matched"] = true
}
if event.Grant != nil {
payload["grant"] = event.Grant
}
writer.writeJSON(payload)
return
}
if writer.format == execOutputStreamJSON {
risk := event.Risk
permissionGranted := event.PermissionGranted
writer.writeStreamJSON(streamjson.Event{
Type: streamJSONPermissionEventType(event),
RunID: writer.runID,
SessionID: writer.sessionID,
ID: event.ToolCallID,
Name: event.ToolName,
Action: string(event.Action),
Permission: event.Permission,
PermissionGranted: &permissionGranted,
PermissionMode: string(event.PermissionMode),
Autonomy: event.Autonomy,
SideEffect: event.SideEffect,
Reason: event.Reason,
DecisionReason: event.DecisionReason,
Risk: &risk,
Violation: event.Violation,
GrantMatched: event.GrantMatched,
Grant: event.Grant,
})
return
}
if event.Action != agent.PermissionActionAllow {
writer.writeStderr("[permission] " + event.ToolName + " " + string(event.Action) + ": " + event.Reason + "\n")
}
}
func streamJSONPermissionEventType(event agent.PermissionEvent) streamjson.EventType {
if event.Action == agent.PermissionActionPrompt {
return streamjson.EventPermissionRequest
}
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny {
return streamjson.EventPermissionDecision
}
return streamjson.EventPermission
}
func (writer *execEventWriter) usage(usage agent.Usage) {
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{
"type": "usage",
"prompt_tokens": usage.PromptTokens,
"completion_tokens": usage.CompletionTokens,
"total_tokens": usage.TotalTokens(),
})
return
}
if writer.format == execOutputStreamJSON {
promptTokens := usage.EffectiveInputTokens()
completionTokens := usage.EffectiveOutputTokens()
totalTokens := usage.TotalTokens()
writer.writeStreamJSON(streamjson.Event{
Type: streamjson.EventUsage,
RunID: writer.runID,
PromptTokens: &promptTokens,
CompletionTokens: &completionTokens,
TotalTokens: &totalTokens,
})
}
}
func (writer *execEventWriter) final(answer string) {
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{"type": "final", "text": answer})
writer.writeJSON(map[string]any{"type": "done", "exit_code": exitSuccess})
return
}
if writer.format == execOutputStreamJSON {
writer.writeStreamJSON(streamjson.Event{Type: streamjson.EventFinal, RunID: writer.runID, Text: answer})
return
}
if writer.streamedText.Len() == 0 && answer != "" {
writer.writeStdout(answer)
writer.streamedText.WriteString(answer)
}
if writer.streamedText.Len() > 0 && !strings.HasSuffix(writer.streamedText.String(), "\n") {
writer.writeStdout("\n")
}
}
func (writer *execEventWriter) errorEvent(code string, message string, recoverable bool) {
if writer.format == execOutputStreamJSON {
writer.writeStreamJSON(streamjson.Event{
Type: streamjson.EventError,
RunID: writer.runID,
Code: code,
Message: message,
Recoverable: &recoverable,
})
return
}
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{"type": "error", "code": code, "message": message})
return
}
writer.writeStderr("[zero] " + message + "\n")
}
func (writer *execEventWriter) runEnd(status string, exitCode int) {
if writer.format != execOutputStreamJSON {
return
}
writer.writeStreamJSON(streamjson.Event{
Type: streamjson.EventRunEnd,
RunID: writer.runID,
Status: status,
ExitCode: &exitCode,
})
}
func (writer *execEventWriter) writeStreamJSON(event streamjson.Event) {
if writer.err != nil {
return
}
line, err := streamjson.FormatEvent(event)
if err != nil {
writer.err = err
return
}
writer.writeStdout(line + "\n")
}
func (writer *execEventWriter) writeJSON(payload map[string]any) {
if writer.err != nil {
return
}
writer.err = writeJSONLine(writer.stdout, payload)
}
func (writer *execEventWriter) writeStdout(value string) {
if writer.err != nil {
return
}
_, writer.err = io.WriteString(writer.stdout, value)
}
func (writer *execEventWriter) writeStderr(value string) {
if writer.err != nil {
return
}
_, writer.err = io.WriteString(writer.stderr, value)
}
func writeJSONLine(w io.Writer, payload map[string]any) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
if _, err := w.Write(append(data, '\n')); err != nil {
return err
}
return nil
}
func parseToolCallArgs(arguments string) any {
if strings.TrimSpace(arguments) == "" {
return nil
}
var args any
if err := json.Unmarshal([]byte(arguments), &args); err != nil {
return arguments
}
return args
}
func streamJSONSideEffect(name string, registry *tools.Registry) string {
tool, ok := registry.Get(name)
if !ok {
return "unknown"
}
switch tool.Safety().SideEffect {
case tools.SideEffectRead:
return "read"
case tools.SideEffectWrite:
return "write"
case tools.SideEffectShell:
return "shell"
case tools.SideEffectNetwork:
return "network"
case tools.SideEffectNone:
return "none"
default:
return "unknown"
}
}
func truncateForStatus(value string) string {
compact := strings.Join(strings.Fields(value), " ")
if len(compact) > 200 {
return compact[:200] + "..."
}
return compact
}
func truncateForStreamJSONOutput(value string) (string, bool) {
if len(value) <= streamJSONToolResultOutputLimit {
return value, false
}
return value[:streamJSONToolResultOutputLimit] + "\n[truncated]", true
}
func writeStreamJSONError(stdout io.Writer, code string, message string, recoverable bool, exitCode int) int {
runID, err := streamjson.CreateRunID(timeNow())
if err != nil {
return exitCrash
}
writer := execEventWriter{
stdout: stdout,
format: execOutputStreamJSON,
runID: runID,
}
writer.errorEvent(code, message, recoverable)
writer.runEnd("error", exitCode)
if writer.err != nil {
return exitCrash
}
return exitCode
}
func timeNow() time.Time {
return time.Now()
}