@kky42/pi-goal 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/LICENSE +21 -0
  3. package/README.md +35 -0
  4. package/package.json +73 -0
  5. package/src/commands.ts +107 -0
  6. package/src/continuation-scheduler.ts +174 -0
  7. package/src/format.ts +232 -0
  8. package/src/goal-accounting.ts +128 -0
  9. package/src/goal-persistence.ts +73 -0
  10. package/src/goal-runtime-agent-handlers.ts +51 -0
  11. package/src/goal-runtime-controller.ts +162 -0
  12. package/src/goal-runtime-event-handler-types.ts +166 -0
  13. package/src/goal-runtime-event-handlers.ts +31 -0
  14. package/src/goal-runtime-event-utils.ts +93 -0
  15. package/src/goal-runtime-events.ts +24 -0
  16. package/src/goal-runtime-input-context-handlers.ts +144 -0
  17. package/src/goal-runtime-session-handlers.ts +131 -0
  18. package/src/goal-runtime-state.ts +22 -0
  19. package/src/goal-runtime-status.ts +62 -0
  20. package/src/goal-runtime-turn-handlers.ts +66 -0
  21. package/src/goal-state-controller.ts +210 -0
  22. package/src/goal-transition-effects.ts +91 -0
  23. package/src/goal-transition.ts +396 -0
  24. package/src/index.ts +9 -0
  25. package/src/prompts.ts +170 -0
  26. package/src/queued-goal-messages.ts +166 -0
  27. package/src/queued-goal-work.ts +96 -0
  28. package/src/recovery-adapters.ts +66 -0
  29. package/src/recovery-machine.ts +196 -0
  30. package/src/recovery-phase.ts +95 -0
  31. package/src/recovery-runtime.ts +97 -0
  32. package/src/recovery.ts +151 -0
  33. package/src/runtime-config.ts +7 -0
  34. package/src/stale-queued-work-guard.ts +114 -0
  35. package/src/stale-queued-work-obligations.ts +291 -0
  36. package/src/stale-queued-work-reducer.ts +483 -0
  37. package/src/stale-queued-work-terminal-cleanup.ts +84 -0
  38. package/src/stale-queued-work-types.ts +81 -0
  39. package/src/state.ts +404 -0
  40. package/src/tools.ts +101 -0
  41. package/src/types.ts +60 -0
