@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.
- package/CHANGELOG.md +112 -0
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/package.json +73 -0
- package/src/commands.ts +107 -0
- package/src/continuation-scheduler.ts +174 -0
- package/src/format.ts +232 -0
- package/src/goal-accounting.ts +128 -0
- package/src/goal-persistence.ts +73 -0
- package/src/goal-runtime-agent-handlers.ts +51 -0
- package/src/goal-runtime-controller.ts +162 -0
- package/src/goal-runtime-event-handler-types.ts +166 -0
- package/src/goal-runtime-event-handlers.ts +31 -0
- package/src/goal-runtime-event-utils.ts +93 -0
- package/src/goal-runtime-events.ts +24 -0
- package/src/goal-runtime-input-context-handlers.ts +144 -0
- package/src/goal-runtime-session-handlers.ts +131 -0
- package/src/goal-runtime-state.ts +22 -0
- package/src/goal-runtime-status.ts +62 -0
- package/src/goal-runtime-turn-handlers.ts +66 -0
- package/src/goal-state-controller.ts +210 -0
- package/src/goal-transition-effects.ts +91 -0
- package/src/goal-transition.ts +396 -0
- package/src/index.ts +9 -0
- package/src/prompts.ts +170 -0
- package/src/queued-goal-messages.ts +166 -0
- package/src/queued-goal-work.ts +96 -0
- package/src/recovery-adapters.ts +66 -0
- package/src/recovery-machine.ts +196 -0
- package/src/recovery-phase.ts +95 -0
- package/src/recovery-runtime.ts +97 -0
- package/src/recovery.ts +151 -0
- package/src/runtime-config.ts +7 -0
- package/src/stale-queued-work-guard.ts +114 -0
- package/src/stale-queued-work-obligations.ts +291 -0
- package/src/stale-queued-work-reducer.ts +483 -0
- package/src/stale-queued-work-terminal-cleanup.ts +84 -0
- package/src/stale-queued-work-types.ts +81 -0
- package/src/state.ts +404 -0
- package/src/tools.ts +101 -0
- package/src/types.ts +60 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { CUSTOM_ENTRY_TYPE } from "./types.js";
|
|
2
|
+
|
|
3
|
+
type GoalQueuedWorkKind = "continuation" | "command_start" | "command_resume";
|
|
4
|
+
|
|
5
|
+
export interface ActiveGoalQueuedDetails {
|
|
6
|
+
kind: GoalQueuedWorkKind;
|
|
7
|
+
goalId: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface QueuedGoalTextPart {
|
|
11
|
+
readonly type: "text";
|
|
12
|
+
readonly text: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type QueuedGoalUserContent = QueuedGoalTextPart[];
|
|
16
|
+
|
|
17
|
+
/** External provider-context message shape before normalization. */
|
|
18
|
+
export interface QueuedGoalContextInput {
|
|
19
|
+
role: string;
|
|
20
|
+
customType?: string;
|
|
21
|
+
content?: unknown;
|
|
22
|
+
display?: boolean;
|
|
23
|
+
details?: unknown;
|
|
24
|
+
timestamp?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Normalized provider-context carrier with required runtime fields. */
|
|
28
|
+
export interface QueuedGoalContextCarrier {
|
|
29
|
+
role: string;
|
|
30
|
+
timestamp: number;
|
|
31
|
+
customType?: string;
|
|
32
|
+
content?: unknown;
|
|
33
|
+
display?: boolean;
|
|
34
|
+
details?: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface QueuedGoalCustomMessage extends QueuedGoalContextCarrier {
|
|
38
|
+
role: "custom";
|
|
39
|
+
customType: typeof CUSTOM_ENTRY_TYPE;
|
|
40
|
+
content: string | QueuedGoalUserContent;
|
|
41
|
+
display: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface QueuedGoalUserMessage extends QueuedGoalContextCarrier {
|
|
45
|
+
role: "user";
|
|
46
|
+
content: QueuedGoalUserContent;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type QueuedGoalWorkSourceMessage = QueuedGoalCustomMessage | QueuedGoalUserMessage;
|
|
50
|
+
|
|
51
|
+
/** Role/customType only — does not prove normalized content or display. */
|
|
52
|
+
interface QueuedGoalCustomRoleCarrier {
|
|
53
|
+
role: "custom";
|
|
54
|
+
customType: typeof CUSTOM_ENTRY_TYPE;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isQueuedGoalCustomRole(
|
|
58
|
+
message: QueuedGoalContextCarrier,
|
|
59
|
+
): message is QueuedGoalContextCarrier & QueuedGoalCustomRoleCarrier {
|
|
60
|
+
return message.role === "custom" && message.customType === CUSTOM_ENTRY_TYPE;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function userContentFromUnknown(content: unknown): QueuedGoalUserContent {
|
|
64
|
+
if (!Array.isArray(content)) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const parts: QueuedGoalTextPart[] = [];
|
|
69
|
+
for (const part of content) {
|
|
70
|
+
if (part === null || typeof part !== "object") {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const candidate = part as { type?: unknown; text?: unknown };
|
|
74
|
+
if (candidate.type === "text" && typeof candidate.text === "string") {
|
|
75
|
+
parts.push({ type: "text", text: candidate.text });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return parts;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function customContentFromUnknown(content: unknown): string | QueuedGoalUserContent {
|
|
82
|
+
if (typeof content === "string") {
|
|
83
|
+
return content;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const normalized = userContentFromUnknown(content);
|
|
87
|
+
return normalized.length > 0 ? normalized : "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Copies provider-context fields into a carrier with the runtime-required timestamp. */
|
|
91
|
+
export function toQueuedGoalContextCarrier(message: QueuedGoalContextInput): QueuedGoalContextCarrier | null {
|
|
92
|
+
if (typeof message.timestamp !== "number") {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const carrier: QueuedGoalContextCarrier = {
|
|
97
|
+
role: message.role,
|
|
98
|
+
timestamp: message.timestamp,
|
|
99
|
+
};
|
|
100
|
+
if (message.customType !== undefined) {
|
|
101
|
+
carrier.customType = message.customType;
|
|
102
|
+
}
|
|
103
|
+
if (message.content !== undefined) {
|
|
104
|
+
carrier.content = message.content;
|
|
105
|
+
}
|
|
106
|
+
if (message.display !== undefined) {
|
|
107
|
+
carrier.display = message.display;
|
|
108
|
+
}
|
|
109
|
+
if (message.details !== undefined) {
|
|
110
|
+
carrier.details = message.details;
|
|
111
|
+
}
|
|
112
|
+
return carrier;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Narrows a carrier to queued-goal user/custom work and normalizes its content. */
|
|
116
|
+
export function toQueuedGoalWorkSource(
|
|
117
|
+
message: QueuedGoalContextCarrier,
|
|
118
|
+
): QueuedGoalWorkSourceMessage | null {
|
|
119
|
+
switch (message.role) {
|
|
120
|
+
case "user":
|
|
121
|
+
return {
|
|
122
|
+
...message,
|
|
123
|
+
role: "user",
|
|
124
|
+
content: userContentFromUnknown(message.content),
|
|
125
|
+
};
|
|
126
|
+
case "custom": {
|
|
127
|
+
if (!isQueuedGoalCustomRole(message)) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const normalized: QueuedGoalCustomMessage = {
|
|
131
|
+
role: "custom",
|
|
132
|
+
customType: message.customType,
|
|
133
|
+
timestamp: message.timestamp,
|
|
134
|
+
content: customContentFromUnknown(message.content),
|
|
135
|
+
display: message.display ?? false,
|
|
136
|
+
};
|
|
137
|
+
if (message.details !== undefined) {
|
|
138
|
+
normalized.details = message.details;
|
|
139
|
+
}
|
|
140
|
+
return normalized;
|
|
141
|
+
}
|
|
142
|
+
default:
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function isActiveGoalQueuedDetails(details: unknown): details is ActiveGoalQueuedDetails {
|
|
148
|
+
if (details === null || typeof details !== "object") {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const candidate = details as { kind?: unknown; goalId?: unknown };
|
|
153
|
+
const kind = candidate.kind;
|
|
154
|
+
return (
|
|
155
|
+
(kind === "continuation" || kind === "command_start" || kind === "command_resume") &&
|
|
156
|
+
typeof candidate.goalId === "string"
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function isCommandResumeQueuedGoalMessage(message: QueuedGoalContextCarrier): boolean {
|
|
161
|
+
return (
|
|
162
|
+
isQueuedGoalCustomRole(message) &&
|
|
163
|
+
isActiveGoalQueuedDetails(message.details) &&
|
|
164
|
+
message.details.kind === "command_resume"
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { continuationGoalIdFromPrompt } from "./prompts.js";
|
|
2
|
+
import {
|
|
3
|
+
isActiveGoalQueuedDetails,
|
|
4
|
+
type QueuedGoalContextInput,
|
|
5
|
+
userContentFromUnknown,
|
|
6
|
+
} from "./queued-goal-messages.js";
|
|
7
|
+
import { CUSTOM_ENTRY_TYPE, type ThreadGoal } from "./types.js";
|
|
8
|
+
|
|
9
|
+
function isSupersededContinuationDetails(details: unknown): boolean {
|
|
10
|
+
return details !== null && typeof details === "object" && (details as { kind?: unknown }).kind === "superseded_continuation";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function textContentFromMessageContent(content: unknown): string | null {
|
|
14
|
+
if (typeof content === "string") {
|
|
15
|
+
return content;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const parts = userContentFromUnknown(content);
|
|
19
|
+
if (parts.length === 0) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return parts.map((part) => part.text).join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function continuationGoalIdFromMessageContent(content: unknown): string | null {
|
|
27
|
+
const text = textContentFromMessageContent(content);
|
|
28
|
+
return text === null ? null : continuationGoalIdFromPrompt(text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function extensionQueuedGoalWorkMessageId(message: QueuedGoalContextInput): string | null {
|
|
32
|
+
if (message.role !== "custom" || message.customType !== CUSTOM_ENTRY_TYPE) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (isSupersededContinuationDetails(message.details)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (isActiveGoalQueuedDetails(message.details)) {
|
|
41
|
+
return message.details.goalId;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return continuationGoalIdFromMessageContent(message.content);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function queuedGoalWorkMessageId(message: QueuedGoalContextInput): string | null {
|
|
48
|
+
if (message.role === "user") {
|
|
49
|
+
return continuationGoalIdFromMessageContent(message.content);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return extensionQueuedGoalWorkMessageId(message);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function applyQueuedGoalProviderContextRewrites<TMessage extends QueuedGoalContextInput>(
|
|
56
|
+
messages: readonly TMessage[],
|
|
57
|
+
_options: {
|
|
58
|
+
goal: ThreadGoal | null;
|
|
59
|
+
resolveStaleQueuedGoalWorkMessageId: (message: QueuedGoalContextInput) => string | null;
|
|
60
|
+
resolveActiveContinuationQueuedGoalWorkMessageId: (message: QueuedGoalContextInput) => string | null;
|
|
61
|
+
},
|
|
62
|
+
): { messages: TMessage[]; changed: boolean } {
|
|
63
|
+
return { messages: [...messages], changed: false };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function extensionQueuedGoalWorkMessageIdForRuntime(
|
|
67
|
+
message: QueuedGoalContextInput,
|
|
68
|
+
resolveContinuationGoalIdFromPrompt: (prompt: string) => string | null,
|
|
69
|
+
): string | null {
|
|
70
|
+
if (message.role === "user") {
|
|
71
|
+
const text = textContentFromMessageContent(message.content);
|
|
72
|
+
return text === null ? null : resolveContinuationGoalIdFromPrompt(text);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return queuedGoalWorkMessageId(message);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function agentEndMessagesIncludeQueuedGoalWork(
|
|
79
|
+
messages: readonly QueuedGoalContextInput[],
|
|
80
|
+
): boolean {
|
|
81
|
+
return messages.some((message) => queuedGoalWorkMessageId(message) !== null);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function pendingStaleQueuedGoalWorkIdsFromMessages(
|
|
85
|
+
messages: readonly QueuedGoalContextInput[],
|
|
86
|
+
staleQueuedGoalWorkAgentEndGoalIds: ReadonlySet<string>,
|
|
87
|
+
): string[] {
|
|
88
|
+
const goalIds: string[] = [];
|
|
89
|
+
for (const message of messages) {
|
|
90
|
+
const queuedGoalId = queuedGoalWorkMessageId(message);
|
|
91
|
+
if (queuedGoalId !== null && staleQueuedGoalWorkAgentEndGoalIds.has(queuedGoalId)) {
|
|
92
|
+
goalIds.push(queuedGoalId);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return goalIds;
|
|
96
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { AssistantMessage, StopReason } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
export interface OverflowCheckAssistantMessage {
|
|
4
|
+
stopReason?: string;
|
|
5
|
+
errorMessage?: string;
|
|
6
|
+
usage?: {
|
|
7
|
+
input: number;
|
|
8
|
+
output: number;
|
|
9
|
+
cacheRead?: number;
|
|
10
|
+
cacheWrite?: number;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const OVERFLOW_CHECK_API = "pi-codex-goal-overflow-check";
|
|
15
|
+
const OVERFLOW_CHECK_PROVIDER = "pi-codex-goal";
|
|
16
|
+
const OVERFLOW_CHECK_MODEL = "overflow-check";
|
|
17
|
+
|
|
18
|
+
function stopReasonFromAssistantError(stopReason: string | undefined): StopReason {
|
|
19
|
+
switch (stopReason) {
|
|
20
|
+
case "stop":
|
|
21
|
+
case "length":
|
|
22
|
+
case "toolUse":
|
|
23
|
+
case "error":
|
|
24
|
+
case "aborted":
|
|
25
|
+
return stopReason;
|
|
26
|
+
default:
|
|
27
|
+
return "error";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Single adapter for pi-ai overflow checks; keeps AssistantMessage casts out of recovery logic. */
|
|
32
|
+
export function assistantMessageForOverflowCheck(message: OverflowCheckAssistantMessage): AssistantMessage {
|
|
33
|
+
const usage = message.usage ?? { input: 0, output: 0 };
|
|
34
|
+
const cacheRead = usage.cacheRead ?? 0;
|
|
35
|
+
const cacheWrite = usage.cacheWrite ?? 0;
|
|
36
|
+
|
|
37
|
+
const assistantMessage: AssistantMessage = {
|
|
38
|
+
role: "assistant",
|
|
39
|
+
content: [],
|
|
40
|
+
api: OVERFLOW_CHECK_API,
|
|
41
|
+
provider: OVERFLOW_CHECK_PROVIDER,
|
|
42
|
+
model: OVERFLOW_CHECK_MODEL,
|
|
43
|
+
usage: {
|
|
44
|
+
input: usage.input,
|
|
45
|
+
output: usage.output,
|
|
46
|
+
cacheRead,
|
|
47
|
+
cacheWrite,
|
|
48
|
+
totalTokens: usage.input + usage.output + cacheRead + cacheWrite,
|
|
49
|
+
cost: {
|
|
50
|
+
input: 0,
|
|
51
|
+
output: 0,
|
|
52
|
+
cacheRead: 0,
|
|
53
|
+
cacheWrite: 0,
|
|
54
|
+
total: 0,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
stopReason: stopReasonFromAssistantError(message.stopReason),
|
|
58
|
+
timestamp: 0,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (message.errorMessage !== undefined) {
|
|
62
|
+
assistantMessage.errorMessage = message.errorMessage;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return assistantMessage;
|
|
66
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyPersistedHostOverflowUserReset,
|
|
3
|
+
clearHostOverflowRecoveryActive,
|
|
4
|
+
hostOverflowRecoveringNeedsUserStartPhase,
|
|
5
|
+
idleRecoveryPhase,
|
|
6
|
+
recoveryPhaseNeedsUserStartTurn,
|
|
7
|
+
type RecoveryPhase,
|
|
8
|
+
} from "./recovery-phase.js";
|
|
9
|
+
import {
|
|
10
|
+
CONTEXT_OVERFLOW_SIGNATURE,
|
|
11
|
+
countersForFailureSignature,
|
|
12
|
+
createErrorRecoveryCounters,
|
|
13
|
+
failureSignature,
|
|
14
|
+
HOST_OVERFLOW_RECOVERY_REASON,
|
|
15
|
+
isContextOverflowError,
|
|
16
|
+
isRetryableTransientError,
|
|
17
|
+
isSuccessfulAssistantTurn,
|
|
18
|
+
MAX_CONTEXT_COMPACTION_RETRIES,
|
|
19
|
+
recoveryPausedAttentionMessage,
|
|
20
|
+
recoveryPendingAttentionMessage,
|
|
21
|
+
type AssistantErrorMessage,
|
|
22
|
+
type ErrorRecoveryCounters,
|
|
23
|
+
} from "./recovery.js";
|
|
24
|
+
|
|
25
|
+
export type { GoalStartTurnStrategy, RecoveryPhase } from "./recovery-phase.js";
|
|
26
|
+
export {
|
|
27
|
+
goalStartTurnStrategy,
|
|
28
|
+
recoveryPhaseBlocksContinuation,
|
|
29
|
+
recoveryPhaseNeedsUserStartTurn,
|
|
30
|
+
} from "./recovery-phase.js";
|
|
31
|
+
|
|
32
|
+
export type RecoveryAction =
|
|
33
|
+
| { type: "noop" }
|
|
34
|
+
| { type: "pending"; reason: string }
|
|
35
|
+
| { type: "pause"; reason: string };
|
|
36
|
+
|
|
37
|
+
export interface GoalRecoveryMachineState {
|
|
38
|
+
counters: ErrorRecoveryCounters;
|
|
39
|
+
attention: string | null;
|
|
40
|
+
phase: RecoveryPhase;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createGoalRecoveryMachine(): GoalRecoveryMachineState {
|
|
44
|
+
return {
|
|
45
|
+
counters: createErrorRecoveryCounters(),
|
|
46
|
+
attention: null,
|
|
47
|
+
phase: idleRecoveryPhase,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resetRecoveryMachine(state: GoalRecoveryMachineState): void {
|
|
52
|
+
state.counters = createErrorRecoveryCounters();
|
|
53
|
+
state.attention = null;
|
|
54
|
+
clearActiveHostOverflowRecovery(state);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resetRecoveryCounters(state: GoalRecoveryMachineState): void {
|
|
58
|
+
state.counters = createErrorRecoveryCounters();
|
|
59
|
+
state.attention = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function onRecoveryUserInput(state: GoalRecoveryMachineState): void {
|
|
63
|
+
resetRecoveryMachine(state);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function onRecoverySuccessfulTurn(
|
|
67
|
+
state: GoalRecoveryMachineState,
|
|
68
|
+
message: AssistantErrorMessage,
|
|
69
|
+
): boolean {
|
|
70
|
+
if (!isSuccessfulAssistantTurn(message)) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
resetRecoveryCounters(state);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function onRecoverySessionCompact(state: GoalRecoveryMachineState): void {
|
|
78
|
+
if (state.attention === recoveryPendingAttentionMessage(HOST_OVERFLOW_RECOVERY_REASON)) {
|
|
79
|
+
state.attention = null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (state.counters.compactionAttempts > 0) {
|
|
83
|
+
state.counters = {
|
|
84
|
+
...state.counters,
|
|
85
|
+
transientAttempts: 0,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function setRecoveryPendingAttention(state: GoalRecoveryMachineState, reason: string): string {
|
|
91
|
+
const message = recoveryPendingAttentionMessage(reason);
|
|
92
|
+
state.attention = message;
|
|
93
|
+
return message;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function setRecoveryPausedAttention(state: GoalRecoveryMachineState, reason: string): string {
|
|
97
|
+
const message = recoveryPausedAttentionMessage(reason);
|
|
98
|
+
state.attention = message;
|
|
99
|
+
return message;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function clearActiveHostOverflowRecovery(state: GoalRecoveryMachineState): void {
|
|
103
|
+
state.phase = clearHostOverflowRecoveryActive(state.phase);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function applyHostOverflowUserResetPersistence(
|
|
107
|
+
state: GoalRecoveryMachineState,
|
|
108
|
+
needsUserReset: boolean,
|
|
109
|
+
): boolean {
|
|
110
|
+
if (recoveryPhaseNeedsUserStartTurn(state.phase) === needsUserReset) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
state.phase = applyPersistedHostOverflowUserReset(state.phase, needsUserReset);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function syncHostOverflowUserResetFromSession(
|
|
118
|
+
state: GoalRecoveryMachineState,
|
|
119
|
+
needsUserReset: boolean,
|
|
120
|
+
): void {
|
|
121
|
+
state.phase = applyPersistedHostOverflowUserReset(state.phase, needsUserReset);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Session-level overflow: require a user-started goal turn even without an active goal. */
|
|
125
|
+
export function requireHostOverflowUserReset(state: GoalRecoveryMachineState): boolean {
|
|
126
|
+
const persistHostOverflowCapReset = !recoveryPhaseNeedsUserStartTurn(state.phase);
|
|
127
|
+
state.phase = applyPersistedHostOverflowUserReset(state.phase, true);
|
|
128
|
+
return persistHostOverflowCapReset;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function beginHostOverflowRecovery(state: GoalRecoveryMachineState): {
|
|
132
|
+
attention: string;
|
|
133
|
+
persistHostOverflowCapReset: boolean;
|
|
134
|
+
} {
|
|
135
|
+
const persistHostOverflowCapReset = !recoveryPhaseNeedsUserStartTurn(state.phase);
|
|
136
|
+
state.phase = hostOverflowRecoveringNeedsUserStartPhase();
|
|
137
|
+
const attention = setRecoveryPendingAttention(state, HOST_OVERFLOW_RECOVERY_REASON);
|
|
138
|
+
return { attention, persistHostOverflowCapReset };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function incrementOverflowCompactionAttempts(state: GoalRecoveryMachineState): RecoveryAction {
|
|
142
|
+
state.counters = {
|
|
143
|
+
...state.counters,
|
|
144
|
+
signature: CONTEXT_OVERFLOW_SIGNATURE,
|
|
145
|
+
compactionAttempts: state.counters.compactionAttempts + 1,
|
|
146
|
+
};
|
|
147
|
+
if (state.counters.compactionAttempts > MAX_CONTEXT_COMPACTION_RETRIES) {
|
|
148
|
+
return {
|
|
149
|
+
type: "pause",
|
|
150
|
+
reason: "context window recovery failed after repeated compaction attempts",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { type: "noop" };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Plans extension recovery only after pi host post-run retry/compaction has finished.
|
|
158
|
+
* Host AgentSession._handlePostAgentRun() owns retry and overflow compaction; this
|
|
159
|
+
* extension tracks persistent failures and pauses with attention when caps are exceeded.
|
|
160
|
+
*/
|
|
161
|
+
export function planRecoveryForAssistantError(
|
|
162
|
+
state: GoalRecoveryMachineState,
|
|
163
|
+
message: AssistantErrorMessage,
|
|
164
|
+
): RecoveryAction {
|
|
165
|
+
if (isContextOverflowError(message.errorMessage)) {
|
|
166
|
+
return incrementOverflowCompactionAttempts(state);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const signature = failureSignature(message.errorMessage);
|
|
170
|
+
state.counters = countersForFailureSignature(state.counters, signature);
|
|
171
|
+
|
|
172
|
+
if (!isRetryableTransientError(message.errorMessage)) {
|
|
173
|
+
return {
|
|
174
|
+
type: "pause",
|
|
175
|
+
reason: `non-retryable provider error (${signature})`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
state.counters = {
|
|
180
|
+
...state.counters,
|
|
181
|
+
transientAttempts: state.counters.transientAttempts + 1,
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
type: "pending",
|
|
185
|
+
reason: `provider error (${signature})`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function planRecoveryForSilentContextOverflow(state: GoalRecoveryMachineState): RecoveryAction {
|
|
190
|
+
return incrementOverflowCompactionAttempts(state);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** True when another overflow in this recovery cycle would exceed the compaction cap. */
|
|
194
|
+
export function isRepeatOverflowCompactionDue(state: GoalRecoveryMachineState): boolean {
|
|
195
|
+
return state.counters.compactionAttempts >= MAX_CONTEXT_COMPACTION_RETRIES;
|
|
196
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export type RecoveryPhase =
|
|
2
|
+
| { kind: "idle" }
|
|
3
|
+
| { kind: "hostOverflowRecoveringNeedsUserStart" }
|
|
4
|
+
| { kind: "hostOverflowRecovering" }
|
|
5
|
+
| { kind: "hostOverflowNeedsUserStart" };
|
|
6
|
+
|
|
7
|
+
export type GoalStartTurnStrategy = "hiddenFollowUp" | "userFollowUp";
|
|
8
|
+
|
|
9
|
+
export const idleRecoveryPhase: RecoveryPhase = { kind: "idle" };
|
|
10
|
+
|
|
11
|
+
function assertNever(value: never): never {
|
|
12
|
+
throw new Error(`Unexpected recovery phase: ${JSON.stringify(value)}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function recoveryPhaseNeedsUserStartTurn(phase: RecoveryPhase): boolean {
|
|
16
|
+
switch (phase.kind) {
|
|
17
|
+
case "idle":
|
|
18
|
+
case "hostOverflowRecovering":
|
|
19
|
+
return false;
|
|
20
|
+
case "hostOverflowRecoveringNeedsUserStart":
|
|
21
|
+
case "hostOverflowNeedsUserStart":
|
|
22
|
+
return true;
|
|
23
|
+
default:
|
|
24
|
+
return assertNever(phase);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function goalStartTurnStrategy(phase: RecoveryPhase): GoalStartTurnStrategy {
|
|
29
|
+
return recoveryPhaseNeedsUserStartTurn(phase) ? "userFollowUp" : "hiddenFollowUp";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function recoveryPhaseBlocksContinuation(phase: RecoveryPhase): boolean {
|
|
33
|
+
switch (phase.kind) {
|
|
34
|
+
case "idle":
|
|
35
|
+
case "hostOverflowNeedsUserStart":
|
|
36
|
+
return false;
|
|
37
|
+
case "hostOverflowRecoveringNeedsUserStart":
|
|
38
|
+
case "hostOverflowRecovering":
|
|
39
|
+
return true;
|
|
40
|
+
default:
|
|
41
|
+
return assertNever(phase);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function hostOverflowRecoveringNeedsUserStartPhase(): RecoveryPhase {
|
|
46
|
+
return { kind: "hostOverflowRecoveringNeedsUserStart" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function clearHostOverflowRecoveryActive(phase: RecoveryPhase): RecoveryPhase {
|
|
50
|
+
switch (phase.kind) {
|
|
51
|
+
case "hostOverflowRecoveringNeedsUserStart":
|
|
52
|
+
return { kind: "hostOverflowNeedsUserStart" };
|
|
53
|
+
case "hostOverflowRecovering":
|
|
54
|
+
return idleRecoveryPhase;
|
|
55
|
+
case "idle":
|
|
56
|
+
case "hostOverflowNeedsUserStart":
|
|
57
|
+
return phase;
|
|
58
|
+
default:
|
|
59
|
+
return assertNever(phase);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function clearHostOverflowUserReset(phase: RecoveryPhase): RecoveryPhase {
|
|
64
|
+
switch (phase.kind) {
|
|
65
|
+
case "hostOverflowRecoveringNeedsUserStart":
|
|
66
|
+
return { kind: "hostOverflowRecovering" };
|
|
67
|
+
case "hostOverflowNeedsUserStart":
|
|
68
|
+
return idleRecoveryPhase;
|
|
69
|
+
case "idle":
|
|
70
|
+
case "hostOverflowRecovering":
|
|
71
|
+
return phase;
|
|
72
|
+
default:
|
|
73
|
+
return assertNever(phase);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function applyPersistedHostOverflowUserReset(
|
|
78
|
+
phase: RecoveryPhase,
|
|
79
|
+
needsUserReset: boolean,
|
|
80
|
+
): RecoveryPhase {
|
|
81
|
+
if (!needsUserReset) {
|
|
82
|
+
return clearHostOverflowUserReset(phase);
|
|
83
|
+
}
|
|
84
|
+
switch (phase.kind) {
|
|
85
|
+
case "hostOverflowRecovering":
|
|
86
|
+
return { kind: "hostOverflowRecoveringNeedsUserStart" };
|
|
87
|
+
case "hostOverflowRecoveringNeedsUserStart":
|
|
88
|
+
case "hostOverflowNeedsUserStart":
|
|
89
|
+
return phase;
|
|
90
|
+
case "idle":
|
|
91
|
+
return { kind: "hostOverflowNeedsUserStart" };
|
|
92
|
+
default:
|
|
93
|
+
return assertNever(phase);
|
|
94
|
+
}
|
|
95
|
+
}
|