Merge pull request #130 from bnema/fix/goal-continuation-loop
fix(goals): prevent unbounded continuations
16 files · +968 -30
M README.md +13 -1
| @@ -335,7 +335,19 @@ Extension-registered commands appear under a divider at the bottom of the popup, | |||
| 335 | 335 | ||
| 336 | 336 | ### `/goal` | |
| 337 | 337 | ||
| 338 | - | 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 a hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. The manager can use `update_goal` to settle the current goal or set a bounded next goal within the same mission. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it. | |
| 338 | + | 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. The manager can use `update_goal` to settle the current goal or set a bounded next goal within the same mission. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session. | |
| 339 | + | ||
| 340 | + | 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`: | |
| 341 | + | ||
| 342 | + | ```json | |
| 343 | + | { | |
| 344 | + | "goals": { | |
| 345 | + | "max_token_budget": 1000000 | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | ``` | |
| 349 | + | ||
| 350 | + | When set, `max_token_budget` is applied to newly created goals. A goal that reaches it becomes `budget_limited`; omitting the setting creates a goal with no token limit. This is separate from the continuation safety controller: a turn that ends without a concrete tool action receives one corrective continuation, then becomes `stalled` rather than repeatedly sending the same prompt. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it. | |
| 339 | 351 | ||
| 340 | 352 | A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it. | |
| 341 | 353 | ||
| @@ -335,7 +335,19 @@ Extension-registered commands appear under a divider at the bottom of the popup, | |||||
| 335 | 335 | ||||
| 336 | ### `/goal` | 336 | ### `/goal` | ||
| 337 | 337 | ||||
| 338 | - | 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 a hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. The manager can use `update_goal` to settle the current goal or set a bounded next goal within the same mission. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it. | 338 | + | 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. The manager can use `update_goal` to settle the current goal or set a bounded next goal within the same mission. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session. |
| 339 | + | ||||
| 340 | + | 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`: | |||
| 341 | + | ||||
| 342 | + | ```json | |||
| 343 | + | { | |||
| 344 | + | "goals": { | |||
| 345 | + | "max_token_budget": 1000000 | |||
| 346 | + | } | |||
| 347 | + | } | |||
| 348 | + | ``` | |||
| 349 | + | ||||
| 350 | + | When set, `max_token_budget` is applied to newly created goals. A goal that reaches it becomes `budget_limited`; omitting the setting creates a goal with no token limit. This is separate from the continuation safety controller: a turn that ends without a concrete tool action receives one corrective continuation, then becomes `stalled` rather than repeatedly sending the same prompt. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it. | |||
| 339 | 351 | ||||
| 340 | A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it. | 352 | A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it. | ||
| 341 | 353 | ||||
M packages/agent/cli.go +71 -10
| @@ -693,8 +693,19 @@ func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawM | |||
| 693 | 693 | return out | |
| 694 | 694 | } | |
| 695 | 695 | ||
| 696 | - | func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result core.ToolResult) error { | |
| 696 | + | var ( | |
| 697 | + | errGoalActiveReplacement = errors.New("active goal replacement") | |
| 698 | + | errGoalMissionMissing = errors.New("goal mission missing") | |
| 699 | + | errGoalMissionMismatch = errors.New("goal mission mismatch") | |
| 700 | + | errGoalStorageUnavailable = errors.New("goal storage unavailable") | |
| 701 | + | errGoalStateWrite = errors.New("goal state write") | |
| 702 | + | ) | |
| 703 | + | ||
| 704 | + | func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result core.ToolResult, goalMaxTokenBudget ...*uint64) error { | |
| 697 | 705 | if sess == nil { | |
| 706 | + | if _, ok := tools.GoalUpdateFromResult(result); ok { | |
| 707 | + | return goalCommitError(errGoalStorageUnavailable) | |
| 708 | + | } | |
| 698 | 709 | return nil | |
| 699 | 710 | } | |
| 700 | 711 | if details, ok := result.Details.(extensions.ToolResultDetails); ok && details.Extension != "" && len(details.State) > 0 { | |
| @@ -705,13 +716,34 @@ func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result | |||
| 705 | 716 | mgr.UpdateSessionState(details.Extension, details.State) | |
| 706 | 717 | } | |
| 707 | 718 | } | |
| 708 | - | if err := persistGoalToolResult(sess, result); err != nil { | |
| 709 | - | return fmt.Errorf("goal state: %w", err) | |
| 719 | + | if err := persistGoalToolResult(sess, result, goalMaxTokenBudget...); err != nil { | |
| 720 | + | return goalCommitError(err) | |
| 710 | 721 | } | |
| 711 | 722 | return nil | |
| 712 | 723 | } | |
| 713 | 724 | ||
| 714 | - | func persistGoalToolResult(sess *core.Session, result core.ToolResult) error { | |
| 725 | + | func goalCommitError(err error) error { | |
| 726 | + | return &core.ToolResultCommitError{Message: safeGoalCommitMessage(err), Err: fmt.Errorf("goal state: %w", err)} | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | func safeGoalCommitMessage(err error) string { | |
| 730 | + | switch { | |
| 731 | + | case errors.Is(err, errGoalActiveReplacement): | |
| 732 | + | return "goal transition rejected: the current goal is still active" | |
| 733 | + | case errors.Is(err, errGoalMissionMissing): | |
| 734 | + | return "goal transition rejected: no active mission" | |
| 735 | + | case errors.Is(err, errGoalMissionMismatch): | |
| 736 | + | return "goal transition rejected: it does not belong to the active mission" | |
| 737 | + | case errors.Is(err, errGoalStorageUnavailable): | |
| 738 | + | return "goal state unavailable: session persistence is disabled" | |
| 739 | + | case errors.Is(err, errGoalStateWrite): | |
| 740 | + | return "goal state could not be saved" | |
| 741 | + | default: | |
| 742 | + | return "goal update could not be persisted" | |
| 743 | + | } | |
| 744 | + | } | |
| 745 | + | ||
| 746 | + | func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTokenBudget ...*uint64) error { | |
| 715 | 747 | if sess == nil { | |
| 716 | 748 | return nil | |
| 717 | 749 | } | |
| @@ -721,12 +753,23 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult) error { | |||
| 721 | 753 | } | |
| 722 | 754 | if update.Status == core.GoalActive { | |
| 723 | 755 | if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive { | |
| 724 | - | return fmt.Errorf("cannot replace an active goal") | |
| 756 | + | return errGoalActiveReplacement | |
| 757 | + | } | |
| 758 | + | if sess.Meta.Mission == nil { | |
| 759 | + | return errGoalMissionMissing | |
| 760 | + | } | |
| 761 | + | if update.MissionID == "" || update.MissionID != sess.Meta.Mission.ID { | |
| 762 | + | return errGoalMissionMismatch | |
| 763 | + | } | |
| 764 | + | goal := &core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager} | |
| 765 | + | if len(goalMaxTokenBudget) > 0 && goalMaxTokenBudget[0] != nil && *goalMaxTokenBudget[0] > 0 { | |
| 766 | + | budget := *goalMaxTokenBudget[0] | |
| 767 | + | goal.TokenBudget = &budget | |
| 725 | 768 | } | |
| 726 | - | if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID { | |
| 727 | - | return fmt.Errorf("goal update does not belong to the active mission") | |
| 769 | + | if err := sess.UpdateGoal(goal); err != nil { | |
| 770 | + | return fmt.Errorf("%w: %w", errGoalStateWrite, err) | |
| 728 | 771 | } | |
| 729 | - | return sess.UpdateGoal(&core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager}) | |
| 772 | + | return nil | |
| 730 | 773 | } | |
| 731 | 774 | if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive { | |
| 732 | 775 | return nil | |
| @@ -734,7 +777,10 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult) error { | |||
| 734 | 777 | goal := *sess.Meta.Goal | |
| 735 | 778 | goal.Status = update.Status | |
| 736 | 779 | goal.Reason = update.Reason | |
| 737 | - | return sess.UpdateGoal(&goal) | |
| 780 | + | if err := sess.UpdateGoal(&goal); err != nil { | |
| 781 | + | return fmt.Errorf("%w: %w", errGoalStateWrite, err) | |
| 782 | + | } | |
| 783 | + | return nil | |
| 738 | 784 | } | |
| 739 | 785 | ||
| 740 | 786 | func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | |
| @@ -1416,6 +1462,9 @@ func reloadResourcesAfterStartupPreWithRegistry(ctx context.Context, args Args, | |||
| 1416 | 1462 | ||
| 1417 | 1463 | func runInteractive(ctx context.Context, args Args, version string) (runErr error) { | |
| 1418 | 1464 | initialCfg, _ := LoadConfig() | |
| 1465 | + | if initialCfg.Goals.MaxTokenBudget != nil && *initialCfg.Goals.MaxTokenBudget == 0 { | |
| 1466 | + | return errors.New("goals.max_token_budget must be greater than zero") | |
| 1467 | + | } | |
| 1419 | 1468 | // Resolve WITHOUT requiring credentials. | |
| 1420 | 1469 | r, err := Resolve(args, false) | |
| 1421 | 1470 | if err != nil { | |
| @@ -1849,6 +1898,16 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1849 | 1898 | } | |
| 1850 | 1899 | return sess.UpdateGoal(goal) | |
| 1851 | 1900 | } | |
| 1901 | + | persistGoalRuntime := func(goal *core.SessionGoal) error { | |
| 1902 | + | sessionTransitionMu.RLock() | |
| 1903 | + | defer sessionTransitionMu.RUnlock() | |
| 1904 | + | persistMu.Lock() | |
| 1905 | + | defer persistMu.Unlock() | |
| 1906 | + | if sess == nil { | |
| 1907 | + | return errors.New("session persistence is disabled") | |
| 1908 | + | } | |
| 1909 | + | return sess.UpdateGoalRuntime(goal) | |
| 1910 | + | } | |
| 1852 | 1911 | ensureMission := func(objective string) error { | |
| 1853 | 1912 | sessionTransitionMu.RLock() | |
| 1854 | 1913 | defer sessionTransitionMu.RUnlock() | |
| @@ -1883,7 +1942,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1883 | 1942 | a.CommitToolResult = func(_ string, result core.ToolResult) error { | |
| 1884 | 1943 | persistMu.Lock() | |
| 1885 | 1944 | defer persistMu.Unlock() | |
| 1886 | - | return persistToolResultState(extMgr, boundSession, result) | |
| 1945 | + | return persistToolResultState(extMgr, boundSession, result, initialCfg.Goals.MaxTokenBudget) | |
| 1887 | 1946 | } | |
| 1888 | 1947 | } | |
| 1889 | 1948 | wireAgentPersist := func(a *core.Agent) *core.Agent { | |
| @@ -2361,6 +2420,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2361 | 2420 | LSPEnabled: initialCfg.LSPEnabled, | |
| 2362 | 2421 | SubagentLSPEnabled: initialCfg.SubagentLSPEnabled, | |
| 2363 | 2422 | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | |
| 2423 | + | GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget, | |
| 2364 | 2424 | JailByDefault: initialCfg.JailByDefault, | |
| 2365 | 2425 | QuickModelShortcuts: quickModelShortcuts, | |
| 2366 | 2426 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | |
| @@ -2483,6 +2543,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2483 | 2543 | PersistCompactHandoff: persistCompactHandoff, | |
| 2484 | 2544 | CurrentCompactHandoff: currentCompactHandoff, | |
| 2485 | 2545 | PersistGoal: persistGoal, | |
| 2546 | + | PersistGoalRuntime: persistGoalRuntime, | |
| 2486 | 2547 | CurrentGoal: currentGoal, | |
| 2487 | 2548 | CurrentGoalHistory: currentGoalHistory, | |
| 2488 | 2549 | EnsureMission: ensureMission, | |
| @@ -693,8 +693,19 @@ func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawM | |||||
| 693 | return out | 693 | return out | ||
| 694 | } | 694 | } | ||
| 695 | 695 | ||||
| 696 | - | func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result core.ToolResult) error { | 696 | + | var ( |
| 697 | + | errGoalActiveReplacement = errors.New("active goal replacement") | |||
| 698 | + | errGoalMissionMissing = errors.New("goal mission missing") | |||
| 699 | + | errGoalMissionMismatch = errors.New("goal mission mismatch") | |||
| 700 | + | errGoalStorageUnavailable = errors.New("goal storage unavailable") | |||
| 701 | + | errGoalStateWrite = errors.New("goal state write") | |||
| 702 | + | ) | |||
| 703 | + | ||||
| 704 | + | func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result core.ToolResult, goalMaxTokenBudget ...*uint64) error { | |||
| 697 | if sess == nil { | 705 | if sess == nil { | ||
| 706 | + | if _, ok := tools.GoalUpdateFromResult(result); ok { | |||
| 707 | + | return goalCommitError(errGoalStorageUnavailable) | |||
| 708 | + | } | |||
| 698 | return nil | 709 | return nil | ||
| 699 | } | 710 | } | ||
| 700 | if details, ok := result.Details.(extensions.ToolResultDetails); ok && details.Extension != "" && len(details.State) > 0 { | 711 | if details, ok := result.Details.(extensions.ToolResultDetails); ok && details.Extension != "" && len(details.State) > 0 { | ||
| @@ -705,13 +716,34 @@ func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result | |||||
| 705 | mgr.UpdateSessionState(details.Extension, details.State) | 716 | mgr.UpdateSessionState(details.Extension, details.State) | ||
| 706 | } | 717 | } | ||
| 707 | } | 718 | } | ||
| 708 | - | if err := persistGoalToolResult(sess, result); err != nil { | 719 | + | if err := persistGoalToolResult(sess, result, goalMaxTokenBudget...); err != nil { |
| 709 | - | return fmt.Errorf("goal state: %w", err) | 720 | + | return goalCommitError(err) |
| 710 | } | 721 | } | ||
| 711 | return nil | 722 | return nil | ||
| 712 | } | 723 | } | ||
| 713 | 724 | ||||
| 714 | - | func persistGoalToolResult(sess *core.Session, result core.ToolResult) error { | 725 | + | func goalCommitError(err error) error { |
| 726 | + | return &core.ToolResultCommitError{Message: safeGoalCommitMessage(err), Err: fmt.Errorf("goal state: %w", err)} | |||
| 727 | + | } | |||
| 728 | + | ||||
| 729 | + | func safeGoalCommitMessage(err error) string { | |||
| 730 | + | switch { | |||
| 731 | + | case errors.Is(err, errGoalActiveReplacement): | |||
| 732 | + | return "goal transition rejected: the current goal is still active" | |||
| 733 | + | case errors.Is(err, errGoalMissionMissing): | |||
| 734 | + | return "goal transition rejected: no active mission" | |||
| 735 | + | case errors.Is(err, errGoalMissionMismatch): | |||
| 736 | + | return "goal transition rejected: it does not belong to the active mission" | |||
| 737 | + | case errors.Is(err, errGoalStorageUnavailable): | |||
| 738 | + | return "goal state unavailable: session persistence is disabled" | |||
| 739 | + | case errors.Is(err, errGoalStateWrite): | |||
| 740 | + | return "goal state could not be saved" | |||
| 741 | + | default: | |||
| 742 | + | return "goal update could not be persisted" | |||
| 743 | + | } | |||
| 744 | + | } | |||
| 745 | + | ||||
| 746 | + | func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTokenBudget ...*uint64) error { | |||
| 715 | if sess == nil { | 747 | if sess == nil { | ||
| 716 | return nil | 748 | return nil | ||
| 717 | } | 749 | } | ||
| @@ -721,12 +753,23 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult) error { | |||||
| 721 | } | 753 | } | ||
| 722 | if update.Status == core.GoalActive { | 754 | if update.Status == core.GoalActive { | ||
| 723 | if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive { | 755 | if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive { | ||
| 724 | - | return fmt.Errorf("cannot replace an active goal") | 756 | + | return errGoalActiveReplacement |
| 757 | + | } | |||
| 758 | + | if sess.Meta.Mission == nil { | |||
| 759 | + | return errGoalMissionMissing | |||
| 760 | + | } | |||
| 761 | + | if update.MissionID == "" || update.MissionID != sess.Meta.Mission.ID { | |||
| 762 | + | return errGoalMissionMismatch | |||
| 763 | + | } | |||
| 764 | + | goal := &core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager} | |||
| 765 | + | if len(goalMaxTokenBudget) > 0 && goalMaxTokenBudget[0] != nil && *goalMaxTokenBudget[0] > 0 { | |||
| 766 | + | budget := *goalMaxTokenBudget[0] | |||
| 767 | + | goal.TokenBudget = &budget | |||
| 725 | } | 768 | } | ||
| 726 | - | if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID { | 769 | + | if err := sess.UpdateGoal(goal); err != nil { |
| 727 | - | return fmt.Errorf("goal update does not belong to the active mission") | 770 | + | return fmt.Errorf("%w: %w", errGoalStateWrite, err) |
| 728 | } | 771 | } | ||
| 729 | - | return sess.UpdateGoal(&core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager}) | 772 | + | return nil |
| 730 | } | 773 | } | ||
| 731 | if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive { | 774 | if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive { | ||
| 732 | return nil | 775 | return nil | ||
| @@ -734,7 +777,10 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult) error { | |||||
| 734 | goal := *sess.Meta.Goal | 777 | goal := *sess.Meta.Goal | ||
| 735 | goal.Status = update.Status | 778 | goal.Status = update.Status | ||
| 736 | goal.Reason = update.Reason | 779 | goal.Reason = update.Reason | ||
| 737 | - | return sess.UpdateGoal(&goal) | 780 | + | if err := sess.UpdateGoal(&goal); err != nil { |
| 781 | + | return fmt.Errorf("%w: %w", errGoalStateWrite, err) | |||
| 782 | + | } | |||
| 783 | + | return nil | |||
| 738 | } | 784 | } | ||
| 739 | 785 | ||||
| 740 | func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | 786 | func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | ||
| @@ -1416,6 +1462,9 @@ func reloadResourcesAfterStartupPreWithRegistry(ctx context.Context, args Args, | |||||
| 1416 | 1462 | ||||
| 1417 | func runInteractive(ctx context.Context, args Args, version string) (runErr error) { | 1463 | func runInteractive(ctx context.Context, args Args, version string) (runErr error) { | ||
| 1418 | initialCfg, _ := LoadConfig() | 1464 | initialCfg, _ := LoadConfig() | ||
| 1465 | + | if initialCfg.Goals.MaxTokenBudget != nil && *initialCfg.Goals.MaxTokenBudget == 0 { | |||
| 1466 | + | return errors.New("goals.max_token_budget must be greater than zero") | |||
| 1467 | + | } | |||
| 1419 | // Resolve WITHOUT requiring credentials. | 1468 | // Resolve WITHOUT requiring credentials. | ||
| 1420 | r, err := Resolve(args, false) | 1469 | r, err := Resolve(args, false) | ||
| 1421 | if err != nil { | 1470 | if err != nil { | ||
| @@ -1849,6 +1898,16 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1849 | } | 1898 | } | ||
| 1850 | return sess.UpdateGoal(goal) | 1899 | return sess.UpdateGoal(goal) | ||
| 1851 | } | 1900 | } | ||
| 1901 | + | persistGoalRuntime := func(goal *core.SessionGoal) error { | |||
| 1902 | + | sessionTransitionMu.RLock() | |||
| 1903 | + | defer sessionTransitionMu.RUnlock() | |||
| 1904 | + | persistMu.Lock() | |||
| 1905 | + | defer persistMu.Unlock() | |||
| 1906 | + | if sess == nil { | |||
| 1907 | + | return errors.New("session persistence is disabled") | |||
| 1908 | + | } | |||
| 1909 | + | return sess.UpdateGoalRuntime(goal) | |||
| 1910 | + | } | |||
| 1852 | ensureMission := func(objective string) error { | 1911 | ensureMission := func(objective string) error { | ||
| 1853 | sessionTransitionMu.RLock() | 1912 | sessionTransitionMu.RLock() | ||
| 1854 | defer sessionTransitionMu.RUnlock() | 1913 | defer sessionTransitionMu.RUnlock() | ||
| @@ -1883,7 +1942,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1883 | a.CommitToolResult = func(_ string, result core.ToolResult) error { | 1942 | a.CommitToolResult = func(_ string, result core.ToolResult) error { | ||
| 1884 | persistMu.Lock() | 1943 | persistMu.Lock() | ||
| 1885 | defer persistMu.Unlock() | 1944 | defer persistMu.Unlock() | ||
| 1886 | - | return persistToolResultState(extMgr, boundSession, result) | 1945 | + | return persistToolResultState(extMgr, boundSession, result, initialCfg.Goals.MaxTokenBudget) |
| 1887 | } | 1946 | } | ||
| 1888 | } | 1947 | } | ||
| 1889 | wireAgentPersist := func(a *core.Agent) *core.Agent { | 1948 | wireAgentPersist := func(a *core.Agent) *core.Agent { | ||
| @@ -2361,6 +2420,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2361 | LSPEnabled: initialCfg.LSPEnabled, | 2420 | LSPEnabled: initialCfg.LSPEnabled, | ||
| 2362 | SubagentLSPEnabled: initialCfg.SubagentLSPEnabled, | 2421 | SubagentLSPEnabled: initialCfg.SubagentLSPEnabled, | ||
| 2363 | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | 2422 | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | ||
| 2423 | + | GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget, | |||
| 2364 | JailByDefault: initialCfg.JailByDefault, | 2424 | JailByDefault: initialCfg.JailByDefault, | ||
| 2365 | QuickModelShortcuts: quickModelShortcuts, | 2425 | QuickModelShortcuts: quickModelShortcuts, | ||
| 2366 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | 2426 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | ||
| @@ -2483,6 +2543,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2483 | PersistCompactHandoff: persistCompactHandoff, | 2543 | PersistCompactHandoff: persistCompactHandoff, | ||
| 2484 | CurrentCompactHandoff: currentCompactHandoff, | 2544 | CurrentCompactHandoff: currentCompactHandoff, | ||
| 2485 | PersistGoal: persistGoal, | 2545 | PersistGoal: persistGoal, | ||
| 2546 | + | PersistGoalRuntime: persistGoalRuntime, | |||
| 2486 | CurrentGoal: currentGoal, | 2547 | CurrentGoal: currentGoal, | ||
| 2487 | CurrentGoalHistory: currentGoalHistory, | 2548 | CurrentGoalHistory: currentGoalHistory, | ||
| 2488 | EnsureMission: ensureMission, | 2549 | EnsureMission: ensureMission, | ||
M packages/agent/cli_session_test.go +140 -1
| @@ -111,14 +111,133 @@ func TestToolResultPersistenceUsesBoundSession(t *testing.T) { | |||
| 111 | 111 | } | |
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | + | func TestPersistToolResultStateUsesSafeGoalPersistenceCategories(t *testing.T) { | |
| 115 | + | const privateGoal = "private goal text" | |
| 116 | + | const privateMission = "private-mission-id" | |
| 117 | + | const privateReason = "private active reason" | |
| 118 | + | const privateSessionContent = "private session content" | |
| 119 | + | var writeFailureGoal core.SessionGoal | |
| 120 | + | ||
| 121 | + | newSession := func(t *testing.T) *core.Session { | |
| 122 | + | t.Helper() | |
| 123 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |
| 124 | + | if err != nil { | |
| 125 | + | t.Fatal(err) | |
| 126 | + | } | |
| 127 | + | if err := sess.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: privateSessionContent}}}); err != nil { | |
| 128 | + | t.Fatal(err) | |
| 129 | + | } | |
| 130 | + | t.Cleanup(func() { _ = sess.Close() }) | |
| 131 | + | return sess | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | tests := []struct { | |
| 135 | + | name string | |
| 136 | + | setup func(*testing.T) *core.Session | |
| 137 | + | update tools.GoalUpdate | |
| 138 | + | message string | |
| 139 | + | assertUnchanged func(*testing.T, *core.Session) | |
| 140 | + | }{ | |
| 141 | + | { | |
| 142 | + | name: "active goal", | |
| 143 | + | setup: func(t *testing.T) *core.Session { | |
| 144 | + | sess := newSession(t) | |
| 145 | + | if err := sess.UpdateGoal(&core.SessionGoal{Objective: privateGoal, Status: core.GoalActive}); err != nil { | |
| 146 | + | t.Fatal(err) | |
| 147 | + | } | |
| 148 | + | return sess | |
| 149 | + | }, | |
| 150 | + | update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal}, | |
| 151 | + | message: "goal transition rejected: the current goal is still active", | |
| 152 | + | }, | |
| 153 | + | { | |
| 154 | + | name: "missing mission", | |
| 155 | + | setup: newSession, | |
| 156 | + | update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal, MissionID: privateMission}, | |
| 157 | + | message: "goal transition rejected: no active mission", | |
| 158 | + | }, | |
| 159 | + | { | |
| 160 | + | name: "mismatched mission", | |
| 161 | + | setup: func(t *testing.T) *core.Session { | |
| 162 | + | sess := newSession(t) | |
| 163 | + | if err := sess.EnsureMission(privateGoal, core.MissionSourceUser); err != nil { | |
| 164 | + | t.Fatal(err) | |
| 165 | + | } | |
| 166 | + | return sess | |
| 167 | + | }, | |
| 168 | + | update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal, MissionID: privateMission}, | |
| 169 | + | message: "goal transition rejected: it does not belong to the active mission", | |
| 170 | + | }, | |
| 171 | + | { | |
| 172 | + | name: "storage unavailable", | |
| 173 | + | setup: func(*testing.T) *core.Session { return nil }, | |
| 174 | + | update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal, MissionID: privateMission}, | |
| 175 | + | message: "goal state unavailable: session persistence is disabled", | |
| 176 | + | }, | |
| 177 | + | { | |
| 178 | + | name: "write failure", | |
| 179 | + | setup: func(t *testing.T) *core.Session { | |
| 180 | + | sess := newSession(t) | |
| 181 | + | if err := sess.UpdateGoal(&core.SessionGoal{Objective: privateGoal, Status: core.GoalActive, Reason: privateReason}); err != nil { | |
| 182 | + | t.Fatal(err) | |
| 183 | + | } | |
| 184 | + | writeFailureGoal = *sess.Meta.Goal | |
| 185 | + | if err := sess.Close(); err != nil { | |
| 186 | + | t.Fatal(err) | |
| 187 | + | } | |
| 188 | + | return sess | |
| 189 | + | }, | |
| 190 | + | update: tools.GoalUpdate{Status: core.GoalDone}, | |
| 191 | + | message: "goal state could not be saved", | |
| 192 | + | assertUnchanged: func(t *testing.T, sess *core.Session) { | |
| 193 | + | t.Helper() | |
| 194 | + | assertGoal := func(source string, goal *core.SessionGoal) { | |
| 195 | + | t.Helper() | |
| 196 | + | if goal == nil || goal.Status != writeFailureGoal.Status || goal.Objective != writeFailureGoal.Objective || goal.Reason != writeFailureGoal.Reason { | |
| 197 | + | t.Fatalf("%s goal after failed update = %#v, want status=%q objective=%q reason=%q", source, goal, writeFailureGoal.Status, writeFailureGoal.Objective, writeFailureGoal.Reason) | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | assertGoal("in-memory", sess.Meta.Goal) | |
| 201 | + | snapshot, err := core.ReadSessionSnapshot(sess.Path) | |
| 202 | + | if err != nil { | |
| 203 | + | t.Fatal(err) | |
| 204 | + | } | |
| 205 | + | assertGoal("persisted", snapshot.Meta.Goal) | |
| 206 | + | }, | |
| 207 | + | }, | |
| 208 | + | } | |
| 209 | + | for _, test := range tests { | |
| 210 | + | t.Run(test.name, func(t *testing.T) { | |
| 211 | + | sess := test.setup(t) | |
| 212 | + | err := persistToolResultState(nil, sess, core.ToolResult{Details: test.update}) | |
| 213 | + | var safe *core.ToolResultCommitError | |
| 214 | + | if !errors.As(err, &safe) { | |
| 215 | + | t.Fatalf("persist error = %T %v, want safe tool result commit error", err, err) | |
| 216 | + | } | |
| 217 | + | if safe.Message != test.message { | |
| 218 | + | t.Fatalf("safe message = %q, want %q", safe.Message, test.message) | |
| 219 | + | } | |
| 220 | + | if strings.Contains(safe.Message, privateGoal) || strings.Contains(safe.Message, privateMission) || strings.Contains(safe.Message, privateSessionContent) || (sess != nil && strings.Contains(safe.Message, sess.Path)) { | |
| 221 | + | t.Fatalf("safe message leaked private state: %q", safe.Message) | |
| 222 | + | } | |
| 223 | + | if test.assertUnchanged != nil { | |
| 224 | + | test.assertUnchanged(t, sess) | |
| 225 | + | } | |
| 226 | + | }) | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 114 | 230 | func TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) { | |
| 115 | 231 | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |
| 116 | 232 | if err != nil { | |
| 117 | 233 | t.Fatal(err) | |
| 118 | 234 | } | |
| 119 | 235 | defer sess.Close() | |
| 236 | + | if err := sess.EnsureMission("reproduce the reported failure", core.MissionSourceUser); err != nil { | |
| 237 | + | t.Fatal(err) | |
| 238 | + | } | |
| 120 | 239 | ||
| 121 | - | result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure"}} | |
| 240 | + | result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure", MissionID: sess.Meta.Mission.ID}} | |
| 122 | 241 | if err := persistGoalToolResult(sess, result); err != nil { | |
| 123 | 242 | t.Fatal(err) | |
| 124 | 243 | } | |
| @@ -127,6 +246,26 @@ func TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) { | |||
| 127 | 246 | } | |
| 128 | 247 | } | |
| 129 | 248 | ||
| 249 | + | func TestPersistGoalToolResultAppliesConfiguredBudget(t *testing.T) { | |
| 250 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |
| 251 | + | if err != nil { | |
| 252 | + | t.Fatal(err) | |
| 253 | + | } | |
| 254 | + | defer sess.Close() | |
| 255 | + | if err := sess.EnsureMission("reproduce the reported failure", core.MissionSourceUser); err != nil { | |
| 256 | + | t.Fatal(err) | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | budget := uint64(1_000_000) | |
| 260 | + | result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure", MissionID: sess.Meta.Mission.ID}} | |
| 261 | + | if err := persistGoalToolResult(sess, result, &budget); err != nil { | |
| 262 | + | t.Fatal(err) | |
| 263 | + | } | |
| 264 | + | if goal := sess.Meta.Goal; goal == nil || goal.TokenBudget == nil || *goal.TokenBudget != budget { | |
| 265 | + | t.Fatalf("persisted goal budget = %#v", goal) | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 130 | 269 | func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) { | |
| 131 | 270 | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |
| 132 | 271 | if err != nil { | |
| @@ -111,14 +111,133 @@ func TestToolResultPersistenceUsesBoundSession(t *testing.T) { | |||||
| 111 | } | 111 | } | ||
| 112 | } | 112 | } | ||
| 113 | 113 | ||||
| 114 | + | func TestPersistToolResultStateUsesSafeGoalPersistenceCategories(t *testing.T) { | |||
| 115 | + | const privateGoal = "private goal text" | |||
| 116 | + | const privateMission = "private-mission-id" | |||
| 117 | + | const privateReason = "private active reason" | |||
| 118 | + | const privateSessionContent = "private session content" | |||
| 119 | + | var writeFailureGoal core.SessionGoal | |||
| 120 | + | ||||
| 121 | + | newSession := func(t *testing.T) *core.Session { | |||
| 122 | + | t.Helper() | |||
| 123 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |||
| 124 | + | if err != nil { | |||
| 125 | + | t.Fatal(err) | |||
| 126 | + | } | |||
| 127 | + | if err := sess.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: privateSessionContent}}}); err != nil { | |||
| 128 | + | t.Fatal(err) | |||
| 129 | + | } | |||
| 130 | + | t.Cleanup(func() { _ = sess.Close() }) | |||
| 131 | + | return sess | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | tests := []struct { | |||
| 135 | + | name string | |||
| 136 | + | setup func(*testing.T) *core.Session | |||
| 137 | + | update tools.GoalUpdate | |||
| 138 | + | message string | |||
| 139 | + | assertUnchanged func(*testing.T, *core.Session) | |||
| 140 | + | }{ | |||
| 141 | + | { | |||
| 142 | + | name: "active goal", | |||
| 143 | + | setup: func(t *testing.T) *core.Session { | |||
| 144 | + | sess := newSession(t) | |||
| 145 | + | if err := sess.UpdateGoal(&core.SessionGoal{Objective: privateGoal, Status: core.GoalActive}); err != nil { | |||
| 146 | + | t.Fatal(err) | |||
| 147 | + | } | |||
| 148 | + | return sess | |||
| 149 | + | }, | |||
| 150 | + | update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal}, | |||
| 151 | + | message: "goal transition rejected: the current goal is still active", | |||
| 152 | + | }, | |||
| 153 | + | { | |||
| 154 | + | name: "missing mission", | |||
| 155 | + | setup: newSession, | |||
| 156 | + | update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal, MissionID: privateMission}, | |||
| 157 | + | message: "goal transition rejected: no active mission", | |||
| 158 | + | }, | |||
| 159 | + | { | |||
| 160 | + | name: "mismatched mission", | |||
| 161 | + | setup: func(t *testing.T) *core.Session { | |||
| 162 | + | sess := newSession(t) | |||
| 163 | + | if err := sess.EnsureMission(privateGoal, core.MissionSourceUser); err != nil { | |||
| 164 | + | t.Fatal(err) | |||
| 165 | + | } | |||
| 166 | + | return sess | |||
| 167 | + | }, | |||
| 168 | + | update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal, MissionID: privateMission}, | |||
| 169 | + | message: "goal transition rejected: it does not belong to the active mission", | |||
| 170 | + | }, | |||
| 171 | + | { | |||
| 172 | + | name: "storage unavailable", | |||
| 173 | + | setup: func(*testing.T) *core.Session { return nil }, | |||
| 174 | + | update: tools.GoalUpdate{Status: core.GoalActive, Objective: privateGoal, MissionID: privateMission}, | |||
| 175 | + | message: "goal state unavailable: session persistence is disabled", | |||
| 176 | + | }, | |||
| 177 | + | { | |||
| 178 | + | name: "write failure", | |||
| 179 | + | setup: func(t *testing.T) *core.Session { | |||
| 180 | + | sess := newSession(t) | |||
| 181 | + | if err := sess.UpdateGoal(&core.SessionGoal{Objective: privateGoal, Status: core.GoalActive, Reason: privateReason}); err != nil { | |||
| 182 | + | t.Fatal(err) | |||
| 183 | + | } | |||
| 184 | + | writeFailureGoal = *sess.Meta.Goal | |||
| 185 | + | if err := sess.Close(); err != nil { | |||
| 186 | + | t.Fatal(err) | |||
| 187 | + | } | |||
| 188 | + | return sess | |||
| 189 | + | }, | |||
| 190 | + | update: tools.GoalUpdate{Status: core.GoalDone}, | |||
| 191 | + | message: "goal state could not be saved", | |||
| 192 | + | assertUnchanged: func(t *testing.T, sess *core.Session) { | |||
| 193 | + | t.Helper() | |||
| 194 | + | assertGoal := func(source string, goal *core.SessionGoal) { | |||
| 195 | + | t.Helper() | |||
| 196 | + | if goal == nil || goal.Status != writeFailureGoal.Status || goal.Objective != writeFailureGoal.Objective || goal.Reason != writeFailureGoal.Reason { | |||
| 197 | + | t.Fatalf("%s goal after failed update = %#v, want status=%q objective=%q reason=%q", source, goal, writeFailureGoal.Status, writeFailureGoal.Objective, writeFailureGoal.Reason) | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | assertGoal("in-memory", sess.Meta.Goal) | |||
| 201 | + | snapshot, err := core.ReadSessionSnapshot(sess.Path) | |||
| 202 | + | if err != nil { | |||
| 203 | + | t.Fatal(err) | |||
| 204 | + | } | |||
| 205 | + | assertGoal("persisted", snapshot.Meta.Goal) | |||
| 206 | + | }, | |||
| 207 | + | }, | |||
| 208 | + | } | |||
| 209 | + | for _, test := range tests { | |||
| 210 | + | t.Run(test.name, func(t *testing.T) { | |||
| 211 | + | sess := test.setup(t) | |||
| 212 | + | err := persistToolResultState(nil, sess, core.ToolResult{Details: test.update}) | |||
| 213 | + | var safe *core.ToolResultCommitError | |||
| 214 | + | if !errors.As(err, &safe) { | |||
| 215 | + | t.Fatalf("persist error = %T %v, want safe tool result commit error", err, err) | |||
| 216 | + | } | |||
| 217 | + | if safe.Message != test.message { | |||
| 218 | + | t.Fatalf("safe message = %q, want %q", safe.Message, test.message) | |||
| 219 | + | } | |||
| 220 | + | if strings.Contains(safe.Message, privateGoal) || strings.Contains(safe.Message, privateMission) || strings.Contains(safe.Message, privateSessionContent) || (sess != nil && strings.Contains(safe.Message, sess.Path)) { | |||
| 221 | + | t.Fatalf("safe message leaked private state: %q", safe.Message) | |||
| 222 | + | } | |||
| 223 | + | if test.assertUnchanged != nil { | |||
| 224 | + | test.assertUnchanged(t, sess) | |||
| 225 | + | } | |||
| 226 | + | }) | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | ||||
| 114 | func TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) { | 230 | func TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) { | ||
| 115 | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | 231 | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | ||
| 116 | if err != nil { | 232 | if err != nil { | ||
| 117 | t.Fatal(err) | 233 | t.Fatal(err) | ||
| 118 | } | 234 | } | ||
| 119 | defer sess.Close() | 235 | defer sess.Close() | ||
| 236 | + | if err := sess.EnsureMission("reproduce the reported failure", core.MissionSourceUser); err != nil { | |||
| 237 | + | t.Fatal(err) | |||
| 238 | + | } | |||
| 120 | 239 | ||||
| 121 | - | result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure"}} | 240 | + | result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure", MissionID: sess.Meta.Mission.ID}} |
| 122 | if err := persistGoalToolResult(sess, result); err != nil { | 241 | if err := persistGoalToolResult(sess, result); err != nil { | ||
| 123 | t.Fatal(err) | 242 | t.Fatal(err) | ||
| 124 | } | 243 | } | ||
| @@ -127,6 +246,26 @@ func TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) { | |||||
| 127 | } | 246 | } | ||
| 128 | } | 247 | } | ||
| 129 | 248 | ||||
| 249 | + | func TestPersistGoalToolResultAppliesConfiguredBudget(t *testing.T) { | |||
| 250 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |||
| 251 | + | if err != nil { | |||
| 252 | + | t.Fatal(err) | |||
| 253 | + | } | |||
| 254 | + | defer sess.Close() | |||
| 255 | + | if err := sess.EnsureMission("reproduce the reported failure", core.MissionSourceUser); err != nil { | |||
| 256 | + | t.Fatal(err) | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | budget := uint64(1_000_000) | |||
| 260 | + | result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure", MissionID: sess.Meta.Mission.ID}} | |||
| 261 | + | if err := persistGoalToolResult(sess, result, &budget); err != nil { | |||
| 262 | + | t.Fatal(err) | |||
| 263 | + | } | |||
| 264 | + | if goal := sess.Meta.Goal; goal == nil || goal.TokenBudget == nil || *goal.TokenBudget != budget { | |||
| 265 | + | t.Fatalf("persisted goal budget = %#v", goal) | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | ||||
| 130 | func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) { | 269 | func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) { | ||
| 131 | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | 270 | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | ||
| 132 | if err != nil { | 271 | if err != nil { | ||
M packages/agent/config.go +11 -0
| @@ -33,6 +33,13 @@ type SubagentsConfig struct { | |||
| 33 | 33 | AllowedRoots []string `json:"allowed_roots,omitempty"` | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | + | // GoalsConfig controls optional resource limits for autonomous goals. A nil | |
| 37 | + | // MaxTokenBudget leaves goals unlimited; when set, it is the host-configured | |
| 38 | + | // cap applied to newly created goals. | |
| 39 | + | type GoalsConfig struct { | |
| 40 | + | MaxTokenBudget *uint64 `json:"max_token_budget,omitempty"` | |
| 41 | + | } | |
| 42 | + | ||
| 36 | 43 | // Config is the persisted user configuration. | |
| 37 | 44 | type Config struct { | |
| 38 | 45 | Provider string `json:"provider"` | |
| @@ -82,6 +89,10 @@ type Config struct { | |||
| 82 | 89 | // Subagents controls manager-owned child lifecycle and resource policy. | |
| 83 | 90 | Subagents SubagentsConfig `json:"subagents,omitempty"` | |
| 84 | 91 | ||
| 92 | + | // Goals configures optional resource limits for autonomous goals. Missing | |
| 93 | + | // configuration preserves unlimited autonomous execution. | |
| 94 | + | Goals GoalsConfig `json:"goals,omitempty"` | |
| 95 | + | ||
| 85 | 96 | // AutoSubagentsEnabled lets the main agent spawn background sub-agents | |
| 86 | 97 | // and query their live state via built-in auto-subagent tools. Off by | |
| 87 | 98 | // default; nil/missing means disabled. Toggle from /settings. | |
| @@ -33,6 +33,13 @@ type SubagentsConfig struct { | |||||
| 33 | AllowedRoots []string `json:"allowed_roots,omitempty"` | 33 | AllowedRoots []string `json:"allowed_roots,omitempty"` | ||
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | + | // GoalsConfig controls optional resource limits for autonomous goals. A nil | |||
| 37 | + | // MaxTokenBudget leaves goals unlimited; when set, it is the host-configured | |||
| 38 | + | // cap applied to newly created goals. | |||
| 39 | + | type GoalsConfig struct { | |||
| 40 | + | MaxTokenBudget *uint64 `json:"max_token_budget,omitempty"` | |||
| 41 | + | } | |||
| 42 | + | ||||
| 36 | // Config is the persisted user configuration. | 43 | // Config is the persisted user configuration. | ||
| 37 | type Config struct { | 44 | type Config struct { | ||
| 38 | Provider string `json:"provider"` | 45 | Provider string `json:"provider"` | ||
| @@ -82,6 +89,10 @@ type Config struct { | |||||
| 82 | // Subagents controls manager-owned child lifecycle and resource policy. | 89 | // Subagents controls manager-owned child lifecycle and resource policy. | ||
| 83 | Subagents SubagentsConfig `json:"subagents,omitempty"` | 90 | Subagents SubagentsConfig `json:"subagents,omitempty"` | ||
| 84 | 91 | ||||
| 92 | + | // Goals configures optional resource limits for autonomous goals. Missing | |||
| 93 | + | // configuration preserves unlimited autonomous execution. | |||
| 94 | + | Goals GoalsConfig `json:"goals,omitempty"` | |||
| 95 | + | ||||
| 85 | // AutoSubagentsEnabled lets the main agent spawn background sub-agents | 96 | // AutoSubagentsEnabled lets the main agent spawn background sub-agents | ||
| 86 | // and query their live state via built-in auto-subagent tools. Off by | 97 | // and query their live state via built-in auto-subagent tools. Off by | ||
| 87 | // default; nil/missing means disabled. Toggle from /settings. | 98 | // default; nil/missing means disabled. Toggle from /settings. | ||
A packages/agent/config_goal_test.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestGoalsConfigTokenBudgetIsOptional(t *testing.T) { | |
| 9 | + | var unlimited Config | |
| 10 | + | if err := json.Unmarshal([]byte(`{}`), &unlimited); err != nil { | |
| 11 | + | t.Fatal(err) | |
| 12 | + | } | |
| 13 | + | if unlimited.Goals.MaxTokenBudget != nil { | |
| 14 | + | t.Fatalf("missing budget = %v, want nil", *unlimited.Goals.MaxTokenBudget) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | var limited Config | |
| 18 | + | if err := json.Unmarshal([]byte(`{"goals":{"max_token_budget":1000000}}`), &limited); err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | if limited.Goals.MaxTokenBudget == nil || *limited.Goals.MaxTokenBudget != 1_000_000 { | |
| 22 | + | t.Fatalf("configured budget = %#v", limited.Goals.MaxTokenBudget) | |
| 23 | + | } | |
| 24 | + | } | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestGoalsConfigTokenBudgetIsOptional(t *testing.T) { | |||
| 9 | + | var unlimited Config | |||
| 10 | + | if err := json.Unmarshal([]byte(`{}`), &unlimited); err != nil { | |||
| 11 | + | t.Fatal(err) | |||
| 12 | + | } | |||
| 13 | + | if unlimited.Goals.MaxTokenBudget != nil { | |||
| 14 | + | t.Fatalf("missing budget = %v, want nil", *unlimited.Goals.MaxTokenBudget) | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | var limited Config | |||
| 18 | + | if err := json.Unmarshal([]byte(`{"goals":{"max_token_budget":1000000}}`), &limited); err != nil { | |||
| 19 | + | t.Fatal(err) | |||
| 20 | + | } | |||
| 21 | + | if limited.Goals.MaxTokenBudget == nil || *limited.Goals.MaxTokenBudget != 1_000_000 { | |||
| 22 | + | t.Fatalf("configured budget = %#v", limited.Goals.MaxTokenBudget) | |||
| 23 | + | } | |||
| 24 | + | } | |||
M packages/agent/modes/goal.go +40 -3
| @@ -31,6 +31,9 @@ func (i *Interactive) goalContinuationMessage() (provider.Message, bool) { | |||
| 31 | 31 | } | |
| 32 | 32 | text := goal.Objective + | |
| 33 | 33 | "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal." | |
| 34 | + | if goal.ConsecutiveNoProgressTurns > 0 { | |
| 35 | + | text += " You ended the prior continuation without taking a concrete action. Inspect the current state and take the next action now; do not merely say that you will continue." | |
| 36 | + | } | |
| 34 | 37 | if goal.MissionID != "" { | |
| 35 | 38 | text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission." | |
| 36 | 39 | } | |
| @@ -47,7 +50,11 @@ func (i *Interactive) goalIdleLocked() bool { | |||
| 47 | 50 | ||
| 48 | 51 | func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool { | |
| 49 | 52 | message, ok := i.goalContinuationMessage() | |
| 50 | - | if !ok || i.coordinatorHasPendingWorkers() { | |
| 53 | + | if !ok || i.coordinatorHasPendingWorkers() || i.cfg.CurrentGoal == nil { | |
| 54 | + | return false | |
| 55 | + | } | |
| 56 | + | goal := copySessionGoal(i.cfg.CurrentGoal()) | |
| 57 | + | if goal == nil || i.limitGoalBeforeRun(goal) { | |
| 51 | 58 | return false | |
| 52 | 59 | } | |
| 53 | 60 | i.mu.Lock() | |
| @@ -57,11 +64,25 @@ func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool | |||
| 57 | 64 | } | |
| 58 | 65 | i.busy = true | |
| 59 | 66 | i.mu.Unlock() | |
| 60 | - | i.startGoalContinuation(parent, message) | |
| 67 | + | run, err := i.startGoalRun(goal) | |
| 68 | + | if err != nil { | |
| 69 | + | i.mu.Lock() | |
| 70 | + | i.busy = false | |
| 71 | + | i.mu.Unlock() | |
| 72 | + | i.ReportError(err) | |
| 73 | + | return false | |
| 74 | + | } | |
| 75 | + | if run == nil { | |
| 76 | + | i.mu.Lock() | |
| 77 | + | i.busy = false | |
| 78 | + | i.mu.Unlock() | |
| 79 | + | return false | |
| 80 | + | } | |
| 81 | + | i.startGoalContinuation(parent, message, run) | |
| 61 | 82 | return true | |
| 62 | 83 | } | |
| 63 | 84 | ||
| 64 | - | func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message) { | |
| 85 | + | func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message, run *goalContinuationRun) { | |
| 65 | 86 | goalMessage, goalActive := i.goalContinuationMessage() | |
| 66 | 87 | i.mu.Lock() | |
| 67 | 88 | ag := i.agent | |
| @@ -70,6 +91,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov | |||
| 70 | 91 | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| 71 | 92 | i.mu.Unlock() | |
| 72 | 93 | runPendingIdleWork(pendingIdleWork) | |
| 94 | + | i.abandonGoalRun(run) | |
| 73 | 95 | i.invalidate() | |
| 74 | 96 | return | |
| 75 | 97 | } | |
| @@ -77,11 +99,13 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov | |||
| 77 | 99 | next := i.queued[0] | |
| 78 | 100 | i.queued = i.queued[1:] | |
| 79 | 101 | i.mu.Unlock() | |
| 102 | + | i.abandonGoalRun(run) | |
| 80 | 103 | i.startTurn(parent, next) | |
| 81 | 104 | return | |
| 82 | 105 | } | |
| 83 | 106 | if ag.QueuedMessageCount() > 0 { | |
| 84 | 107 | i.mu.Unlock() | |
| 108 | + | i.abandonGoalRun(run) | |
| 85 | 109 | i.startTurnRequest(parent, "", nil, true, false) | |
| 86 | 110 | return | |
| 87 | 111 | } | |
| @@ -93,6 +117,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov | |||
| 93 | 117 | // The objective may have been replaced while the prior turn was | |
| 94 | 118 | // finishing. Re-evaluate it now instead of waiting for another event; | |
| 95 | 119 | // the idle gate still gives queued user work priority. | |
| 120 | + | i.abandonGoalRun(run) | |
| 96 | 121 | i.requestGoalContinuationIfIdle(parent) | |
| 97 | 122 | i.invalidate() | |
| 98 | 123 | return | |
| @@ -108,6 +133,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov | |||
| 108 | 133 | next := i.queued[0] | |
| 109 | 134 | i.queued = i.queued[1:] | |
| 110 | 135 | i.mu.Unlock() | |
| 136 | + | i.abandonGoalRun(run) | |
| 111 | 137 | i.startTurn(parent, next) | |
| 112 | 138 | return | |
| 113 | 139 | } | |
| @@ -182,6 +208,11 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st | |||
| 182 | 208 | owner = core.GoalOwnerUser | |
| 183 | 209 | } | |
| 184 | 210 | status := fmt.Sprintf("goal %s (%s): %s", current.Status, owner, current.Objective) | |
| 211 | + | if current.TokenBudget != nil { | |
| 212 | + | status += fmt.Sprintf(" (%d / %d tokens)", current.TokensUsed, *current.TokenBudget) | |
| 213 | + | } else if current.TokensUsed > 0 { | |
| 214 | + | status += fmt.Sprintf(" (%d tokens)", current.TokensUsed) | |
| 215 | + | } | |
| 185 | 216 | if current.Reason != "" { | |
| 186 | 217 | status += " (" + current.Reason + ")" | |
| 187 | 218 | } | |
| @@ -226,6 +257,12 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st | |||
| 226 | 257 | return | |
| 227 | 258 | } | |
| 228 | 259 | current = &core.SessionGoal{Objective: objective, Status: core.GoalActive, Owner: core.GoalOwnerUser} | |
| 260 | + | if i.cfg.GoalMaxTokenBudget != nil { | |
| 261 | + | budget := *i.cfg.GoalMaxTokenBudget | |
| 262 | + | if budget > 0 { | |
| 263 | + | current.TokenBudget = &budget | |
| 264 | + | } | |
| 265 | + | } | |
| 229 | 266 | startIfIdle = true | |
| 230 | 267 | startTitle = true | |
| 231 | 268 | } | |
| @@ -31,6 +31,9 @@ func (i *Interactive) goalContinuationMessage() (provider.Message, bool) { | |||||
| 31 | } | 31 | } | ||
| 32 | text := goal.Objective + | 32 | text := goal.Objective + | ||
| 33 | "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal." | 33 | "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal." | ||
| 34 | + | if goal.ConsecutiveNoProgressTurns > 0 { | |||
| 35 | + | text += " You ended the prior continuation without taking a concrete action. Inspect the current state and take the next action now; do not merely say that you will continue." | |||
| 36 | + | } | |||
| 34 | if goal.MissionID != "" { | 37 | if goal.MissionID != "" { | ||
| 35 | text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission." | 38 | text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission." | ||
| 36 | } | 39 | } | ||
| @@ -47,7 +50,11 @@ func (i *Interactive) goalIdleLocked() bool { | |||||
| 47 | 50 | ||||
| 48 | func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool { | 51 | func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool { | ||
| 49 | message, ok := i.goalContinuationMessage() | 52 | message, ok := i.goalContinuationMessage() | ||
| 50 | - | if !ok || i.coordinatorHasPendingWorkers() { | 53 | + | if !ok || i.coordinatorHasPendingWorkers() || i.cfg.CurrentGoal == nil { |
| 54 | + | return false | |||
| 55 | + | } | |||
| 56 | + | goal := copySessionGoal(i.cfg.CurrentGoal()) | |||
| 57 | + | if goal == nil || i.limitGoalBeforeRun(goal) { | |||
| 51 | return false | 58 | return false | ||
| 52 | } | 59 | } | ||
| 53 | i.mu.Lock() | 60 | i.mu.Lock() | ||
| @@ -57,11 +64,25 @@ func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool | |||||
| 57 | } | 64 | } | ||
| 58 | i.busy = true | 65 | i.busy = true | ||
| 59 | i.mu.Unlock() | 66 | i.mu.Unlock() | ||
| 60 | - | i.startGoalContinuation(parent, message) | 67 | + | run, err := i.startGoalRun(goal) |
| 68 | + | if err != nil { | |||
| 69 | + | i.mu.Lock() | |||
| 70 | + | i.busy = false | |||
| 71 | + | i.mu.Unlock() | |||
| 72 | + | i.ReportError(err) | |||
| 73 | + | return false | |||
| 74 | + | } | |||
| 75 | + | if run == nil { | |||
| 76 | + | i.mu.Lock() | |||
| 77 | + | i.busy = false | |||
| 78 | + | i.mu.Unlock() | |||
| 79 | + | return false | |||
| 80 | + | } | |||
| 81 | + | i.startGoalContinuation(parent, message, run) | |||
| 61 | return true | 82 | return true | ||
| 62 | } | 83 | } | ||
| 63 | 84 | ||||
| 64 | - | func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message) { | 85 | + | func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message, run *goalContinuationRun) { |
| 65 | goalMessage, goalActive := i.goalContinuationMessage() | 86 | goalMessage, goalActive := i.goalContinuationMessage() | ||
| 66 | i.mu.Lock() | 87 | i.mu.Lock() | ||
| 67 | ag := i.agent | 88 | ag := i.agent | ||
| @@ -70,6 +91,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov | |||||
| 70 | pendingIdleWork := i.takePendingIdleWorkLocked() | 91 | pendingIdleWork := i.takePendingIdleWorkLocked() | ||
| 71 | i.mu.Unlock() | 92 | i.mu.Unlock() | ||
| 72 | runPendingIdleWork(pendingIdleWork) | 93 | runPendingIdleWork(pendingIdleWork) | ||
| 94 | + | i.abandonGoalRun(run) | |||
| 73 | i.invalidate() | 95 | i.invalidate() | ||
| 74 | return | 96 | return | ||
| 75 | } | 97 | } | ||
| @@ -77,11 +99,13 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov | |||||
| 77 | next := i.queued[0] | 99 | next := i.queued[0] | ||
| 78 | i.queued = i.queued[1:] | 100 | i.queued = i.queued[1:] | ||
| 79 | i.mu.Unlock() | 101 | i.mu.Unlock() | ||
| 102 | + | i.abandonGoalRun(run) | |||
| 80 | i.startTurn(parent, next) | 103 | i.startTurn(parent, next) | ||
| 81 | return | 104 | return | ||
| 82 | } | 105 | } | ||
| 83 | if ag.QueuedMessageCount() > 0 { | 106 | if ag.QueuedMessageCount() > 0 { | ||
| 84 | i.mu.Unlock() | 107 | i.mu.Unlock() | ||
| 108 | + | i.abandonGoalRun(run) | |||
| 85 | i.startTurnRequest(parent, "", nil, true, false) | 109 | i.startTurnRequest(parent, "", nil, true, false) | ||
| 86 | return | 110 | return | ||
| 87 | } | 111 | } | ||
| @@ -93,6 +117,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov | |||||
| 93 | // The objective may have been replaced while the prior turn was | 117 | // The objective may have been replaced while the prior turn was | ||
| 94 | // finishing. Re-evaluate it now instead of waiting for another event; | 118 | // finishing. Re-evaluate it now instead of waiting for another event; | ||
| 95 | // the idle gate still gives queued user work priority. | 119 | // the idle gate still gives queued user work priority. | ||
| 120 | + | i.abandonGoalRun(run) | |||
| 96 | i.requestGoalContinuationIfIdle(parent) | 121 | i.requestGoalContinuationIfIdle(parent) | ||
| 97 | i.invalidate() | 122 | i.invalidate() | ||
| 98 | return | 123 | return | ||
| @@ -108,6 +133,7 @@ func (i *Interactive) startGoalContinuation(parent context.Context, message prov | |||||
| 108 | next := i.queued[0] | 133 | next := i.queued[0] | ||
| 109 | i.queued = i.queued[1:] | 134 | i.queued = i.queued[1:] | ||
| 110 | i.mu.Unlock() | 135 | i.mu.Unlock() | ||
| 136 | + | i.abandonGoalRun(run) | |||
| 111 | i.startTurn(parent, next) | 137 | i.startTurn(parent, next) | ||
| 112 | return | 138 | return | ||
| 113 | } | 139 | } | ||
| @@ -182,6 +208,11 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st | |||||
| 182 | owner = core.GoalOwnerUser | 208 | owner = core.GoalOwnerUser | ||
| 183 | } | 209 | } | ||
| 184 | status := fmt.Sprintf("goal %s (%s): %s", current.Status, owner, current.Objective) | 210 | status := fmt.Sprintf("goal %s (%s): %s", current.Status, owner, current.Objective) | ||
| 211 | + | if current.TokenBudget != nil { | |||
| 212 | + | status += fmt.Sprintf(" (%d / %d tokens)", current.TokensUsed, *current.TokenBudget) | |||
| 213 | + | } else if current.TokensUsed > 0 { | |||
| 214 | + | status += fmt.Sprintf(" (%d tokens)", current.TokensUsed) | |||
| 215 | + | } | |||
| 185 | if current.Reason != "" { | 216 | if current.Reason != "" { | ||
| 186 | status += " (" + current.Reason + ")" | 217 | status += " (" + current.Reason + ")" | ||
| 187 | } | 218 | } | ||
| @@ -226,6 +257,12 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st | |||||
| 226 | return | 257 | return | ||
| 227 | } | 258 | } | ||
| 228 | current = &core.SessionGoal{Objective: objective, Status: core.GoalActive, Owner: core.GoalOwnerUser} | 259 | current = &core.SessionGoal{Objective: objective, Status: core.GoalActive, Owner: core.GoalOwnerUser} | ||
| 260 | + | if i.cfg.GoalMaxTokenBudget != nil { | |||
| 261 | + | budget := *i.cfg.GoalMaxTokenBudget | |||
| 262 | + | if budget > 0 { | |||
| 263 | + | current.TokenBudget = &budget | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 229 | startIfIdle = true | 266 | startIfIdle = true | ||
| 230 | startTitle = true | 267 | startTitle = true | ||
| 231 | } | 268 | } | ||
A packages/agent/modes/goal_runtime.go +210 -0
| @@ -0,0 +1,210 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | agenttools "github.com/bnema/zut/packages/agent/tools" | |
| 8 | + | "github.com/bnema/zut/packages/core" | |
| 9 | + | "github.com/bnema/zut/packages/provider" | |
| 10 | + | "github.com/google/uuid" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // A second empty continuation is enough to distinguish a model that briefly | |
| 14 | + | // paused to summarize from one that is only acknowledging the continuation | |
| 15 | + | // prompt. This is a progress invariant, not a resource limit: productive | |
| 16 | + | // autonomous goals remain unbounded unless the user configures a budget. | |
| 17 | + | const maxConsecutiveGoalNoProgressTurns = 2 | |
| 18 | + | ||
| 19 | + | type goalContinuationRun struct { | |
| 20 | + | id string | |
| 21 | + | goalID string | |
| 22 | + | tokens uint64 | |
| 23 | + | hadTool bool | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func goalTokenCount(usage provider.Usage) uint64 { | |
| 27 | + | // OutputTokens already includes provider-reported reasoning tokens. Cache | |
| 28 | + | // usage is separately billable and therefore included in the goal total. | |
| 29 | + | counts := []int{usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens} | |
| 30 | + | var total uint64 | |
| 31 | + | for _, count := range counts { | |
| 32 | + | if count > 0 { | |
| 33 | + | total += uint64(count) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | return total | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (i *Interactive) limitGoalBeforeRun(goal *core.SessionGoal) bool { | |
| 40 | + | if goal == nil || goal.Status != core.GoalActive || goal.TokenBudget == nil || goal.TokensUsed < *goal.TokenBudget { | |
| 41 | + | return false | |
| 42 | + | } | |
| 43 | + | goal = copySessionGoal(goal) | |
| 44 | + | goal.Status = core.GoalBudgetLimited | |
| 45 | + | goal.Reason = "configured token budget exhausted" | |
| 46 | + | goal.ContinuationID = "" | |
| 47 | + | if i.cfg.PersistGoal == nil { | |
| 48 | + | i.ReportError(fmt.Errorf("goal persistence is unavailable")) | |
| 49 | + | return true | |
| 50 | + | } | |
| 51 | + | if err := i.cfg.PersistGoal(goal); err != nil { | |
| 52 | + | i.ReportError(fmt.Errorf("persist goal budget limit: %w", err)) | |
| 53 | + | return true | |
| 54 | + | } | |
| 55 | + | i.setGoalStatus(goal) | |
| 56 | + | return true | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (i *Interactive) startGoalRun(goal *core.SessionGoal) (*goalContinuationRun, error) { | |
| 60 | + | if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" { | |
| 61 | + | return nil, nil | |
| 62 | + | } | |
| 63 | + | i.mu.Lock() | |
| 64 | + | if i.goalRun != nil { | |
| 65 | + | i.mu.Unlock() | |
| 66 | + | return nil, nil | |
| 67 | + | } | |
| 68 | + | run := &goalContinuationRun{id: uuid.NewString(), goalID: goal.ID} | |
| 69 | + | i.goalRun = run | |
| 70 | + | i.mu.Unlock() | |
| 71 | + | ||
| 72 | + | goal = copySessionGoal(goal) | |
| 73 | + | goal.ContinuationID = run.id | |
| 74 | + | if i.cfg.PersistGoalRuntime == nil { | |
| 75 | + | return run, nil | |
| 76 | + | } | |
| 77 | + | if err := i.cfg.PersistGoalRuntime(goal); err != nil { | |
| 78 | + | i.clearGoalRun(run.id) | |
| 79 | + | return nil, fmt.Errorf("persist goal continuation: %w", err) | |
| 80 | + | } | |
| 81 | + | return run, nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (i *Interactive) clearGoalRun(runID string) { | |
| 85 | + | i.mu.Lock() | |
| 86 | + | if i.goalRun != nil && i.goalRun.id == runID { | |
| 87 | + | i.goalRun = nil | |
| 88 | + | } | |
| 89 | + | i.mu.Unlock() | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (i *Interactive) abandonGoalRun(run *goalContinuationRun) { | |
| 93 | + | if run == nil { | |
| 94 | + | return | |
| 95 | + | } | |
| 96 | + | i.clearGoalRun(run.id) | |
| 97 | + | if i.cfg.CurrentGoal == nil || i.cfg.PersistGoalRuntime == nil { | |
| 98 | + | return | |
| 99 | + | } | |
| 100 | + | goal := copySessionGoal(i.cfg.CurrentGoal()) | |
| 101 | + | if goal == nil || goal.ID != run.goalID || goal.ContinuationID != run.id { | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | goal.ContinuationID = "" | |
| 105 | + | if err := i.cfg.PersistGoalRuntime(goal); err != nil { | |
| 106 | + | i.ReportError(fmt.Errorf("clear goal continuation: %w", err)) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (i *Interactive) observeGoalRun(ev core.AgentEvent) { | |
| 111 | + | i.mu.Lock() | |
| 112 | + | defer i.mu.Unlock() | |
| 113 | + | if i.goalRun == nil { | |
| 114 | + | return | |
| 115 | + | } | |
| 116 | + | switch e := ev.(type) { | |
| 117 | + | case core.EvUsage: | |
| 118 | + | i.goalRun.tokens += goalTokenCount(e.Usage) | |
| 119 | + | case core.EvToolCall: | |
| 120 | + | if e.Name != agenttools.UpdateGoalToolName { | |
| 121 | + | i.goalRun.hadTool = true | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // finishGoalRun records accounting exactly once and returns whether the | |
| 127 | + | // controller should attempt another autonomous continuation. A stale run is | |
| 128 | + | // discarded without changing a newer goal. | |
| 129 | + | func (i *Interactive) finishGoalRun() bool { | |
| 130 | + | i.mu.Lock() | |
| 131 | + | run := i.goalRun | |
| 132 | + | i.goalRun = nil | |
| 133 | + | i.mu.Unlock() | |
| 134 | + | if run == nil || i.cfg.CurrentGoal == nil { | |
| 135 | + | return false | |
| 136 | + | } | |
| 137 | + | goal := copySessionGoal(i.cfg.CurrentGoal()) | |
| 138 | + | if goal == nil || goal.ID != run.goalID { | |
| 139 | + | return false | |
| 140 | + | } | |
| 141 | + | goal.ContinuationID = "" | |
| 142 | + | goal.TokensUsed += run.tokens | |
| 143 | + | ||
| 144 | + | persistRuntime := func() bool { | |
| 145 | + | if i.cfg.PersistGoalRuntime == nil { | |
| 146 | + | return true | |
| 147 | + | } | |
| 148 | + | if err := i.cfg.PersistGoalRuntime(goal); err != nil { | |
| 149 | + | i.ReportError(fmt.Errorf("persist goal runtime: %w", err)) | |
| 150 | + | return false | |
| 151 | + | } | |
| 152 | + | i.setGoalStatus(goal) | |
| 153 | + | return true | |
| 154 | + | } | |
| 155 | + | if goal.Status != core.GoalActive { | |
| 156 | + | persistRuntime() | |
| 157 | + | return false | |
| 158 | + | } | |
| 159 | + | if goal.TokenBudget != nil && goal.TokensUsed >= *goal.TokenBudget { | |
| 160 | + | goal.Status = core.GoalBudgetLimited | |
| 161 | + | goal.Reason = "configured token budget exhausted" | |
| 162 | + | if i.cfg.PersistGoal == nil { | |
| 163 | + | i.ReportError(fmt.Errorf("goal persistence is unavailable")) | |
| 164 | + | return false | |
| 165 | + | } | |
| 166 | + | if err := i.cfg.PersistGoal(goal); err != nil { | |
| 167 | + | i.ReportError(fmt.Errorf("persist goal budget limit: %w", err)) | |
| 168 | + | return false | |
| 169 | + | } | |
| 170 | + | i.setGoalStatus(goal) | |
| 171 | + | return false | |
| 172 | + | } | |
| 173 | + | if run.hadTool { | |
| 174 | + | goal.ConsecutiveNoProgressTurns = 0 | |
| 175 | + | } else { | |
| 176 | + | goal.ConsecutiveNoProgressTurns++ | |
| 177 | + | if goal.ConsecutiveNoProgressTurns >= maxConsecutiveGoalNoProgressTurns { | |
| 178 | + | goal.Status = core.GoalStalled | |
| 179 | + | goal.Reason = "agent ended consecutive continuations without a concrete action" | |
| 180 | + | if i.cfg.PersistGoal == nil { | |
| 181 | + | i.ReportError(fmt.Errorf("goal persistence is unavailable")) | |
| 182 | + | return false | |
| 183 | + | } | |
| 184 | + | if err := i.cfg.PersistGoal(goal); err != nil { | |
| 185 | + | i.ReportError(fmt.Errorf("persist stalled goal: %w", err)) | |
| 186 | + | return false | |
| 187 | + | } | |
| 188 | + | i.setGoalStatus(goal) | |
| 189 | + | return false | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | return persistRuntime() | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // recoverGoalRun clears a lease left by a previous process. The new process | |
| 196 | + | // has no provider turn associated with it, so retaining that lease would make | |
| 197 | + | // the active goal permanently unschedulable. | |
| 198 | + | func (i *Interactive) recoverGoalRun() { | |
| 199 | + | if i.cfg.CurrentGoal == nil || i.cfg.PersistGoalRuntime == nil { | |
| 200 | + | return | |
| 201 | + | } | |
| 202 | + | goal := copySessionGoal(i.cfg.CurrentGoal()) | |
| 203 | + | if goal == nil || goal.Status != core.GoalActive || goal.ContinuationID == "" { | |
| 204 | + | return | |
| 205 | + | } | |
| 206 | + | goal.ContinuationID = "" | |
| 207 | + | if err := i.cfg.PersistGoalRuntime(goal); err != nil { | |
| 208 | + | i.ReportError(fmt.Errorf("recover goal continuation: %w", err)) | |
| 209 | + | } | |
| 210 | + | } | |
| @@ -0,0 +1,210 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | agenttools "github.com/bnema/zut/packages/agent/tools" | |||
| 8 | + | "github.com/bnema/zut/packages/core" | |||
| 9 | + | "github.com/bnema/zut/packages/provider" | |||
| 10 | + | "github.com/google/uuid" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // A second empty continuation is enough to distinguish a model that briefly | |||
| 14 | + | // paused to summarize from one that is only acknowledging the continuation | |||
| 15 | + | // prompt. This is a progress invariant, not a resource limit: productive | |||
| 16 | + | // autonomous goals remain unbounded unless the user configures a budget. | |||
| 17 | + | const maxConsecutiveGoalNoProgressTurns = 2 | |||
| 18 | + | ||||
| 19 | + | type goalContinuationRun struct { | |||
| 20 | + | id string | |||
| 21 | + | goalID string | |||
| 22 | + | tokens uint64 | |||
| 23 | + | hadTool bool | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func goalTokenCount(usage provider.Usage) uint64 { | |||
| 27 | + | // OutputTokens already includes provider-reported reasoning tokens. Cache | |||
| 28 | + | // usage is separately billable and therefore included in the goal total. | |||
| 29 | + | counts := []int{usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens} | |||
| 30 | + | var total uint64 | |||
| 31 | + | for _, count := range counts { | |||
| 32 | + | if count > 0 { | |||
| 33 | + | total += uint64(count) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | return total | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (i *Interactive) limitGoalBeforeRun(goal *core.SessionGoal) bool { | |||
| 40 | + | if goal == nil || goal.Status != core.GoalActive || goal.TokenBudget == nil || goal.TokensUsed < *goal.TokenBudget { | |||
| 41 | + | return false | |||
| 42 | + | } | |||
| 43 | + | goal = copySessionGoal(goal) | |||
| 44 | + | goal.Status = core.GoalBudgetLimited | |||
| 45 | + | goal.Reason = "configured token budget exhausted" | |||
| 46 | + | goal.ContinuationID = "" | |||
| 47 | + | if i.cfg.PersistGoal == nil { | |||
| 48 | + | i.ReportError(fmt.Errorf("goal persistence is unavailable")) | |||
| 49 | + | return true | |||
| 50 | + | } | |||
| 51 | + | if err := i.cfg.PersistGoal(goal); err != nil { | |||
| 52 | + | i.ReportError(fmt.Errorf("persist goal budget limit: %w", err)) | |||
| 53 | + | return true | |||
| 54 | + | } | |||
| 55 | + | i.setGoalStatus(goal) | |||
| 56 | + | return true | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (i *Interactive) startGoalRun(goal *core.SessionGoal) (*goalContinuationRun, error) { | |||
| 60 | + | if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" { | |||
| 61 | + | return nil, nil | |||
| 62 | + | } | |||
| 63 | + | i.mu.Lock() | |||
| 64 | + | if i.goalRun != nil { | |||
| 65 | + | i.mu.Unlock() | |||
| 66 | + | return nil, nil | |||
| 67 | + | } | |||
| 68 | + | run := &goalContinuationRun{id: uuid.NewString(), goalID: goal.ID} | |||
| 69 | + | i.goalRun = run | |||
| 70 | + | i.mu.Unlock() | |||
| 71 | + | ||||
| 72 | + | goal = copySessionGoal(goal) | |||
| 73 | + | goal.ContinuationID = run.id | |||
| 74 | + | if i.cfg.PersistGoalRuntime == nil { | |||
| 75 | + | return run, nil | |||
| 76 | + | } | |||
| 77 | + | if err := i.cfg.PersistGoalRuntime(goal); err != nil { | |||
| 78 | + | i.clearGoalRun(run.id) | |||
| 79 | + | return nil, fmt.Errorf("persist goal continuation: %w", err) | |||
| 80 | + | } | |||
| 81 | + | return run, nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func (i *Interactive) clearGoalRun(runID string) { | |||
| 85 | + | i.mu.Lock() | |||
| 86 | + | if i.goalRun != nil && i.goalRun.id == runID { | |||
| 87 | + | i.goalRun = nil | |||
| 88 | + | } | |||
| 89 | + | i.mu.Unlock() | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (i *Interactive) abandonGoalRun(run *goalContinuationRun) { | |||
| 93 | + | if run == nil { | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 96 | + | i.clearGoalRun(run.id) | |||
| 97 | + | if i.cfg.CurrentGoal == nil || i.cfg.PersistGoalRuntime == nil { | |||
| 98 | + | return | |||
| 99 | + | } | |||
| 100 | + | goal := copySessionGoal(i.cfg.CurrentGoal()) | |||
| 101 | + | if goal == nil || goal.ID != run.goalID || goal.ContinuationID != run.id { | |||
| 102 | + | return | |||
| 103 | + | } | |||
| 104 | + | goal.ContinuationID = "" | |||
| 105 | + | if err := i.cfg.PersistGoalRuntime(goal); err != nil { | |||
| 106 | + | i.ReportError(fmt.Errorf("clear goal continuation: %w", err)) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func (i *Interactive) observeGoalRun(ev core.AgentEvent) { | |||
| 111 | + | i.mu.Lock() | |||
| 112 | + | defer i.mu.Unlock() | |||
| 113 | + | if i.goalRun == nil { | |||
| 114 | + | return | |||
| 115 | + | } | |||
| 116 | + | switch e := ev.(type) { | |||
| 117 | + | case core.EvUsage: | |||
| 118 | + | i.goalRun.tokens += goalTokenCount(e.Usage) | |||
| 119 | + | case core.EvToolCall: | |||
| 120 | + | if e.Name != agenttools.UpdateGoalToolName { | |||
| 121 | + | i.goalRun.hadTool = true | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // finishGoalRun records accounting exactly once and returns whether the | |||
| 127 | + | // controller should attempt another autonomous continuation. A stale run is | |||
| 128 | + | // discarded without changing a newer goal. | |||
| 129 | + | func (i *Interactive) finishGoalRun() bool { | |||
| 130 | + | i.mu.Lock() | |||
| 131 | + | run := i.goalRun | |||
| 132 | + | i.goalRun = nil | |||
| 133 | + | i.mu.Unlock() | |||
| 134 | + | if run == nil || i.cfg.CurrentGoal == nil { | |||
| 135 | + | return false | |||
| 136 | + | } | |||
| 137 | + | goal := copySessionGoal(i.cfg.CurrentGoal()) | |||
| 138 | + | if goal == nil || goal.ID != run.goalID { | |||
| 139 | + | return false | |||
| 140 | + | } | |||
| 141 | + | goal.ContinuationID = "" | |||
| 142 | + | goal.TokensUsed += run.tokens | |||
| 143 | + | ||||
| 144 | + | persistRuntime := func() bool { | |||
| 145 | + | if i.cfg.PersistGoalRuntime == nil { | |||
| 146 | + | return true | |||
| 147 | + | } | |||
| 148 | + | if err := i.cfg.PersistGoalRuntime(goal); err != nil { | |||
| 149 | + | i.ReportError(fmt.Errorf("persist goal runtime: %w", err)) | |||
| 150 | + | return false | |||
| 151 | + | } | |||
| 152 | + | i.setGoalStatus(goal) | |||
| 153 | + | return true | |||
| 154 | + | } | |||
| 155 | + | if goal.Status != core.GoalActive { | |||
| 156 | + | persistRuntime() | |||
| 157 | + | return false | |||
| 158 | + | } | |||
| 159 | + | if goal.TokenBudget != nil && goal.TokensUsed >= *goal.TokenBudget { | |||
| 160 | + | goal.Status = core.GoalBudgetLimited | |||
| 161 | + | goal.Reason = "configured token budget exhausted" | |||
| 162 | + | if i.cfg.PersistGoal == nil { | |||
| 163 | + | i.ReportError(fmt.Errorf("goal persistence is unavailable")) | |||
| 164 | + | return false | |||
| 165 | + | } | |||
| 166 | + | if err := i.cfg.PersistGoal(goal); err != nil { | |||
| 167 | + | i.ReportError(fmt.Errorf("persist goal budget limit: %w", err)) | |||
| 168 | + | return false | |||
| 169 | + | } | |||
| 170 | + | i.setGoalStatus(goal) | |||
| 171 | + | return false | |||
| 172 | + | } | |||
| 173 | + | if run.hadTool { | |||
| 174 | + | goal.ConsecutiveNoProgressTurns = 0 | |||
| 175 | + | } else { | |||
| 176 | + | goal.ConsecutiveNoProgressTurns++ | |||
| 177 | + | if goal.ConsecutiveNoProgressTurns >= maxConsecutiveGoalNoProgressTurns { | |||
| 178 | + | goal.Status = core.GoalStalled | |||
| 179 | + | goal.Reason = "agent ended consecutive continuations without a concrete action" | |||
| 180 | + | if i.cfg.PersistGoal == nil { | |||
| 181 | + | i.ReportError(fmt.Errorf("goal persistence is unavailable")) | |||
| 182 | + | return false | |||
| 183 | + | } | |||
| 184 | + | if err := i.cfg.PersistGoal(goal); err != nil { | |||
| 185 | + | i.ReportError(fmt.Errorf("persist stalled goal: %w", err)) | |||
| 186 | + | return false | |||
| 187 | + | } | |||
| 188 | + | i.setGoalStatus(goal) | |||
| 189 | + | return false | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | return persistRuntime() | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // recoverGoalRun clears a lease left by a previous process. The new process | |||
| 196 | + | // has no provider turn associated with it, so retaining that lease would make | |||
| 197 | + | // the active goal permanently unschedulable. | |||
| 198 | + | func (i *Interactive) recoverGoalRun() { | |||
| 199 | + | if i.cfg.CurrentGoal == nil || i.cfg.PersistGoalRuntime == nil { | |||
| 200 | + | return | |||
| 201 | + | } | |||
| 202 | + | goal := copySessionGoal(i.cfg.CurrentGoal()) | |||
| 203 | + | if goal == nil || goal.Status != core.GoalActive || goal.ContinuationID == "" { | |||
| 204 | + | return | |||
| 205 | + | } | |||
| 206 | + | goal.ContinuationID = "" | |||
| 207 | + | if err := i.cfg.PersistGoalRuntime(goal); err != nil { | |||
| 208 | + | i.ReportError(fmt.Errorf("recover goal continuation: %w", err)) | |||
| 209 | + | } | |||
| 210 | + | } | |||
A packages/agent/modes/goal_runtime_test.go +250 -0
| @@ -0,0 +1,250 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/agent/tools" | |
| 10 | + | "github.com/bnema/zut/packages/core" | |
| 11 | + | "github.com/bnema/zut/packages/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type noProgressGoalClient struct { | |
| 15 | + | mu sync.Mutex | |
| 16 | + | calls int | |
| 17 | + | hits chan int | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (c *noProgressGoalClient) Name() string { return "goal-no-progress-test" } | |
| 21 | + | ||
| 22 | + | func (c *noProgressGoalClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) { | |
| 23 | + | c.mu.Lock() | |
| 24 | + | c.calls++ | |
| 25 | + | call := c.calls | |
| 26 | + | c.mu.Unlock() | |
| 27 | + | c.hits <- call | |
| 28 | + | out := make(chan provider.Event, 1) | |
| 29 | + | out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{ | |
| 30 | + | Role: provider.RoleAssistant, | |
| 31 | + | Content: []provider.Content{provider.TextBlock{Text: "Continuing autonomously."}}, | |
| 32 | + | }} | |
| 33 | + | close(out) | |
| 34 | + | return out, nil | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (c *noProgressGoalClient) count() int { | |
| 38 | + | c.mu.Lock() | |
| 39 | + | defer c.mu.Unlock() | |
| 40 | + | return c.calls | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestGoalContinuationDoesNotResendNoProgressMessagesIndefinitely(t *testing.T) { | |
| 44 | + | var mu sync.Mutex | |
| 45 | + | var goal *core.SessionGoal | |
| 46 | + | current := func() *core.SessionGoal { | |
| 47 | + | mu.Lock() | |
| 48 | + | defer mu.Unlock() | |
| 49 | + | return cloneSessionGoal(goal) | |
| 50 | + | } | |
| 51 | + | persist := func(next *core.SessionGoal) error { | |
| 52 | + | mu.Lock() | |
| 53 | + | goal = cloneSessionGoal(next) | |
| 54 | + | mu.Unlock() | |
| 55 | + | return nil | |
| 56 | + | } | |
| 57 | + | client := &noProgressGoalClient{hits: make(chan int, 3)} | |
| 58 | + | agent := core.NewAgent(client, "test", "", core.Registry{tools.UpdateGoalToolName: &tools.UpdateGoalTool{}}) | |
| 59 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 60 | + | Agent: agent, | |
| 61 | + | CurrentGoal: current, | |
| 62 | + | PersistGoal: persist, | |
| 63 | + | PersistGoalRuntime: persist, | |
| 64 | + | }) | |
| 65 | + | interactive.runCtx = context.Background() | |
| 66 | + | interactive.runGoalCommand(context.Background(), "/goal finish the work", []string{"/goal", "finish", "the", "work"}) | |
| 67 | + | ||
| 68 | + | for want := 1; want <= maxConsecutiveGoalNoProgressTurns; want++ { | |
| 69 | + | select { | |
| 70 | + | case got := <-client.hits: | |
| 71 | + | if got != want { | |
| 72 | + | t.Fatalf("provider call = %d, want %d", got, want) | |
| 73 | + | } | |
| 74 | + | case <-time.After(2 * time.Second): | |
| 75 | + | t.Fatalf("provider call %d did not start", want) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | waitInteractiveIdle(t, interactive) | |
| 79 | + | if got := current(); got == nil || got.Status != core.GoalStalled { | |
| 80 | + | t.Fatalf("goal after repeated no-progress responses = %#v", got) | |
| 81 | + | } | |
| 82 | + | if got := client.count(); got != maxConsecutiveGoalNoProgressTurns { | |
| 83 | + | t.Fatalf("provider call count = %d, want %d", got, maxConsecutiveGoalNoProgressTurns) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestGoalRuntimeStallsAfterRepeatedNoProgress(t *testing.T) { | |
| 88 | + | var mu sync.Mutex | |
| 89 | + | goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive} | |
| 90 | + | current := func() *core.SessionGoal { | |
| 91 | + | mu.Lock() | |
| 92 | + | defer mu.Unlock() | |
| 93 | + | return cloneSessionGoal(goal) | |
| 94 | + | } | |
| 95 | + | persistRuntime := func(next *core.SessionGoal) error { | |
| 96 | + | mu.Lock() | |
| 97 | + | goal = cloneSessionGoal(next) | |
| 98 | + | mu.Unlock() | |
| 99 | + | return nil | |
| 100 | + | } | |
| 101 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 102 | + | CurrentGoal: current, | |
| 103 | + | PersistGoal: persistRuntime, | |
| 104 | + | PersistGoalRuntime: persistRuntime, | |
| 105 | + | }) | |
| 106 | + | ||
| 107 | + | first, err := interactive.startGoalRun(current()) | |
| 108 | + | if err != nil || first == nil { | |
| 109 | + | t.Fatalf("start first goal run = (%v, %v), want run without error", first, err) | |
| 110 | + | } | |
| 111 | + | if !interactive.finishGoalRun() { | |
| 112 | + | t.Fatal("first non-progress run should receive one corrective continuation") | |
| 113 | + | } | |
| 114 | + | if got := current(); got.ConsecutiveNoProgressTurns != 1 || got.Status != core.GoalActive || got.ContinuationID != "" { | |
| 115 | + | t.Fatalf("first run state = %#v", got) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | second, err := interactive.startGoalRun(current()) | |
| 119 | + | if err != nil || second == nil { | |
| 120 | + | t.Fatalf("start second goal run = (%v, %v), want run without error", second, err) | |
| 121 | + | } | |
| 122 | + | if interactive.finishGoalRun() { | |
| 123 | + | t.Fatal("second non-progress run scheduled another continuation") | |
| 124 | + | } | |
| 125 | + | got := current() | |
| 126 | + | if got.Status != core.GoalStalled || got.ConsecutiveNoProgressTurns != maxConsecutiveGoalNoProgressTurns { | |
| 127 | + | t.Fatalf("stalled goal = %#v", got) | |
| 128 | + | } | |
| 129 | + | if got.Reason == "" || got.ContinuationID != "" { | |
| 130 | + | t.Fatalf("stalled goal diagnostics = %#v", got) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestGoalRuntimeBudgetIsOptionalAndEnforcedWhenSet(t *testing.T) { | |
| 135 | + | for _, test := range []struct { | |
| 136 | + | name string | |
| 137 | + | budget *uint64 | |
| 138 | + | wantStatus core.GoalStatus | |
| 139 | + | wantNext bool | |
| 140 | + | }{ | |
| 141 | + | {name: "unlimited", wantStatus: core.GoalActive, wantNext: true}, | |
| 142 | + | {name: "limited", budget: uint64ptr(100), wantStatus: core.GoalBudgetLimited}, | |
| 143 | + | } { | |
| 144 | + | t.Run(test.name, func(t *testing.T) { | |
| 145 | + | var mu sync.Mutex | |
| 146 | + | goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, TokenBudget: test.budget} | |
| 147 | + | current := func() *core.SessionGoal { | |
| 148 | + | mu.Lock() | |
| 149 | + | defer mu.Unlock() | |
| 150 | + | return cloneSessionGoal(goal) | |
| 151 | + | } | |
| 152 | + | persist := func(next *core.SessionGoal) error { | |
| 153 | + | mu.Lock() | |
| 154 | + | goal = cloneSessionGoal(next) | |
| 155 | + | mu.Unlock() | |
| 156 | + | return nil | |
| 157 | + | } | |
| 158 | + | interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist}) | |
| 159 | + | run, err := interactive.startGoalRun(current()) | |
| 160 | + | if err != nil || run == nil { | |
| 161 | + | t.Fatalf("start goal run = (%v, %v)", run, err) | |
| 162 | + | } | |
| 163 | + | interactive.observeGoalRun(core.EvToolCall{Name: "bash"}) | |
| 164 | + | interactive.observeGoalRun(core.EvUsage{Usage: provider.Usage{InputTokens: 40, OutputTokens: 60}}) | |
| 165 | + | if got := interactive.finishGoalRun(); got != test.wantNext { | |
| 166 | + | t.Fatalf("finish goal run = %t, want %t", got, test.wantNext) | |
| 167 | + | } | |
| 168 | + | got := current() | |
| 169 | + | if got.Status != test.wantStatus || got.TokensUsed != 100 { | |
| 170 | + | t.Fatalf("goal = %#v", got) | |
| 171 | + | } | |
| 172 | + | }) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestGoalRuntimeLimitsPersistedExhaustedBudgetBeforeRun(t *testing.T) { | |
| 177 | + | budget := uint64(100) | |
| 178 | + | var mu sync.Mutex | |
| 179 | + | goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, TokenBudget: &budget, TokensUsed: budget} | |
| 180 | + | current := func() *core.SessionGoal { | |
| 181 | + | mu.Lock() | |
| 182 | + | defer mu.Unlock() | |
| 183 | + | return cloneSessionGoal(goal) | |
| 184 | + | } | |
| 185 | + | persist := func(next *core.SessionGoal) error { | |
| 186 | + | mu.Lock() | |
| 187 | + | goal = cloneSessionGoal(next) | |
| 188 | + | mu.Unlock() | |
| 189 | + | return nil | |
| 190 | + | } | |
| 191 | + | interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist}) | |
| 192 | + | if !interactive.limitGoalBeforeRun(current()) { | |
| 193 | + | t.Fatal("exhausted budget was not limited before starting a new provider turn") | |
| 194 | + | } | |
| 195 | + | if got := current(); got.Status != core.GoalBudgetLimited || got.ContinuationID != "" { | |
| 196 | + | t.Fatalf("limited goal = %#v", got) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestGoalRuntimeRejectsStaleRun(t *testing.T) { | |
| 201 | + | var mu sync.Mutex | |
| 202 | + | goal := &core.SessionGoal{ID: "goal-1", Objective: "first goal", Status: core.GoalActive} | |
| 203 | + | current := func() *core.SessionGoal { | |
| 204 | + | mu.Lock() | |
| 205 | + | defer mu.Unlock() | |
| 206 | + | return cloneSessionGoal(goal) | |
| 207 | + | } | |
| 208 | + | persist := func(next *core.SessionGoal) error { | |
| 209 | + | mu.Lock() | |
| 210 | + | goal = cloneSessionGoal(next) | |
| 211 | + | mu.Unlock() | |
| 212 | + | return nil | |
| 213 | + | } | |
| 214 | + | interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist}) | |
| 215 | + | run, err := interactive.startGoalRun(current()) | |
| 216 | + | if err != nil || run == nil { | |
| 217 | + | t.Fatalf("start goal run = (%v, %v)", run, err) | |
| 218 | + | } | |
| 219 | + | if err := persist(&core.SessionGoal{ID: "goal-2", Objective: "new goal", Status: core.GoalActive}); err != nil { | |
| 220 | + | t.Fatal(err) | |
| 221 | + | } | |
| 222 | + | if interactive.finishGoalRun() { | |
| 223 | + | t.Fatal("stale run scheduled a continuation") | |
| 224 | + | } | |
| 225 | + | if got := current(); got.ID != "goal-2" || got.ContinuationID != "" || got.TokensUsed != 0 { | |
| 226 | + | t.Fatalf("stale run changed current goal: %#v", got) | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func TestGoalRuntimeRecoversAbandonedLease(t *testing.T) { | |
| 231 | + | var mu sync.Mutex | |
| 232 | + | goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, ContinuationID: "abandoned"} | |
| 233 | + | current := func() *core.SessionGoal { | |
| 234 | + | mu.Lock() | |
| 235 | + | defer mu.Unlock() | |
| 236 | + | return cloneSessionGoal(goal) | |
| 237 | + | } | |
| 238 | + | persist := func(next *core.SessionGoal) error { | |
| 239 | + | mu.Lock() | |
| 240 | + | goal = cloneSessionGoal(next) | |
| 241 | + | mu.Unlock() | |
| 242 | + | return nil | |
| 243 | + | } | |
| 244 | + | _ = NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoalRuntime: persist}) | |
| 245 | + | if got := current(); got.ContinuationID != "" { | |
| 246 | + | t.Fatalf("abandoned lease was not cleared: %#v", got) | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func uint64ptr(value uint64) *uint64 { return &value } | |
| @@ -0,0 +1,250 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zut/packages/agent/tools" | |||
| 10 | + | "github.com/bnema/zut/packages/core" | |||
| 11 | + | "github.com/bnema/zut/packages/provider" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | type noProgressGoalClient struct { | |||
| 15 | + | mu sync.Mutex | |||
| 16 | + | calls int | |||
| 17 | + | hits chan int | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func (c *noProgressGoalClient) Name() string { return "goal-no-progress-test" } | |||
| 21 | + | ||||
| 22 | + | func (c *noProgressGoalClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) { | |||
| 23 | + | c.mu.Lock() | |||
| 24 | + | c.calls++ | |||
| 25 | + | call := c.calls | |||
| 26 | + | c.mu.Unlock() | |||
| 27 | + | c.hits <- call | |||
| 28 | + | out := make(chan provider.Event, 1) | |||
| 29 | + | out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{ | |||
| 30 | + | Role: provider.RoleAssistant, | |||
| 31 | + | Content: []provider.Content{provider.TextBlock{Text: "Continuing autonomously."}}, | |||
| 32 | + | }} | |||
| 33 | + | close(out) | |||
| 34 | + | return out, nil | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (c *noProgressGoalClient) count() int { | |||
| 38 | + | c.mu.Lock() | |||
| 39 | + | defer c.mu.Unlock() | |||
| 40 | + | return c.calls | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestGoalContinuationDoesNotResendNoProgressMessagesIndefinitely(t *testing.T) { | |||
| 44 | + | var mu sync.Mutex | |||
| 45 | + | var goal *core.SessionGoal | |||
| 46 | + | current := func() *core.SessionGoal { | |||
| 47 | + | mu.Lock() | |||
| 48 | + | defer mu.Unlock() | |||
| 49 | + | return cloneSessionGoal(goal) | |||
| 50 | + | } | |||
| 51 | + | persist := func(next *core.SessionGoal) error { | |||
| 52 | + | mu.Lock() | |||
| 53 | + | goal = cloneSessionGoal(next) | |||
| 54 | + | mu.Unlock() | |||
| 55 | + | return nil | |||
| 56 | + | } | |||
| 57 | + | client := &noProgressGoalClient{hits: make(chan int, 3)} | |||
| 58 | + | agent := core.NewAgent(client, "test", "", core.Registry{tools.UpdateGoalToolName: &tools.UpdateGoalTool{}}) | |||
| 59 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 60 | + | Agent: agent, | |||
| 61 | + | CurrentGoal: current, | |||
| 62 | + | PersistGoal: persist, | |||
| 63 | + | PersistGoalRuntime: persist, | |||
| 64 | + | }) | |||
| 65 | + | interactive.runCtx = context.Background() | |||
| 66 | + | interactive.runGoalCommand(context.Background(), "/goal finish the work", []string{"/goal", "finish", "the", "work"}) | |||
| 67 | + | ||||
| 68 | + | for want := 1; want <= maxConsecutiveGoalNoProgressTurns; want++ { | |||
| 69 | + | select { | |||
| 70 | + | case got := <-client.hits: | |||
| 71 | + | if got != want { | |||
| 72 | + | t.Fatalf("provider call = %d, want %d", got, want) | |||
| 73 | + | } | |||
| 74 | + | case <-time.After(2 * time.Second): | |||
| 75 | + | t.Fatalf("provider call %d did not start", want) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | waitInteractiveIdle(t, interactive) | |||
| 79 | + | if got := current(); got == nil || got.Status != core.GoalStalled { | |||
| 80 | + | t.Fatalf("goal after repeated no-progress responses = %#v", got) | |||
| 81 | + | } | |||
| 82 | + | if got := client.count(); got != maxConsecutiveGoalNoProgressTurns { | |||
| 83 | + | t.Fatalf("provider call count = %d, want %d", got, maxConsecutiveGoalNoProgressTurns) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestGoalRuntimeStallsAfterRepeatedNoProgress(t *testing.T) { | |||
| 88 | + | var mu sync.Mutex | |||
| 89 | + | goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive} | |||
| 90 | + | current := func() *core.SessionGoal { | |||
| 91 | + | mu.Lock() | |||
| 92 | + | defer mu.Unlock() | |||
| 93 | + | return cloneSessionGoal(goal) | |||
| 94 | + | } | |||
| 95 | + | persistRuntime := func(next *core.SessionGoal) error { | |||
| 96 | + | mu.Lock() | |||
| 97 | + | goal = cloneSessionGoal(next) | |||
| 98 | + | mu.Unlock() | |||
| 99 | + | return nil | |||
| 100 | + | } | |||
| 101 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 102 | + | CurrentGoal: current, | |||
| 103 | + | PersistGoal: persistRuntime, | |||
| 104 | + | PersistGoalRuntime: persistRuntime, | |||
| 105 | + | }) | |||
| 106 | + | ||||
| 107 | + | first, err := interactive.startGoalRun(current()) | |||
| 108 | + | if err != nil || first == nil { | |||
| 109 | + | t.Fatalf("start first goal run = (%v, %v), want run without error", first, err) | |||
| 110 | + | } | |||
| 111 | + | if !interactive.finishGoalRun() { | |||
| 112 | + | t.Fatal("first non-progress run should receive one corrective continuation") | |||
| 113 | + | } | |||
| 114 | + | if got := current(); got.ConsecutiveNoProgressTurns != 1 || got.Status != core.GoalActive || got.ContinuationID != "" { | |||
| 115 | + | t.Fatalf("first run state = %#v", got) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | second, err := interactive.startGoalRun(current()) | |||
| 119 | + | if err != nil || second == nil { | |||
| 120 | + | t.Fatalf("start second goal run = (%v, %v), want run without error", second, err) | |||
| 121 | + | } | |||
| 122 | + | if interactive.finishGoalRun() { | |||
| 123 | + | t.Fatal("second non-progress run scheduled another continuation") | |||
| 124 | + | } | |||
| 125 | + | got := current() | |||
| 126 | + | if got.Status != core.GoalStalled || got.ConsecutiveNoProgressTurns != maxConsecutiveGoalNoProgressTurns { | |||
| 127 | + | t.Fatalf("stalled goal = %#v", got) | |||
| 128 | + | } | |||
| 129 | + | if got.Reason == "" || got.ContinuationID != "" { | |||
| 130 | + | t.Fatalf("stalled goal diagnostics = %#v", got) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func TestGoalRuntimeBudgetIsOptionalAndEnforcedWhenSet(t *testing.T) { | |||
| 135 | + | for _, test := range []struct { | |||
| 136 | + | name string | |||
| 137 | + | budget *uint64 | |||
| 138 | + | wantStatus core.GoalStatus | |||
| 139 | + | wantNext bool | |||
| 140 | + | }{ | |||
| 141 | + | {name: "unlimited", wantStatus: core.GoalActive, wantNext: true}, | |||
| 142 | + | {name: "limited", budget: uint64ptr(100), wantStatus: core.GoalBudgetLimited}, | |||
| 143 | + | } { | |||
| 144 | + | t.Run(test.name, func(t *testing.T) { | |||
| 145 | + | var mu sync.Mutex | |||
| 146 | + | goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, TokenBudget: test.budget} | |||
| 147 | + | current := func() *core.SessionGoal { | |||
| 148 | + | mu.Lock() | |||
| 149 | + | defer mu.Unlock() | |||
| 150 | + | return cloneSessionGoal(goal) | |||
| 151 | + | } | |||
| 152 | + | persist := func(next *core.SessionGoal) error { | |||
| 153 | + | mu.Lock() | |||
| 154 | + | goal = cloneSessionGoal(next) | |||
| 155 | + | mu.Unlock() | |||
| 156 | + | return nil | |||
| 157 | + | } | |||
| 158 | + | interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist}) | |||
| 159 | + | run, err := interactive.startGoalRun(current()) | |||
| 160 | + | if err != nil || run == nil { | |||
| 161 | + | t.Fatalf("start goal run = (%v, %v)", run, err) | |||
| 162 | + | } | |||
| 163 | + | interactive.observeGoalRun(core.EvToolCall{Name: "bash"}) | |||
| 164 | + | interactive.observeGoalRun(core.EvUsage{Usage: provider.Usage{InputTokens: 40, OutputTokens: 60}}) | |||
| 165 | + | if got := interactive.finishGoalRun(); got != test.wantNext { | |||
| 166 | + | t.Fatalf("finish goal run = %t, want %t", got, test.wantNext) | |||
| 167 | + | } | |||
| 168 | + | got := current() | |||
| 169 | + | if got.Status != test.wantStatus || got.TokensUsed != 100 { | |||
| 170 | + | t.Fatalf("goal = %#v", got) | |||
| 171 | + | } | |||
| 172 | + | }) | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestGoalRuntimeLimitsPersistedExhaustedBudgetBeforeRun(t *testing.T) { | |||
| 177 | + | budget := uint64(100) | |||
| 178 | + | var mu sync.Mutex | |||
| 179 | + | goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, TokenBudget: &budget, TokensUsed: budget} | |||
| 180 | + | current := func() *core.SessionGoal { | |||
| 181 | + | mu.Lock() | |||
| 182 | + | defer mu.Unlock() | |||
| 183 | + | return cloneSessionGoal(goal) | |||
| 184 | + | } | |||
| 185 | + | persist := func(next *core.SessionGoal) error { | |||
| 186 | + | mu.Lock() | |||
| 187 | + | goal = cloneSessionGoal(next) | |||
| 188 | + | mu.Unlock() | |||
| 189 | + | return nil | |||
| 190 | + | } | |||
| 191 | + | interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist}) | |||
| 192 | + | if !interactive.limitGoalBeforeRun(current()) { | |||
| 193 | + | t.Fatal("exhausted budget was not limited before starting a new provider turn") | |||
| 194 | + | } | |||
| 195 | + | if got := current(); got.Status != core.GoalBudgetLimited || got.ContinuationID != "" { | |||
| 196 | + | t.Fatalf("limited goal = %#v", got) | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func TestGoalRuntimeRejectsStaleRun(t *testing.T) { | |||
| 201 | + | var mu sync.Mutex | |||
| 202 | + | goal := &core.SessionGoal{ID: "goal-1", Objective: "first goal", Status: core.GoalActive} | |||
| 203 | + | current := func() *core.SessionGoal { | |||
| 204 | + | mu.Lock() | |||
| 205 | + | defer mu.Unlock() | |||
| 206 | + | return cloneSessionGoal(goal) | |||
| 207 | + | } | |||
| 208 | + | persist := func(next *core.SessionGoal) error { | |||
| 209 | + | mu.Lock() | |||
| 210 | + | goal = cloneSessionGoal(next) | |||
| 211 | + | mu.Unlock() | |||
| 212 | + | return nil | |||
| 213 | + | } | |||
| 214 | + | interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist}) | |||
| 215 | + | run, err := interactive.startGoalRun(current()) | |||
| 216 | + | if err != nil || run == nil { | |||
| 217 | + | t.Fatalf("start goal run = (%v, %v)", run, err) | |||
| 218 | + | } | |||
| 219 | + | if err := persist(&core.SessionGoal{ID: "goal-2", Objective: "new goal", Status: core.GoalActive}); err != nil { | |||
| 220 | + | t.Fatal(err) | |||
| 221 | + | } | |||
| 222 | + | if interactive.finishGoalRun() { | |||
| 223 | + | t.Fatal("stale run scheduled a continuation") | |||
| 224 | + | } | |||
| 225 | + | if got := current(); got.ID != "goal-2" || got.ContinuationID != "" || got.TokensUsed != 0 { | |||
| 226 | + | t.Fatalf("stale run changed current goal: %#v", got) | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func TestGoalRuntimeRecoversAbandonedLease(t *testing.T) { | |||
| 231 | + | var mu sync.Mutex | |||
| 232 | + | goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, ContinuationID: "abandoned"} | |||
| 233 | + | current := func() *core.SessionGoal { | |||
| 234 | + | mu.Lock() | |||
| 235 | + | defer mu.Unlock() | |||
| 236 | + | return cloneSessionGoal(goal) | |||
| 237 | + | } | |||
| 238 | + | persist := func(next *core.SessionGoal) error { | |||
| 239 | + | mu.Lock() | |||
| 240 | + | goal = cloneSessionGoal(next) | |||
| 241 | + | mu.Unlock() | |||
| 242 | + | return nil | |||
| 243 | + | } | |||
| 244 | + | _ = NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoalRuntime: persist}) | |||
| 245 | + | if got := current(); got.ContinuationID != "" { | |||
| 246 | + | t.Fatalf("abandoned lease was not cleared: %#v", got) | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | func uint64ptr(value uint64) *uint64 { return &value } | |||
M packages/agent/modes/interactive.go +7 -0
| @@ -370,6 +370,11 @@ type InteractiveConfig struct { | |||
| 370 | 370 | CurrentGoalHistory func() []core.SessionGoal | |
| 371 | 371 | EnsureMission func(objective string) error | |
| 372 | 372 | PersistGoal func(goal *core.SessionGoal) error | |
| 373 | + | // PersistGoalRuntime records execution bookkeeping without adding a mission | |
| 374 | + | // transition to goal history. | |
| 375 | + | PersistGoalRuntime func(goal *core.SessionGoal) error | |
| 376 | + | // GoalMaxTokenBudget is optional. Nil means autonomous goals are unlimited. | |
| 377 | + | GoalMaxTokenBudget *uint64 | |
| 373 | 378 | ||
| 374 | 379 | OnAssistant func(m provider.Message) | |
| 375 | 380 | ||
| @@ -665,6 +670,7 @@ type Interactive struct { | |||
| 665 | 670 | statusErr string | |
| 666 | 671 | statusOK string | |
| 667 | 672 | goalStatus core.GoalStatus | |
| 673 | + | goalRun *goalContinuationRun | |
| 668 | 674 | reloadStatusSeq uint64 | |
| 669 | 675 | extStatuses map[string]map[string]extensionStatus | |
| 670 | 676 | extWidgets map[string]map[string]extensionWidget | |
| @@ -1037,6 +1043,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 1037 | 1043 | i.titleGenerationStarted = i.titleRealPromptSeen | |
| 1038 | 1044 | } | |
| 1039 | 1045 | i.applyAutoSubagentsTool() | |
| 1046 | + | i.recoverGoalRun() | |
| 1040 | 1047 | return i | |
| 1041 | 1048 | } | |
| 1042 | 1049 | ||
| @@ -370,6 +370,11 @@ type InteractiveConfig struct { | |||||
| 370 | CurrentGoalHistory func() []core.SessionGoal | 370 | CurrentGoalHistory func() []core.SessionGoal | ||
| 371 | EnsureMission func(objective string) error | 371 | EnsureMission func(objective string) error | ||
| 372 | PersistGoal func(goal *core.SessionGoal) error | 372 | PersistGoal func(goal *core.SessionGoal) error | ||
| 373 | + | // PersistGoalRuntime records execution bookkeeping without adding a mission | |||
| 374 | + | // transition to goal history. | |||
| 375 | + | PersistGoalRuntime func(goal *core.SessionGoal) error | |||
| 376 | + | // GoalMaxTokenBudget is optional. Nil means autonomous goals are unlimited. | |||
| 377 | + | GoalMaxTokenBudget *uint64 | |||
| 373 | 378 | ||||
| 374 | OnAssistant func(m provider.Message) | 379 | OnAssistant func(m provider.Message) | ||
| 375 | 380 | ||||
| @@ -665,6 +670,7 @@ type Interactive struct { | |||||
| 665 | statusErr string | 670 | statusErr string | ||
| 666 | statusOK string | 671 | statusOK string | ||
| 667 | goalStatus core.GoalStatus | 672 | goalStatus core.GoalStatus | ||
| 673 | + | goalRun *goalContinuationRun | |||
| 668 | reloadStatusSeq uint64 | 674 | reloadStatusSeq uint64 | ||
| 669 | extStatuses map[string]map[string]extensionStatus | 675 | extStatuses map[string]map[string]extensionStatus | ||
| 670 | extWidgets map[string]map[string]extensionWidget | 676 | extWidgets map[string]map[string]extensionWidget | ||
| @@ -1037,6 +1043,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 1037 | i.titleGenerationStarted = i.titleRealPromptSeen | 1043 | i.titleGenerationStarted = i.titleRealPromptSeen | ||
| 1038 | } | 1044 | } | ||
| 1039 | i.applyAutoSubagentsTool() | 1045 | i.applyAutoSubagentsTool() | ||
| 1046 | + | i.recoverGoalRun() | |||
| 1040 | return i | 1047 | return i | ||
| 1041 | } | 1048 | } | ||
| 1042 | 1049 | ||||
M packages/agent/modes/interactive_compaction.go +17 -1
| @@ -393,7 +393,23 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 393 | 393 | case continueAutomatically: | |
| 394 | 394 | i.startAutoCompactContinuation(p) | |
| 395 | 395 | case continueGoal: | |
| 396 | - | i.startGoalContinuation(p, goalMessage) | |
| 396 | + | var run *goalContinuationRun | |
| 397 | + | if i.cfg.CurrentGoal != nil { | |
| 398 | + | goal := copySessionGoal(i.cfg.CurrentGoal()) | |
| 399 | + | if !i.limitGoalBeforeRun(goal) { | |
| 400 | + | run, err = i.startGoalRun(goal) | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | if err != nil || run == nil { | |
| 404 | + | i.mu.Lock() | |
| 405 | + | i.busy = false | |
| 406 | + | i.mu.Unlock() | |
| 407 | + | if err != nil { | |
| 408 | + | i.ReportError(err) | |
| 409 | + | } | |
| 410 | + | return | |
| 411 | + | } | |
| 412 | + | i.startGoalContinuation(p, goalMessage, run) | |
| 397 | 413 | } | |
| 398 | 414 | } | |
| 399 | 415 | }() | |
| @@ -393,7 +393,23 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||||
| 393 | case continueAutomatically: | 393 | case continueAutomatically: | ||
| 394 | i.startAutoCompactContinuation(p) | 394 | i.startAutoCompactContinuation(p) | ||
| 395 | case continueGoal: | 395 | case continueGoal: | ||
| 396 | - | i.startGoalContinuation(p, goalMessage) | 396 | + | var run *goalContinuationRun |
| 397 | + | if i.cfg.CurrentGoal != nil { | |||
| 398 | + | goal := copySessionGoal(i.cfg.CurrentGoal()) | |||
| 399 | + | if !i.limitGoalBeforeRun(goal) { | |||
| 400 | + | run, err = i.startGoalRun(goal) | |||
| 401 | + | } | |||
| 402 | + | } | |||
| 403 | + | if err != nil || run == nil { | |||
| 404 | + | i.mu.Lock() | |||
| 405 | + | i.busy = false | |||
| 406 | + | i.mu.Unlock() | |||
| 407 | + | if err != nil { | |||
| 408 | + | i.ReportError(err) | |||
| 409 | + | } | |||
| 410 | + | return | |||
| 411 | + | } | |||
| 412 | + | i.startGoalContinuation(p, goalMessage, run) | |||
| 397 | } | 413 | } | ||
| 398 | } | 414 | } | ||
| 399 | }() | 415 | }() | ||
M packages/agent/modes/interactive_turn.go +5 -1
| @@ -236,6 +236,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 236 | 236 | lastStop = e.Stop | |
| 237 | 237 | lastTurnErr = e.Err | |
| 238 | 238 | } | |
| 239 | + | i.observeGoalRun(ev) | |
| 239 | 240 | i.handleEventForPresentation(ev) | |
| 240 | 241 | } | |
| 241 | 242 | ||
| @@ -322,6 +323,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 322 | 323 | if terminalGoalError { | |
| 323 | 324 | i.updateActiveGoal(core.GoalBlocked, "turn ended with an error") | |
| 324 | 325 | } | |
| 326 | + | continueGoal := i.finishGoalRun() | |
| 325 | 327 | i.mu.Lock() | |
| 326 | 328 | awaitingPre := i.awaitingStartupPre | |
| 327 | 329 | // A newer explicit prompt may have cleared the handoff while the | |
| @@ -379,7 +381,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 379 | 381 | if recoverContextOverflow || shouldAutoCompact { | |
| 380 | 382 | i.resetStreamingStateLocked() | |
| 381 | 383 | } | |
| 382 | - | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | |
| 384 | + | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact) | |
| 383 | 385 | i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact | |
| 384 | 386 | i.mu.Unlock() | |
| 385 | 387 | if persistHandoff { | |
| @@ -415,6 +417,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 415 | 417 | lastStop: lastStop, | |
| 416 | 418 | turnError: lastTurnErr, | |
| 417 | 419 | }) | |
| 420 | + | case continueGoal: | |
| 421 | + | i.requestGoalContinuationIfIdle(parent) | |
| 418 | 422 | } | |
| 419 | 423 | }() | |
| 420 | 424 | } | |
| @@ -236,6 +236,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 236 | lastStop = e.Stop | 236 | lastStop = e.Stop | ||
| 237 | lastTurnErr = e.Err | 237 | lastTurnErr = e.Err | ||
| 238 | } | 238 | } | ||
| 239 | + | i.observeGoalRun(ev) | |||
| 239 | i.handleEventForPresentation(ev) | 240 | i.handleEventForPresentation(ev) | ||
| 240 | } | 241 | } | ||
| 241 | 242 | ||||
| @@ -322,6 +323,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 322 | if terminalGoalError { | 323 | if terminalGoalError { | ||
| 323 | i.updateActiveGoal(core.GoalBlocked, "turn ended with an error") | 324 | i.updateActiveGoal(core.GoalBlocked, "turn ended with an error") | ||
| 324 | } | 325 | } | ||
| 326 | + | continueGoal := i.finishGoalRun() | |||
| 325 | i.mu.Lock() | 327 | i.mu.Lock() | ||
| 326 | awaitingPre := i.awaitingStartupPre | 328 | awaitingPre := i.awaitingStartupPre | ||
| 327 | // A newer explicit prompt may have cleared the handoff while the | 329 | // A newer explicit prompt may have cleared the handoff while the | ||
| @@ -379,7 +381,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 379 | if recoverContextOverflow || shouldAutoCompact { | 381 | if recoverContextOverflow || shouldAutoCompact { | ||
| 380 | i.resetStreamingStateLocked() | 382 | i.resetStreamingStateLocked() | ||
| 381 | } | 383 | } | ||
| 382 | - | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | 384 | + | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact) |
| 383 | i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact | 385 | i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact | ||
| 384 | i.mu.Unlock() | 386 | i.mu.Unlock() | ||
| 385 | if persistHandoff { | 387 | if persistHandoff { | ||
| @@ -415,6 +417,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 415 | lastStop: lastStop, | 417 | lastStop: lastStop, | ||
| 416 | turnError: lastTurnErr, | 418 | turnError: lastTurnErr, | ||
| 417 | }) | 419 | }) | ||
| 420 | + | case continueGoal: | |||
| 421 | + | i.requestGoalContinuationIfIdle(parent) | |||
| 418 | } | 422 | } | ||
| 419 | }() | 423 | }() | ||
| 420 | } | 424 | } | ||
M packages/core/agent.go +31 -1
| @@ -1153,6 +1153,36 @@ func drainProviderStream(stream <-chan provider.Event) { | |||
| 1153 | 1153 | } | |
| 1154 | 1154 | } | |
| 1155 | 1155 | ||
| 1156 | + | // ToolResultCommitError supplies a user-safe explanation when a host rejects | |
| 1157 | + | // a tool result during durable-state commit. Internal storage errors remain in | |
| 1158 | + | // Err; callers without this type retain the generic failure message. | |
| 1159 | + | type ToolResultCommitError struct { | |
| 1160 | + | Message string | |
| 1161 | + | Err error | |
| 1162 | + | } | |
| 1163 | + | ||
| 1164 | + | func (e *ToolResultCommitError) Error() string { | |
| 1165 | + | if e == nil || e.Err == nil { | |
| 1166 | + | return "tool result commit failed" | |
| 1167 | + | } | |
| 1168 | + | return e.Err.Error() | |
| 1169 | + | } | |
| 1170 | + | ||
| 1171 | + | func (e *ToolResultCommitError) Unwrap() error { | |
| 1172 | + | if e == nil { | |
| 1173 | + | return nil | |
| 1174 | + | } | |
| 1175 | + | return e.Err | |
| 1176 | + | } | |
| 1177 | + | ||
| 1178 | + | func toolResultCommitMessage(err error) string { | |
| 1179 | + | var safe *ToolResultCommitError | |
| 1180 | + | if errors.As(err, &safe) && strings.TrimSpace(safe.Message) != "" { | |
| 1181 | + | return safe.Message | |
| 1182 | + | } | |
| 1183 | + | return "tool result state could not be persisted" | |
| 1184 | + | } | |
| 1185 | + | ||
| 1156 | 1186 | // executeTools runs every tool call in the assistant message and returns | |
| 1157 | 1187 | // a single tool-role message carrying all results. | |
| 1158 | 1188 | func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) { | |
| @@ -1170,7 +1200,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun | |||
| 1170 | 1200 | if a.CommitToolResult != nil { | |
| 1171 | 1201 | if err := a.CommitToolResult(tc.ID, res); err != nil { | |
| 1172 | 1202 | res = ToolResult{ | |
| 1173 | - | Content: []provider.Content{provider.TextBlock{Text: "tool result state could not be persisted"}}, | |
| 1203 | + | Content: []provider.Content{provider.TextBlock{Text: toolResultCommitMessage(err)}}, | |
| 1174 | 1204 | IsError: true, | |
| 1175 | 1205 | Timing: res.Timing, | |
| 1176 | 1206 | } | |
| @@ -1153,6 +1153,36 @@ func drainProviderStream(stream <-chan provider.Event) { | |||||
| 1153 | } | 1153 | } | ||
| 1154 | } | 1154 | } | ||
| 1155 | 1155 | ||||
| 1156 | + | // ToolResultCommitError supplies a user-safe explanation when a host rejects | |||
| 1157 | + | // a tool result during durable-state commit. Internal storage errors remain in | |||
| 1158 | + | // Err; callers without this type retain the generic failure message. | |||
| 1159 | + | type ToolResultCommitError struct { | |||
| 1160 | + | Message string | |||
| 1161 | + | Err error | |||
| 1162 | + | } | |||
| 1163 | + | ||||
| 1164 | + | func (e *ToolResultCommitError) Error() string { | |||
| 1165 | + | if e == nil || e.Err == nil { | |||
| 1166 | + | return "tool result commit failed" | |||
| 1167 | + | } | |||
| 1168 | + | return e.Err.Error() | |||
| 1169 | + | } | |||
| 1170 | + | ||||
| 1171 | + | func (e *ToolResultCommitError) Unwrap() error { | |||
| 1172 | + | if e == nil { | |||
| 1173 | + | return nil | |||
| 1174 | + | } | |||
| 1175 | + | return e.Err | |||
| 1176 | + | } | |||
| 1177 | + | ||||
| 1178 | + | func toolResultCommitMessage(err error) string { | |||
| 1179 | + | var safe *ToolResultCommitError | |||
| 1180 | + | if errors.As(err, &safe) && strings.TrimSpace(safe.Message) != "" { | |||
| 1181 | + | return safe.Message | |||
| 1182 | + | } | |||
| 1183 | + | return "tool result state could not be persisted" | |||
| 1184 | + | } | |||
| 1185 | + | ||||
| 1156 | // executeTools runs every tool call in the assistant message and returns | 1186 | // executeTools runs every tool call in the assistant message and returns | ||
| 1157 | // a single tool-role message carrying all results. | 1187 | // a single tool-role message carrying all results. | ||
| 1158 | func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) { | 1188 | func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) { | ||
| @@ -1170,7 +1200,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun | |||||
| 1170 | if a.CommitToolResult != nil { | 1200 | if a.CommitToolResult != nil { | ||
| 1171 | if err := a.CommitToolResult(tc.ID, res); err != nil { | 1201 | if err := a.CommitToolResult(tc.ID, res); err != nil { | ||
| 1172 | res = ToolResult{ | 1202 | res = ToolResult{ | ||
| 1173 | - | Content: []provider.Content{provider.TextBlock{Text: "tool result state could not be persisted"}}, | 1203 | + | Content: []provider.Content{provider.TextBlock{Text: toolResultCommitMessage(err)}}, |
| 1174 | IsError: true, | 1204 | IsError: true, | ||
| 1175 | Timing: res.Timing, | 1205 | Timing: res.Timing, | ||
| 1176 | } | 1206 | } | ||
M packages/core/core_test.go +68 -0
| @@ -486,6 +486,74 @@ func TestSessionGoalRoundTripAndClear(t *testing.T) { | |||
| 486 | 486 | } | |
| 487 | 487 | } | |
| 488 | 488 | ||
| 489 | + | func TestSessionGoalRuntimeRoundTripDoesNotAddHistory(t *testing.T) { | |
| 490 | + | sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test") | |
| 491 | + | if err != nil { | |
| 492 | + | t.Fatal(err) | |
| 493 | + | } | |
| 494 | + | budget := uint64(1_000_000) | |
| 495 | + | if err := sess.UpdateGoal(&SessionGoal{Objective: "finish the requested change", Status: GoalActive, TokenBudget: &budget}); err != nil { | |
| 496 | + | t.Fatal(err) | |
| 497 | + | } | |
| 498 | + | goal := cloneSessionGoal(sess.Meta.Goal) | |
| 499 | + | goal.TokensUsed = 1234 | |
| 500 | + | goal.ContinuationID = "run-1" | |
| 501 | + | goal.ConsecutiveNoProgressTurns = 1 | |
| 502 | + | if err := sess.UpdateGoalRuntime(goal); err != nil { | |
| 503 | + | t.Fatal(err) | |
| 504 | + | } | |
| 505 | + | if len(sess.Meta.GoalHistory) != 0 { | |
| 506 | + | t.Fatalf("runtime update added history: %#v", sess.Meta.GoalHistory) | |
| 507 | + | } | |
| 508 | + | if err := sess.Close(); err != nil { | |
| 509 | + | t.Fatal(err) | |
| 510 | + | } | |
| 511 | + | reopened, _, err := OpenSession(sess.Path) | |
| 512 | + | if err != nil { | |
| 513 | + | t.Fatal(err) | |
| 514 | + | } | |
| 515 | + | defer reopened.Close() | |
| 516 | + | if goal := reopened.Meta.Goal; goal == nil || goal.TokenBudget == nil || *goal.TokenBudget != budget || goal.TokensUsed != 1234 || goal.ContinuationID != "run-1" || goal.ConsecutiveNoProgressTurns != 1 { | |
| 517 | + | t.Fatalf("reopened runtime goal = %#v", goal) | |
| 518 | + | } | |
| 519 | + | if len(reopened.Meta.GoalHistory) != 0 { | |
| 520 | + | t.Fatalf("reopened runtime history = %#v", reopened.Meta.GoalHistory) | |
| 521 | + | } | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | func TestSessionGoalTerminalStatusSettlesMission(t *testing.T) { | |
| 525 | + | for _, test := range []struct { | |
| 526 | + | goalStatus GoalStatus | |
| 527 | + | missionStatus MissionStatus | |
| 528 | + | }{ | |
| 529 | + | {GoalDone, MissionCompleted}, | |
| 530 | + | {GoalPaused, MissionPaused}, | |
| 531 | + | {GoalBlocked, MissionBlocked}, | |
| 532 | + | {GoalBudgetLimited, MissionBlocked}, | |
| 533 | + | {GoalStalled, MissionBlocked}, | |
| 534 | + | } { | |
| 535 | + | t.Run(string(test.goalStatus), func(t *testing.T) { | |
| 536 | + | sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test") | |
| 537 | + | if err != nil { | |
| 538 | + | t.Fatal(err) | |
| 539 | + | } | |
| 540 | + | defer sess.Close() | |
| 541 | + | if err := sess.UpdateGoal(&SessionGoal{Objective: "finish", Status: GoalActive}); err != nil { | |
| 542 | + | t.Fatal(err) | |
| 543 | + | } | |
| 544 | + | goal := cloneSessionGoal(sess.Meta.Goal) | |
| 545 | + | goal.Status = test.goalStatus | |
| 546 | + | goal.Reason = "stopped" | |
| 547 | + | if err := sess.UpdateGoal(goal); err != nil { | |
| 548 | + | t.Fatal(err) | |
| 549 | + | } | |
| 550 | + | if sess.Meta.Mission == nil || sess.Meta.Mission.Status != test.missionStatus || sess.Meta.Mission.ActiveGoalID != "" { | |
| 551 | + | t.Fatalf("mission = %#v", sess.Meta.Mission) | |
| 552 | + | } | |
| 553 | + | }) | |
| 554 | + | } | |
| 555 | + | } | |
| 556 | + | ||
| 489 | 557 | func TestCostAdd(t *testing.T) { | |
| 490 | 558 | var c CostTracker | |
| 491 | 559 | c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01}) | |
| @@ -486,6 +486,74 @@ func TestSessionGoalRoundTripAndClear(t *testing.T) { | |||||
| 486 | } | 486 | } | ||
| 487 | } | 487 | } | ||
| 488 | 488 | ||||
| 489 | + | func TestSessionGoalRuntimeRoundTripDoesNotAddHistory(t *testing.T) { | |||
| 490 | + | sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test") | |||
| 491 | + | if err != nil { | |||
| 492 | + | t.Fatal(err) | |||
| 493 | + | } | |||
| 494 | + | budget := uint64(1_000_000) | |||
| 495 | + | if err := sess.UpdateGoal(&SessionGoal{Objective: "finish the requested change", Status: GoalActive, TokenBudget: &budget}); err != nil { | |||
| 496 | + | t.Fatal(err) | |||
| 497 | + | } | |||
| 498 | + | goal := cloneSessionGoal(sess.Meta.Goal) | |||
| 499 | + | goal.TokensUsed = 1234 | |||
| 500 | + | goal.ContinuationID = "run-1" | |||
| 501 | + | goal.ConsecutiveNoProgressTurns = 1 | |||
| 502 | + | if err := sess.UpdateGoalRuntime(goal); err != nil { | |||
| 503 | + | t.Fatal(err) | |||
| 504 | + | } | |||
| 505 | + | if len(sess.Meta.GoalHistory) != 0 { | |||
| 506 | + | t.Fatalf("runtime update added history: %#v", sess.Meta.GoalHistory) | |||
| 507 | + | } | |||
| 508 | + | if err := sess.Close(); err != nil { | |||
| 509 | + | t.Fatal(err) | |||
| 510 | + | } | |||
| 511 | + | reopened, _, err := OpenSession(sess.Path) | |||
| 512 | + | if err != nil { | |||
| 513 | + | t.Fatal(err) | |||
| 514 | + | } | |||
| 515 | + | defer reopened.Close() | |||
| 516 | + | if goal := reopened.Meta.Goal; goal == nil || goal.TokenBudget == nil || *goal.TokenBudget != budget || goal.TokensUsed != 1234 || goal.ContinuationID != "run-1" || goal.ConsecutiveNoProgressTurns != 1 { | |||
| 517 | + | t.Fatalf("reopened runtime goal = %#v", goal) | |||
| 518 | + | } | |||
| 519 | + | if len(reopened.Meta.GoalHistory) != 0 { | |||
| 520 | + | t.Fatalf("reopened runtime history = %#v", reopened.Meta.GoalHistory) | |||
| 521 | + | } | |||
| 522 | + | } | |||
| 523 | + | ||||
| 524 | + | func TestSessionGoalTerminalStatusSettlesMission(t *testing.T) { | |||
| 525 | + | for _, test := range []struct { | |||
| 526 | + | goalStatus GoalStatus | |||
| 527 | + | missionStatus MissionStatus | |||
| 528 | + | }{ | |||
| 529 | + | {GoalDone, MissionCompleted}, | |||
| 530 | + | {GoalPaused, MissionPaused}, | |||
| 531 | + | {GoalBlocked, MissionBlocked}, | |||
| 532 | + | {GoalBudgetLimited, MissionBlocked}, | |||
| 533 | + | {GoalStalled, MissionBlocked}, | |||
| 534 | + | } { | |||
| 535 | + | t.Run(string(test.goalStatus), func(t *testing.T) { | |||
| 536 | + | sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test") | |||
| 537 | + | if err != nil { | |||
| 538 | + | t.Fatal(err) | |||
| 539 | + | } | |||
| 540 | + | defer sess.Close() | |||
| 541 | + | if err := sess.UpdateGoal(&SessionGoal{Objective: "finish", Status: GoalActive}); err != nil { | |||
| 542 | + | t.Fatal(err) | |||
| 543 | + | } | |||
| 544 | + | goal := cloneSessionGoal(sess.Meta.Goal) | |||
| 545 | + | goal.Status = test.goalStatus | |||
| 546 | + | goal.Reason = "stopped" | |||
| 547 | + | if err := sess.UpdateGoal(goal); err != nil { | |||
| 548 | + | t.Fatal(err) | |||
| 549 | + | } | |||
| 550 | + | if sess.Meta.Mission == nil || sess.Meta.Mission.Status != test.missionStatus || sess.Meta.Mission.ActiveGoalID != "" { | |||
| 551 | + | t.Fatalf("mission = %#v", sess.Meta.Mission) | |||
| 552 | + | } | |||
| 553 | + | }) | |||
| 554 | + | } | |||
| 555 | + | } | |||
| 556 | + | ||||
| 489 | func TestCostAdd(t *testing.T) { | 557 | func TestCostAdd(t *testing.T) { | ||
| 490 | var c CostTracker | 558 | var c CostTracker | ||
| 491 | c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01}) | 559 | c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01}) | ||
M packages/core/intercept_test.go +18 -0
| @@ -80,6 +80,24 @@ func TestToolResultPersistenceFailureBecomesToolError(t *testing.T) { | |||
| 80 | 80 | } | |
| 81 | 81 | } | |
| 82 | 82 | ||
| 83 | + | func TestToolResultCommitErrorUsesSafeMessage(t *testing.T) { | |
| 84 | + | agent := NewAgent(nil, "test", "", Registry{ | |
| 85 | + | "result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "done"}}}}, | |
| 86 | + | }) | |
| 87 | + | agent.CommitToolResult = func(string, ToolResult) error { | |
| 88 | + | return &ToolResultCommitError{Message: "goal transition rejected: the current goal is still active", Err: errors.New("database path /private/session is unavailable")} | |
| 89 | + | } | |
| 90 | + | message := provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "result", Arguments: json.RawMessage(`{}`)}}} | |
| 91 | + | toolMessage, hadError := agent.executeTools(context.Background(), message, func(AgentEvent) {}) | |
| 92 | + | if !hadError { | |
| 93 | + | t.Fatal("commit error did not mark the tool batch failed") | |
| 94 | + | } | |
| 95 | + | block := toolMessage.Content[0].(provider.ToolResultBlock) | |
| 96 | + | if got := block.Content[0].(provider.TextBlock).Text; got != "goal transition rejected: the current goal is still active" { | |
| 97 | + | t.Fatalf("tool error text = %q", got) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 83 | 101 | // TestBeforeToolExecuteModifiesArgs verifies that a non-nil | |
| 84 | 102 | // modifiedArgs returned from BeforeToolExecute is what the tool | |
| 85 | 103 | // actually sees. | |
| @@ -80,6 +80,24 @@ func TestToolResultPersistenceFailureBecomesToolError(t *testing.T) { | |||||
| 80 | } | 80 | } | ||
| 81 | } | 81 | } | ||
| 82 | 82 | ||||
| 83 | + | func TestToolResultCommitErrorUsesSafeMessage(t *testing.T) { | |||
| 84 | + | agent := NewAgent(nil, "test", "", Registry{ | |||
| 85 | + | "result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "done"}}}}, | |||
| 86 | + | }) | |||
| 87 | + | agent.CommitToolResult = func(string, ToolResult) error { | |||
| 88 | + | return &ToolResultCommitError{Message: "goal transition rejected: the current goal is still active", Err: errors.New("database path /private/session is unavailable")} | |||
| 89 | + | } | |||
| 90 | + | message := provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "result", Arguments: json.RawMessage(`{}`)}}} | |||
| 91 | + | toolMessage, hadError := agent.executeTools(context.Background(), message, func(AgentEvent) {}) | |||
| 92 | + | if !hadError { | |||
| 93 | + | t.Fatal("commit error did not mark the tool batch failed") | |||
| 94 | + | } | |||
| 95 | + | block := toolMessage.Content[0].(provider.ToolResultBlock) | |||
| 96 | + | if got := block.Content[0].(provider.TextBlock).Text; got != "goal transition rejected: the current goal is still active" { | |||
| 97 | + | t.Fatalf("tool error text = %q", got) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 83 | // TestBeforeToolExecuteModifiesArgs verifies that a non-nil | 101 | // TestBeforeToolExecuteModifiesArgs verifies that a non-nil | ||
| 84 | // modifiedArgs returned from BeforeToolExecute is what the tool | 102 | // modifiedArgs returned from BeforeToolExecute is what the tool | ||
| 85 | // actually sees. | 103 | // actually sees. | ||
M packages/core/session.go +51 -11
| @@ -55,10 +55,12 @@ type Session struct { | |||
| 55 | 55 | type GoalStatus string | |
| 56 | 56 | ||
| 57 | 57 | const ( | |
| 58 | - | GoalActive GoalStatus = "active" | |
| 59 | - | GoalPaused GoalStatus = "paused" | |
| 60 | - | GoalBlocked GoalStatus = "blocked" | |
| 61 | - | GoalDone GoalStatus = "done" | |
| 58 | + | GoalActive GoalStatus = "active" | |
| 59 | + | GoalPaused GoalStatus = "paused" | |
| 60 | + | GoalBlocked GoalStatus = "blocked" | |
| 61 | + | GoalDone GoalStatus = "done" | |
| 62 | + | GoalBudgetLimited GoalStatus = "budget_limited" | |
| 63 | + | GoalStalled GoalStatus = "stalled" | |
| 62 | 64 | ) | |
| 63 | 65 | ||
| 64 | 66 | // GoalOwner identifies who established a session goal. Missing owners in | |
| @@ -102,13 +104,17 @@ type SessionMission struct { | |||
| 102 | 104 | ||
| 103 | 105 | // SessionGoal is the concise autonomous objective attached to one session. | |
| 104 | 106 | type SessionGoal struct { | |
| 105 | - | ID string `json:"id,omitempty"` | |
| 106 | - | MissionID string `json:"mission_id,omitempty"` | |
| 107 | - | Objective string `json:"objective"` | |
| 108 | - | Status GoalStatus `json:"status"` | |
| 109 | - | Owner GoalOwner `json:"owner,omitempty"` | |
| 110 | - | Ordinal int `json:"ordinal,omitempty"` | |
| 111 | - | Reason string `json:"reason,omitempty"` | |
| 107 | + | ID string `json:"id,omitempty"` | |
| 108 | + | MissionID string `json:"mission_id,omitempty"` | |
| 109 | + | Objective string `json:"objective"` | |
| 110 | + | Status GoalStatus `json:"status"` | |
| 111 | + | Owner GoalOwner `json:"owner,omitempty"` | |
| 112 | + | Ordinal int `json:"ordinal,omitempty"` | |
| 113 | + | Reason string `json:"reason,omitempty"` | |
| 114 | + | TokenBudget *uint64 `json:"token_budget,omitempty"` | |
| 115 | + | TokensUsed uint64 `json:"tokens_used,omitempty"` | |
| 116 | + | ContinuationID string `json:"continuation_id,omitempty"` | |
| 117 | + | ConsecutiveNoProgressTurns int `json:"consecutive_no_progress_turns,omitempty"` | |
| 112 | 118 | } | |
| 113 | 119 | ||
| 114 | 120 | // SessionMeta is written as the first line of every session file. | |
| @@ -1439,6 +1445,29 @@ func (s *Session) UpdateGoal(goal *SessionGoal) error { | |||
| 1439 | 1445 | return nil | |
| 1440 | 1446 | } | |
| 1441 | 1447 | ||
| 1448 | + | // UpdateGoalRuntime persists mutable execution state without treating each | |
| 1449 | + | // continuation as a mission transition. The goal ID must match the current | |
| 1450 | + | // goal so stale workers cannot overwrite a newer user or manager goal. | |
| 1451 | + | func (s *Session) UpdateGoalRuntime(goal *SessionGoal) error { | |
| 1452 | + | if s == nil { | |
| 1453 | + | return nil | |
| 1454 | + | } | |
| 1455 | + | if goal == nil || s.Meta.Goal == nil || goal.ID == "" || goal.ID != s.Meta.Goal.ID { | |
| 1456 | + | return errors.New("goal runtime update does not match the current goal") | |
| 1457 | + | } | |
| 1458 | + | previous := cloneSessionGoal(s.Meta.Goal) | |
| 1459 | + | copyGoal := *s.Meta.Goal | |
| 1460 | + | copyGoal.TokensUsed = goal.TokensUsed | |
| 1461 | + | copyGoal.ContinuationID = goal.ContinuationID | |
| 1462 | + | copyGoal.ConsecutiveNoProgressTurns = goal.ConsecutiveNoProgressTurns | |
| 1463 | + | s.Meta.Goal = ©Goal | |
| 1464 | + | if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil { | |
| 1465 | + | s.Meta.Goal = previous | |
| 1466 | + | return fmt.Errorf("update goal runtime: %w", err) | |
| 1467 | + | } | |
| 1468 | + | return nil | |
| 1469 | + | } | |
| 1470 | + | ||
| 1442 | 1471 | func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error { | |
| 1443 | 1472 | if s.Meta.Mission == nil { | |
| 1444 | 1473 | source := MissionSourceUser | |
| @@ -1476,6 +1505,17 @@ func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error { | |||
| 1476 | 1505 | mission.Reason = "" | |
| 1477 | 1506 | } else if mission.ActiveGoalID == goal.ID { | |
| 1478 | 1507 | mission.ActiveGoalID = "" | |
| 1508 | + | switch goal.Status { | |
| 1509 | + | case GoalDone: | |
| 1510 | + | mission.Status = MissionCompleted | |
| 1511 | + | mission.Reason = "" | |
| 1512 | + | case GoalPaused: | |
| 1513 | + | mission.Status = MissionPaused | |
| 1514 | + | mission.Reason = "" | |
| 1515 | + | case GoalBlocked, GoalBudgetLimited, GoalStalled: | |
| 1516 | + | mission.Status = MissionBlocked | |
| 1517 | + | mission.Reason = goal.Reason | |
| 1518 | + | } | |
| 1479 | 1519 | } | |
| 1480 | 1520 | return nil | |
| 1481 | 1521 | } | |
| @@ -55,10 +55,12 @@ type Session struct { | |||||
| 55 | type GoalStatus string | 55 | type GoalStatus string | ||
| 56 | 56 | ||||
| 57 | const ( | 57 | const ( | ||
| 58 | - | GoalActive GoalStatus = "active" | 58 | + | GoalActive GoalStatus = "active" |
| 59 | - | GoalPaused GoalStatus = "paused" | 59 | + | GoalPaused GoalStatus = "paused" |
| 60 | - | GoalBlocked GoalStatus = "blocked" | 60 | + | GoalBlocked GoalStatus = "blocked" |
| 61 | - | GoalDone GoalStatus = "done" | 61 | + | GoalDone GoalStatus = "done" |
| 62 | + | GoalBudgetLimited GoalStatus = "budget_limited" | |||
| 63 | + | GoalStalled GoalStatus = "stalled" | |||
| 62 | ) | 64 | ) | ||
| 63 | 65 | ||||
| 64 | // GoalOwner identifies who established a session goal. Missing owners in | 66 | // GoalOwner identifies who established a session goal. Missing owners in | ||
| @@ -102,13 +104,17 @@ type SessionMission struct { | |||||
| 102 | 104 | ||||
| 103 | // SessionGoal is the concise autonomous objective attached to one session. | 105 | // SessionGoal is the concise autonomous objective attached to one session. | ||
| 104 | type SessionGoal struct { | 106 | type SessionGoal struct { | ||
| 105 | - | ID string `json:"id,omitempty"` | 107 | + | ID string `json:"id,omitempty"` |
| 106 | - | MissionID string `json:"mission_id,omitempty"` | 108 | + | MissionID string `json:"mission_id,omitempty"` |
| 107 | - | Objective string `json:"objective"` | 109 | + | Objective string `json:"objective"` |
| 108 | - | Status GoalStatus `json:"status"` | 110 | + | Status GoalStatus `json:"status"` |
| 109 | - | Owner GoalOwner `json:"owner,omitempty"` | 111 | + | Owner GoalOwner `json:"owner,omitempty"` |
| 110 | - | Ordinal int `json:"ordinal,omitempty"` | 112 | + | Ordinal int `json:"ordinal,omitempty"` |
| 111 | - | Reason string `json:"reason,omitempty"` | 113 | + | Reason string `json:"reason,omitempty"` |
| 114 | + | TokenBudget *uint64 `json:"token_budget,omitempty"` | |||
| 115 | + | TokensUsed uint64 `json:"tokens_used,omitempty"` | |||
| 116 | + | ContinuationID string `json:"continuation_id,omitempty"` | |||
| 117 | + | ConsecutiveNoProgressTurns int `json:"consecutive_no_progress_turns,omitempty"` | |||
| 112 | } | 118 | } | ||
| 113 | 119 | ||||
| 114 | // SessionMeta is written as the first line of every session file. | 120 | // SessionMeta is written as the first line of every session file. | ||
| @@ -1439,6 +1445,29 @@ func (s *Session) UpdateGoal(goal *SessionGoal) error { | |||||
| 1439 | return nil | 1445 | return nil | ||
| 1440 | } | 1446 | } | ||
| 1441 | 1447 | ||||
| 1448 | + | // UpdateGoalRuntime persists mutable execution state without treating each | |||
| 1449 | + | // continuation as a mission transition. The goal ID must match the current | |||
| 1450 | + | // goal so stale workers cannot overwrite a newer user or manager goal. | |||
| 1451 | + | func (s *Session) UpdateGoalRuntime(goal *SessionGoal) error { | |||
| 1452 | + | if s == nil { | |||
| 1453 | + | return nil | |||
| 1454 | + | } | |||
| 1455 | + | if goal == nil || s.Meta.Goal == nil || goal.ID == "" || goal.ID != s.Meta.Goal.ID { | |||
| 1456 | + | return errors.New("goal runtime update does not match the current goal") | |||
| 1457 | + | } | |||
| 1458 | + | previous := cloneSessionGoal(s.Meta.Goal) | |||
| 1459 | + | copyGoal := *s.Meta.Goal | |||
| 1460 | + | copyGoal.TokensUsed = goal.TokensUsed | |||
| 1461 | + | copyGoal.ContinuationID = goal.ContinuationID | |||
| 1462 | + | copyGoal.ConsecutiveNoProgressTurns = goal.ConsecutiveNoProgressTurns | |||
| 1463 | + | s.Meta.Goal = ©Goal | |||
| 1464 | + | if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil { | |||
| 1465 | + | s.Meta.Goal = previous | |||
| 1466 | + | return fmt.Errorf("update goal runtime: %w", err) | |||
| 1467 | + | } | |||
| 1468 | + | return nil | |||
| 1469 | + | } | |||
| 1470 | + | ||||
| 1442 | func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error { | 1471 | func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error { | ||
| 1443 | if s.Meta.Mission == nil { | 1472 | if s.Meta.Mission == nil { | ||
| 1444 | source := MissionSourceUser | 1473 | source := MissionSourceUser | ||
| @@ -1476,6 +1505,17 @@ func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error { | |||||
| 1476 | mission.Reason = "" | 1505 | mission.Reason = "" | ||
| 1477 | } else if mission.ActiveGoalID == goal.ID { | 1506 | } else if mission.ActiveGoalID == goal.ID { | ||
| 1478 | mission.ActiveGoalID = "" | 1507 | mission.ActiveGoalID = "" | ||
| 1508 | + | switch goal.Status { | |||
| 1509 | + | case GoalDone: | |||
| 1510 | + | mission.Status = MissionCompleted | |||
| 1511 | + | mission.Reason = "" | |||
| 1512 | + | case GoalPaused: | |||
| 1513 | + | mission.Status = MissionPaused | |||
| 1514 | + | mission.Reason = "" | |||
| 1515 | + | case GoalBlocked, GoalBudgetLimited, GoalStalled: | |||
| 1516 | + | mission.Status = MissionBlocked | |||
| 1517 | + | mission.Reason = goal.Reason | |||
| 1518 | + | } | |||
| 1479 | } | 1519 | } | ||
| 1480 | return nil | 1520 | return nil | ||
| 1481 | } | 1521 | } | ||
M packages/core/session_portable.go +12 -1
| @@ -460,11 +460,22 @@ func cloneSessionGoal(goal *SessionGoal) *SessionGoal { | |||
| 460 | 460 | return nil | |
| 461 | 461 | } | |
| 462 | 462 | clone := *goal | |
| 463 | + | if goal.TokenBudget != nil { | |
| 464 | + | budget := *goal.TokenBudget | |
| 465 | + | clone.TokenBudget = &budget | |
| 466 | + | } | |
| 463 | 467 | return &clone | |
| 464 | 468 | } | |
| 465 | 469 | ||
| 466 | 470 | func cloneGoalHistory(history []SessionGoal) []SessionGoal { | |
| 467 | - | return append([]SessionGoal(nil), history...) | |
| 471 | + | if len(history) == 0 { | |
| 472 | + | return nil | |
| 473 | + | } | |
| 474 | + | clone := make([]SessionGoal, len(history)) | |
| 475 | + | for index := range history { | |
| 476 | + | clone[index] = *cloneSessionGoal(&history[index]) | |
| 477 | + | } | |
| 478 | + | return clone | |
| 468 | 479 | } | |
| 469 | 480 | ||
| 470 | 481 | func goalHistoryAtBranch(parent SessionMeta, messages []provider.Message, limit int) []SessionGoal { | |
| @@ -460,11 +460,22 @@ func cloneSessionGoal(goal *SessionGoal) *SessionGoal { | |||||
| 460 | return nil | 460 | return nil | ||
| 461 | } | 461 | } | ||
| 462 | clone := *goal | 462 | clone := *goal | ||
| 463 | + | if goal.TokenBudget != nil { | |||
| 464 | + | budget := *goal.TokenBudget | |||
| 465 | + | clone.TokenBudget = &budget | |||
| 466 | + | } | |||
| 463 | return &clone | 467 | return &clone | ||
| 464 | } | 468 | } | ||
| 465 | 469 | ||||
| 466 | func cloneGoalHistory(history []SessionGoal) []SessionGoal { | 470 | func cloneGoalHistory(history []SessionGoal) []SessionGoal { | ||
| 467 | - | return append([]SessionGoal(nil), history...) | 471 | + | if len(history) == 0 { |
| 472 | + | return nil | |||
| 473 | + | } | |||
| 474 | + | clone := make([]SessionGoal, len(history)) | |||
| 475 | + | for index := range history { | |||
| 476 | + | clone[index] = *cloneSessionGoal(&history[index]) | |||
| 477 | + | } | |||
| 478 | + | return clone | |||
| 468 | } | 479 | } | ||
| 469 | 480 | ||||
| 470 | func goalHistoryAtBranch(parent SessionMeta, messages []provider.Message, limit int) []SessionGoal { | 481 | func goalHistoryAtBranch(parent SessionMeta, messages []provider.Message, limit int) []SessionGoal { | ||