@@ -0,0 +1,97 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import {
4
+ onRecoverySessionCompact,
5
+ onRecoverySuccessfulTurn,
6
+ onRecoveryUserInput,
7
+ planRecoveryForAssistantError,
8
+ planRecoveryForSilentContextOverflow,
9
+ setRecoveryPendingAttention,
10
+ type GoalRecoveryMachineState,
11
+ type RecoveryAction,
12
+ } from "./recovery-machine.js";
13
+ import type { AssistantErrorMessage } from "./recovery.js";
14
+ import type { ThreadGoal } from "./types.js";
15
+
16
+ interface RecoveryRuntimeDeps {
17
+ getGoal: () => ThreadGoal | null;
18
+ getRecoveryState: () => GoalRecoveryMachineState;
19
+ clearContinuationState: () => void;
20
+ pauseGoalForRecovery: (ctx: ExtensionContext, recoveryReason: string) => void;
21
+ refreshUi: (ctx: ExtensionContext) => void;
22
+ requestContinuation: (ctx: ExtensionContext) => void;
23
+ }
24
+
25
+ export function createGoalRecoveryRuntime(deps: RecoveryRuntimeDeps) {
26
+ const pauseForRecoveryAttention = (ctx: ExtensionContext, reason: string): void => {
27
+ const goal = deps.getGoal();
28
+ if (!goal || goal.status !== "active") {
29
+ return;
30
+ }
31
+
32
+ deps.pauseGoalForRecovery(ctx, reason);
33
+ };
34
+
35
+ const applyRecoveryAction = (action: RecoveryAction, ctx: ExtensionContext): void => {
36
+ switch (action.type) {
37
+ case "noop":
38
+ return;
39
+ case "pending": {
40
+ const goal = deps.getGoal();
41
+ if (!goal || goal.status !== "active") {
42
+ return;
43
+ }
44
+ deps.clearContinuationState();
45
+ setRecoveryPendingAttention(deps.getRecoveryState(), action.reason);
46
+ deps.refreshUi(ctx);
47
+ return;
48
+ }
49
+ case "pause":
50
+ pauseForRecoveryAttention(ctx, action.reason);
51
+ return;
52
+ }
53
+ };
54
+
55
+ const handlePersistentAssistantError = (message: AssistantErrorMessage, ctx: ExtensionContext): void => {
56
+ const goal = deps.getGoal();
57
+ if (!goal || goal.status !== "active") {
58
+ return;
59
+ }
60
+
61
+ applyRecoveryAction(planRecoveryForAssistantError(deps.getRecoveryState(), message), ctx);
62
+ };
63
+
64
+ const handleSilentContextOverflow = (ctx: ExtensionContext): void => {
65
+ const goal = deps.getGoal();
66
+ if (!goal || goal.status !== "active") {
67
+ return;
68
+ }
69
+
70
+ applyRecoveryAction(planRecoveryForSilentContextOverflow(deps.getRecoveryState()), ctx);
71
+ };
72
+
73
+ const finishSuccessfulAssistantTurn = (
74
+ message: AssistantErrorMessage,
75
+ ctx: ExtensionContext,
76
+ options?: { continueGoal?: boolean },
77
+ ): void => {
78
+ if (onRecoverySuccessfulTurn(deps.getRecoveryState(), message)) {
79
+ deps.refreshUi(ctx);
80
+ if (options?.continueGoal !== false) {
81
+ deps.requestContinuation(ctx);
82
+ }
83
+ }
84
+ };
85
+
86
+ return {
87
+ onUserInput: () => {
88
+ onRecoveryUserInput(deps.getRecoveryState());
89
+ },
90
+ onSessionCompact: () => {
91
+ onRecoverySessionCompact(deps.getRecoveryState());
92
+ },
93
+ handlePersistentAssistantError,
94
+ handleSilentContextOverflow,
95
+ finishSuccessfulAssistantTurn,
96
+ };
97
+ }
@@ -0,0 +1,151 @@
1
+ import { isContextOverflow } from "@earendil-works/pi-ai";
2
+
3
+ import { assistantMessageForOverflowCheck } from "./recovery-adapters.js";
4
+
5
+ export const CONTEXT_OVERFLOW_SIGNATURE = "context_overflow";
6
+
7
+ /** Host AgentSession performs one overflow compact-and-retry before giving up. */
8
+ export const MAX_CONTEXT_COMPACTION_RETRIES = 1;
9
+ export const HOST_OVERFLOW_RECOVERY_REASON = "recovering from context overflow";
10
+
11
+ const RECOVERY_PENDING_ATTENTION_SUFFIX =
12
+ "wait for host retry/compaction or send a new user message if it does not recover.";
13
+
14
+ export interface AssistantErrorMessage {
15
+ role: string;
16
+ stopReason?: string;
17
+ errorMessage?: string;
18
+ usage?: {
19
+ input: number;
20
+ output: number;
21
+ cacheRead?: number;
22
+ cacheWrite?: number;
23
+ };
24
+ }
25
+
26
+ export interface ErrorRecoveryCounters {
27
+ signature: string | null;
28
+ transientAttempts: number;
29
+ compactionAttempts: number;
30
+ }
31
+
32
+ export function createErrorRecoveryCounters(): ErrorRecoveryCounters {
33
+ return {
34
+ signature: null,
35
+ transientAttempts: 0,
36
+ compactionAttempts: 0,
37
+ };
38
+ }
39
+
40
+ export function isErrorAssistantMessage(message: AssistantErrorMessage): boolean {
41
+ return message.role === "assistant" && message.stopReason === "error";
42
+ }
43
+
44
+ export function isSuccessfulAssistantTurn(message: AssistantErrorMessage): boolean {
45
+ if (message.role !== "assistant") {
46
+ return false;
47
+ }
48
+ return message.stopReason !== "error" && message.stopReason !== "aborted";
49
+ }
50
+
51
+ export function isAssistantContextOverflow(
52
+ message: AssistantErrorMessage,
53
+ contextWindow: number,
54
+ ): boolean {
55
+ if (message.role !== "assistant") {
56
+ return false;
57
+ }
58
+ if (contextWindow <= 0) {
59
+ return isContextOverflowError(message.errorMessage);
60
+ }
61
+ return isContextOverflow(assistantMessageForOverflowCheck(message), contextWindow);
62
+ }
63
+
64
+ export function isContextOverflowError(errorMessage: string | undefined): boolean {
65
+ return isContextOverflow(
66
+ assistantMessageForOverflowCheck({
67
+ stopReason: "error",
68
+ errorMessage: errorMessage ?? "",
69
+ }),
70
+ );
71
+ }
72
+
73
+ function isNonRetryableProviderLimitError(errorMessage: string): boolean {
74
+ return /GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i.test(
75
+ errorMessage,
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Mirrors Pi 0.76.0 host AgentSession._isRetryableError() classification for transient provider failures.
81
+ * Context overflow is not transient retryable because host compaction handles that path.
82
+ * Terminal quota, billing, and provider-limit errors are not retryable even when they contain 429 or rate-limit wording.
83
+ */
84
+ export function isRetryableTransientError(errorMessage: string | undefined): boolean {
85
+ if (!errorMessage) {
86
+ return false;
87
+ }
88
+ if (isContextOverflowError(errorMessage)) {
89
+ return false;
90
+ }
91
+ if (isNonRetryableProviderLimitError(errorMessage)) {
92
+ return false;
93
+ }
94
+ return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test(
95
+ errorMessage,
96
+ );
97
+ }
98
+
99
+ function normalizeTransientSignature(line: string): string {
100
+ return line
101
+ .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "<id>")
102
+ .replace(/\breq[_-]?[a-z0-9-]+\b/gi, "req_<id>")
103
+ .replace(/\b\d{4,}\b/g, "<n>")
104
+ .slice(0, 200);
105
+ }
106
+
107
+ export function failureSignature(errorMessage: string | undefined): string {
108
+ if (isContextOverflowError(errorMessage)) {
109
+ return CONTEXT_OVERFLOW_SIGNATURE;
110
+ }
111
+ const message = (errorMessage ?? "unknown_error").trim();
112
+ const firstLine = message.split("\n")[0] ?? message;
113
+ return normalizeTransientSignature(firstLine);
114
+ }
115
+
116
+ /** Resets transient retry counters when the failure signature changes; overflow compaction attempts are independent. */
117
+ export function countersForFailureSignature(
118
+ counters: ErrorRecoveryCounters,
119
+ signature: string,
120
+ ): ErrorRecoveryCounters {
121
+ if (counters.signature === signature) {
122
+ return counters;
123
+ }
124
+ return {
125
+ signature,
126
+ transientAttempts: 0,
127
+ compactionAttempts: counters.compactionAttempts,
128
+ };
129
+ }
130
+
131
+ export function recoveryPendingAttentionMessage(reason: string): string {
132
+ return `Goal recovery pending (${reason}); ${RECOVERY_PENDING_ATTENTION_SUFFIX}`;
133
+ }
134
+
135
+ export function isRecoveryPendingAttention(attention: string | null): boolean {
136
+ return attention?.startsWith("Goal recovery pending (") ?? false;
137
+ }
138
+
139
+ export function reasonFromRecoveryPendingAttention(attention: string): string | null {
140
+ const match = /^Goal recovery pending \((.+)\); /.exec(attention);
141
+ return match?.[1] ?? null;
142
+ }
143
+
144
+ export function recoveryPausedAttentionMessage(reason: string): string {
145
+ return `Goal needs attention (${reason}). Use /goal resume to continue.`;
146
+ }
147
+
148
+ /** Paused goals use /goal resume guidance in footer attention copy. */
149
+ export function recoveryAttentionMessage(reason: string): string {
150
+ return recoveryPausedAttentionMessage(reason);
151
+ }
@@ -0,0 +1,7 @@
1
+ export const CONTINUATION_RETRY_MS = 50;
2
+ export const RUNTIME_PERSIST_INTERVAL_MS = 60_000;
3
+
4
+ export const __testHooks = {
5
+ continuationRetryMs: CONTINUATION_RETRY_MS,
6
+ runtimePersistIntervalMs: RUNTIME_PERSIST_INTERVAL_MS,
7
+ };
@@ -0,0 +1,114 @@
1
+ import {
2
+ createInitialStaleQueuedWorkState,
3
+ lifecycleKindFromState,
4
+ reduceStaleQueuedWork,
5
+ type AgentEndMessage,
6
+ type StaleQueuedWorkEffect,
7
+ type StaleQueuedWorkEvent,
8
+ type StaleQueuedWorkLifecycleKind,
9
+ type StaleQueuedWorkPlan,
10
+ type StaleQueuedWorkState,
11
+ } from "./stale-queued-work-reducer.js";
12
+
13
+ export type {
14
+ AgentEndMessage,
15
+ StaleQueuedWorkEffect,
16
+ StaleQueuedWorkLifecycleKind,
17
+ StaleQueuedWorkPlan,
18
+ } from "./stale-queued-work-reducer.js";
19
+
20
+ export interface StaleQueuedWorkGuard {
21
+ lifecycleKind(): StaleQueuedWorkLifecycleKind;
22
+ isBlockingContinuation(): boolean;
23
+ noteRunnableWorkStarted(): void;
24
+ noteStaleWorkStarted(goalId: string): void;
25
+ planContextAbort(currentTurnIndex: number | null): StaleQueuedWorkPlan | null;
26
+ planUserInputClearAbort(): StaleQueuedWorkPlan;
27
+ planExtensionContinuationClearAbort(): StaleQueuedWorkPlan;
28
+ planBeforeAgentStartClearAbort(): StaleQueuedWorkPlan;
29
+ planTurnStart(): StaleQueuedWorkPlan;
30
+ planToolExecutionEnd(): StaleQueuedWorkPlan;
31
+ planSessionBeforeCompact(): StaleQueuedWorkPlan;
32
+ planSessionCompact(): StaleQueuedWorkPlan;
33
+ planTurnEnd(turnIndex: number | null): StaleQueuedWorkPlan;
34
+ planAgentEnd(messages: AgentEndMessage[]): StaleQueuedWorkPlan;
35
+ planSessionShutdown(): StaleQueuedWorkPlan;
36
+ }
37
+
38
+ function emptyPlan(): StaleQueuedWorkPlan {
39
+ return { skip: false, effects: [] };
40
+ }
41
+
42
+ export function createStaleQueuedWorkGuard(): StaleQueuedWorkGuard {
43
+ let state: StaleQueuedWorkState = createInitialStaleQueuedWorkState();
44
+
45
+ const dispatch = (event: StaleQueuedWorkEvent): StaleQueuedWorkPlan | null => {
46
+ const result = reduceStaleQueuedWork(state, event);
47
+ state = result.state;
48
+ return result.plan;
49
+ };
50
+
51
+ const plan = (event: StaleQueuedWorkEvent): StaleQueuedWorkPlan => dispatch(event) ?? emptyPlan();
52
+
53
+ return {
54
+ lifecycleKind(): StaleQueuedWorkLifecycleKind {
55
+ return lifecycleKindFromState(state);
56
+ },
57
+
58
+ isBlockingContinuation(): boolean {
59
+ return state.kind === "abortingTurn";
60
+ },
61
+
62
+ noteRunnableWorkStarted(): void {
63
+ dispatch({ type: "runnableWorkStarted" });
64
+ },
65
+
66
+ noteStaleWorkStarted(goalId: string): void {
67
+ dispatch({ type: "staleWorkStarted", goalId });
68
+ },
69
+
70
+ planContextAbort(currentTurnIndex: number | null): StaleQueuedWorkPlan | null {
71
+ return dispatch({ type: "contextAbort", currentTurnIndex });
72
+ },
73
+
74
+ planUserInputClearAbort(): StaleQueuedWorkPlan {
75
+ return plan({ type: "userInputClearAbort" });
76
+ },
77
+
78
+ planExtensionContinuationClearAbort(): StaleQueuedWorkPlan {
79
+ return plan({ type: "extensionContinuationClearAbort" });
80
+ },
81
+
82
+ planBeforeAgentStartClearAbort(): StaleQueuedWorkPlan {
83
+ return plan({ type: "beforeAgentStartClearAbort" });
84
+ },
85
+
86
+ planTurnStart(): StaleQueuedWorkPlan {
87
+ return plan({ type: "turnStart" });
88
+ },
89
+
90
+ planToolExecutionEnd(): StaleQueuedWorkPlan {
91
+ return plan({ type: "toolExecutionEnd" });
92
+ },
93
+
94
+ planSessionBeforeCompact(): StaleQueuedWorkPlan {
95
+ return plan({ type: "sessionBeforeCompact" });
96
+ },
97
+
98
+ planSessionCompact(): StaleQueuedWorkPlan {
99
+ return plan({ type: "sessionCompact" });
100
+ },
101
+
102
+ planTurnEnd(turnIndex: number | null): StaleQueuedWorkPlan {
103
+ return plan({ type: "turnEnd", turnIndex });
104
+ },
105
+
106
+ planAgentEnd(messages: AgentEndMessage[]): StaleQueuedWorkPlan {
107
+ return plan({ type: "agentEnd", messages });
108
+ },
109
+
110
+ planSessionShutdown(): StaleQueuedWorkPlan {
111
+ return plan({ type: "sessionShutdown" });
112
+ },
113
+ };
114
+ }
@@ -0,0 +1,291 @@
1
+ import {
2
+ agentEndMessagesIncludeQueuedGoalWork,
3
+ pendingStaleQueuedGoalWorkIdsFromMessages,
4
+ } from "./queued-goal-work.js";
5
+ import type {
6
+ AbortingTurnState,
7
+ AgentEndMessage,
8
+ AgentEndObligation,
9
+ TerminalCleanup,
10
+ TerminalObligationPhase,
11
+ } from "./stale-queued-work-types.js";
12
+
13
+ export function obligationsForStaleAbort(
14
+ staleGoalIds: ReadonlySet<string>,
15
+ phase: TerminalObligationPhase,
16
+ ): AgentEndObligation[] {
17
+ if (staleGoalIds.size === 0) {
18
+ return [];
19
+ }
20
+ return [{ goalIds: new Set(staleGoalIds), acceptsAnonymous: true, phase }];
21
+ }
22
+
23
+ export function setAnonymousMatching(
24
+ obligations: AgentEndObligation[],
25
+ acceptsAnonymous: boolean,
26
+ ): void {
27
+ for (const obligation of obligations) {
28
+ obligation.acceptsAnonymous = acceptsAnonymous;
29
+ }
30
+ }
31
+
32
+ export function markAllObligationsOlder(cleanup: TerminalCleanup): void {
33
+ for (const obligation of cleanup.pendingAgentEndObligations) {
34
+ obligation.phase = "older";
35
+ }
36
+ }
37
+
38
+ export function dropActiveObligations(cleanup: TerminalCleanup): void {
39
+ cleanup.pendingAgentEndObligations = cleanup.pendingAgentEndObligations.filter(
40
+ (obligation) => obligation.phase !== "active",
41
+ );
42
+ }
43
+
44
+ export function consumePendingStaleAgentEnd(
45
+ cleanup: TerminalCleanup,
46
+ messages: AgentEndMessage[],
47
+ ): boolean {
48
+ const pendingGoalIds = pendingGoalIdsFromObligations(cleanup.pendingAgentEndObligations);
49
+ const matchedGoalIds = pendingStaleQueuedGoalWorkIdsFromMessages(messages, pendingGoalIds);
50
+ const goalMatch = consumeGoalBearingTerminal(
51
+ cleanup.pendingAgentEndObligations,
52
+ matchedGoalIds,
53
+ ["older", "active"],
54
+ );
55
+ if (goalMatch.consumed) {
56
+ return true;
57
+ }
58
+ if (!matchesAnonymousStaleAgentEnd(messages)) {
59
+ return false;
60
+ }
61
+ return consumeAnonymousTerminal(
62
+ cleanup.pendingAgentEndObligations,
63
+ ["older", "active"],
64
+ ).consumed;
65
+ }
66
+
67
+ export type AbortingAgentEndConsumption = {
68
+ consumedActive: boolean;
69
+ consumedOlder: boolean;
70
+ activePending: boolean;
71
+ };
72
+
73
+ export function consumeAbortingAgentEnd(
74
+ aborting: AbortingTurnState,
75
+ messages: AgentEndMessage[],
76
+ ): AbortingAgentEndConsumption {
77
+ const { terminalCleanup } = aborting;
78
+ const matchedGoalIds = pendingStaleQueuedGoalWorkIdsFromMessages(
79
+ messages,
80
+ allPendingGoalIds(terminalCleanup),
81
+ );
82
+ const preferActiveFirst =
83
+ activeTurnEndConsumed(aborting) &&
84
+ matchedGoalIds.length > 0 &&
85
+ isSubsetOfSet(matchedGoalIds, pendingGoalIdsByPhase(terminalCleanup, "active"));
86
+
87
+ const goalMatch = consumeGoalBearingTerminal(
88
+ terminalCleanup.pendingAgentEndObligations,
89
+ matchedGoalIds,
90
+ preferActiveFirst ? ["active", "older"] : ["older", "active"],
91
+ );
92
+
93
+ let consumedActive = goalMatch.consumedActive;
94
+ let consumedOlder = goalMatch.consumedOlder;
95
+ if (matchesAnonymousStaleAgentEnd(messages)) {
96
+ const preferActiveAnonymous =
97
+ activeTurnEndConsumed(aborting) &&
98
+ terminalCleanup.pendingAgentEndObligations.some(
99
+ (obligation) => obligation.phase === "active" && obligation.acceptsAnonymous,
100
+ );
101
+ const anonymousMatch = consumeAnonymousTerminalForAbortingTurn(
102
+ terminalCleanup.pendingAgentEndObligations,
103
+ preferActiveAnonymous ? ["active", "older"] : ["older", "active"],
104
+ );
105
+ consumedActive ||= anonymousMatch.consumedActive;
106
+ consumedOlder ||= anonymousMatch.consumedOlder;
107
+ }
108
+
109
+ return {
110
+ consumedActive,
111
+ consumedOlder,
112
+ activePending: terminalCleanup.pendingAgentEndObligations.some(
113
+ (obligation) => obligation.phase === "active",
114
+ ),
115
+ };
116
+ }
117
+
118
+ function isStaleTerminalAssistantMessage(message: { role: string; stopReason?: string }): boolean {
119
+ return (
120
+ message.role === "assistant" &&
121
+ (message.stopReason === "aborted" ||
122
+ message.stopReason === "stop" ||
123
+ message.stopReason === "error")
124
+ );
125
+ }
126
+
127
+ function matchesAnonymousStaleAgentEnd(messages: AgentEndMessage[]): boolean {
128
+ if (agentEndMessagesIncludeQueuedGoalWork(messages)) {
129
+ return false;
130
+ }
131
+ return messages.some(isStaleTerminalAssistantMessage);
132
+ }
133
+
134
+ function activeTurnEndConsumed(aborting: AbortingTurnState): boolean {
135
+ const { activeTurnIndex, terminalCleanup } = aborting;
136
+ return activeTurnIndex !== null && !terminalCleanup.pendingTurnEndIndexes.has(activeTurnIndex);
137
+ }
138
+
139
+ function allPendingGoalIds(cleanup: TerminalCleanup): Set<string> {
140
+ return pendingGoalIdsFromObligations(cleanup.pendingAgentEndObligations);
141
+ }
142
+
143
+ function pendingGoalIdsByPhase(
144
+ cleanup: TerminalCleanup,
145
+ phase: TerminalObligationPhase,
146
+ ): Set<string> {
147
+ return pendingGoalIdsFromObligations(
148
+ cleanup.pendingAgentEndObligations.filter((obligation) => obligation.phase === phase),
149
+ );
150
+ }
151
+
152
+ function pendingGoalIdsFromObligations(obligations: readonly AgentEndObligation[]): Set<string> {
153
+ const goalIds = new Set<string>();
154
+ for (const obligation of obligations) {
155
+ for (const goalId of obligation.goalIds) {
156
+ goalIds.add(goalId);
157
+ }
158
+ }
159
+ return goalIds;
160
+ }
161
+
162
+ function isSubsetOfSet(values: readonly string[], superset: ReadonlySet<string>): boolean {
163
+ for (const value of values) {
164
+ if (!superset.has(value)) {
165
+ return false;
166
+ }
167
+ }
168
+ return true;
169
+ }
170
+
171
+ function obligationMatchesAnyGoal(
172
+ obligation: AgentEndObligation,
173
+ matchedGoalIds: ReadonlySet<string>,
174
+ ): boolean {
175
+ for (const goalId of obligation.goalIds) {
176
+ if (matchedGoalIds.has(goalId)) {
177
+ return true;
178
+ }
179
+ }
180
+ return false;
181
+ }
182
+
183
+ type ConsumptionResult = {
184
+ consumed: boolean;
185
+ consumedOlder: boolean;
186
+ consumedActive: boolean;
187
+ };
188
+
189
+ function emptyConsumptionResult(): ConsumptionResult {
190
+ return { consumed: false, consumedOlder: false, consumedActive: false };
191
+ }
192
+
193
+ function recordConsumedObligation(result: ConsumptionResult, obligation: AgentEndObligation): void {
194
+ result.consumed = true;
195
+ result.consumedOlder ||= obligation.phase === "older";
196
+ result.consumedActive ||= obligation.phase === "active";
197
+ }
198
+
199
+ function consumeObligationAt(
200
+ obligations: AgentEndObligation[],
201
+ index: number,
202
+ ): AgentEndObligation | null {
203
+ const [obligation] = obligations.splice(index, 1);
204
+ return obligation ?? null;
205
+ }
206
+
207
+ function consumeGoalBearingTerminal(
208
+ obligations: AgentEndObligation[],
209
+ matchedGoalIds: readonly string[],
210
+ phaseOrder: readonly TerminalObligationPhase[],
211
+ ): ConsumptionResult {
212
+ const result = emptyConsumptionResult();
213
+ const remainingGoalIds = new Set(matchedGoalIds);
214
+
215
+ for (const phase of phaseOrder) {
216
+ for (let index = 0; index < obligations.length; ) {
217
+ const obligation = obligations[index]!;
218
+ if (
219
+ obligation.phase !== phase ||
220
+ remainingGoalIds.size === 0 ||
221
+ !obligationMatchesAnyGoal(obligation, remainingGoalIds)
222
+ ) {
223
+ index += 1;
224
+ continue;
225
+ }
226
+
227
+ const consumed = consumeObligationAt(obligations, index);
228
+ if (consumed) {
229
+ recordConsumedObligation(result, consumed);
230
+ for (const goalId of consumed.goalIds) {
231
+ remainingGoalIds.delete(goalId);
232
+ }
233
+ }
234
+ if (remainingGoalIds.size === 0) {
235
+ return result;
236
+ }
237
+ }
238
+ }
239
+
240
+ return result;
241
+ }
242
+
243
+ function consumeAnonymousTerminal(
244
+ obligations: AgentEndObligation[],
245
+ phaseOrder: readonly TerminalObligationPhase[],
246
+ ): ConsumptionResult {
247
+ const result = emptyConsumptionResult();
248
+
249
+ for (const phase of phaseOrder) {
250
+ for (let index = 0; index < obligations.length; index += 1) {
251
+ const obligation = obligations[index]!;
252
+ if (obligation.phase !== phase || !obligation.acceptsAnonymous) {
253
+ continue;
254
+ }
255
+
256
+ const consumed = consumeObligationAt(obligations, index);
257
+ if (consumed) {
258
+ recordConsumedObligation(result, consumed);
259
+ }
260
+ return result;
261
+ }
262
+ }
263
+
264
+ return result;
265
+ }
266
+
267
+ function consumeAnonymousTerminalForAbortingTurn(
268
+ obligations: AgentEndObligation[],
269
+ phaseOrder: readonly TerminalObligationPhase[],
270
+ ): ConsumptionResult {
271
+ const result = emptyConsumptionResult();
272
+ const fallbackPhase = phaseOrder.at(-1);
273
+
274
+ for (const phase of phaseOrder) {
275
+ for (let index = 0; index < obligations.length; index += 1) {
276
+ const obligation = obligations[index]!;
277
+ const matchesCurrentAbort = obligation.acceptsAnonymous || phase === fallbackPhase;
278
+ if (obligation.phase !== phase || !matchesCurrentAbort) {
279
+ continue;
280
+ }
281
+
282
+ const consumed = consumeObligationAt(obligations, index);
283
+ if (consumed) {
284
+ recordConsumedObligation(result, consumed);
285
+ }
286
+ return result;
287
+ }
288
+ }
289
+
290
+ return result;
291
+ }