@signalridge/pi-goal 1.4.3 → 1.4.4
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 +6 -0
- package/README.md +10 -5
- package/package.json +1 -1
- package/src/accounting.ts +37 -3
- package/src/commands.ts +123 -17
- package/src/goal.ts +1 -1
- package/src/lifecycle.ts +327 -25
- package/src/menu.ts +1 -1
- package/src/persistence.ts +12 -0
- package/src/prompts.ts +8 -1
- package/src/queue.ts +2 -3
- package/src/run-protocol.ts +15 -0
- package/src/runtime.ts +258 -24
- package/src/tools.ts +16 -4
- package/src/wait.ts +6 -2
package/src/runtime.ts
CHANGED
|
@@ -17,7 +17,7 @@ export { queueGoalSafetyReset, resetGoalSafetyEpoch } from "./safety.js";
|
|
|
17
17
|
|
|
18
18
|
import { DEFAULT_GOAL_SETTINGS, type GoalSettings, type GoalSettingsLoadIssue } from "./settings.js";
|
|
19
19
|
import { GoalToolPolicy, type GoalToolVisibilitySnapshot } from "./tool-policy.js";
|
|
20
|
-
import { GoalWaitTimer } from "./wait.js";
|
|
20
|
+
import { type GoalWait, GoalWaitTimer } from "./wait.js";
|
|
21
21
|
|
|
22
22
|
export { GOAL_BLOCKED_TOOL, GOAL_COMPLETE_TOOL, GOAL_TOOL_NAMES, GOAL_WAIT_TOOL } from "./tool-policy.js";
|
|
23
23
|
|
|
@@ -69,7 +69,7 @@ export type GoalStopRequest =
|
|
|
69
69
|
* same stale-call block — but lands on `paused`, which is already the state
|
|
70
70
|
* every resume path knows how to bring back.
|
|
71
71
|
*/
|
|
72
|
-
| { kind: "wait"; expectedGoalId: string;
|
|
72
|
+
| { kind: "wait"; expectedGoalId: string; wait: GoalWait }
|
|
73
73
|
| {
|
|
74
74
|
kind: "tools_unavailable";
|
|
75
75
|
expectedGoalId: string;
|
|
@@ -152,6 +152,9 @@ export interface GoalSettingsRuntimeSnapshot {
|
|
|
152
152
|
goalRecovery?: GoalRecovery;
|
|
153
153
|
budgetWrapUp?: BudgetWrapUp;
|
|
154
154
|
guardAbortGoalId?: string;
|
|
155
|
+
pendingStoppedUsageGoalId?: string;
|
|
156
|
+
pendingReplacementUsage?: { runGoalId: string | null; replacementGoalId: string };
|
|
157
|
+
ambiguousWaitInputGoalId?: string;
|
|
155
158
|
staleGoalToolCallsBlocked: boolean;
|
|
156
159
|
cancelledContinuationMarkers: string[];
|
|
157
160
|
terminalDetails?: GoalTerminalDetails;
|
|
@@ -166,12 +169,26 @@ interface PendingGoalPrompt {
|
|
|
166
169
|
interface PendingNonGoalInput {
|
|
167
170
|
behavior: "steer" | "followUp";
|
|
168
171
|
fingerprint: string;
|
|
169
|
-
|
|
172
|
+
realInput: boolean;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface PendingDirectInput {
|
|
176
|
+
goalId: string;
|
|
177
|
+
fingerprint: string;
|
|
178
|
+
realInput: boolean;
|
|
179
|
+
waiting: boolean;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface WaitingInputWake {
|
|
183
|
+
waitingGoal: ActiveGoal;
|
|
184
|
+
resumedGoal: ActiveGoal;
|
|
185
|
+
generation: number;
|
|
170
186
|
}
|
|
171
187
|
|
|
172
188
|
const MAX_CANCELLED_CONTINUATION_PROMPTS = 20;
|
|
173
189
|
const MAX_PENDING_GOAL_PROMPTS = 20;
|
|
174
190
|
const MAX_PENDING_NON_GOAL_INPUTS = 20;
|
|
191
|
+
const WAIT_WAKE_RECHECK_MS = 60_000;
|
|
175
192
|
const BUDGET_WRAP_UP_MESSAGE_TYPE = "goal-budget-wrap-up";
|
|
176
193
|
const BUDGET_WRAP_UP_PROMPT =
|
|
177
194
|
"The active /goal token budget is exhausted. Stop substantive work and do not call substantive tools. Summarize progress, verified results, remaining work, and blockers concisely. Treat completion as unproven. Do not call goal_complete unless authoritative, requirement-by-requirement evidence already proves every requirement is complete. Weak, indirect, or missing evidence is not enough. Budget exhaustion is not completion.";
|
|
@@ -210,8 +227,22 @@ export class GoalRuntime {
|
|
|
210
227
|
/** `null` marks a run that must not be charged to the active goal. */
|
|
211
228
|
agentRunGoalId?: string | null;
|
|
212
229
|
agentRunOrigin?: GoalRunOrigin;
|
|
230
|
+
/** Input wakes inside an existing run cannot refresh its system prompt. */
|
|
231
|
+
inputWakeGoalId?: string;
|
|
232
|
+
inputWakeNeedsPrompt = false;
|
|
233
|
+
/** Accepted idle inputs, not their early input hooks, own wait wakes. */
|
|
234
|
+
pendingDirectInputs: PendingDirectInput[] = [];
|
|
235
|
+
/** A mixed-source delivery cannot prove which waiting input remains pending. */
|
|
236
|
+
ambiguousWaitInputGoalId?: string;
|
|
237
|
+
/** The accepted prompt whose initial user message must not claim queued input. */
|
|
238
|
+
acceptedRunPrompt?: string;
|
|
239
|
+
pendingInputWake?: WaitingInputWake;
|
|
213
240
|
agentRunToolAttempted = false;
|
|
214
241
|
guardAbortGoalId?: string;
|
|
242
|
+
/** A stopped goal still owns its aborted run's final assistant usage. */
|
|
243
|
+
pendingStoppedUsageGoalId?: string;
|
|
244
|
+
/** Exclude a replaced run's late response from its successor's baseline. */
|
|
245
|
+
pendingReplacementUsage?: { runGoalId: string | null; replacementGoalId: string };
|
|
215
246
|
staleGoalToolCallsBlocked = false;
|
|
216
247
|
readonly toolPolicy: GoalToolPolicy;
|
|
217
248
|
pendingGoalPromptMarkers = new Map<string, PendingGoalPrompt>();
|
|
@@ -319,6 +350,17 @@ export class GoalRuntime {
|
|
|
319
350
|
return true;
|
|
320
351
|
}
|
|
321
352
|
|
|
353
|
+
finalizeStoppedRunUsage(ctx: StatusContext, runGoalId = this.agentRunGoalId) {
|
|
354
|
+
if (!runGoalId || this.pendingStoppedUsageGoalId !== runGoalId) return false;
|
|
355
|
+
this.pendingStoppedUsageGoalId = undefined;
|
|
356
|
+
const goal = this.activeGoal;
|
|
357
|
+
if (goal?.id !== runGoalId || goal.status === "active") return false;
|
|
358
|
+
updateGoalUsage(goal, ctx, false);
|
|
359
|
+
this.persistGoal(goal);
|
|
360
|
+
if (this.activeGoal === goal) this.updateStatus(ctx, goal);
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
322
364
|
requestContinuation(goal: ActiveGoal) {
|
|
323
365
|
if (this.hasContinuationWorkForGoal(goal.id)) return false;
|
|
324
366
|
const marker = continuationMarker(goal);
|
|
@@ -383,12 +425,19 @@ export class GoalRuntime {
|
|
|
383
425
|
let terminalReason: string | undefined;
|
|
384
426
|
switch (request.kind) {
|
|
385
427
|
case "explicit_pause":
|
|
386
|
-
this.recordGoalUsage(goal, ctx);
|
|
428
|
+
if (goal.status === "active") this.recordGoalUsage(goal, ctx);
|
|
387
429
|
this.cancelContinuationWork();
|
|
388
430
|
this.clearGoalRecoveryForGoal(goal.id);
|
|
389
431
|
this.clearBudgetWrapUp();
|
|
390
|
-
|
|
391
|
-
|
|
432
|
+
if (goal.status === "active") {
|
|
433
|
+
if (this.agentRunGoalId === goal.id) this.pendingStoppedUsageGoalId = goal.id;
|
|
434
|
+
this.blockStaleGoalToolCalls();
|
|
435
|
+
abortCurrentTurn(ctx);
|
|
436
|
+
} else if (this.agentRunGoalId === goal.id && ctx.isIdle?.() === false) {
|
|
437
|
+
this.blockStaleGoalToolCalls();
|
|
438
|
+
} else {
|
|
439
|
+
this.clearStaleGoalToolCallBlock();
|
|
440
|
+
}
|
|
392
441
|
status = "paused";
|
|
393
442
|
break;
|
|
394
443
|
case "budget_limit":
|
|
@@ -404,6 +453,7 @@ export class GoalRuntime {
|
|
|
404
453
|
this.clearBudgetWrapUp();
|
|
405
454
|
this.blockStaleGoalToolCalls();
|
|
406
455
|
if (request.abortTurn) {
|
|
456
|
+
if (this.agentRunGoalId === goal.id) this.pendingStoppedUsageGoalId = goal.id;
|
|
407
457
|
this.guardAbortGoalId = goal.id;
|
|
408
458
|
abortCurrentTurn(ctx);
|
|
409
459
|
}
|
|
@@ -418,7 +468,7 @@ export class GoalRuntime {
|
|
|
418
468
|
this.clearBudgetWrapUp();
|
|
419
469
|
this.blockStaleGoalToolCalls();
|
|
420
470
|
status = "paused";
|
|
421
|
-
terminalReason = request.reason;
|
|
471
|
+
terminalReason = request.wait.reason;
|
|
422
472
|
break;
|
|
423
473
|
case "retry_exhausted":
|
|
424
474
|
this.clearGoalRecoveryForGoal(goal.id);
|
|
@@ -434,6 +484,7 @@ export class GoalRuntime {
|
|
|
434
484
|
this.clearGoalRecoveryForGoal(goal.id);
|
|
435
485
|
this.clearBudgetWrapUp();
|
|
436
486
|
if (request.abortTurn) {
|
|
487
|
+
if (this.agentRunGoalId === goal.id) this.pendingStoppedUsageGoalId = goal.id;
|
|
437
488
|
this.blockStaleGoalToolCalls();
|
|
438
489
|
abortCurrentTurn(ctx);
|
|
439
490
|
} else {
|
|
@@ -467,6 +518,10 @@ export class GoalRuntime {
|
|
|
467
518
|
}
|
|
468
519
|
|
|
469
520
|
this.activeGoal = transitionGoal(goal, status);
|
|
521
|
+
if (request.kind === "wait") {
|
|
522
|
+
this.activeGoal.wait = request.wait;
|
|
523
|
+
this.activeGoal.safetyPauseCause = undefined;
|
|
524
|
+
}
|
|
470
525
|
if (terminalReason !== undefined) this.setTerminalReason(this.activeGoal.id, terminalReason);
|
|
471
526
|
const stoppedGoal = this.activeGoal;
|
|
472
527
|
this.persistGoal(stoppedGoal);
|
|
@@ -675,6 +730,8 @@ export class GoalRuntime {
|
|
|
675
730
|
|
|
676
731
|
clearSettledSafetyTracking() {
|
|
677
732
|
this.guardAbortGoalId = undefined;
|
|
733
|
+
this.pendingStoppedUsageGoalId = undefined;
|
|
734
|
+
this.pendingReplacementUsage = undefined;
|
|
678
735
|
this.pendingNonGoalInputs = [];
|
|
679
736
|
this.claimedGoalPromptMarkers.clear();
|
|
680
737
|
this.claimedContinuationMarkers.clear();
|
|
@@ -736,21 +793,55 @@ export class GoalRuntime {
|
|
|
736
793
|
*/
|
|
737
794
|
onGoalWaitElapsed?: (ctx: StatusContext, goalId: string) => void;
|
|
738
795
|
|
|
739
|
-
scheduleGoalWaitWake(ctx: StatusContext,
|
|
740
|
-
this.
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
796
|
+
scheduleGoalWaitWake(ctx: StatusContext, recheckAt?: number): void {
|
|
797
|
+
this.clearGoalWaitWake();
|
|
798
|
+
const goal = this.activeGoal;
|
|
799
|
+
if (this.queueFrozen || this.pendingQueueAction || goal?.status !== "paused" || !goal.wait) return;
|
|
800
|
+
const resumeAt = goal.wait.resumeAt;
|
|
801
|
+
if (resumeAt === undefined) return;
|
|
802
|
+
const generation = this.menuGeneration;
|
|
803
|
+
this.goalWaitTimer.schedule(recheckAt ?? resumeAt, () => {
|
|
804
|
+
if (
|
|
805
|
+
generation !== this.menuGeneration ||
|
|
806
|
+
this.activeGoal?.id !== goal.id ||
|
|
807
|
+
this.activeGoal.wait?.resumeAt !== resumeAt
|
|
808
|
+
)
|
|
809
|
+
return;
|
|
810
|
+
this.dispatchGoalWaitIfDue(ctx);
|
|
746
811
|
});
|
|
747
812
|
}
|
|
748
813
|
|
|
814
|
+
dispatchGoalWaitIfDue(ctx: StatusContext): void {
|
|
815
|
+
const goal = this.activeGoal;
|
|
816
|
+
if (
|
|
817
|
+
this.queueFrozen ||
|
|
818
|
+
this.pendingQueueAction ||
|
|
819
|
+
goal?.status !== "paused" ||
|
|
820
|
+
goal.wait?.resumeAt === undefined ||
|
|
821
|
+
goal.wait.resumeAt > Date.now()
|
|
822
|
+
)
|
|
823
|
+
return;
|
|
824
|
+
const pendingRealInput =
|
|
825
|
+
this.ambiguousWaitInputGoalId === goal.id ||
|
|
826
|
+
this.pendingInputWake?.waitingGoal === goal ||
|
|
827
|
+
this.pendingDirectInputs.some((pending) => pending.realInput && pending.waiting && pending.goalId === goal.id);
|
|
828
|
+
if (pendingRealInput || !this.toolPolicy.toolsAvailable() || ctx.isIdle?.() !== true || hasPendingMessages(ctx)) {
|
|
829
|
+
// Input preflight and tool-loadout changes need not emit agent_settled.
|
|
830
|
+
// Never infer rejection from elapsed time: a slow accepted input could
|
|
831
|
+
// otherwise race a second prompt. Explicit /goal resume remains available.
|
|
832
|
+
this.scheduleGoalWaitWake(ctx, Date.now() + WAIT_WAKE_RECHECK_MS);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
this.clearGoalWaitWake();
|
|
836
|
+
this.onGoalWaitElapsed?.(ctx, goal.id);
|
|
837
|
+
}
|
|
838
|
+
|
|
749
839
|
clearGoalWaitWake(): void {
|
|
750
840
|
this.goalWaitTimer.clear();
|
|
751
841
|
}
|
|
752
842
|
|
|
753
843
|
cancelContinuationWork() {
|
|
844
|
+
this.pendingInputWake = undefined;
|
|
754
845
|
this.clearGoalWaitWake();
|
|
755
846
|
this.clearContinuationDispatchTimer();
|
|
756
847
|
if (this.continuationDelivery) {
|
|
@@ -821,11 +912,110 @@ export class GoalRuntime {
|
|
|
821
912
|
return true;
|
|
822
913
|
}
|
|
823
914
|
|
|
824
|
-
|
|
915
|
+
noteDirectInput(prompt: string, realInput: boolean) {
|
|
916
|
+
const goal = this.activeGoal;
|
|
917
|
+
if (this.queueFrozen || !goal || (goal.status !== "active" && (goal.status !== "paused" || !goal.wait))) return;
|
|
918
|
+
// Only the latest real idle attempt can be awaiting delivery; an earlier
|
|
919
|
+
// handled or rejected prompt must not make a transformed successor ambiguous.
|
|
920
|
+
if (realInput) {
|
|
921
|
+
this.pendingInputWake = undefined;
|
|
922
|
+
this.pendingDirectInputs = this.pendingDirectInputs.filter((pending) => !pending.realInput);
|
|
923
|
+
}
|
|
924
|
+
this.pendingDirectInputs.push({
|
|
925
|
+
goalId: goal.id,
|
|
926
|
+
fingerprint: inputFingerprint(prompt),
|
|
927
|
+
realInput,
|
|
928
|
+
waiting: goal.status === "paused",
|
|
929
|
+
});
|
|
930
|
+
if (this.pendingDirectInputs.length > MAX_PENDING_NON_GOAL_INPUTS) this.pendingDirectInputs.shift();
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
hasDirectInputCandidate(prompt: string) {
|
|
934
|
+
if (this.pendingDirectInputs.length === 0) return false;
|
|
935
|
+
const fingerprint = inputFingerprint(prompt);
|
|
936
|
+
return this.pendingDirectInputs.some((pending) => pending.fingerprint === fingerprint);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
hasQueuedInputCandidate(prompt: string) {
|
|
940
|
+
if (this.pendingNonGoalInputs.length === 0) return false;
|
|
941
|
+
const fingerprint = inputFingerprint(prompt);
|
|
942
|
+
return this.pendingNonGoalInputs.some((pending) => pending.fingerprint === fingerprint);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
consumeDirectInput(prompt: unknown, allowFallback = true) {
|
|
946
|
+
if (typeof prompt !== "string" || this.pendingDirectInputs.length === 0) return undefined;
|
|
947
|
+
const fingerprint = inputFingerprint(prompt);
|
|
948
|
+
const matches = this.pendingDirectInputs.filter((pending) => pending.fingerprint === fingerprint);
|
|
949
|
+
if (matches.length > 0) {
|
|
950
|
+
// Identical real and extension prompts have no source at this boundary.
|
|
951
|
+
// Drop ambiguous candidates rather than claiming another extension's turn.
|
|
952
|
+
if (matches.some((pending) => pending.realInput) && matches.some((pending) => !pending.realInput)) {
|
|
953
|
+
this.markAmbiguousWaitInput();
|
|
954
|
+
this.pendingDirectInputs = this.pendingDirectInputs.filter((pending) => pending.fingerprint !== fingerprint);
|
|
955
|
+
return { ...matches[0], realInput: false };
|
|
956
|
+
}
|
|
957
|
+
const index = this.pendingDirectInputs.findIndex((pending) => pending.fingerprint === fingerprint);
|
|
958
|
+
const [pending] = this.pendingDirectInputs.splice(index, 1);
|
|
959
|
+
return this.currentDirectInput(pending);
|
|
960
|
+
}
|
|
961
|
+
// Pi can expand templates or another input handler can transform the text.
|
|
962
|
+
// Without an exact match, only one pending candidate proves provenance.
|
|
963
|
+
if (!allowFallback || this.pendingDirectInputs.length !== 1) return undefined;
|
|
964
|
+
return this.currentDirectInput(this.pendingDirectInputs.shift());
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
private currentDirectInput(pending: PendingDirectInput | undefined) {
|
|
968
|
+
const goal = this.activeGoal;
|
|
969
|
+
if (
|
|
970
|
+
!pending ||
|
|
971
|
+
goal?.id !== pending.goalId ||
|
|
972
|
+
(goal.status !== "active" && !(goal.status === "paused" && goal.wait))
|
|
973
|
+
) {
|
|
974
|
+
return undefined;
|
|
975
|
+
}
|
|
976
|
+
pending = { ...pending, waiting: goal.status === "paused" };
|
|
977
|
+
// A different extension input may be transformed to the same delivered text.
|
|
978
|
+
if (pending.realInput && this.hasUnresolvedExtensionInput()) {
|
|
979
|
+
this.markAmbiguousWaitInput();
|
|
980
|
+
return { ...pending, realInput: false };
|
|
981
|
+
}
|
|
982
|
+
return pending;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
private markAmbiguousWaitInput() {
|
|
986
|
+
const goal = this.activeGoal;
|
|
987
|
+
if (goal?.status === "paused" && goal.wait) this.ambiguousWaitInputGoalId = goal.id;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
private hasUnresolvedRealInput() {
|
|
991
|
+
return (
|
|
992
|
+
this.pendingDirectInputs.some((pending) => pending.realInput) ||
|
|
993
|
+
this.pendingNonGoalInputs.some((pending) => pending.realInput)
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
private hasUnresolvedExtensionInput() {
|
|
998
|
+
return (
|
|
999
|
+
this.pendingDirectInputs.some((pending) => !pending.realInput) ||
|
|
1000
|
+
this.pendingNonGoalInputs.some((pending) => !pending.realInput)
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
noteQueuedNonGoalInput(prompt: string, behavior: "steer" | "followUp", realInput = false) {
|
|
1005
|
+
const fingerprint = inputFingerprint(prompt);
|
|
1006
|
+
if (!realInput) {
|
|
1007
|
+
// Pi does not attach input provenance to message_start. An extension
|
|
1008
|
+
// message with identical text must not inherit a prior real-input wake.
|
|
1009
|
+
for (const pending of this.pendingNonGoalInputs) {
|
|
1010
|
+
if (pending.behavior === behavior && pending.fingerprint === fingerprint) {
|
|
1011
|
+
pending.realInput = false;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
825
1015
|
this.pendingNonGoalInputs.push({
|
|
826
1016
|
behavior,
|
|
827
|
-
fingerprint
|
|
828
|
-
|
|
1017
|
+
fingerprint,
|
|
1018
|
+
realInput,
|
|
829
1019
|
});
|
|
830
1020
|
if (this.pendingNonGoalInputs.length > MAX_PENDING_NON_GOAL_INPUTS) {
|
|
831
1021
|
this.pendingNonGoalInputs.shift();
|
|
@@ -833,10 +1023,18 @@ export class GoalRuntime {
|
|
|
833
1023
|
}
|
|
834
1024
|
|
|
835
1025
|
consumeQueuedNonGoalInput(prompt: string, allowDeliveryFallback = true) {
|
|
836
|
-
if (typeof prompt !== "string") return undefined;
|
|
1026
|
+
if (typeof prompt !== "string" || this.pendingNonGoalInputs.length === 0) return undefined;
|
|
837
1027
|
const fingerprint = inputFingerprint(prompt);
|
|
838
|
-
|
|
839
|
-
|
|
1028
|
+
const matching = this.pendingNonGoalInputs.filter((pending) => pending.fingerprint === fingerprint);
|
|
1029
|
+
if (matching.some((pending) => pending.realInput) && matching.some((pending) => !pending.realInput)) {
|
|
1030
|
+
// A follow-up may already be draining when a same-text steer arrives.
|
|
1031
|
+
// Without delivery identity, either lane can own this message.
|
|
1032
|
+
this.markAmbiguousWaitInput();
|
|
1033
|
+
this.pendingNonGoalInputs = this.pendingNonGoalInputs.filter((pending) => pending.fingerprint !== fingerprint);
|
|
1034
|
+
return { ...matching[0], realInput: false };
|
|
1035
|
+
}
|
|
1036
|
+
// Pi normally delivers steers before follow-ups. Prefer a matching steer
|
|
1037
|
+
// only when the candidates agree on real-input provenance.
|
|
840
1038
|
const steerIndex = this.pendingNonGoalInputs.findIndex(
|
|
841
1039
|
(pending) => pending.behavior === "steer" && pending.fingerprint === fingerprint,
|
|
842
1040
|
);
|
|
@@ -846,7 +1044,15 @@ export class GoalRuntime {
|
|
|
846
1044
|
: this.pendingNonGoalInputs.findIndex(
|
|
847
1045
|
(pending) => pending.behavior === "followUp" && pending.fingerprint === fingerprint,
|
|
848
1046
|
);
|
|
849
|
-
if (exactIndex >= 0)
|
|
1047
|
+
if (exactIndex >= 0) {
|
|
1048
|
+
const [pending] = this.pendingNonGoalInputs.splice(exactIndex, 1);
|
|
1049
|
+
if (pending?.realInput && this.hasUnresolvedExtensionInput()) {
|
|
1050
|
+
this.markAmbiguousWaitInput();
|
|
1051
|
+
return { ...pending, realInput: false };
|
|
1052
|
+
}
|
|
1053
|
+
if (pending && !pending.realInput && this.hasUnresolvedRealInput()) this.markAmbiguousWaitInput();
|
|
1054
|
+
return pending;
|
|
1055
|
+
}
|
|
850
1056
|
if (!allowDeliveryFallback) return undefined;
|
|
851
1057
|
|
|
852
1058
|
// Skills, templates, and later input handlers can transform the raw text after
|
|
@@ -858,7 +1064,16 @@ export class GoalRuntime {
|
|
|
858
1064
|
? fallbackSteerIndex
|
|
859
1065
|
: this.pendingNonGoalInputs.findIndex((pending) => pending.behavior === "followUp");
|
|
860
1066
|
if (fallbackIndex < 0) return undefined;
|
|
861
|
-
|
|
1067
|
+
const fallback = this.pendingNonGoalInputs.splice(fallbackIndex, 1)[0];
|
|
1068
|
+
// A transformed input can still own its wake when it is the only pending
|
|
1069
|
+
// candidate. With multiple inputs Pi exposes no source at delivery, so do
|
|
1070
|
+
// not attribute an unmatched message to a real user.
|
|
1071
|
+
const unambiguousRealInput = Boolean(
|
|
1072
|
+
fallback?.realInput &&
|
|
1073
|
+
this.pendingNonGoalInputs.every((pending) => pending.realInput) &&
|
|
1074
|
+
this.pendingDirectInputs.every((pending) => pending.realInput),
|
|
1075
|
+
);
|
|
1076
|
+
return fallback ? { ...fallback, realInput: unambiguousRealInput } : undefined;
|
|
862
1077
|
}
|
|
863
1078
|
|
|
864
1079
|
consumeQueuedNonGoalFollowUpForAgentStart() {
|
|
@@ -866,7 +1081,7 @@ export class GoalRuntime {
|
|
|
866
1081
|
// follow-up suppress cleanup until all earlier-priority steers have started.
|
|
867
1082
|
if (this.pendingNonGoalInputs.some((pending) => pending.behavior === "steer")) return false;
|
|
868
1083
|
const index = this.pendingNonGoalInputs.findIndex((pending) => pending.behavior === "followUp");
|
|
869
|
-
if (index < 0) return false;
|
|
1084
|
+
if (index < 0 || (this.activeGoal?.wait && this.pendingNonGoalInputs[index]?.realInput)) return false;
|
|
870
1085
|
this.pendingNonGoalInputs.splice(index, 1);
|
|
871
1086
|
return true;
|
|
872
1087
|
}
|
|
@@ -874,6 +1089,9 @@ export class GoalRuntime {
|
|
|
874
1089
|
markContinuationStarted(prompt: string) {
|
|
875
1090
|
const marker = extractContinuationMarker(prompt);
|
|
876
1091
|
if (!marker) {
|
|
1092
|
+
// A paused wait is not continuation work. An unrelated prompt can fail
|
|
1093
|
+
// before agent_settled, so its deadline must remain armed.
|
|
1094
|
+
if (this.activeGoal?.status === "paused" && this.activeGoal.wait) return undefined;
|
|
877
1095
|
// A user, retry, or another extension started newer work. Cancel both an
|
|
878
1096
|
// unsent intent and a delivery that may have lost the non-atomic idle race;
|
|
879
1097
|
// the newer work's agent_end will record a fresh intent.
|
|
@@ -910,6 +1128,9 @@ export class GoalRuntime {
|
|
|
910
1128
|
|
|
911
1129
|
clearActiveGoal(ctx: StatusContext, reason = "goal cleared") {
|
|
912
1130
|
const clearedGoal = this.activeGoal;
|
|
1131
|
+
this.ambiguousWaitInputGoalId = undefined;
|
|
1132
|
+
this.pendingStoppedUsageGoalId = undefined;
|
|
1133
|
+
this.pendingReplacementUsage = undefined;
|
|
913
1134
|
this.cancelContinuationWork();
|
|
914
1135
|
this.clearGoalRecovery();
|
|
915
1136
|
this.clearBudgetWrapUp();
|
|
@@ -936,6 +1157,9 @@ export class GoalRuntime {
|
|
|
936
1157
|
goalRecovery: this.goalRecovery ? structuredClone(this.goalRecovery) : undefined,
|
|
937
1158
|
budgetWrapUp: this.budgetWrapUp ? structuredClone(this.budgetWrapUp) : undefined,
|
|
938
1159
|
guardAbortGoalId: this.guardAbortGoalId,
|
|
1160
|
+
pendingStoppedUsageGoalId: this.pendingStoppedUsageGoalId,
|
|
1161
|
+
pendingReplacementUsage: this.pendingReplacementUsage ? { ...this.pendingReplacementUsage } : undefined,
|
|
1162
|
+
ambiguousWaitInputGoalId: this.ambiguousWaitInputGoalId,
|
|
939
1163
|
staleGoalToolCallsBlocked: this.staleGoalToolCallsBlocked,
|
|
940
1164
|
cancelledContinuationMarkers: [...this.cancelledContinuationMarkers],
|
|
941
1165
|
terminalDetails: this.terminalDetails ? structuredClone(this.terminalDetails) : undefined,
|
|
@@ -955,6 +1179,11 @@ export class GoalRuntime {
|
|
|
955
1179
|
this.goalRecovery = snapshot.goalRecovery ? structuredClone(snapshot.goalRecovery) : undefined;
|
|
956
1180
|
this.budgetWrapUp = snapshot.budgetWrapUp ? structuredClone(snapshot.budgetWrapUp) : undefined;
|
|
957
1181
|
this.guardAbortGoalId = snapshot.guardAbortGoalId;
|
|
1182
|
+
this.pendingStoppedUsageGoalId = snapshot.pendingStoppedUsageGoalId;
|
|
1183
|
+
this.pendingReplacementUsage = snapshot.pendingReplacementUsage
|
|
1184
|
+
? { ...snapshot.pendingReplacementUsage }
|
|
1185
|
+
: undefined;
|
|
1186
|
+
this.ambiguousWaitInputGoalId = snapshot.ambiguousWaitInputGoalId;
|
|
958
1187
|
this.staleGoalToolCallsBlocked = snapshot.staleGoalToolCallsBlocked;
|
|
959
1188
|
this.cancelledContinuationMarkers = new Set(snapshot.cancelledContinuationMarkers);
|
|
960
1189
|
this.terminalDetails = snapshot.terminalDetails ? structuredClone(snapshot.terminalDetails) : undefined;
|
|
@@ -1069,7 +1298,7 @@ export function transitionGoal(goal: ActiveGoal, requestedStatus: GoalStatus): A
|
|
|
1069
1298
|
requestedStatus === "active" && goal.tokenBudget !== undefined && goal.tokensUsed >= goal.tokenBudget
|
|
1070
1299
|
? "budget_limited"
|
|
1071
1300
|
: requestedStatus;
|
|
1072
|
-
const next = { ...goal, status, updatedAt: now };
|
|
1301
|
+
const next = { ...goal, status, updatedAt: now, wait: undefined };
|
|
1073
1302
|
checkpointGoalActiveTime(next, now, status === "active");
|
|
1074
1303
|
return next;
|
|
1075
1304
|
}
|
|
@@ -1105,6 +1334,7 @@ export function formatStatus(
|
|
|
1105
1334
|
}
|
|
1106
1335
|
return `paused · automatic limit ${goal.automaticModelTurns}/${automaticTurnLimit}`;
|
|
1107
1336
|
}
|
|
1337
|
+
if (goal.status === "paused" && goal.wait) return `waiting · ${automatic}`;
|
|
1108
1338
|
if (goal.status === "paused") return `paused · ${automatic}`;
|
|
1109
1339
|
if (goal.status === "blocked") return `blocked · ${automatic}`;
|
|
1110
1340
|
if (goal.status === "usage_limited") return `usage · ${automatic}`;
|
|
@@ -1135,6 +1365,10 @@ export function goalSummary(
|
|
|
1135
1365
|
`Active elapsed: ${formatDuration(goal.timeUsedSeconds)}`,
|
|
1136
1366
|
`Tokens: ${goal.tokenBudget === undefined ? formatTokenCount(goal.tokensUsed) : formatBudget(goal)}`,
|
|
1137
1367
|
];
|
|
1368
|
+
if (goal.wait) {
|
|
1369
|
+
summary.push(`Waiting: ${goal.wait.reason}`);
|
|
1370
|
+
if (goal.wait.resumeAt !== undefined) summary.push(`Wake deadline: ${new Date(goal.wait.resumeAt).toISOString()}`);
|
|
1371
|
+
}
|
|
1138
1372
|
if (goal.safetyPauseCause) {
|
|
1139
1373
|
summary.push(
|
|
1140
1374
|
goal.safetyPauseCause === "continuation_limit"
|
package/src/tools.ts
CHANGED
|
@@ -20,7 +20,13 @@ import {
|
|
|
20
20
|
transitionGoal,
|
|
21
21
|
truncateNotification,
|
|
22
22
|
} from "./runtime.js";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
createGoalWait,
|
|
25
|
+
MAX_GOAL_WAIT_REASON_LENGTH,
|
|
26
|
+
MIN_GOAL_WAIT_DELAY_MS,
|
|
27
|
+
normalizeGoalWait,
|
|
28
|
+
resolveGoalWaitDelay,
|
|
29
|
+
} from "./wait.js";
|
|
24
30
|
|
|
25
31
|
interface GoalCompleteDetails {
|
|
26
32
|
goal: string;
|
|
@@ -366,17 +372,23 @@ export function registerGoalTools(pi: ExtensionAPI, runtime: GoalRuntime) {
|
|
|
366
372
|
if (goal.status !== "active") return reject(`goal is ${goal.status}, not active`);
|
|
367
373
|
if (!reason) return reject("reason is empty");
|
|
368
374
|
|
|
369
|
-
|
|
375
|
+
if (
|
|
376
|
+
requestedResumeAfterMs !== undefined &&
|
|
377
|
+
(!Number.isSafeInteger(requestedResumeAfterMs) || requestedResumeAfterMs < 1)
|
|
378
|
+
)
|
|
379
|
+
return reject("resume_after_ms must be a positive safe integer");
|
|
380
|
+
const wait = normalizeGoalWait(createGoalWait(reason, requestedResumeAfterMs));
|
|
381
|
+
if (!wait) return reject("invalid wait reason or deadline");
|
|
370
382
|
const stoppedGoal = runtime.stopActiveGoal(ctx, {
|
|
371
383
|
kind: "wait",
|
|
372
384
|
expectedGoalId: goal.id,
|
|
373
|
-
|
|
385
|
+
wait,
|
|
374
386
|
});
|
|
375
387
|
if (!stoppedGoal) return reject("active goal changed before the wait took effect");
|
|
376
388
|
|
|
377
389
|
// Armed after the stop, so a wake can never race a goal that is still
|
|
378
390
|
// being transitioned out of `active`.
|
|
379
|
-
if (
|
|
391
|
+
if (runtime.activeGoal === stoppedGoal) runtime.scheduleGoalWaitWake(ctx);
|
|
380
392
|
|
|
381
393
|
const { requestedMs, effectiveMs } = resolveGoalWaitDelay(requestedResumeAfterMs);
|
|
382
394
|
const clamped = requestedMs !== undefined && effectiveMs !== undefined && effectiveMs !== requestedMs;
|
package/src/wait.ts
CHANGED
|
@@ -100,12 +100,16 @@ export class GoalWaitTimer {
|
|
|
100
100
|
schedule(resumeAt: number, onDue: () => void): void {
|
|
101
101
|
this.clear();
|
|
102
102
|
const generation = this.generation;
|
|
103
|
-
//
|
|
104
|
-
//
|
|
103
|
+
// Long waits use bounded timer segments, never an early wake. Overdue
|
|
104
|
+
// deadlines run on the next tick so restore can finish before resuming.
|
|
105
105
|
const delay = Math.max(0, Math.min(MAX_GOAL_WAIT_DELAY_MS, resumeAt - Date.now()));
|
|
106
106
|
this.timer = setTimeout(() => {
|
|
107
107
|
if (generation !== this.generation) return;
|
|
108
108
|
this.timer = undefined;
|
|
109
|
+
if (resumeAt > Date.now()) {
|
|
110
|
+
this.schedule(resumeAt, onDue);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
109
113
|
onDue();
|
|
110
114
|
}, delay);
|
|
111
115
|
}
|