feat(goal): support pausing and resuming goals (#218)
- commit
- 6084eb1f98083e6f487a2a00e128241b32d2409b
- author
- bnema · · 2026-09-21 11:36 UTC
- committer
- GitHub · · 2026-09-21 11:36 UTC
- parent
- 076e2bb
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.41.0
6 files · +107 -18
M README.md +2 -2
| @@ -271,7 +271,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 271 | 271 | - `worktree`: manage Git worktrees with the `create`, `list`, and `cleanup` actions. Creation checks out a new branch under `$ZUT_HOME/worktrees/<repo-id>`; an absolute local Git setting, `zut.worktrees.path`, overrides that root per repository. Listing inventories registered worktrees. Cleanup requires exact paths, confirmation through the normal preview gate, and revalidation immediately before removal. It refuses the primary worktree, dirty or detached checkouts, and commits not merged into the current branch. It removes neither local branches nor unmerged work. | |
| 272 | 272 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md). | |
| 273 | 273 | - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | |
| 274 | - | - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Pausing, resuming, and clearing missions remain user-controlled through `/goal`. | |
| 274 | + | - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, pause work while a decision is pending, resume it once settled, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Users can also pause and resume through `/goal`; clearing remains user-controlled. | |
| 275 | 275 | - `plan`: maintain a session-scoped checklist of `pending`, `in_progress`, and `completed` steps through `set` (replace every step), `add`, `update` one step, `remove` one step, `clear`, and `show`. At most one step may be `in_progress`. `set` requires at least one step; `clear` is how every step is removed. There is no step-count limit, but a plan carried across compaction shows only its first 12 steps. The plan is persisted in every mode through the tool-result commit path, inherited by forks and imports, and cleared by `/clear`. Successful updates appear inline as a checklist and emit `plan_update` events in JSON and RPC modes. | |
| 276 | 276 | - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool. | |
| 277 | 277 | ||
| @@ -360,7 +360,7 @@ Extension-registered commands appear under a divider at the bottom of the popup, | |||
| 360 | 360 | ||
| 361 | 361 | ### `/goal` | |
| 362 | 362 | ||
| 363 | - | In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can settle the current goal, set a bounded next goal, or atomically mark an invalid objective `superseded` and replace it with a necessary objective inside the same mission. Superseding cannot broaden scope or merely avoid difficult work. An omitted `mission_id` targets the current mission; when supplied, it must match that mission. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session. | |
| 363 | + | In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can pause while a decision is pending, resume after that decision is settled, settle the current goal, set a bounded next goal, or atomically mark an invalid objective `superseded` and replace it with a necessary objective inside the same mission. Superseding cannot broaden scope or merely avoid difficult work. An omitted `mission_id` targets the current mission; when supplied, it must match that mission. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session. | |
| 364 | 364 | ||
| 365 | 365 | Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`: | |
| 366 | 366 | ||
| @@ -271,7 +271,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||||
| 271 | - `worktree`: manage Git worktrees with the `create`, `list`, and `cleanup` actions. Creation checks out a new branch under `$ZUT_HOME/worktrees/<repo-id>`; an absolute local Git setting, `zut.worktrees.path`, overrides that root per repository. Listing inventories registered worktrees. Cleanup requires exact paths, confirmation through the normal preview gate, and revalidation immediately before removal. It refuses the primary worktree, dirty or detached checkouts, and commits not merged into the current branch. It removes neither local branches nor unmerged work. | 271 | - `worktree`: manage Git worktrees with the `create`, `list`, and `cleanup` actions. Creation checks out a new branch under `$ZUT_HOME/worktrees/<repo-id>`; an absolute local Git setting, `zut.worktrees.path`, overrides that root per repository. Listing inventories registered worktrees. Cleanup requires exact paths, confirmation through the normal preview gate, and revalidation immediately before removal. It refuses the primary worktree, dirty or detached checkouts, and commits not merged into the current branch. It removes neither local branches nor unmerged work. | ||
| 272 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md). | 272 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md). | ||
| 273 | - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | 273 | - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | ||
| 274 | - | - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Pausing, resuming, and clearing missions remain user-controlled through `/goal`. | 274 | + | - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, pause work while a decision is pending, resume it once settled, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Users can also pause and resume through `/goal`; clearing remains user-controlled. |
| 275 | - `plan`: maintain a session-scoped checklist of `pending`, `in_progress`, and `completed` steps through `set` (replace every step), `add`, `update` one step, `remove` one step, `clear`, and `show`. At most one step may be `in_progress`. `set` requires at least one step; `clear` is how every step is removed. There is no step-count limit, but a plan carried across compaction shows only its first 12 steps. The plan is persisted in every mode through the tool-result commit path, inherited by forks and imports, and cleared by `/clear`. Successful updates appear inline as a checklist and emit `plan_update` events in JSON and RPC modes. | 275 | - `plan`: maintain a session-scoped checklist of `pending`, `in_progress`, and `completed` steps through `set` (replace every step), `add`, `update` one step, `remove` one step, `clear`, and `show`. At most one step may be `in_progress`. `set` requires at least one step; `clear` is how every step is removed. There is no step-count limit, but a plan carried across compaction shows only its first 12 steps. The plan is persisted in every mode through the tool-result commit path, inherited by forks and imports, and cleared by `/clear`. Successful updates appear inline as a checklist and emit `plan_update` events in JSON and RPC modes. | ||
| 276 | - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool. | 276 | - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool. | ||
| 277 | 277 | ||||
| @@ -360,7 +360,7 @@ Extension-registered commands appear under a divider at the bottom of the popup, | |||||
| 360 | 360 | ||||
| 361 | ### `/goal` | 361 | ### `/goal` | ||
| 362 | 362 | ||||
| 363 | - | In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can settle the current goal, set a bounded next goal, or atomically mark an invalid objective `superseded` and replace it with a necessary objective inside the same mission. Superseding cannot broaden scope or merely avoid difficult work. An omitted `mission_id` targets the current mission; when supplied, it must match that mission. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session. | 363 | + | In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can pause while a decision is pending, resume after that decision is settled, settle the current goal, set a bounded next goal, or atomically mark an invalid objective `superseded` and replace it with a necessary objective inside the same mission. Superseding cannot broaden scope or merely avoid difficult work. An omitted `mission_id` targets the current mission; when supplied, it must match that mission. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session. |
| 364 | 364 | ||||
| 365 | Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`: | 365 | Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`: | ||
| 366 | 366 | ||||
M packages/agent/cli.go +18 -0
| @@ -821,6 +821,21 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo | |||
| 821 | 821 | return nil | |
| 822 | 822 | } | |
| 823 | 823 | if update.Status == core.GoalActive { | |
| 824 | + | if strings.TrimSpace(update.Objective) == "" { | |
| 825 | + | if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalPaused { | |
| 826 | + | return nil | |
| 827 | + | } | |
| 828 | + | if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID { | |
| 829 | + | return errGoalMissionMismatch | |
| 830 | + | } | |
| 831 | + | goal := *sess.Meta.Goal | |
| 832 | + | goal.Status = core.GoalActive | |
| 833 | + | goal.Reason = "" | |
| 834 | + | if err := sess.UpdateGoal(&goal); err != nil { | |
| 835 | + | return fmt.Errorf("%w: %w", errGoalStateWrite, err) | |
| 836 | + | } | |
| 837 | + | return nil | |
| 838 | + | } | |
| 824 | 839 | if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive { | |
| 825 | 840 | return errGoalActiveReplacement | |
| 826 | 841 | } | |
| @@ -840,6 +855,9 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo | |||
| 840 | 855 | if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive { | |
| 841 | 856 | return nil | |
| 842 | 857 | } | |
| 858 | + | if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID { | |
| 859 | + | return errGoalMissionMismatch | |
| 860 | + | } | |
| 843 | 861 | goal := *sess.Meta.Goal | |
| 844 | 862 | goal.Status = update.Status | |
| 845 | 863 | goal.Reason = update.Reason | |
| @@ -821,6 +821,21 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo | |||||
| 821 | return nil | 821 | return nil | ||
| 822 | } | 822 | } | ||
| 823 | if update.Status == core.GoalActive { | 823 | if update.Status == core.GoalActive { | ||
| 824 | + | if strings.TrimSpace(update.Objective) == "" { | |||
| 825 | + | if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalPaused { | |||
| 826 | + | return nil | |||
| 827 | + | } | |||
| 828 | + | if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID { | |||
| 829 | + | return errGoalMissionMismatch | |||
| 830 | + | } | |||
| 831 | + | goal := *sess.Meta.Goal | |||
| 832 | + | goal.Status = core.GoalActive | |||
| 833 | + | goal.Reason = "" | |||
| 834 | + | if err := sess.UpdateGoal(&goal); err != nil { | |||
| 835 | + | return fmt.Errorf("%w: %w", errGoalStateWrite, err) | |||
| 836 | + | } | |||
| 837 | + | return nil | |||
| 838 | + | } | |||
| 824 | if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive { | 839 | if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive { | ||
| 825 | return errGoalActiveReplacement | 840 | return errGoalActiveReplacement | ||
| 826 | } | 841 | } | ||
| @@ -840,6 +855,9 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo | |||||
| 840 | if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive { | 855 | if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive { | ||
| 841 | return nil | 856 | return nil | ||
| 842 | } | 857 | } | ||
| 858 | + | if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID { | |||
| 859 | + | return errGoalMissionMismatch | |||
| 860 | + | } | |||
| 843 | goal := *sess.Meta.Goal | 861 | goal := *sess.Meta.Goal | ||
| 844 | goal.Status = update.Status | 862 | goal.Status = update.Status | ||
| 845 | goal.Reason = update.Reason | 863 | goal.Reason = update.Reason | ||
M packages/agent/cli_session_test.go +46 -0
| @@ -283,6 +283,52 @@ func TestPersistGoalToolResultStartsGoalInCurrentMissionWithoutID(t *testing.T) | |||
| 283 | 283 | } | |
| 284 | 284 | } | |
| 285 | 285 | ||
| 286 | + | func TestPersistGoalToolResultPausesAndResumesCurrentGoal(t *testing.T) { | |
| 287 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |
| 288 | + | if err != nil { | |
| 289 | + | t.Fatal(err) | |
| 290 | + | } | |
| 291 | + | defer sess.Close() | |
| 292 | + | if err := sess.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil { | |
| 293 | + | t.Fatal(err) | |
| 294 | + | } | |
| 295 | + | goalID := sess.Meta.Goal.ID | |
| 296 | + | missionID := sess.Meta.Mission.ID | |
| 297 | + | ||
| 298 | + | if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalPaused}}); err != nil { | |
| 299 | + | t.Fatal(err) | |
| 300 | + | } | |
| 301 | + | if got := sess.Meta.Goal; got == nil || got.Status != core.GoalPaused || got.ID != goalID || got.Objective != "current work" { | |
| 302 | + | t.Fatalf("paused goal = %#v", got) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, MissionID: missionID}}); err != nil { | |
| 306 | + | t.Fatal(err) | |
| 307 | + | } | |
| 308 | + | if got := sess.Meta.Goal; got == nil || got.Status != core.GoalActive || got.ID != goalID || got.Objective != "current work" { | |
| 309 | + | t.Fatalf("resumed goal = %#v", got) | |
| 310 | + | } | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func TestPersistGoalToolResultRejectsPauseForDifferentMission(t *testing.T) { | |
| 314 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |
| 315 | + | if err != nil { | |
| 316 | + | t.Fatal(err) | |
| 317 | + | } | |
| 318 | + | defer sess.Close() | |
| 319 | + | if err := sess.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil { | |
| 320 | + | t.Fatal(err) | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalPaused, MissionID: "other-mission"}}) | |
| 324 | + | if !errors.Is(err, errGoalMissionMismatch) { | |
| 325 | + | t.Fatalf("error = %v, want %v", err, errGoalMissionMismatch) | |
| 326 | + | } | |
| 327 | + | if got := sess.Meta.Goal; got == nil || got.Status != core.GoalActive { | |
| 328 | + | t.Fatalf("goal changed after rejected pause: %#v", got) | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 286 | 332 | func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) { | |
| 287 | 333 | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |
| 288 | 334 | if err != nil { | |
| @@ -283,6 +283,52 @@ func TestPersistGoalToolResultStartsGoalInCurrentMissionWithoutID(t *testing.T) | |||||
| 283 | } | 283 | } | ||
| 284 | } | 284 | } | ||
| 285 | 285 | ||||
| 286 | + | func TestPersistGoalToolResultPausesAndResumesCurrentGoal(t *testing.T) { | |||
| 287 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |||
| 288 | + | if err != nil { | |||
| 289 | + | t.Fatal(err) | |||
| 290 | + | } | |||
| 291 | + | defer sess.Close() | |||
| 292 | + | if err := sess.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil { | |||
| 293 | + | t.Fatal(err) | |||
| 294 | + | } | |||
| 295 | + | goalID := sess.Meta.Goal.ID | |||
| 296 | + | missionID := sess.Meta.Mission.ID | |||
| 297 | + | ||||
| 298 | + | if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalPaused}}); err != nil { | |||
| 299 | + | t.Fatal(err) | |||
| 300 | + | } | |||
| 301 | + | if got := sess.Meta.Goal; got == nil || got.Status != core.GoalPaused || got.ID != goalID || got.Objective != "current work" { | |||
| 302 | + | t.Fatalf("paused goal = %#v", got) | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, MissionID: missionID}}); err != nil { | |||
| 306 | + | t.Fatal(err) | |||
| 307 | + | } | |||
| 308 | + | if got := sess.Meta.Goal; got == nil || got.Status != core.GoalActive || got.ID != goalID || got.Objective != "current work" { | |||
| 309 | + | t.Fatalf("resumed goal = %#v", got) | |||
| 310 | + | } | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | func TestPersistGoalToolResultRejectsPauseForDifferentMission(t *testing.T) { | |||
| 314 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |||
| 315 | + | if err != nil { | |||
| 316 | + | t.Fatal(err) | |||
| 317 | + | } | |||
| 318 | + | defer sess.Close() | |||
| 319 | + | if err := sess.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil { | |||
| 320 | + | t.Fatal(err) | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalPaused, MissionID: "other-mission"}}) | |||
| 324 | + | if !errors.Is(err, errGoalMissionMismatch) { | |||
| 325 | + | t.Fatalf("error = %v, want %v", err, errGoalMissionMismatch) | |||
| 326 | + | } | |||
| 327 | + | if got := sess.Meta.Goal; got == nil || got.Status != core.GoalActive { | |||
| 328 | + | t.Fatalf("goal changed after rejected pause: %#v", got) | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | ||||
| 286 | func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) { | 332 | func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) { | ||
| 287 | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | 333 | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | ||
| 288 | if err != nil { | 334 | if err != nil { | ||
M packages/agent/modes/interactive_events.go +9 -7
| @@ -180,17 +180,19 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 180 | 180 | // Esc return for the replaced goal must not fire. | |
| 181 | 181 | i.interruptedGoalReturn = nil | |
| 182 | 182 | } else if update.Status == core.GoalActive { | |
| 183 | - | // A manager may advance a terminal goal to the next persisted | |
| 184 | - | // goal in the same mission. Do not let a late tool result resume | |
| 185 | - | // a goal explicitly paused by the user. | |
| 186 | - | if i.goalStatus != core.GoalPaused { | |
| 183 | + | // Persistence runs before this event. Reflect a manager resume or | |
| 184 | + | // newly active goal, but do not let a stale result undo a user pause. | |
| 185 | + | if i.cfg.CurrentGoal != nil { | |
| 186 | + | if current := i.cfg.CurrentGoal(); current != nil { | |
| 187 | + | i.goalStatus = current.Status | |
| 188 | + | } | |
| 189 | + | } else if i.goalStatus != core.GoalPaused { | |
| 187 | 190 | i.goalStatus = core.GoalActive | |
| 188 | 191 | } | |
| 189 | 192 | } else if i.goalStatus == core.GoalActive { | |
| 190 | 193 | i.goalStatus = update.Status | |
| 191 | - | // Terminal update_goal transitions settle the mission; an Esc | |
| 192 | - | // return for the settled goal is inert by identity, drop it. | |
| 193 | - | if update.Status == core.GoalDone || update.Status == core.GoalBlocked { | |
| 194 | + | // Paused and terminal goals must not inherit an Esc-armed return. | |
| 195 | + | if update.Status == core.GoalPaused || update.Status == core.GoalDone || update.Status == core.GoalBlocked { | |
| 194 | 196 | i.interruptedGoalReturn = nil | |
| 195 | 197 | } | |
| 196 | 198 | } | |
| @@ -180,17 +180,19 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 180 | // Esc return for the replaced goal must not fire. | 180 | // Esc return for the replaced goal must not fire. | ||
| 181 | i.interruptedGoalReturn = nil | 181 | i.interruptedGoalReturn = nil | ||
| 182 | } else if update.Status == core.GoalActive { | 182 | } else if update.Status == core.GoalActive { | ||
| 183 | - | // A manager may advance a terminal goal to the next persisted | 183 | + | // Persistence runs before this event. Reflect a manager resume or |
| 184 | - | // goal in the same mission. Do not let a late tool result resume | 184 | + | // newly active goal, but do not let a stale result undo a user pause. |
| 185 | - | // a goal explicitly paused by the user. | 185 | + | if i.cfg.CurrentGoal != nil { |
| 186 | - | if i.goalStatus != core.GoalPaused { | 186 | + | if current := i.cfg.CurrentGoal(); current != nil { |
| 187 | + | i.goalStatus = current.Status | |||
| 188 | + | } | |||
| 189 | + | } else if i.goalStatus != core.GoalPaused { | |||
| 187 | i.goalStatus = core.GoalActive | 190 | i.goalStatus = core.GoalActive | ||
| 188 | } | 191 | } | ||
| 189 | } else if i.goalStatus == core.GoalActive { | 192 | } else if i.goalStatus == core.GoalActive { | ||
| 190 | i.goalStatus = update.Status | 193 | i.goalStatus = update.Status | ||
| 191 | - | // Terminal update_goal transitions settle the mission; an Esc | 194 | + | // Paused and terminal goals must not inherit an Esc-armed return. |
| 192 | - | // return for the settled goal is inert by identity, drop it. | 195 | + | if update.Status == core.GoalPaused || update.Status == core.GoalDone || update.Status == core.GoalBlocked { |
| 193 | - | if update.Status == core.GoalDone || update.Status == core.GoalBlocked { | |||
| 194 | i.interruptedGoalReturn = nil | 196 | i.interruptedGoalReturn = nil | ||
| 195 | } | 197 | } | ||
| 196 | } | 198 | } | ||
M packages/agent/tools/update_goal.go +8 -8
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | const ( | |
| 15 | 15 | UpdateGoalToolName = "update_goal" | |
| 16 | - | updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked","superseded"],"description":"Start a mission, set the next active goal, settle the active goal, or atomically replace it within the same mission."},"objective":{"type":"string","description":"Concrete objective required for active and superseded."},"goal_id":{"type":"string","description":"Current goal identifier required for superseded, copied from the active-goal context."},"mission_id":{"type":"string","description":"Optional identifier from the active-goal context. When supplied, it must match the current mission; omit it when starting or continuing the current mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}` | |
| 16 | + | updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","paused","complete","blocked","superseded"],"description":"Start or resume a mission, pause it, set the next active goal, settle the active goal, or atomically replace it within the same mission."},"objective":{"type":"string","description":"Concrete objective required when starting or advancing with active, and when superseding. Omit active's objective to resume the paused goal."},"goal_id":{"type":"string","description":"Current goal identifier required for superseded, copied from the active-goal context."},"mission_id":{"type":"string","description":"Optional identifier from the active-goal context. When supplied, it must match the current mission; omit it when starting or continuing the current mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}` | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | // UpdateGoalTool lets the main agent start a mission, set its next concrete | |
| @@ -42,7 +42,7 @@ type GoalUpdate struct { | |||
| 42 | 42 | func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName } | |
| 43 | 43 | ||
| 44 | 44 | func (t *UpdateGoalTool) Description() string { | |
| 45 | - | return "When no mission is active and the user's objective is clear, bounded, settled, and requires substantial multi-step work, call update_goal with status active before substantial execution. Do not start a mission for brief questions, casual conversation, or requests completed immediately. Inside a mission, set the next concrete goal, mark the active goal complete or blocked, or supersede it with a necessary replacement inside the same mission. Never broaden the mission or use superseded to avoid difficult work. Incidental messages and interrupted turns do not end an active mission." | |
| 45 | + | return "When no mission is active and the user's objective is clear, bounded, settled, and requires substantial multi-step work, call update_goal with status active and an objective before substantial execution. Do not start a mission for brief questions, casual conversation, or requests completed immediately. Inside a mission, set the next concrete goal, pause the active goal when work should wait for a decision, resume the paused goal with status active and no objective once the decision is settled, mark the active goal complete or blocked, or supersede it with a necessary replacement inside the same mission. Never broaden the mission or use superseded to avoid difficult work. Incidental messages and interrupted turns do not end an active mission." | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | 48 | func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) } | |
| @@ -66,12 +66,12 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func( | |||
| 66 | 66 | var update GoalUpdate | |
| 67 | 67 | switch args.Status { | |
| 68 | 68 | case "active": | |
| 69 | - | if args.Objective == "" { | |
| 70 | - | return goalToolError("objective is required when setting a goal"), nil | |
| 71 | - | } | |
| 72 | 69 | update.Status = core.GoalActive | |
| 73 | 70 | update.Objective = args.Objective | |
| 74 | 71 | update.MissionID = args.MissionID | |
| 72 | + | case "paused": | |
| 73 | + | update.Status = core.GoalPaused | |
| 74 | + | update.MissionID = args.MissionID | |
| 75 | 75 | case "complete": | |
| 76 | 76 | update.Status = core.GoalDone | |
| 77 | 77 | case "superseded": | |
| @@ -92,7 +92,7 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func( | |||
| 92 | 92 | update.Status = core.GoalBlocked | |
| 93 | 93 | update.Reason = args.Reason | |
| 94 | 94 | default: | |
| 95 | - | return goalToolError("status must be active, complete, blocked, or superseded"), nil | |
| 95 | + | return goalToolError("status must be active, paused, complete, blocked, or superseded"), nil | |
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | 98 | text := "autonomous goal marked " + string(update.Status) | |
| @@ -114,10 +114,10 @@ func GoalUpdateFromResult(result core.ToolResult) (GoalUpdate, bool) { | |||
| 114 | 114 | if !ok { | |
| 115 | 115 | return GoalUpdate{}, false | |
| 116 | 116 | } | |
| 117 | - | if update.Status != core.GoalActive && update.Status != core.GoalDone && update.Status != core.GoalBlocked && update.Status != core.GoalSuperseded { | |
| 117 | + | if update.Status != core.GoalActive && update.Status != core.GoalPaused && update.Status != core.GoalDone && update.Status != core.GoalBlocked && update.Status != core.GoalSuperseded { | |
| 118 | 118 | return GoalUpdate{}, false | |
| 119 | 119 | } | |
| 120 | - | if (update.Status == core.GoalActive || update.Status == core.GoalSuperseded) && strings.TrimSpace(update.Objective) == "" { | |
| 120 | + | if update.Status == core.GoalSuperseded && strings.TrimSpace(update.Objective) == "" { | |
| 121 | 121 | return GoalUpdate{}, false | |
| 122 | 122 | } | |
| 123 | 123 | if update.Status == core.GoalSuperseded && strings.TrimSpace(update.GoalID) == "" { | |
| @@ -13,7 +13,7 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | const ( | 14 | const ( | ||
| 15 | UpdateGoalToolName = "update_goal" | 15 | UpdateGoalToolName = "update_goal" | ||
| 16 | - | updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked","superseded"],"description":"Start a mission, set the next active goal, settle the active goal, or atomically replace it within the same mission."},"objective":{"type":"string","description":"Concrete objective required for active and superseded."},"goal_id":{"type":"string","description":"Current goal identifier required for superseded, copied from the active-goal context."},"mission_id":{"type":"string","description":"Optional identifier from the active-goal context. When supplied, it must match the current mission; omit it when starting or continuing the current mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}` | 16 | + | updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","paused","complete","blocked","superseded"],"description":"Start or resume a mission, pause it, set the next active goal, settle the active goal, or atomically replace it within the same mission."},"objective":{"type":"string","description":"Concrete objective required when starting or advancing with active, and when superseding. Omit active's objective to resume the paused goal."},"goal_id":{"type":"string","description":"Current goal identifier required for superseded, copied from the active-goal context."},"mission_id":{"type":"string","description":"Optional identifier from the active-goal context. When supplied, it must match the current mission; omit it when starting or continuing the current mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}` |
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | // UpdateGoalTool lets the main agent start a mission, set its next concrete | 19 | // UpdateGoalTool lets the main agent start a mission, set its next concrete | ||
| @@ -42,7 +42,7 @@ type GoalUpdate struct { | |||||
| 42 | func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName } | 42 | func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName } | ||
| 43 | 43 | ||||
| 44 | func (t *UpdateGoalTool) Description() string { | 44 | func (t *UpdateGoalTool) Description() string { | ||
| 45 | - | return "When no mission is active and the user's objective is clear, bounded, settled, and requires substantial multi-step work, call update_goal with status active before substantial execution. Do not start a mission for brief questions, casual conversation, or requests completed immediately. Inside a mission, set the next concrete goal, mark the active goal complete or blocked, or supersede it with a necessary replacement inside the same mission. Never broaden the mission or use superseded to avoid difficult work. Incidental messages and interrupted turns do not end an active mission." | 45 | + | return "When no mission is active and the user's objective is clear, bounded, settled, and requires substantial multi-step work, call update_goal with status active and an objective before substantial execution. Do not start a mission for brief questions, casual conversation, or requests completed immediately. Inside a mission, set the next concrete goal, pause the active goal when work should wait for a decision, resume the paused goal with status active and no objective once the decision is settled, mark the active goal complete or blocked, or supersede it with a necessary replacement inside the same mission. Never broaden the mission or use superseded to avoid difficult work. Incidental messages and interrupted turns do not end an active mission." |
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| 48 | func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) } | 48 | func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) } | ||
| @@ -66,12 +66,12 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func( | |||||
| 66 | var update GoalUpdate | 66 | var update GoalUpdate | ||
| 67 | switch args.Status { | 67 | switch args.Status { | ||
| 68 | case "active": | 68 | case "active": | ||
| 69 | - | if args.Objective == "" { | |||
| 70 | - | return goalToolError("objective is required when setting a goal"), nil | |||
| 71 | - | } | |||
| 72 | update.Status = core.GoalActive | 69 | update.Status = core.GoalActive | ||
| 73 | update.Objective = args.Objective | 70 | update.Objective = args.Objective | ||
| 74 | update.MissionID = args.MissionID | 71 | update.MissionID = args.MissionID | ||
| 72 | + | case "paused": | |||
| 73 | + | update.Status = core.GoalPaused | |||
| 74 | + | update.MissionID = args.MissionID | |||
| 75 | case "complete": | 75 | case "complete": | ||
| 76 | update.Status = core.GoalDone | 76 | update.Status = core.GoalDone | ||
| 77 | case "superseded": | 77 | case "superseded": | ||
| @@ -92,7 +92,7 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func( | |||||
| 92 | update.Status = core.GoalBlocked | 92 | update.Status = core.GoalBlocked | ||
| 93 | update.Reason = args.Reason | 93 | update.Reason = args.Reason | ||
| 94 | default: | 94 | default: | ||
| 95 | - | return goalToolError("status must be active, complete, blocked, or superseded"), nil | 95 | + | return goalToolError("status must be active, paused, complete, blocked, or superseded"), nil |
| 96 | } | 96 | } | ||
| 97 | 97 | ||||
| 98 | text := "autonomous goal marked " + string(update.Status) | 98 | text := "autonomous goal marked " + string(update.Status) | ||
| @@ -114,10 +114,10 @@ func GoalUpdateFromResult(result core.ToolResult) (GoalUpdate, bool) { | |||||
| 114 | if !ok { | 114 | if !ok { | ||
| 115 | return GoalUpdate{}, false | 115 | return GoalUpdate{}, false | ||
| 116 | } | 116 | } | ||
| 117 | - | if update.Status != core.GoalActive && update.Status != core.GoalDone && update.Status != core.GoalBlocked && update.Status != core.GoalSuperseded { | 117 | + | if update.Status != core.GoalActive && update.Status != core.GoalPaused && update.Status != core.GoalDone && update.Status != core.GoalBlocked && update.Status != core.GoalSuperseded { |
| 118 | return GoalUpdate{}, false | 118 | return GoalUpdate{}, false | ||
| 119 | } | 119 | } | ||
| 120 | - | if (update.Status == core.GoalActive || update.Status == core.GoalSuperseded) && strings.TrimSpace(update.Objective) == "" { | 120 | + | if update.Status == core.GoalSuperseded && strings.TrimSpace(update.Objective) == "" { |
| 121 | return GoalUpdate{}, false | 121 | return GoalUpdate{}, false | ||
| 122 | } | 122 | } | ||
| 123 | if update.Status == core.GoalSuperseded && strings.TrimSpace(update.GoalID) == "" { | 123 | if update.Status == core.GoalSuperseded && strings.TrimSpace(update.GoalID) == "" { | ||
M packages/agent/tools/update_goal_test.go +24 -1
| @@ -46,6 +46,29 @@ func TestUpdateGoalToolReturnsPersistableStatus(t *testing.T) { | |||
| 46 | 46 | } | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | func TestUpdateGoalToolReturnsPauseAndResumeTransitions(t *testing.T) { | |
| 50 | + | tool := &UpdateGoalTool{} | |
| 51 | + | for _, test := range []struct { | |
| 52 | + | name string | |
| 53 | + | raw string | |
| 54 | + | want core.GoalStatus | |
| 55 | + | }{ | |
| 56 | + | {name: "pause", raw: `{"status":"paused"}`, want: core.GoalPaused}, | |
| 57 | + | {name: "resume", raw: `{"status":"active"}`, want: core.GoalActive}, | |
| 58 | + | } { | |
| 59 | + | t.Run(test.name, func(t *testing.T) { | |
| 60 | + | result, err := tool.Execute(context.Background(), json.RawMessage(test.raw), nil) | |
| 61 | + | if err != nil { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | update, ok := GoalUpdateFromResult(result) | |
| 65 | + | if !ok || update.Status != test.want || update.Objective != "" { | |
| 66 | + | t.Fatalf("update = %#v, ok = %v", update, ok) | |
| 67 | + | } | |
| 68 | + | }) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 49 | 72 | func TestUpdateGoalToolReturnsPersistableManagerGoal(t *testing.T) { | |
| 50 | 73 | tool := &UpdateGoalTool{} | |
| 51 | 74 | result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"active","objective":"reproduce the reported failure"}`), nil) | |
| @@ -117,7 +140,7 @@ func TestUpdateGoalToolRejectsSupersededWithoutReplacement(t *testing.T) { | |||
| 117 | 140 | ||
| 118 | 141 | func TestUpdateGoalToolRejectsUnsupportedStatus(t *testing.T) { | |
| 119 | 142 | tool := &UpdateGoalTool{} | |
| 120 | - | raw := json.RawMessage(`{"status":"active"}`) | |
| 143 | + | raw := json.RawMessage(`{"status":"waiting"}`) | |
| 121 | 144 | result, err := tool.Execute(context.Background(), raw, nil) | |
| 122 | 145 | if err != nil { | |
| 123 | 146 | t.Fatal(err) | |
| @@ -46,6 +46,29 @@ func TestUpdateGoalToolReturnsPersistableStatus(t *testing.T) { | |||||
| 46 | } | 46 | } | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | + | func TestUpdateGoalToolReturnsPauseAndResumeTransitions(t *testing.T) { | |||
| 50 | + | tool := &UpdateGoalTool{} | |||
| 51 | + | for _, test := range []struct { | |||
| 52 | + | name string | |||
| 53 | + | raw string | |||
| 54 | + | want core.GoalStatus | |||
| 55 | + | }{ | |||
| 56 | + | {name: "pause", raw: `{"status":"paused"}`, want: core.GoalPaused}, | |||
| 57 | + | {name: "resume", raw: `{"status":"active"}`, want: core.GoalActive}, | |||
| 58 | + | } { | |||
| 59 | + | t.Run(test.name, func(t *testing.T) { | |||
| 60 | + | result, err := tool.Execute(context.Background(), json.RawMessage(test.raw), nil) | |||
| 61 | + | if err != nil { | |||
| 62 | + | t.Fatal(err) | |||
| 63 | + | } | |||
| 64 | + | update, ok := GoalUpdateFromResult(result) | |||
| 65 | + | if !ok || update.Status != test.want || update.Objective != "" { | |||
| 66 | + | t.Fatalf("update = %#v, ok = %v", update, ok) | |||
| 67 | + | } | |||
| 68 | + | }) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 49 | func TestUpdateGoalToolReturnsPersistableManagerGoal(t *testing.T) { | 72 | func TestUpdateGoalToolReturnsPersistableManagerGoal(t *testing.T) { | ||
| 50 | tool := &UpdateGoalTool{} | 73 | tool := &UpdateGoalTool{} | ||
| 51 | result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"active","objective":"reproduce the reported failure"}`), nil) | 74 | result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"active","objective":"reproduce the reported failure"}`), nil) | ||
| @@ -117,7 +140,7 @@ func TestUpdateGoalToolRejectsSupersededWithoutReplacement(t *testing.T) { | |||||
| 117 | 140 | ||||
| 118 | func TestUpdateGoalToolRejectsUnsupportedStatus(t *testing.T) { | 141 | func TestUpdateGoalToolRejectsUnsupportedStatus(t *testing.T) { | ||
| 119 | tool := &UpdateGoalTool{} | 142 | tool := &UpdateGoalTool{} | ||
| 120 | - | raw := json.RawMessage(`{"status":"active"}`) | 143 | + | raw := json.RawMessage(`{"status":"waiting"}`) |
| 121 | result, err := tool.Execute(context.Background(), raw, nil) | 144 | result, err := tool.Execute(context.Background(), raw, nil) | ||
| 122 | if err != nil { | 145 | if err != nil { | ||
| 123 | t.Fatal(err) | 146 | t.Fatal(err) | ||