Merge pull request #63 from bnema/feat/p2-host-continuation-helper
feat(modes): recover context overflows once
8 files · +377 -54
M README.md +2 -0
| @@ -247,6 +247,8 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r | |||
| 247 | 247 | - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | |
| 248 | 248 | - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | |
| 249 | 249 | ||
| 250 | + | When an initial print, stream, or JSON request exceeds the provider context window, zut compacts the existing transcript and continues the already-appended prompt once. Print still writes only the recovered final text, stream keeps assistant text on stdout and tool diagnostics on stderr, and JSON suppresses the recoverable first terminal error so stdout remains JSONL for the successful turn. A compaction failure or a second context-window error remains terminal; this is not a general autonomous-follow-up policy. The same one-shot recovery applies to subagent workers. It does not apply to RPC, SDK, bot, or Telegram requests. | |
| 251 | + | ||
| 250 | 252 | ## zutfile agents | |
| 251 | 253 | ||
| 252 | 254 | A zutfile packages an agent's instructions, skills, requirements, and enforced tool permissions as a shareable agent. Run one from a local directory, a packed `.zut` artifact, a short name, or directly from a public GitHub repository: | |
| @@ -247,6 +247,8 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r | |||||
| 247 | - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | 247 | - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | ||
| 248 | - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | 248 | - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | ||
| 249 | 249 | ||||
| 250 | + | When an initial print, stream, or JSON request exceeds the provider context window, zut compacts the existing transcript and continues the already-appended prompt once. Print still writes only the recovered final text, stream keeps assistant text on stdout and tool diagnostics on stderr, and JSON suppresses the recoverable first terminal error so stdout remains JSONL for the successful turn. A compaction failure or a second context-window error remains terminal; this is not a general autonomous-follow-up policy. The same one-shot recovery applies to subagent workers. It does not apply to RPC, SDK, bot, or Telegram requests. | |||
| 251 | + | ||||
| 250 | ## zutfile agents | 252 | ## zutfile agents | ||
| 251 | 253 | ||||
| 252 | A zutfile packages an agent's instructions, skills, requirements, and enforced tool permissions as a shareable agent. Run one from a local directory, a packed `.zut` artifact, a short name, or directly from a public GitHub repository: | 254 | A zutfile packages an agent's instructions, skills, requirements, and enforced tool permissions as a shareable agent. Run one from a local directory, a packed `.zut` artifact, a short name, or directly from a public GitHub repository: | ||
M packages/agent/cli.go +30 -6
| @@ -931,10 +931,18 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||
| 931 | 931 | if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil { | |
| 932 | 932 | return err | |
| 933 | 933 | } | |
| 934 | + | var persistCompaction func([]provider.Message) error | |
| 935 | + | if sess != nil { | |
| 936 | + | persistCompaction = sess.AppendCompaction | |
| 937 | + | } | |
| 934 | 938 | started := time.Now() | |
| 935 | - | usage, err := modes.RunPrint(ctx, ag, prompt, nil, os.Stdout) | |
| 939 | + | usage, recovery, err := modes.RunPrintWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction) | |
| 936 | 940 | elapsed := time.Since(started) | |
| 937 | - | WriteNewTranscript(ag, sess, start) | |
| 941 | + | transcriptStart := start | |
| 942 | + | if recovery.Compacted { | |
| 943 | + | transcriptStart = recovery.OutputStart | |
| 944 | + | } | |
| 945 | + | WriteNewTranscript(ag, sess, transcriptStart) | |
| 938 | 946 | if err != nil { | |
| 939 | 947 | return err | |
| 940 | 948 | } | |
| @@ -999,8 +1007,16 @@ func runStreamMode(ctx context.Context, args Args, version string) error { | |||
| 999 | 1007 | if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil { | |
| 1000 | 1008 | return err | |
| 1001 | 1009 | } | |
| 1002 | - | err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout) | |
| 1003 | - | WriteNewTranscript(ag, sess, start) | |
| 1010 | + | var persistCompaction func([]provider.Message) error | |
| 1011 | + | if sess != nil { | |
| 1012 | + | persistCompaction = sess.AppendCompaction | |
| 1013 | + | } | |
| 1014 | + | recovery, err := modes.RunStreamWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, os.Stderr, persistCompaction) | |
| 1015 | + | transcriptStart := start | |
| 1016 | + | if recovery.Compacted { | |
| 1017 | + | transcriptStart = recovery.OutputStart | |
| 1018 | + | } | |
| 1019 | + | WriteNewTranscript(ag, sess, transcriptStart) | |
| 1004 | 1020 | return err | |
| 1005 | 1021 | } | |
| 1006 | 1022 | ||
| @@ -1106,8 +1122,16 @@ func runJSONMode(ctx context.Context, args Args, version string) error { | |||
| 1106 | 1122 | _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()}) | |
| 1107 | 1123 | return err | |
| 1108 | 1124 | } | |
| 1109 | - | err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout) | |
| 1110 | - | WriteNewTranscript(ag, sess, start) | |
| 1125 | + | var persistCompaction func([]provider.Message) error | |
| 1126 | + | if sess != nil { | |
| 1127 | + | persistCompaction = sess.AppendCompaction | |
| 1128 | + | } | |
| 1129 | + | recovery, err := modes.RunJSONWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction) | |
| 1130 | + | transcriptStart := start | |
| 1131 | + | if recovery.Compacted { | |
| 1132 | + | transcriptStart = recovery.OutputStart | |
| 1133 | + | } | |
| 1134 | + | WriteNewTranscript(ag, sess, transcriptStart) | |
| 1111 | 1135 | return err | |
| 1112 | 1136 | } | |
| 1113 | 1137 | ||
| @@ -931,10 +931,18 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||||
| 931 | if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil { | 931 | if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil { | ||
| 932 | return err | 932 | return err | ||
| 933 | } | 933 | } | ||
| 934 | + | var persistCompaction func([]provider.Message) error | |||
| 935 | + | if sess != nil { | |||
| 936 | + | persistCompaction = sess.AppendCompaction | |||
| 937 | + | } | |||
| 934 | started := time.Now() | 938 | started := time.Now() | ||
| 935 | - | usage, err := modes.RunPrint(ctx, ag, prompt, nil, os.Stdout) | 939 | + | usage, recovery, err := modes.RunPrintWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction) |
| 936 | elapsed := time.Since(started) | 940 | elapsed := time.Since(started) | ||
| 937 | - | WriteNewTranscript(ag, sess, start) | 941 | + | transcriptStart := start |
| 942 | + | if recovery.Compacted { | |||
| 943 | + | transcriptStart = recovery.OutputStart | |||
| 944 | + | } | |||
| 945 | + | WriteNewTranscript(ag, sess, transcriptStart) | |||
| 938 | if err != nil { | 946 | if err != nil { | ||
| 939 | return err | 947 | return err | ||
| 940 | } | 948 | } | ||
| @@ -999,8 +1007,16 @@ func runStreamMode(ctx context.Context, args Args, version string) error { | |||||
| 999 | if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil { | 1007 | if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil { | ||
| 1000 | return err | 1008 | return err | ||
| 1001 | } | 1009 | } | ||
| 1002 | - | err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout) | 1010 | + | var persistCompaction func([]provider.Message) error |
| 1003 | - | WriteNewTranscript(ag, sess, start) | 1011 | + | if sess != nil { |
| 1012 | + | persistCompaction = sess.AppendCompaction | |||
| 1013 | + | } | |||
| 1014 | + | recovery, err := modes.RunStreamWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, os.Stderr, persistCompaction) | |||
| 1015 | + | transcriptStart := start | |||
| 1016 | + | if recovery.Compacted { | |||
| 1017 | + | transcriptStart = recovery.OutputStart | |||
| 1018 | + | } | |||
| 1019 | + | WriteNewTranscript(ag, sess, transcriptStart) | |||
| 1004 | return err | 1020 | return err | ||
| 1005 | } | 1021 | } | ||
| 1006 | 1022 | ||||
| @@ -1106,8 +1122,16 @@ func runJSONMode(ctx context.Context, args Args, version string) error { | |||||
| 1106 | _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()}) | 1122 | _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()}) | ||
| 1107 | return err | 1123 | return err | ||
| 1108 | } | 1124 | } | ||
| 1109 | - | err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout) | 1125 | + | var persistCompaction func([]provider.Message) error |
| 1110 | - | WriteNewTranscript(ag, sess, start) | 1126 | + | if sess != nil { |
| 1127 | + | persistCompaction = sess.AppendCompaction | |||
| 1128 | + | } | |||
| 1129 | + | recovery, err := modes.RunJSONWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction) | |||
| 1130 | + | transcriptStart := start | |||
| 1131 | + | if recovery.Compacted { | |||
| 1132 | + | transcriptStart = recovery.OutputStart | |||
| 1133 | + | } | |||
| 1134 | + | WriteNewTranscript(ag, sess, transcriptStart) | |||
| 1111 | return err | 1135 | return err | ||
| 1112 | } | 1136 | } | ||
| 1113 | 1137 | ||||
A packages/agent/modes/context_recovery.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zut/packages/core" | |
| 8 | + | "github.com/bnema/zut/packages/provider" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // ContextRecoveryOptions configures the host-owned, one-shot recovery for a | |
| 12 | + | // provider context-overflow error. It deliberately does not add a terminal | |
| 13 | + | // continuation policy to core.Agent. | |
| 14 | + | type ContextRecoveryOptions struct { | |
| 15 | + | // PersistCompaction records the compacted transcript before the existing | |
| 16 | + | // prompt is continued. A persistence failure restores the pre-compaction | |
| 17 | + | // transcript and prevents the continuation request. | |
| 18 | + | PersistCompaction func([]provider.Message) error | |
| 19 | + | ||
| 20 | + | // SuppressInitialOverflowEvent hides the recoverable first-attempt | |
| 21 | + | // EvTurnEnd overflow from sinks whose protocol cannot retract a terminal | |
| 22 | + | // error frame, such as JSONL. A retry overflow is never suppressed. | |
| 23 | + | SuppressInitialOverflowEvent bool | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // ContextRecoveryResult describes a completed host-level context recovery. | |
| 27 | + | type ContextRecoveryResult struct { | |
| 28 | + | // OutputStart is the first message to append after a persisted compaction. | |
| 29 | + | // When Compacted is false, it is the message count before Prompt. | |
| 30 | + | OutputStart int | |
| 31 | + | Compacted bool | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // PromptWithContextRecovery appends prompt and runs it once. If that provider | |
| 35 | + | // request exceeds its context window, it compacts the existing transcript and | |
| 36 | + | // continues the already-appended prompt exactly once. Cancellation, a | |
| 37 | + | // one-message transcript, a compaction failure, and a second overflow remain | |
| 38 | + | // terminal outcomes. | |
| 39 | + | func PromptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, sink func(core.AgentEvent), options ContextRecoveryOptions) (result ContextRecoveryResult, err error) { | |
| 40 | + | result.OutputStart = len(ag.Messages()) | |
| 41 | + | if sink == nil { | |
| 42 | + | sink = func(core.AgentEvent) {} | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | firstSink := sink | |
| 46 | + | if options.SuppressInitialOverflowEvent { | |
| 47 | + | firstSink = func(event core.AgentEvent) { | |
| 48 | + | if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) { | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | sink(event) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | if err = ag.Prompt(ctx, prompt, images, firstSink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) { | |
| 56 | + | return result, err | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | messages := ag.Messages() | |
| 60 | + | if len(messages) <= 1 { | |
| 61 | + | // The failed prompt is the only message. Summarizing it would replace | |
| 62 | + | // the user's task with an LLM-generated paraphrase. | |
| 63 | + | return result, err | |
| 64 | + | } | |
| 65 | + | keepTail := min(4, len(messages)-1) | |
| 66 | + | if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil { | |
| 67 | + | return result, fmt.Errorf("compact transcript after initial overflow: %w", compactErr) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | compacted := ag.Messages() | |
| 71 | + | if options.PersistCompaction != nil { | |
| 72 | + | if persistErr := options.PersistCompaction(compacted); persistErr != nil { | |
| 73 | + | // The session did not receive the compaction checkpoint, so restore | |
| 74 | + | // the original history plus the already-appended prompt rather than | |
| 75 | + | // let a later transcript flush make the session inconsistent. | |
| 76 | + | ag.SetMessages(messages) | |
| 77 | + | return result, fmt.Errorf("persist compacted transcript: %w", persistErr) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | result.OutputStart = len(compacted) | |
| 82 | + | result.Compacted = true | |
| 83 | + | return result, ag.Continue(ctx, sink) | |
| 84 | + | } | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zut/packages/core" | |||
| 8 | + | "github.com/bnema/zut/packages/provider" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // ContextRecoveryOptions configures the host-owned, one-shot recovery for a | |||
| 12 | + | // provider context-overflow error. It deliberately does not add a terminal | |||
| 13 | + | // continuation policy to core.Agent. | |||
| 14 | + | type ContextRecoveryOptions struct { | |||
| 15 | + | // PersistCompaction records the compacted transcript before the existing | |||
| 16 | + | // prompt is continued. A persistence failure restores the pre-compaction | |||
| 17 | + | // transcript and prevents the continuation request. | |||
| 18 | + | PersistCompaction func([]provider.Message) error | |||
| 19 | + | ||||
| 20 | + | // SuppressInitialOverflowEvent hides the recoverable first-attempt | |||
| 21 | + | // EvTurnEnd overflow from sinks whose protocol cannot retract a terminal | |||
| 22 | + | // error frame, such as JSONL. A retry overflow is never suppressed. | |||
| 23 | + | SuppressInitialOverflowEvent bool | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // ContextRecoveryResult describes a completed host-level context recovery. | |||
| 27 | + | type ContextRecoveryResult struct { | |||
| 28 | + | // OutputStart is the first message to append after a persisted compaction. | |||
| 29 | + | // When Compacted is false, it is the message count before Prompt. | |||
| 30 | + | OutputStart int | |||
| 31 | + | Compacted bool | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // PromptWithContextRecovery appends prompt and runs it once. If that provider | |||
| 35 | + | // request exceeds its context window, it compacts the existing transcript and | |||
| 36 | + | // continues the already-appended prompt exactly once. Cancellation, a | |||
| 37 | + | // one-message transcript, a compaction failure, and a second overflow remain | |||
| 38 | + | // terminal outcomes. | |||
| 39 | + | func PromptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, sink func(core.AgentEvent), options ContextRecoveryOptions) (result ContextRecoveryResult, err error) { | |||
| 40 | + | result.OutputStart = len(ag.Messages()) | |||
| 41 | + | if sink == nil { | |||
| 42 | + | sink = func(core.AgentEvent) {} | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | firstSink := sink | |||
| 46 | + | if options.SuppressInitialOverflowEvent { | |||
| 47 | + | firstSink = func(event core.AgentEvent) { | |||
| 48 | + | if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) { | |||
| 49 | + | return | |||
| 50 | + | } | |||
| 51 | + | sink(event) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | if err = ag.Prompt(ctx, prompt, images, firstSink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) { | |||
| 56 | + | return result, err | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | messages := ag.Messages() | |||
| 60 | + | if len(messages) <= 1 { | |||
| 61 | + | // The failed prompt is the only message. Summarizing it would replace | |||
| 62 | + | // the user's task with an LLM-generated paraphrase. | |||
| 63 | + | return result, err | |||
| 64 | + | } | |||
| 65 | + | keepTail := min(4, len(messages)-1) | |||
| 66 | + | if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil { | |||
| 67 | + | return result, fmt.Errorf("compact transcript after initial overflow: %w", compactErr) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | compacted := ag.Messages() | |||
| 71 | + | if options.PersistCompaction != nil { | |||
| 72 | + | if persistErr := options.PersistCompaction(compacted); persistErr != nil { | |||
| 73 | + | // The session did not receive the compaction checkpoint, so restore | |||
| 74 | + | // the original history plus the already-appended prompt rather than | |||
| 75 | + | // let a later transcript flush make the session inconsistent. | |||
| 76 | + | ag.SetMessages(messages) | |||
| 77 | + | return result, fmt.Errorf("persist compacted transcript: %w", persistErr) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | result.OutputStart = len(compacted) | |||
| 82 | + | result.Compacted = true | |||
| 83 | + | return result, ag.Continue(ctx, sink) | |||
| 84 | + | } | |||
A packages/agent/modes/context_recovery_test.go +203 -0
| @@ -0,0 +1,203 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "bytes" | |
| 6 | + | "context" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "sync" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/zut/packages/core" | |
| 13 | + | "github.com/bnema/zut/packages/provider" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | type hostRecoveryClient struct { | |
| 17 | + | mu sync.Mutex | |
| 18 | + | calls int | |
| 19 | + | retried chan provider.Request | |
| 20 | + | retryOverflow bool | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (c *hostRecoveryClient) Name() string { return "host-context-recovery-test" } | |
| 24 | + | ||
| 25 | + | func (c *hostRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 26 | + | c.mu.Lock() | |
| 27 | + | c.calls++ | |
| 28 | + | call := c.calls | |
| 29 | + | c.mu.Unlock() | |
| 30 | + | ||
| 31 | + | events := make(chan provider.Event, 2) | |
| 32 | + | go func() { | |
| 33 | + | defer close(events) | |
| 34 | + | switch call { | |
| 35 | + | case 1: | |
| 36 | + | events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider error: input exceeds the context window")} | |
| 37 | + | case 2: | |
| 38 | + | events <- provider.EventTextDelta{Delta: "summary"} | |
| 39 | + | events <- provider.EventDone{Stop: provider.StopEnd} | |
| 40 | + | default: | |
| 41 | + | c.retried <- req | |
| 42 | + | if c.retryOverflow { | |
| 43 | + | events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("context window exceeded after compaction")} | |
| 44 | + | return | |
| 45 | + | } | |
| 46 | + | events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{ | |
| 47 | + | Role: provider.RoleAssistant, | |
| 48 | + | Content: []provider.Content{provider.TextBlock{Text: "recovered"}}, | |
| 49 | + | }} | |
| 50 | + | } | |
| 51 | + | }() | |
| 52 | + | return events, nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestPromptWithContextRecoveryCompactsOnceAndSuppressesInitialOverflow(t *testing.T) { | |
| 56 | + | client := &hostRecoveryClient{retried: make(chan provider.Request, 1)} | |
| 57 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 58 | + | agent.SetMessages([]provider.Message{ | |
| 59 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}, | |
| 60 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}}, | |
| 61 | + | }) | |
| 62 | + | ||
| 63 | + | var sawOverflowTurnEnd bool | |
| 64 | + | compacted := false | |
| 65 | + | result, err := PromptWithContextRecovery(context.Background(), agent, "finish the task", nil, func(event core.AgentEvent) { | |
| 66 | + | if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) { | |
| 67 | + | sawOverflowTurnEnd = true | |
| 68 | + | } | |
| 69 | + | }, ContextRecoveryOptions{ | |
| 70 | + | PersistCompaction: func([]provider.Message) error { | |
| 71 | + | compacted = true | |
| 72 | + | return nil | |
| 73 | + | }, | |
| 74 | + | SuppressInitialOverflowEvent: true, | |
| 75 | + | }) | |
| 76 | + | if err != nil { | |
| 77 | + | t.Fatalf("PromptWithContextRecovery returned %v", err) | |
| 78 | + | } | |
| 79 | + | if !result.Compacted || !compacted { | |
| 80 | + | t.Fatalf("compaction result/persistence = %v/%v, want true/true", result.Compacted, compacted) | |
| 81 | + | } | |
| 82 | + | if sawOverflowTurnEnd { | |
| 83 | + | t.Fatal("recoverable initial overflow turn_end reached the sink") | |
| 84 | + | } | |
| 85 | + | if got := recoveredAssistantText(agent.Messages()[result.OutputStart:]); got != "recovered" { | |
| 86 | + | t.Fatalf("recovery output = %q, want recovered", got) | |
| 87 | + | } | |
| 88 | + | request := <-client.retried | |
| 89 | + | if got := requestUserTextCount(request, "finish the task"); got != 1 { | |
| 90 | + | t.Fatalf("retried request prompt count = %d, want 1: %#v", got, request.Messages) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func recoveredAssistantText(messages []provider.Message) string { | |
| 95 | + | for _, message := range messages { | |
| 96 | + | if message.Role != provider.RoleAssistant { | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | for _, content := range message.Content { | |
| 100 | + | if text, ok := content.(provider.TextBlock); ok { | |
| 101 | + | return text.Text | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | return "" | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestPromptWithContextRecoveryReportsRetryOverflowToSink(t *testing.T) { | |
| 109 | + | client := &hostRecoveryClient{retried: make(chan provider.Request, 1), retryOverflow: true} | |
| 110 | + | agent := core.NewAgent(client, "test-model", "", nil) | |
| 111 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |
| 112 | + | ||
| 113 | + | overflowTurnEnds := 0 | |
| 114 | + | _, err := PromptWithContextRecovery(context.Background(), agent, "finish the task", nil, func(event core.AgentEvent) { | |
| 115 | + | if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) { | |
| 116 | + | overflowTurnEnds++ | |
| 117 | + | } | |
| 118 | + | }, ContextRecoveryOptions{SuppressInitialOverflowEvent: true}) | |
| 119 | + | if !provider.IsContextOverflowError(err) { | |
| 120 | + | t.Fatalf("PromptWithContextRecovery error = %v, want retry context overflow", err) | |
| 121 | + | } | |
| 122 | + | if overflowTurnEnds != 1 { | |
| 123 | + | t.Fatalf("overflow turn_end count = %d, want retry overflow only", overflowTurnEnds) | |
| 124 | + | } | |
| 125 | + | client.mu.Lock() | |
| 126 | + | calls := client.calls | |
| 127 | + | client.mu.Unlock() | |
| 128 | + | if calls != 3 { | |
| 129 | + | t.Fatalf("provider calls = %d, want prompt, compaction, and one continuation", calls) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func newRecoveredModeAgent() *core.Agent { | |
| 134 | + | return core.NewAgent(&hostRecoveryClient{retried: make(chan provider.Request, 1)}, "test-model", "", nil) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestRunPrintRecoversContextOverflowWithoutLeakingIntermediateOutput(t *testing.T) { | |
| 138 | + | agent := newRecoveredModeAgent() | |
| 139 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |
| 140 | + | var output bytes.Buffer | |
| 141 | + | ||
| 142 | + | if _, err := RunPrint(context.Background(), agent, "finish the task", nil, &output); err != nil { | |
| 143 | + | t.Fatalf("RunPrint returned %v", err) | |
| 144 | + | } | |
| 145 | + | if got := output.String(); got != "recovered\n" { | |
| 146 | + | t.Fatalf("stdout = %q, want recovered only", got) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestRunStreamRecoversContextOverflowWithoutDiagnosticsLeak(t *testing.T) { | |
| 151 | + | agent := newRecoveredModeAgent() | |
| 152 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |
| 153 | + | var output, diagnostics bytes.Buffer | |
| 154 | + | ||
| 155 | + | if err := RunStreamWithDiag(context.Background(), agent, "finish the task", nil, &output, &diagnostics); err != nil { | |
| 156 | + | t.Fatalf("RunStreamWithDiag returned %v", err) | |
| 157 | + | } | |
| 158 | + | if got := output.String(); got != "recovered\n" { | |
| 159 | + | t.Fatalf("stdout = %q, want recovered assistant text once", got) | |
| 160 | + | } | |
| 161 | + | if got := diagnostics.String(); got != "" { | |
| 162 | + | t.Fatalf("stderr diagnostics = %q, want no context-overflow diagnostic", got) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestRunJSONRecoversContextOverflowWithJSONLOnly(t *testing.T) { | |
| 167 | + | agent := newRecoveredModeAgent() | |
| 168 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |
| 169 | + | var output bytes.Buffer | |
| 170 | + | ||
| 171 | + | if err := RunJSON(context.Background(), agent, "finish the task", nil, &output); err != nil { | |
| 172 | + | t.Fatalf("RunJSON returned %v", err) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | var ( | |
| 176 | + | turnEnds int | |
| 177 | + | done int | |
| 178 | + | ) | |
| 179 | + | scanner := bufio.NewScanner(&output) | |
| 180 | + | for scanner.Scan() { | |
| 181 | + | var event map[string]any | |
| 182 | + | if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { | |
| 183 | + | t.Fatalf("stdout line is not JSONL: %q: %v", scanner.Text(), err) | |
| 184 | + | } | |
| 185 | + | switch event["type"] { | |
| 186 | + | case "error": | |
| 187 | + | t.Fatalf("recoverable overflow wrote error frame: %#v", event) | |
| 188 | + | case "turn_end": | |
| 189 | + | turnEnds++ | |
| 190 | + | if _, hasError := event["error"]; hasError { | |
| 191 | + | t.Fatalf("recoverable overflow wrote terminal turn_end: %#v", event) | |
| 192 | + | } | |
| 193 | + | case "done": | |
| 194 | + | done++ | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | if err := scanner.Err(); err != nil { | |
| 198 | + | t.Fatalf("scan JSONL: %v", err) | |
| 199 | + | } | |
| 200 | + | if turnEnds != 1 || done != 1 { | |
| 201 | + | t.Fatalf("turn_end/done count = %d/%d, want one recovered terminal sequence", turnEnds, done) | |
| 202 | + | } | |
| 203 | + | } | |
| @@ -0,0 +1,203 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "bytes" | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "errors" | |||
| 9 | + | "sync" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/zut/packages/core" | |||
| 13 | + | "github.com/bnema/zut/packages/provider" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | type hostRecoveryClient struct { | |||
| 17 | + | mu sync.Mutex | |||
| 18 | + | calls int | |||
| 19 | + | retried chan provider.Request | |||
| 20 | + | retryOverflow bool | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (c *hostRecoveryClient) Name() string { return "host-context-recovery-test" } | |||
| 24 | + | ||||
| 25 | + | func (c *hostRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 26 | + | c.mu.Lock() | |||
| 27 | + | c.calls++ | |||
| 28 | + | call := c.calls | |||
| 29 | + | c.mu.Unlock() | |||
| 30 | + | ||||
| 31 | + | events := make(chan provider.Event, 2) | |||
| 32 | + | go func() { | |||
| 33 | + | defer close(events) | |||
| 34 | + | switch call { | |||
| 35 | + | case 1: | |||
| 36 | + | events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider error: input exceeds the context window")} | |||
| 37 | + | case 2: | |||
| 38 | + | events <- provider.EventTextDelta{Delta: "summary"} | |||
| 39 | + | events <- provider.EventDone{Stop: provider.StopEnd} | |||
| 40 | + | default: | |||
| 41 | + | c.retried <- req | |||
| 42 | + | if c.retryOverflow { | |||
| 43 | + | events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("context window exceeded after compaction")} | |||
| 44 | + | return | |||
| 45 | + | } | |||
| 46 | + | events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{ | |||
| 47 | + | Role: provider.RoleAssistant, | |||
| 48 | + | Content: []provider.Content{provider.TextBlock{Text: "recovered"}}, | |||
| 49 | + | }} | |||
| 50 | + | } | |||
| 51 | + | }() | |||
| 52 | + | return events, nil | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestPromptWithContextRecoveryCompactsOnceAndSuppressesInitialOverflow(t *testing.T) { | |||
| 56 | + | client := &hostRecoveryClient{retried: make(chan provider.Request, 1)} | |||
| 57 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 58 | + | agent.SetMessages([]provider.Message{ | |||
| 59 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}, | |||
| 60 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}}, | |||
| 61 | + | }) | |||
| 62 | + | ||||
| 63 | + | var sawOverflowTurnEnd bool | |||
| 64 | + | compacted := false | |||
| 65 | + | result, err := PromptWithContextRecovery(context.Background(), agent, "finish the task", nil, func(event core.AgentEvent) { | |||
| 66 | + | if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) { | |||
| 67 | + | sawOverflowTurnEnd = true | |||
| 68 | + | } | |||
| 69 | + | }, ContextRecoveryOptions{ | |||
| 70 | + | PersistCompaction: func([]provider.Message) error { | |||
| 71 | + | compacted = true | |||
| 72 | + | return nil | |||
| 73 | + | }, | |||
| 74 | + | SuppressInitialOverflowEvent: true, | |||
| 75 | + | }) | |||
| 76 | + | if err != nil { | |||
| 77 | + | t.Fatalf("PromptWithContextRecovery returned %v", err) | |||
| 78 | + | } | |||
| 79 | + | if !result.Compacted || !compacted { | |||
| 80 | + | t.Fatalf("compaction result/persistence = %v/%v, want true/true", result.Compacted, compacted) | |||
| 81 | + | } | |||
| 82 | + | if sawOverflowTurnEnd { | |||
| 83 | + | t.Fatal("recoverable initial overflow turn_end reached the sink") | |||
| 84 | + | } | |||
| 85 | + | if got := recoveredAssistantText(agent.Messages()[result.OutputStart:]); got != "recovered" { | |||
| 86 | + | t.Fatalf("recovery output = %q, want recovered", got) | |||
| 87 | + | } | |||
| 88 | + | request := <-client.retried | |||
| 89 | + | if got := requestUserTextCount(request, "finish the task"); got != 1 { | |||
| 90 | + | t.Fatalf("retried request prompt count = %d, want 1: %#v", got, request.Messages) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func recoveredAssistantText(messages []provider.Message) string { | |||
| 95 | + | for _, message := range messages { | |||
| 96 | + | if message.Role != provider.RoleAssistant { | |||
| 97 | + | continue | |||
| 98 | + | } | |||
| 99 | + | for _, content := range message.Content { | |||
| 100 | + | if text, ok := content.(provider.TextBlock); ok { | |||
| 101 | + | return text.Text | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | return "" | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestPromptWithContextRecoveryReportsRetryOverflowToSink(t *testing.T) { | |||
| 109 | + | client := &hostRecoveryClient{retried: make(chan provider.Request, 1), retryOverflow: true} | |||
| 110 | + | agent := core.NewAgent(client, "test-model", "", nil) | |||
| 111 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |||
| 112 | + | ||||
| 113 | + | overflowTurnEnds := 0 | |||
| 114 | + | _, err := PromptWithContextRecovery(context.Background(), agent, "finish the task", nil, func(event core.AgentEvent) { | |||
| 115 | + | if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) { | |||
| 116 | + | overflowTurnEnds++ | |||
| 117 | + | } | |||
| 118 | + | }, ContextRecoveryOptions{SuppressInitialOverflowEvent: true}) | |||
| 119 | + | if !provider.IsContextOverflowError(err) { | |||
| 120 | + | t.Fatalf("PromptWithContextRecovery error = %v, want retry context overflow", err) | |||
| 121 | + | } | |||
| 122 | + | if overflowTurnEnds != 1 { | |||
| 123 | + | t.Fatalf("overflow turn_end count = %d, want retry overflow only", overflowTurnEnds) | |||
| 124 | + | } | |||
| 125 | + | client.mu.Lock() | |||
| 126 | + | calls := client.calls | |||
| 127 | + | client.mu.Unlock() | |||
| 128 | + | if calls != 3 { | |||
| 129 | + | t.Fatalf("provider calls = %d, want prompt, compaction, and one continuation", calls) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func newRecoveredModeAgent() *core.Agent { | |||
| 134 | + | return core.NewAgent(&hostRecoveryClient{retried: make(chan provider.Request, 1)}, "test-model", "", nil) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestRunPrintRecoversContextOverflowWithoutLeakingIntermediateOutput(t *testing.T) { | |||
| 138 | + | agent := newRecoveredModeAgent() | |||
| 139 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |||
| 140 | + | var output bytes.Buffer | |||
| 141 | + | ||||
| 142 | + | if _, err := RunPrint(context.Background(), agent, "finish the task", nil, &output); err != nil { | |||
| 143 | + | t.Fatalf("RunPrint returned %v", err) | |||
| 144 | + | } | |||
| 145 | + | if got := output.String(); got != "recovered\n" { | |||
| 146 | + | t.Fatalf("stdout = %q, want recovered only", got) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func TestRunStreamRecoversContextOverflowWithoutDiagnosticsLeak(t *testing.T) { | |||
| 151 | + | agent := newRecoveredModeAgent() | |||
| 152 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |||
| 153 | + | var output, diagnostics bytes.Buffer | |||
| 154 | + | ||||
| 155 | + | if err := RunStreamWithDiag(context.Background(), agent, "finish the task", nil, &output, &diagnostics); err != nil { | |||
| 156 | + | t.Fatalf("RunStreamWithDiag returned %v", err) | |||
| 157 | + | } | |||
| 158 | + | if got := output.String(); got != "recovered\n" { | |||
| 159 | + | t.Fatalf("stdout = %q, want recovered assistant text once", got) | |||
| 160 | + | } | |||
| 161 | + | if got := diagnostics.String(); got != "" { | |||
| 162 | + | t.Fatalf("stderr diagnostics = %q, want no context-overflow diagnostic", got) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func TestRunJSONRecoversContextOverflowWithJSONLOnly(t *testing.T) { | |||
| 167 | + | agent := newRecoveredModeAgent() | |||
| 168 | + | agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}}) | |||
| 169 | + | var output bytes.Buffer | |||
| 170 | + | ||||
| 171 | + | if err := RunJSON(context.Background(), agent, "finish the task", nil, &output); err != nil { | |||
| 172 | + | t.Fatalf("RunJSON returned %v", err) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | var ( | |||
| 176 | + | turnEnds int | |||
| 177 | + | done int | |||
| 178 | + | ) | |||
| 179 | + | scanner := bufio.NewScanner(&output) | |||
| 180 | + | for scanner.Scan() { | |||
| 181 | + | var event map[string]any | |||
| 182 | + | if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { | |||
| 183 | + | t.Fatalf("stdout line is not JSONL: %q: %v", scanner.Text(), err) | |||
| 184 | + | } | |||
| 185 | + | switch event["type"] { | |||
| 186 | + | case "error": | |||
| 187 | + | t.Fatalf("recoverable overflow wrote error frame: %#v", event) | |||
| 188 | + | case "turn_end": | |||
| 189 | + | turnEnds++ | |||
| 190 | + | if _, hasError := event["error"]; hasError { | |||
| 191 | + | t.Fatalf("recoverable overflow wrote terminal turn_end: %#v", event) | |||
| 192 | + | } | |||
| 193 | + | case "done": | |||
| 194 | + | done++ | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | if err := scanner.Err(); err != nil { | |||
| 198 | + | t.Fatalf("scan JSONL: %v", err) | |||
| 199 | + | } | |||
| 200 | + | if turnEnds != 1 || done != 1 { | |||
| 201 | + | t.Fatalf("turn_end/done count = %d/%d, want one recovered terminal sequence", turnEnds, done) | |||
| 202 | + | } | |||
| 203 | + | } | |||
M packages/agent/modes/json.go +16 -2
| @@ -13,6 +13,16 @@ import ( | |||
| 13 | 13 | // RunJSON runs the agent to completion, writing one JSON object per | |
| 14 | 14 | // AgentEvent as newline-delimited JSON. | |
| 15 | 15 | func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) error { | |
| 16 | + | _, err := RunJSONWithContextRecovery(ctx, ag, prompt, images, out, nil) | |
| 17 | + | return err | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // RunJSONWithContextRecovery is RunJSON with an optional checkpoint writer | |
| 21 | + | // for a one-shot context-overflow compaction. It suppresses the recoverable | |
| 22 | + | // first turn_end so stdout never contains a terminal error frame before the | |
| 23 | + | // continued turn succeeds. OutputStart identifies the suffix to persist after | |
| 24 | + | // a successful checkpoint. | |
| 25 | + | func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) { | |
| 16 | 26 | enc := json.NewEncoder(out) | |
| 17 | 27 | write := func(v any) { | |
| 18 | 28 | _ = enc.Encode(v) | |
| @@ -23,14 +33,18 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid | |||
| 23 | 33 | write(EventToJSON(ev)) | |
| 24 | 34 | } | |
| 25 | 35 | ||
| 26 | - | if err := ag.Prompt(ctx, prompt, images, sink); err != nil { | |
| 36 | + | recovery, err := PromptWithContextRecovery(ctx, ag, prompt, images, sink, ContextRecoveryOptions{ | |
| 37 | + | PersistCompaction: persistCompaction, | |
| 38 | + | SuppressInitialOverflowEvent: true, | |
| 39 | + | }) | |
| 40 | + | if err != nil { | |
| 27 | 41 | runErr = err | |
| 28 | 42 | } | |
| 29 | 43 | ||
| 30 | 44 | if runErr != nil { | |
| 31 | 45 | fmt.Fprintln(out, `{"type":"error","message":`+jsonString(runErr.Error())+`}`) | |
| 32 | 46 | } | |
| 33 | - | return runErr | |
| 47 | + | return recovery, runErr | |
| 34 | 48 | } | |
| 35 | 49 | ||
| 36 | 50 | // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire | |
| @@ -13,6 +13,16 @@ import ( | |||||
| 13 | // RunJSON runs the agent to completion, writing one JSON object per | 13 | // RunJSON runs the agent to completion, writing one JSON object per | ||
| 14 | // AgentEvent as newline-delimited JSON. | 14 | // AgentEvent as newline-delimited JSON. | ||
| 15 | func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) error { | 15 | func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) error { | ||
| 16 | + | _, err := RunJSONWithContextRecovery(ctx, ag, prompt, images, out, nil) | |||
| 17 | + | return err | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // RunJSONWithContextRecovery is RunJSON with an optional checkpoint writer | |||
| 21 | + | // for a one-shot context-overflow compaction. It suppresses the recoverable | |||
| 22 | + | // first turn_end so stdout never contains a terminal error frame before the | |||
| 23 | + | // continued turn succeeds. OutputStart identifies the suffix to persist after | |||
| 24 | + | // a successful checkpoint. | |||
| 25 | + | func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) { | |||
| 16 | enc := json.NewEncoder(out) | 26 | enc := json.NewEncoder(out) | ||
| 17 | write := func(v any) { | 27 | write := func(v any) { | ||
| 18 | _ = enc.Encode(v) | 28 | _ = enc.Encode(v) | ||
| @@ -23,14 +33,18 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid | |||||
| 23 | write(EventToJSON(ev)) | 33 | write(EventToJSON(ev)) | ||
| 24 | } | 34 | } | ||
| 25 | 35 | ||||
| 26 | - | if err := ag.Prompt(ctx, prompt, images, sink); err != nil { | 36 | + | recovery, err := PromptWithContextRecovery(ctx, ag, prompt, images, sink, ContextRecoveryOptions{ |
| 37 | + | PersistCompaction: persistCompaction, | |||
| 38 | + | SuppressInitialOverflowEvent: true, | |||
| 39 | + | }) | |||
| 40 | + | if err != nil { | |||
| 27 | runErr = err | 41 | runErr = err | ||
| 28 | } | 42 | } | ||
| 29 | 43 | ||||
| 30 | if runErr != nil { | 44 | if runErr != nil { | ||
| 31 | fmt.Fprintln(out, `{"type":"error","message":`+jsonString(runErr.Error())+`}`) | 45 | fmt.Fprintln(out, `{"type":"error","message":`+jsonString(runErr.Error())+`}`) | ||
| 32 | } | 46 | } | ||
| 33 | - | return runErr | 47 | + | return recovery, runErr |
| 34 | } | 48 | } | ||
| 35 | 49 | ||||
| 36 | // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire | 50 | // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire | ||
M packages/agent/modes/print.go +17 -5
| @@ -15,6 +15,14 @@ import ( | |||
| 15 | 15 | // assistant text block to out. It returns usage for this invocation, | |
| 16 | 16 | // excluding any cumulative usage restored from a session. | |
| 17 | 17 | func RunPrint(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) (provider.Usage, error) { | |
| 18 | + | usage, _, err := RunPrintWithContextRecovery(ctx, ag, prompt, images, out, nil) | |
| 19 | + | return usage, err | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // RunPrintWithContextRecovery is RunPrint with an optional checkpoint writer | |
| 23 | + | // for a one-shot context-overflow compaction. OutputStart identifies the | |
| 24 | + | // transcript suffix that callers must persist after a successful checkpoint. | |
| 25 | + | func RunPrintWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (provider.Usage, ContextRecoveryResult, error) { | |
| 18 | 26 | var finalText strings.Builder | |
| 19 | 27 | var lastAssistant string | |
| 20 | 28 | var usage provider.Usage | |
| @@ -51,17 +59,21 @@ func RunPrint(ctx context.Context, ag *core.Agent, prompt string, images []provi | |||
| 51 | 59 | } | |
| 52 | 60 | } | |
| 53 | 61 | ||
| 54 | - | if err := ag.Prompt(ctx, prompt, images, sink); err != nil { | |
| 55 | - | return usage, err | |
| 62 | + | recovery, err := PromptWithContextRecovery(ctx, ag, prompt, images, sink, ContextRecoveryOptions{ | |
| 63 | + | PersistCompaction: persistCompaction, | |
| 64 | + | SuppressInitialOverflowEvent: true, | |
| 65 | + | }) | |
| 66 | + | if err != nil { | |
| 67 | + | return usage, recovery, err | |
| 56 | 68 | } | |
| 57 | 69 | if runErr != nil { | |
| 58 | - | return usage, runErr | |
| 70 | + | return usage, recovery, runErr | |
| 59 | 71 | } | |
| 60 | 72 | ||
| 61 | 73 | finalText.WriteString(lastAssistant) | |
| 62 | 74 | if finalText.Len() > 0 && !strings.HasSuffix(finalText.String(), "\n") { | |
| 63 | 75 | finalText.WriteString("\n") | |
| 64 | 76 | } | |
| 65 | - | _, err := fmt.Fprint(out, finalText.String()) | |
| 66 | - | return usage, err | |
| 77 | + | _, err = fmt.Fprint(out, finalText.String()) | |
| 78 | + | return usage, recovery, err | |
| 67 | 79 | } | |
| @@ -15,6 +15,14 @@ import ( | |||||
| 15 | // assistant text block to out. It returns usage for this invocation, | 15 | // assistant text block to out. It returns usage for this invocation, | ||
| 16 | // excluding any cumulative usage restored from a session. | 16 | // excluding any cumulative usage restored from a session. | ||
| 17 | func RunPrint(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) (provider.Usage, error) { | 17 | func RunPrint(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) (provider.Usage, error) { | ||
| 18 | + | usage, _, err := RunPrintWithContextRecovery(ctx, ag, prompt, images, out, nil) | |||
| 19 | + | return usage, err | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // RunPrintWithContextRecovery is RunPrint with an optional checkpoint writer | |||
| 23 | + | // for a one-shot context-overflow compaction. OutputStart identifies the | |||
| 24 | + | // transcript suffix that callers must persist after a successful checkpoint. | |||
| 25 | + | func RunPrintWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (provider.Usage, ContextRecoveryResult, error) { | |||
| 18 | var finalText strings.Builder | 26 | var finalText strings.Builder | ||
| 19 | var lastAssistant string | 27 | var lastAssistant string | ||
| 20 | var usage provider.Usage | 28 | var usage provider.Usage | ||
| @@ -51,17 +59,21 @@ func RunPrint(ctx context.Context, ag *core.Agent, prompt string, images []provi | |||||
| 51 | } | 59 | } | ||
| 52 | } | 60 | } | ||
| 53 | 61 | ||||
| 54 | - | if err := ag.Prompt(ctx, prompt, images, sink); err != nil { | 62 | + | recovery, err := PromptWithContextRecovery(ctx, ag, prompt, images, sink, ContextRecoveryOptions{ |
| 55 | - | return usage, err | 63 | + | PersistCompaction: persistCompaction, |
| 64 | + | SuppressInitialOverflowEvent: true, | |||
| 65 | + | }) | |||
| 66 | + | if err != nil { | |||
| 67 | + | return usage, recovery, err | |||
| 56 | } | 68 | } | ||
| 57 | if runErr != nil { | 69 | if runErr != nil { | ||
| 58 | - | return usage, runErr | 70 | + | return usage, recovery, runErr |
| 59 | } | 71 | } | ||
| 60 | 72 | ||||
| 61 | finalText.WriteString(lastAssistant) | 73 | finalText.WriteString(lastAssistant) | ||
| 62 | if finalText.Len() > 0 && !strings.HasSuffix(finalText.String(), "\n") { | 74 | if finalText.Len() > 0 && !strings.HasSuffix(finalText.String(), "\n") { | ||
| 63 | finalText.WriteString("\n") | 75 | finalText.WriteString("\n") | ||
| 64 | } | 76 | } | ||
| 65 | - | _, err := fmt.Fprint(out, finalText.String()) | 77 | + | _, err = fmt.Fprint(out, finalText.String()) |
| 66 | - | return usage, err | 78 | + | return usage, recovery, err |
| 67 | } | 79 | } | ||
M packages/agent/modes/stream.go +17 -4
| @@ -22,6 +22,15 @@ func RunStream(ctx context.Context, ag *core.Agent, prompt string, images []prov | |||
| 22 | 22 | // RunStreamWithDiag is RunStream with an explicit diagnostics writer | |
| 23 | 23 | // for tool activity (tests pass a buffer). | |
| 24 | 24 | func RunStreamWithDiag(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out, diag io.Writer) error { | |
| 25 | + | _, err := RunStreamWithContextRecovery(ctx, ag, prompt, images, out, diag, nil) | |
| 26 | + | return err | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // RunStreamWithContextRecovery is RunStreamWithDiag with an optional | |
| 30 | + | // checkpoint writer for a one-shot context-overflow compaction. OutputStart | |
| 31 | + | // identifies the transcript suffix that callers must persist after a | |
| 32 | + | // successful checkpoint. | |
| 33 | + | func RunStreamWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out, diag io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) { | |
| 25 | 34 | if diag == nil { | |
| 26 | 35 | diag = io.Discard | |
| 27 | 36 | } | |
| @@ -101,15 +110,19 @@ func RunStreamWithDiag(ctx context.Context, ag *core.Agent, prompt string, image | |||
| 101 | 110 | } | |
| 102 | 111 | } | |
| 103 | 112 | ||
| 104 | - | if err := ag.Prompt(ctx, prompt, images, sink); err != nil { | |
| 105 | - | return err | |
| 113 | + | recovery, err := PromptWithContextRecovery(ctx, ag, prompt, images, sink, ContextRecoveryOptions{ | |
| 114 | + | PersistCompaction: persistCompaction, | |
| 115 | + | SuppressInitialOverflowEvent: true, | |
| 116 | + | }) | |
| 117 | + | if err != nil { | |
| 118 | + | return recovery, err | |
| 106 | 119 | } | |
| 107 | 120 | if runErr != nil { | |
| 108 | - | return runErr | |
| 121 | + | return recovery, runErr | |
| 109 | 122 | } | |
| 110 | 123 | if wroteText && !lastWasNL { | |
| 111 | 124 | _, _ = fmt.Fprint(out, "\n") | |
| 112 | 125 | flush() | |
| 113 | 126 | } | |
| 114 | - | return nil | |
| 127 | + | return recovery, nil | |
| 115 | 128 | } | |
| @@ -22,6 +22,15 @@ func RunStream(ctx context.Context, ag *core.Agent, prompt string, images []prov | |||||
| 22 | // RunStreamWithDiag is RunStream with an explicit diagnostics writer | 22 | // RunStreamWithDiag is RunStream with an explicit diagnostics writer | ||
| 23 | // for tool activity (tests pass a buffer). | 23 | // for tool activity (tests pass a buffer). | ||
| 24 | func RunStreamWithDiag(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out, diag io.Writer) error { | 24 | func RunStreamWithDiag(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out, diag io.Writer) error { | ||
| 25 | + | _, err := RunStreamWithContextRecovery(ctx, ag, prompt, images, out, diag, nil) | |||
| 26 | + | return err | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // RunStreamWithContextRecovery is RunStreamWithDiag with an optional | |||
| 30 | + | // checkpoint writer for a one-shot context-overflow compaction. OutputStart | |||
| 31 | + | // identifies the transcript suffix that callers must persist after a | |||
| 32 | + | // successful checkpoint. | |||
| 33 | + | func RunStreamWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out, diag io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) { | |||
| 25 | if diag == nil { | 34 | if diag == nil { | ||
| 26 | diag = io.Discard | 35 | diag = io.Discard | ||
| 27 | } | 36 | } | ||
| @@ -101,15 +110,19 @@ func RunStreamWithDiag(ctx context.Context, ag *core.Agent, prompt string, image | |||||
| 101 | } | 110 | } | ||
| 102 | } | 111 | } | ||
| 103 | 112 | ||||
| 104 | - | if err := ag.Prompt(ctx, prompt, images, sink); err != nil { | 113 | + | recovery, err := PromptWithContextRecovery(ctx, ag, prompt, images, sink, ContextRecoveryOptions{ |
| 105 | - | return err | 114 | + | PersistCompaction: persistCompaction, |
| 115 | + | SuppressInitialOverflowEvent: true, | |||
| 116 | + | }) | |||
| 117 | + | if err != nil { | |||
| 118 | + | return recovery, err | |||
| 106 | } | 119 | } | ||
| 107 | if runErr != nil { | 120 | if runErr != nil { | ||
| 108 | - | return runErr | 121 | + | return recovery, runErr |
| 109 | } | 122 | } | ||
| 110 | if wroteText && !lastWasNL { | 123 | if wroteText && !lastWasNL { | ||
| 111 | _, _ = fmt.Fprint(out, "\n") | 124 | _, _ = fmt.Fprint(out, "\n") | ||
| 112 | flush() | 125 | flush() | ||
| 113 | } | 126 | } | ||
| 114 | - | return nil | 127 | + | return recovery, nil |
| 115 | } | 128 | } | ||
M packages/agent/subagent_worker.go +8 -37
| @@ -436,44 +436,15 @@ func canonicalWorkerEvent(typ string) string { | |||
| 436 | 436 | ||
| 437 | 437 | const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context" | |
| 438 | 438 | ||
| 439 | - | // promptWithContextRecovery appends prompt and runs it once. When the provider | |
| 440 | - | // rejects that request for exceeding its context limit, it compacts the current | |
| 441 | - | // transcript and continues the already-appended prompt exactly once. | |
| 442 | - | // | |
| 443 | - | // The returned index marks the first message that can contribute to the turn | |
| 444 | - | // result or be appended after a compaction checkpoint. Compact replaces the | |
| 445 | - | // transcript, so callers must not reuse the index captured before Prompt. | |
| 439 | + | // promptWithContextRecovery preserves the worker's existing helper boundary | |
| 440 | + | // while delegating its one-shot overflow recovery to modes. The returned index | |
| 441 | + | // is the first message to include in the result or append after a persisted | |
| 442 | + | // compaction checkpoint. | |
| 446 | 443 | func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), persistCompaction func([]provider.Message) error) (outputStart int, err error) { | |
| 447 | - | outputStart = len(ag.Messages()) | |
| 448 | - | if err = ag.Prompt(ctx, prompt, nil, sink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) { | |
| 449 | - | return outputStart, err | |
| 450 | - | } | |
| 451 | - | ||
| 452 | - | messages := ag.Messages() | |
| 453 | - | if len(messages) <= 1 { | |
| 454 | - | // The only message is the failed prompt. Summarizing it would replace | |
| 455 | - | // the user's task with an LLM-generated paraphrase, so report the | |
| 456 | - | // provider's limit and ask the caller to narrow the task instead. | |
| 457 | - | return outputStart, err | |
| 458 | - | } | |
| 459 | - | keepTail := min(4, len(messages)-1) | |
| 460 | - | if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil { | |
| 461 | - | return outputStart, fmt.Errorf("compact transcript after initial overflow: %w", compactErr) | |
| 462 | - | } | |
| 463 | - | ||
| 464 | - | compacted := ag.Messages() | |
| 465 | - | if persistCompaction != nil { | |
| 466 | - | if persistErr := persistCompaction(compacted); persistErr != nil { | |
| 467 | - | // Keep the original history plus the user's pending task so the | |
| 468 | - | // outer transcript flush can persist that task without committing | |
| 469 | - | // an in-memory compaction that the session did not receive. | |
| 470 | - | ag.SetMessages(messages) | |
| 471 | - | return outputStart, fmt.Errorf("persist compacted transcript: %w", persistErr) | |
| 472 | - | } | |
| 473 | - | } | |
| 474 | - | ||
| 475 | - | outputStart = len(compacted) | |
| 476 | - | return outputStart, ag.Continue(ctx, sink) | |
| 444 | + | result, err := modes.PromptWithContextRecovery(ctx, ag, prompt, nil, sink, modes.ContextRecoveryOptions{ | |
| 445 | + | PersistCompaction: persistCompaction, | |
| 446 | + | }) | |
| 447 | + | return result.OutputStart, err | |
| 477 | 448 | } | |
| 478 | 449 | ||
| 479 | 450 | func finalAssistantText(messages []provider.Message) string { | |
| @@ -436,44 +436,15 @@ func canonicalWorkerEvent(typ string) string { | |||||
| 436 | 436 | ||||
| 437 | const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context" | 437 | const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context" | ||
| 438 | 438 | ||||
| 439 | - | // promptWithContextRecovery appends prompt and runs it once. When the provider | 439 | + | // promptWithContextRecovery preserves the worker's existing helper boundary |
| 440 | - | // rejects that request for exceeding its context limit, it compacts the current | 440 | + | // while delegating its one-shot overflow recovery to modes. The returned index |
| 441 | - | // transcript and continues the already-appended prompt exactly once. | 441 | + | // is the first message to include in the result or append after a persisted |
| 442 | - | // | 442 | + | // compaction checkpoint. |
| 443 | - | // The returned index marks the first message that can contribute to the turn | |||
| 444 | - | // result or be appended after a compaction checkpoint. Compact replaces the | |||
| 445 | - | // transcript, so callers must not reuse the index captured before Prompt. | |||
| 446 | func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), persistCompaction func([]provider.Message) error) (outputStart int, err error) { | 443 | func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), persistCompaction func([]provider.Message) error) (outputStart int, err error) { | ||
| 447 | - | outputStart = len(ag.Messages()) | 444 | + | result, err := modes.PromptWithContextRecovery(ctx, ag, prompt, nil, sink, modes.ContextRecoveryOptions{ |
| 448 | - | if err = ag.Prompt(ctx, prompt, nil, sink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) { | 445 | + | PersistCompaction: persistCompaction, |
| 449 | - | return outputStart, err | 446 | + | }) |
| 450 | - | } | 447 | + | return result.OutputStart, err |
| 451 | - | ||||
| 452 | - | messages := ag.Messages() | |||
| 453 | - | if len(messages) <= 1 { | |||
| 454 | - | // The only message is the failed prompt. Summarizing it would replace | |||
| 455 | - | // the user's task with an LLM-generated paraphrase, so report the | |||
| 456 | - | // provider's limit and ask the caller to narrow the task instead. | |||
| 457 | - | return outputStart, err | |||
| 458 | - | } | |||
| 459 | - | keepTail := min(4, len(messages)-1) | |||
| 460 | - | if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil { | |||
| 461 | - | return outputStart, fmt.Errorf("compact transcript after initial overflow: %w", compactErr) | |||
| 462 | - | } | |||
| 463 | - | ||||
| 464 | - | compacted := ag.Messages() | |||
| 465 | - | if persistCompaction != nil { | |||
| 466 | - | if persistErr := persistCompaction(compacted); persistErr != nil { | |||
| 467 | - | // Keep the original history plus the user's pending task so the | |||
| 468 | - | // outer transcript flush can persist that task without committing | |||
| 469 | - | // an in-memory compaction that the session did not receive. | |||
| 470 | - | ag.SetMessages(messages) | |||
| 471 | - | return outputStart, fmt.Errorf("persist compacted transcript: %w", persistErr) | |||
| 472 | - | } | |||
| 473 | - | } | |||
| 474 | - | ||||
| 475 | - | outputStart = len(compacted) | |||
| 476 | - | return outputStart, ag.Continue(ctx, sink) | |||
| 477 | } | 448 | } | ||
| 478 | 449 | ||||
| 479 | func finalAssistantText(messages []provider.Message) string { | 450 | func finalAssistantText(messages []provider.Message) string { | ||