@yaag/runtime 0.1.4 → 0.2.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/package.json +1 -1
- package/src/agent.ts +11 -3
- package/src/ask-contract-identity.ts +23 -5
- package/src/ask-exchange-events.ts +14 -1
- package/src/ask-exchange-options.ts +7 -2
- package/src/ask-exchange.ts +198 -26
- package/src/ask-hash.ts +7 -3
- package/src/ask-limit.ts +27 -11
- package/src/ask-output-steering.ts +1 -3
- package/src/ask-output.ts +20 -4
- package/src/ask-turn.ts +11 -0
- package/src/cassette-loader.ts +24 -0
- package/src/cassette-replay.ts +76 -7
- package/src/cassette-schema.ts +17 -2
- package/src/cassette.ts +10 -9
- package/src/checkpoint-flush.ts +101 -0
- package/src/connection.ts +20 -0
- package/src/define-agent.ts +11 -5
- package/src/errors.ts +11 -2
- package/src/events.ts +29 -3
- package/src/fake-transport.ts +55 -0
- package/src/frame-queue.ts +5 -0
- package/src/index.ts +11 -1
- package/src/model-resolution.ts +119 -0
- package/src/model-suffix.ts +24 -0
- package/src/pi-state.ts +63 -8
- package/src/recording-transport.ts +8 -8
- package/src/replay-divergence.ts +1 -0
- package/src/replay-transport.ts +4 -0
- package/src/report-result-extension.ts +128 -0
- package/src/report-result-output.ts +73 -0
- package/src/report-result-steering.ts +83 -0
- package/src/report-result.ts +122 -0
- package/src/resume-transport.ts +26 -8
- package/src/run-checkpoint.ts +93 -41
- package/src/run.ts +86 -32
- package/src/spawn.ts +29 -4
- package/src/stall-watchdog.ts +193 -0
- package/src/summary-agent.ts +11 -2
- package/src/summary.ts +23 -2
- package/src/thinking-level.ts +32 -0
- package/src/transport.ts +43 -8
- package/src/types.ts +50 -14
- package/src/validation-errors.ts +10 -4
package/package.json
CHANGED
package/src/agent.ts
CHANGED
|
@@ -5,8 +5,9 @@ import type { EffectiveAskOptions } from "./ask-hash.ts";
|
|
|
5
5
|
import { Connection } from "./connection.ts";
|
|
6
6
|
import { agentError } from "./errors.ts";
|
|
7
7
|
import type { EventSink } from "./events.ts";
|
|
8
|
+
import { ReportResultTool } from "./report-result.ts";
|
|
8
9
|
import type { AgentStats, AgentTransport } from "./transport.ts";
|
|
9
|
-
import type { AskOptions, Handle,
|
|
10
|
+
import type { AskOptions, Handle, ResolvedSpawnOptions, StructuredAskOptions } from "./types.ts";
|
|
10
11
|
|
|
11
12
|
export interface AgentOptions {
|
|
12
13
|
readonly name: string;
|
|
@@ -15,7 +16,7 @@ export interface AgentOptions {
|
|
|
15
16
|
readonly transport: AgentTransport;
|
|
16
17
|
readonly emit: EventSink;
|
|
17
18
|
/** The options this Agent was spawned with, for the ADR-0014 Ask hash. */
|
|
18
|
-
readonly spawnOptions:
|
|
19
|
+
readonly spawnOptions: ResolvedSpawnOptions;
|
|
19
20
|
/** Definition-owned defaults merged below explicit per-Ask options. */
|
|
20
21
|
readonly askDefaults?: AskOptions;
|
|
21
22
|
/** Definition identity recorded on its Asks, outside replay identity. */
|
|
@@ -24,6 +25,8 @@ export interface AgentOptions {
|
|
|
24
25
|
readonly askLimitGraceMs?: number;
|
|
25
26
|
/** Test-only override for the bounded wait for `agent_settled` after an idle abort. */
|
|
26
27
|
readonly idleAbortSettleMs?: number;
|
|
28
|
+
/** Test-only override for the bounded wait for the Stall Watchdog probe. */
|
|
29
|
+
readonly stallProbeSettleMs?: number;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/** One Agent as an Orchestration Program sees it. Lives above the transport seam. */
|
|
@@ -36,12 +39,14 @@ export class Agent implements Handle {
|
|
|
36
39
|
readonly #transport: AgentTransport;
|
|
37
40
|
readonly #connection: Connection;
|
|
38
41
|
readonly #emit: EventSink;
|
|
39
|
-
readonly #spawnOptions:
|
|
42
|
+
readonly #spawnOptions: ResolvedSpawnOptions;
|
|
40
43
|
readonly #askDefaults: AskOptions;
|
|
41
44
|
readonly #definitionName: string | undefined;
|
|
42
45
|
readonly #askLimitGraceMs: number | undefined;
|
|
43
46
|
readonly #idleAbortSettleMs: number | undefined;
|
|
47
|
+
readonly #stallProbeSettleMs: number | undefined;
|
|
44
48
|
readonly #usage: AgentUsage;
|
|
49
|
+
readonly #reportResultTool = new ReportResultTool();
|
|
45
50
|
#busy = false;
|
|
46
51
|
#incomplete = false;
|
|
47
52
|
#askIndex = 0;
|
|
@@ -59,6 +64,7 @@ export class Agent implements Handle {
|
|
|
59
64
|
this.#definitionName = options.definitionName;
|
|
60
65
|
this.#askLimitGraceMs = options.askLimitGraceMs;
|
|
61
66
|
this.#idleAbortSettleMs = options.idleAbortSettleMs;
|
|
67
|
+
this.#stallProbeSettleMs = options.stallProbeSettleMs;
|
|
62
68
|
this.#usage = new AgentUsage((snapshot) =>
|
|
63
69
|
this.#emit({ type: "agent_usage", agent: this.name, ...snapshot }),
|
|
64
70
|
);
|
|
@@ -92,11 +98,13 @@ export class Agent implements Handle {
|
|
|
92
98
|
ask: { ...this.#askDefaults, ...options },
|
|
93
99
|
spawnOptions: this.#spawnOptions,
|
|
94
100
|
definitionName: this.#definitionName,
|
|
101
|
+
reportResultTool: this.#reportResultTool,
|
|
95
102
|
emit: this.#emit,
|
|
96
103
|
usage: this.#usage,
|
|
97
104
|
close: () => void this.close().catch(() => {}),
|
|
98
105
|
askLimitGraceMs: this.#askLimitGraceMs,
|
|
99
106
|
idleAbortSettleMs: this.#idleAbortSettleMs,
|
|
107
|
+
stallProbeSettleMs: this.#stallProbeSettleMs,
|
|
100
108
|
});
|
|
101
109
|
} finally {
|
|
102
110
|
this.#busy = false;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TSchema } from "typebox";
|
|
2
|
-
import { ASK_OUTPUT_EXTRACTION_POLICY } from "./ask-output.ts";
|
|
2
|
+
import { ASK_OUTPUT_EXTRACTION_POLICY, type AskOutputExtractionPolicy } from "./ask-output.ts";
|
|
3
3
|
|
|
4
4
|
/** A JSON value suitable for deterministic Cassette identity. */
|
|
5
5
|
export type CanonicalJson =
|
|
@@ -32,11 +32,29 @@ export function canonicalizeSchema(value: unknown): CanonicalJsonObject {
|
|
|
32
32
|
return canonicalize(value, new Set<object>()) as CanonicalJsonObject;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Builds the recorded and hashed behavioral contract for one structured Ask.
|
|
37
|
+
*
|
|
38
|
+
* `policy` is the live policy unless a Cassette recorded this Ask under an
|
|
39
|
+
* older one, in which case that Ask keeps its recorded identity and behavior.
|
|
40
|
+
* Throws a TypeError when a live Ask declares a schema that is not an object
|
|
41
|
+
* schema: the Agent reports the result as one tool call's arguments (ADR-0032),
|
|
42
|
+
* and an Ask recorded before that decision keeps whatever schema it recorded.
|
|
43
|
+
*/
|
|
44
|
+
export function createAskOutputContract(
|
|
45
|
+
schema: TSchema,
|
|
46
|
+
maxSteers?: number,
|
|
47
|
+
policy: AskOutputExtractionPolicy = ASK_OUTPUT_EXTRACTION_POLICY,
|
|
48
|
+
): AskOutputContract {
|
|
49
|
+
const outputSchema = canonicalizeSchema(schema);
|
|
50
|
+
if (policy === ASK_OUTPUT_EXTRACTION_POLICY && outputSchema.type !== "object") {
|
|
51
|
+
throw new TypeError(
|
|
52
|
+
"outputSchema must be an object schema: the Agent reports it as one tool call's arguments",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
37
55
|
return {
|
|
38
|
-
outputSchema
|
|
39
|
-
extractionPolicy:
|
|
56
|
+
outputSchema,
|
|
57
|
+
extractionPolicy: policy,
|
|
40
58
|
...(maxSteers === undefined ? {} : { maxSteers }),
|
|
41
59
|
};
|
|
42
60
|
}
|
|
@@ -2,6 +2,16 @@ import type { AgentActivity, AskOutputChannel, EventSink } from "./events.ts";
|
|
|
2
2
|
import { agentAskPath, childPath } from "./node-path.ts";
|
|
3
3
|
import type { NodeSnapshot } from "./node-tracker.ts";
|
|
4
4
|
import { promptGist } from "./prompt-gist.ts";
|
|
5
|
+
import type { SettlementCause } from "./stall-watchdog.ts";
|
|
6
|
+
|
|
7
|
+
/** What one finished Ask reports in its `ask_end` Lifecycle Event. */
|
|
8
|
+
export interface AskEndOutcome {
|
|
9
|
+
readonly durationMs: number;
|
|
10
|
+
readonly ok: boolean;
|
|
11
|
+
/** Largest inter-frame silence; absent during Cassette playback. */
|
|
12
|
+
readonly maxFrameGapMs: number | undefined;
|
|
13
|
+
readonly cause?: SettlementCause;
|
|
14
|
+
}
|
|
5
15
|
|
|
6
16
|
/** Emits the four Ask-scoped Lifecycle Events for one exchange. */
|
|
7
17
|
export class AskEvents {
|
|
@@ -47,7 +57,9 @@ export class AskEvents {
|
|
|
47
57
|
});
|
|
48
58
|
};
|
|
49
59
|
|
|
50
|
-
|
|
60
|
+
/** A `normal` cause is the absent default, so ordinary settlements stay lean. */
|
|
61
|
+
end(outcome: AskEndOutcome): void {
|
|
62
|
+
const { durationMs, ok, maxFrameGapMs, cause = "normal" } = outcome;
|
|
51
63
|
this.#emit({
|
|
52
64
|
type: "ask_end",
|
|
53
65
|
agent: this.#agent,
|
|
@@ -55,6 +67,7 @@ export class AskEvents {
|
|
|
55
67
|
durationMs,
|
|
56
68
|
ok,
|
|
57
69
|
...(maxFrameGapMs === undefined ? {} : { maxFrameGapMs }),
|
|
70
|
+
...(cause === "normal" ? {} : { cause }),
|
|
58
71
|
});
|
|
59
72
|
}
|
|
60
73
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { EffectiveAskOptions } from "./ask-hash.ts";
|
|
2
2
|
import type { Connection } from "./connection.ts";
|
|
3
3
|
import type { EventSink } from "./events.ts";
|
|
4
|
+
import type { ReportResultTool } from "./report-result.ts";
|
|
4
5
|
import type { AgentTransport, Frame } from "./transport.ts";
|
|
5
|
-
import type {
|
|
6
|
+
import type { ResolvedSpawnOptions } from "./types.ts";
|
|
6
7
|
|
|
7
8
|
/** A frame consumer whose lifetime exceeds one Ask; the Agent's AgentUsage satisfies it. */
|
|
8
9
|
export interface FrameObserver {
|
|
@@ -17,8 +18,10 @@ export interface AskExchangeOptions {
|
|
|
17
18
|
readonly prompt: string;
|
|
18
19
|
readonly index: number;
|
|
19
20
|
readonly ask: EffectiveAskOptions;
|
|
20
|
-
readonly spawnOptions:
|
|
21
|
+
readonly spawnOptions: ResolvedSpawnOptions;
|
|
21
22
|
readonly definitionName: string | undefined;
|
|
23
|
+
/** The Agent's report_result activation state, which spans its Asks (ADR-0032). */
|
|
24
|
+
readonly reportResultTool: ReportResultTool;
|
|
22
25
|
/** Lifecycle Event sink; the exchange emits every Ask-scoped event itself. */
|
|
23
26
|
readonly emit: EventSink;
|
|
24
27
|
/** The Agent's persistent usage accumulator; fed frames only during live Asks. */
|
|
@@ -29,4 +32,6 @@ export interface AskExchangeOptions {
|
|
|
29
32
|
readonly askLimitGraceMs: number | undefined;
|
|
30
33
|
/** Test-only override for the bounded settle wait after an idle abort. */
|
|
31
34
|
readonly idleAbortSettleMs: number | undefined;
|
|
35
|
+
/** Test-only override for the bounded wait for the Stall Watchdog probe. */
|
|
36
|
+
readonly stallProbeSettleMs: number | undefined;
|
|
32
37
|
}
|
package/src/ask-exchange.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import type { TSchema } from "typebox";
|
|
1
2
|
import { AskActivityTracker } from "./ask-activity.ts";
|
|
2
|
-
import {
|
|
3
|
+
import type { AskOutputContract } from "./ask-contract-identity.ts";
|
|
4
|
+
import { type AskEndOutcome, AskEvents } from "./ask-exchange-events.ts";
|
|
3
5
|
import type { AskExchangeOptions } from "./ask-exchange-options.ts";
|
|
4
6
|
import { askHash, askMarkerContext, structuredOutputContract } from "./ask-hash.ts";
|
|
5
7
|
import { AskLimit } from "./ask-limit.ts";
|
|
6
|
-
import { resolvePlaybackAskOutput } from "./ask-output.ts";
|
|
8
|
+
import { LEGACY_ASK_OUTPUT_EXTRACTION_POLICY, resolvePlaybackAskOutput } from "./ask-output.ts";
|
|
7
9
|
import { AskOutputSteering } from "./ask-output-steering.ts";
|
|
8
10
|
import { AskOutputTail } from "./ask-output-tail.ts";
|
|
9
11
|
import { awaitAskSettlement } from "./ask-settlement.ts";
|
|
@@ -19,6 +21,15 @@ import {
|
|
|
19
21
|
import { FrameGapTracker } from "./frame-gap.ts";
|
|
20
22
|
import { IdleKillSignal, IdleWatch } from "./idle-watch.ts";
|
|
21
23
|
import { NodeTracker } from "./node-tracker.ts";
|
|
24
|
+
import { ReportResultCall } from "./report-result.ts";
|
|
25
|
+
import { REPORT_RESULT_WRAP_UP, resolvePlaybackReportedResult } from "./report-result-output.ts";
|
|
26
|
+
import { ReportResultSteering } from "./report-result-steering.ts";
|
|
27
|
+
import {
|
|
28
|
+
type SettlementCause,
|
|
29
|
+
StallSignal,
|
|
30
|
+
StallWatchdog,
|
|
31
|
+
stallBudgetMs,
|
|
32
|
+
} from "./stall-watchdog.ts";
|
|
22
33
|
import type { AskPlayback } from "./transport.ts";
|
|
23
34
|
|
|
24
35
|
export type { AskExchangeOptions } from "./ask-exchange-options.ts";
|
|
@@ -32,10 +43,14 @@ class AskExchange {
|
|
|
32
43
|
readonly #events: AskEvents;
|
|
33
44
|
#limit: AskLimit | null = null;
|
|
34
45
|
#idle: IdleWatch | null = null;
|
|
46
|
+
#stall: StallWatchdog | null = null;
|
|
35
47
|
#gap: FrameGapTracker | null = null;
|
|
36
48
|
#activity: AskActivityTracker | null = null;
|
|
37
49
|
#output: AskOutputTail | null = null;
|
|
38
50
|
#nodes: NodeTracker | null = null;
|
|
51
|
+
#call: ReportResultCall | null = null;
|
|
52
|
+
#legacyExtraction = false;
|
|
53
|
+
#contract: AskOutputContract | undefined;
|
|
39
54
|
#playback: AskPlayback | undefined;
|
|
40
55
|
#began = false;
|
|
41
56
|
#outcome: AskLimitOutcome | undefined;
|
|
@@ -45,6 +60,8 @@ class AskExchange {
|
|
|
45
60
|
#controlFailure: Promise<never> = new Promise<never>(() => {});
|
|
46
61
|
#timeoutDeadline: number | undefined;
|
|
47
62
|
#maxFrameGapMs: number | undefined;
|
|
63
|
+
#cause: SettlementCause = "normal";
|
|
64
|
+
#drainStaleFrames = false;
|
|
48
65
|
constructor(options: AskExchangeOptions) {
|
|
49
66
|
this.#options = options;
|
|
50
67
|
this.#events = new AskEvents(options.emit, options.agent, options.index);
|
|
@@ -54,19 +71,31 @@ class AskExchange {
|
|
|
54
71
|
const startedAt = Date.now();
|
|
55
72
|
try {
|
|
56
73
|
const result = await this.#exchange();
|
|
57
|
-
this.#events.end(
|
|
74
|
+
this.#events.end(this.#endOutcome(startedAt, true));
|
|
58
75
|
return result;
|
|
59
76
|
} catch (error) {
|
|
60
|
-
if (this.#began) this.#events.end(
|
|
77
|
+
if (this.#began) this.#events.end(this.#endOutcome(startedAt, false));
|
|
61
78
|
throw error;
|
|
62
79
|
}
|
|
63
80
|
}
|
|
64
81
|
|
|
82
|
+
#endOutcome(startedAt: number, ok: boolean): AskEndOutcome {
|
|
83
|
+
return {
|
|
84
|
+
durationMs: Date.now() - startedAt,
|
|
85
|
+
ok,
|
|
86
|
+
maxFrameGapMs: this.#maxFrameGapMs,
|
|
87
|
+
cause: this.#cause,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
65
91
|
async #exchange(): Promise<unknown> {
|
|
66
92
|
try {
|
|
67
93
|
this.#begin();
|
|
68
|
-
const settled = this.#armSettlement();
|
|
69
94
|
this.#timeoutDeadline = this.#deadline();
|
|
95
|
+
// The schema command is answered without a turn, so settlement tracking
|
|
96
|
+
// starts after it: nothing it could emit may settle this Ask (ADR-0032).
|
|
97
|
+
await this.#deliverSchema();
|
|
98
|
+
const settled = this.#armSettlement();
|
|
70
99
|
await this.#sendPrompt();
|
|
71
100
|
await this.#verify(settled);
|
|
72
101
|
const abortSettled =
|
|
@@ -74,7 +103,7 @@ class AskExchange {
|
|
|
74
103
|
let result: unknown;
|
|
75
104
|
let outputError: unknown;
|
|
76
105
|
try {
|
|
77
|
-
result = await this.#resolveOutput(
|
|
106
|
+
result = await this.#resolveOutput();
|
|
78
107
|
} catch (error) {
|
|
79
108
|
if (abortSettled === undefined) throw error;
|
|
80
109
|
outputError = error;
|
|
@@ -91,7 +120,12 @@ class AskExchange {
|
|
|
91
120
|
}
|
|
92
121
|
|
|
93
122
|
#begin(): void {
|
|
94
|
-
|
|
123
|
+
this.#legacyExtraction = this.#recordedLegacyPolicy();
|
|
124
|
+
const outputContract = structuredOutputContract(
|
|
125
|
+
this.#options.ask,
|
|
126
|
+
this.#legacyExtraction ? LEGACY_ASK_OUTPUT_EXTRACTION_POLICY : undefined,
|
|
127
|
+
);
|
|
128
|
+
this.#contract = outputContract;
|
|
95
129
|
const identity = {
|
|
96
130
|
spawnOptions: this.#options.spawnOptions,
|
|
97
131
|
index: this.#options.index,
|
|
@@ -118,11 +152,16 @@ class AskExchange {
|
|
|
118
152
|
// Nested Node is decoded from recorded Agent frames only, so playback must
|
|
119
153
|
// re-derive the identical `node_update` stream (ADR-0013, spec §3).
|
|
120
154
|
this.#nodes = new NodeTracker({ report: this.#events.node });
|
|
155
|
+
// Playback reads the recorded call from the Cassette instead, so its value
|
|
156
|
+
// cannot depend on how far the replayed frame stream has drained.
|
|
157
|
+
this.#call = playback === undefined ? new ReportResultCall() : null;
|
|
121
158
|
this.#limit = playback === undefined ? this.#createLimit() : null;
|
|
122
159
|
this.#idle = playback === undefined ? this.#createIdleWatch() : null;
|
|
160
|
+
this.#stall = playback === undefined ? this.#createStallWatchdog() : null;
|
|
123
161
|
this.#observe();
|
|
124
162
|
this.#limit?.start();
|
|
125
163
|
this.#idle?.start();
|
|
164
|
+
this.#stall?.start();
|
|
126
165
|
this.#gap?.start();
|
|
127
166
|
this.#controlFailure = this.#createControlFailure();
|
|
128
167
|
}
|
|
@@ -132,9 +171,35 @@ class AskExchange {
|
|
|
132
171
|
ask: this.#options.ask,
|
|
133
172
|
durationGraceMs: this.#options.askLimitGraceMs,
|
|
134
173
|
command: async (frame) => (await this.#options.connection.command(frame)).success,
|
|
174
|
+
...(this.#reportsResult() ? { wrapUpSuffix: REPORT_RESULT_WRAP_UP } : {}),
|
|
135
175
|
});
|
|
136
176
|
}
|
|
137
177
|
|
|
178
|
+
/** The Ask's declared output schema, or undefined when it takes final text. */
|
|
179
|
+
#outputSchema(): TSchema | undefined {
|
|
180
|
+
return "outputSchema" in this.#options.ask ? this.#options.ask.outputSchema : undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The Ask's explicit correction bound, absent unless it declared a schema. */
|
|
184
|
+
#maxSteers(): number | undefined {
|
|
185
|
+
return "maxSteers" in this.#options.ask ? this.#options.ask.maxSteers : undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** True when this Ask settles from a report_result call rather than from text. */
|
|
189
|
+
#reportsResult(): boolean {
|
|
190
|
+
return this.#outputSchema() !== undefined && !this.#legacyExtraction;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* True when a Cassette recorded this Ask before ADR-0032. Such an Ask keeps
|
|
195
|
+
* its recorded identity and its recorded ADR-0027 behavior, so it replays
|
|
196
|
+
* frame for frame.
|
|
197
|
+
*/
|
|
198
|
+
#recordedLegacyPolicy(): boolean {
|
|
199
|
+
const recorded = this.#options.transport.recordedExtractionPolicy?.(this.#options.index);
|
|
200
|
+
return recorded === LEGACY_ASK_OUTPUT_EXTRACTION_POLICY;
|
|
201
|
+
}
|
|
202
|
+
|
|
138
203
|
#createIdleWatch(): IdleWatch | null {
|
|
139
204
|
if (this.#options.ask.idleMs === undefined) return null;
|
|
140
205
|
return new IdleWatch({
|
|
@@ -146,28 +211,67 @@ class AskExchange {
|
|
|
146
211
|
});
|
|
147
212
|
}
|
|
148
213
|
|
|
214
|
+
#createStallWatchdog(): StallWatchdog | null {
|
|
215
|
+
const stallMs = stallBudgetMs(this.#options.ask.stallMs);
|
|
216
|
+
if (stallMs === null) return null;
|
|
217
|
+
return new StallWatchdog({
|
|
218
|
+
stallMs,
|
|
219
|
+
...(this.#options.stallProbeSettleMs === undefined
|
|
220
|
+
? {}
|
|
221
|
+
: { probeSettleMs: this.#options.stallProbeSettleMs }),
|
|
222
|
+
command: async (frame) => await this.#options.connection.command(frame),
|
|
223
|
+
terminal: () => this.#turn.terminal,
|
|
224
|
+
recover: () => {
|
|
225
|
+
this.#cause = "recovered";
|
|
226
|
+
// The missing `agent_settled` may still arrive: it belongs to the turn
|
|
227
|
+
// yaag just settled by hand, never to the next Ask.
|
|
228
|
+
this.#drainStaleFrames = true;
|
|
229
|
+
this.#onSettled();
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
149
234
|
#observe(): void {
|
|
150
235
|
this.#options.connection.observe((frame) => {
|
|
151
236
|
this.#turn.observe(frame);
|
|
152
237
|
this.#limit?.observe(frame);
|
|
153
238
|
if (frame.type !== "agent_settled" || this.#idle?.tripped) this.#idle?.observe(frame);
|
|
239
|
+
this.#stall?.observe(frame);
|
|
154
240
|
this.#gap?.observe();
|
|
155
241
|
this.#activity?.observe(frame);
|
|
156
242
|
this.#output?.observe(frame);
|
|
157
243
|
this.#nodes?.observe(frame);
|
|
244
|
+
this.#call?.observe(frame);
|
|
158
245
|
if (this.#playback === undefined) this.#options.usage.observe(frame);
|
|
159
|
-
|
|
246
|
+
// A recorded recovery has no `agent_settled`: its terminal assistant
|
|
247
|
+
// message is what the live watchdog settled on, so playback does the same.
|
|
248
|
+
if (this.#playback?.recovered === true && this.#turn.terminal) {
|
|
249
|
+
this.#cause = "recovered";
|
|
250
|
+
this.#onSettled();
|
|
251
|
+
}
|
|
252
|
+
if (this.#turn.settled) {
|
|
253
|
+
this.#stall?.settled();
|
|
254
|
+
this.#onSettled();
|
|
255
|
+
}
|
|
160
256
|
});
|
|
161
257
|
}
|
|
162
258
|
|
|
163
259
|
#createControlFailure(): Promise<never> {
|
|
164
|
-
const controls = [
|
|
165
|
-
|
|
166
|
-
|
|
260
|
+
const controls = [
|
|
261
|
+
this.#limit?.failed,
|
|
262
|
+
this.#idle?.failed,
|
|
263
|
+
this.#idle?.killed,
|
|
264
|
+
this.#stall?.failed,
|
|
265
|
+
].filter((promise): promise is Promise<never> => promise !== undefined);
|
|
167
266
|
return Promise.race(controls.length === 0 ? [new Promise<never>(() => {})] : controls).catch(
|
|
168
267
|
(error: unknown) => {
|
|
169
268
|
if (error instanceof IdleKillSignal)
|
|
170
269
|
throw askStalledError(this.#options.agent, error.outcome);
|
|
270
|
+
if (error instanceof StallSignal) {
|
|
271
|
+
this.#cause = "stalled";
|
|
272
|
+
this.#drainStaleFrames = !error.outcome.destructive;
|
|
273
|
+
throw askStalledError(this.#options.agent, error.outcome);
|
|
274
|
+
}
|
|
171
275
|
throw this.#failed(error instanceof Error ? error.message : "limit control command failed");
|
|
172
276
|
},
|
|
173
277
|
);
|
|
@@ -175,6 +279,7 @@ class AskExchange {
|
|
|
175
279
|
|
|
176
280
|
#armSettlement(): Promise<void> {
|
|
177
281
|
this.#turn.rearm();
|
|
282
|
+
this.#stall?.rearm();
|
|
178
283
|
return new Promise<void>((resolve) => {
|
|
179
284
|
this.#onSettled = resolve;
|
|
180
285
|
});
|
|
@@ -186,7 +291,30 @@ class AskExchange {
|
|
|
186
291
|
: Date.now() + this.#options.ask.timeoutMs;
|
|
187
292
|
}
|
|
188
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Carries this Ask's schema to the Agent's report_result tool, before the
|
|
296
|
+
* prompt (ADR-0032).
|
|
297
|
+
*
|
|
298
|
+
* pi routes the command to the extension and never to the model, and the
|
|
299
|
+
* frame belongs to this same logical Ask, so the Ask's own prompt bytes and
|
|
300
|
+
* its Ask index are untouched.
|
|
301
|
+
*/
|
|
302
|
+
async #deliverSchema(): Promise<void> {
|
|
303
|
+
if (this.#legacyExtraction) return;
|
|
304
|
+
const command = this.#options.reportResultTool.begin(this.#contract?.outputSchema);
|
|
305
|
+
if (command === null) return;
|
|
306
|
+
const response = await this.#options.connection.command({
|
|
307
|
+
type: "prompt",
|
|
308
|
+
message: command.message,
|
|
309
|
+
});
|
|
310
|
+
if (!response.success) {
|
|
311
|
+
throw this.#failed(response.error ?? "report_result schema command was rejected");
|
|
312
|
+
}
|
|
313
|
+
command.accepted();
|
|
314
|
+
}
|
|
315
|
+
|
|
189
316
|
async #sendPrompt(): Promise<void> {
|
|
317
|
+
this.#call?.arm();
|
|
190
318
|
const response = await this.#options.connection.command({
|
|
191
319
|
type: "prompt",
|
|
192
320
|
message: this.#options.prompt,
|
|
@@ -199,6 +327,12 @@ class AskExchange {
|
|
|
199
327
|
}
|
|
200
328
|
|
|
201
329
|
async #verify(settled: Promise<void>, ignoreAbortFailure = false): Promise<void> {
|
|
330
|
+
// A recorded Ask that never settled has no settlement frame to wait for.
|
|
331
|
+
// Waiting for one would reproduce, in replay, the exact stall this
|
|
332
|
+
// watchdog exists to end.
|
|
333
|
+
if (this.#playback?.settles === false && this.#playback.recovered !== true) {
|
|
334
|
+
throw this.#recordedOutcome();
|
|
335
|
+
}
|
|
202
336
|
await awaitAskSettlement({
|
|
203
337
|
settled,
|
|
204
338
|
closed: this.#options.connection.closed,
|
|
@@ -218,6 +352,15 @@ class AskExchange {
|
|
|
218
352
|
if (failure !== null && !ignoreAbortFailure) throw this.#failed(failure);
|
|
219
353
|
}
|
|
220
354
|
|
|
355
|
+
/** The rejection a recorded unsettled Ask replays as. */
|
|
356
|
+
#recordedOutcome(): Error {
|
|
357
|
+
this.#outcome = this.#playback?.limit;
|
|
358
|
+
if (this.#outcome !== undefined) return askLimitError(this.#options.agent, this.#outcome);
|
|
359
|
+
this.#stalled = this.#playback?.stalled;
|
|
360
|
+
if (this.#stalled !== undefined) return askStalledError(this.#options.agent, this.#stalled);
|
|
361
|
+
return this.#failed("recorded Ask never settled");
|
|
362
|
+
}
|
|
363
|
+
|
|
221
364
|
async #lastAssistantText(): Promise<string> {
|
|
222
365
|
const response = await this.#options.connection.command({ type: "get_last_assistant_text" });
|
|
223
366
|
const text: unknown = response.data?.text;
|
|
@@ -227,36 +370,58 @@ class AskExchange {
|
|
|
227
370
|
return text;
|
|
228
371
|
}
|
|
229
372
|
|
|
230
|
-
async #resolveOutput(
|
|
231
|
-
|
|
373
|
+
async #resolveOutput(): Promise<unknown> {
|
|
374
|
+
const schema = this.#outputSchema();
|
|
375
|
+
if (schema === undefined) return await this.#lastAssistantText();
|
|
376
|
+
if (this.#legacyExtraction) return await this.#resolveRecordedText(schema);
|
|
232
377
|
if (this.#playback !== undefined) {
|
|
233
|
-
return
|
|
234
|
-
this.#options.agent,
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
this.#playback.outcome,
|
|
238
|
-
);
|
|
378
|
+
return resolvePlaybackReportedResult({
|
|
379
|
+
agent: this.#options.agent,
|
|
380
|
+
schema,
|
|
381
|
+
reported: this.#playback.reported,
|
|
382
|
+
recordedOutcome: this.#playback.outcome,
|
|
383
|
+
});
|
|
239
384
|
}
|
|
240
|
-
return new
|
|
385
|
+
return new ReportResultSteering({
|
|
241
386
|
agent: this.#options.agent,
|
|
242
|
-
schema
|
|
243
|
-
maxSteers: this.#
|
|
387
|
+
schema,
|
|
388
|
+
maxSteers: this.#maxSteers(),
|
|
389
|
+
reported: () => this.#call?.reported,
|
|
244
390
|
beginCorrection: () => this.#limit?.enterOutputCorrection() ?? true,
|
|
245
391
|
wrappingUp: () => this.#limit?.tripped ?? false,
|
|
246
392
|
correct: async (message) => this.#correct(message),
|
|
247
393
|
abort: async () => this.#abort(),
|
|
394
|
+
}).resolve();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** The ADR-0027 path, reachable only for an Ask recorded under its policy. */
|
|
398
|
+
async #resolveRecordedText(schema: TSchema): Promise<unknown> {
|
|
399
|
+
const text = await this.#lastAssistantText();
|
|
400
|
+
if (this.#playback !== undefined) {
|
|
401
|
+
return resolvePlaybackAskOutput(this.#options.agent, schema, text, this.#playback.outcome);
|
|
402
|
+
}
|
|
403
|
+
return new AskOutputSteering({
|
|
404
|
+
agent: this.#options.agent,
|
|
405
|
+
schema,
|
|
406
|
+
maxSteers: this.#maxSteers(),
|
|
407
|
+
beginCorrection: () => this.#limit?.enterOutputCorrection() ?? true,
|
|
408
|
+
wrappingUp: () => this.#limit?.tripped ?? false,
|
|
409
|
+
correct: async (message) => {
|
|
410
|
+
await this.#correct(message);
|
|
411
|
+
return await this.#lastAssistantText();
|
|
412
|
+
},
|
|
413
|
+
abort: async () => this.#abort(),
|
|
248
414
|
}).resolve(text);
|
|
249
415
|
}
|
|
250
416
|
|
|
251
417
|
// A follow-up prompt within the same logical Ask: real pi parks a post-settlement
|
|
252
418
|
// steer behind queue_update and never starts a correction turn (ADR-0027 amendment).
|
|
253
|
-
async #correct(message: string): Promise<
|
|
419
|
+
async #correct(message: string): Promise<void> {
|
|
254
420
|
try {
|
|
255
421
|
const settled = this.#armSettlement();
|
|
256
422
|
const response = await this.#options.connection.command({ type: "prompt", message });
|
|
257
423
|
if (!response.success) throw this.#failed(response.error ?? "correction prompt was rejected");
|
|
258
424
|
await this.#verify(settled);
|
|
259
|
-
return await this.#lastAssistantText();
|
|
260
425
|
} finally {
|
|
261
426
|
this.#limit?.leaveOutputCorrection();
|
|
262
427
|
}
|
|
@@ -276,14 +441,21 @@ class AskExchange {
|
|
|
276
441
|
|
|
277
442
|
#cleanup(): void {
|
|
278
443
|
this.#maxFrameGapMs = this.#gap?.maxGapMs;
|
|
279
|
-
this.#stalled ??= this.#idle?.result ?? undefined;
|
|
444
|
+
this.#stalled ??= this.#idle?.result ?? this.#stall?.result ?? undefined;
|
|
280
445
|
this.#limit?.cleanup();
|
|
281
446
|
this.#idle?.cleanup();
|
|
447
|
+
this.#stall?.cleanup();
|
|
282
448
|
this.#output?.close();
|
|
283
449
|
this.#options.connection.observe(null);
|
|
450
|
+
if (this.#drainStaleFrames) this.#options.connection.drainStaleTurn();
|
|
284
451
|
if (!this.#began) return;
|
|
285
452
|
try {
|
|
286
|
-
this.#options.transport.finishAsk(
|
|
453
|
+
this.#options.transport.finishAsk({
|
|
454
|
+
...(this.#outcome === undefined ? {} : { limit: this.#outcome }),
|
|
455
|
+
...(this.#stalled === undefined ? {} : { stalled: this.#stalled }),
|
|
456
|
+
...(this.#invalidOutput === undefined ? {} : { invalidOutput: this.#invalidOutput }),
|
|
457
|
+
...(this.#cause === "recovered" ? { recovered: true as const } : {}),
|
|
458
|
+
});
|
|
287
459
|
} catch {
|
|
288
460
|
// Recording must not hide the Ask outcome it was observing.
|
|
289
461
|
}
|
package/src/ask-hash.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import type { TSchema } from "typebox";
|
|
2
2
|
import { type AskOutputContract, createAskOutputContract } from "./ask-contract-identity.ts";
|
|
3
|
+
import type { AskOutputExtractionPolicy } from "./ask-output.ts";
|
|
3
4
|
import type { AskMarkerContext } from "./transport.ts";
|
|
4
|
-
import type { AskOptions,
|
|
5
|
+
import type { AskOptions, ResolvedSpawnOptions, StructuredAskOptions } from "./types.ts";
|
|
5
6
|
|
|
6
7
|
/** The combined option shape used internally after Definition defaults merge. */
|
|
7
8
|
export type EffectiveAskOptions = AskOptions | StructuredAskOptions<TSchema>;
|
|
8
9
|
|
|
9
10
|
/** Inputs that identify an Ask in a Cassette. */
|
|
10
11
|
export interface AskHashOptions {
|
|
11
|
-
readonly spawnOptions:
|
|
12
|
+
readonly spawnOptions: ResolvedSpawnOptions;
|
|
12
13
|
readonly index: number;
|
|
13
14
|
readonly prompt: string;
|
|
14
15
|
/** Soft-limit behavior and structured output contract, unlike timeoutMs, identify replay. */
|
|
@@ -43,6 +44,7 @@ export function askHash(options: AskHashOptions): string {
|
|
|
43
44
|
...(ask?.maxToolCalls === undefined ? {} : { maxToolCalls: ask.maxToolCalls }),
|
|
44
45
|
...(ask?.maxDurationMs === undefined ? {} : { maxDurationMs: ask.maxDurationMs }),
|
|
45
46
|
...(ask?.idleMs === undefined ? {} : { idleMs: ask.idleMs }),
|
|
47
|
+
...(ask?.stallMs === undefined ? {} : { stallMs: ask.stallMs }),
|
|
46
48
|
...(ask?.wrapUpPrompt === undefined ? {} : { wrapUpPrompt: ask.wrapUpPrompt }),
|
|
47
49
|
...(outputContract === undefined ? {} : outputContract),
|
|
48
50
|
}),
|
|
@@ -71,6 +73,7 @@ export function askMarkerContext({
|
|
|
71
73
|
...(ask?.maxToolCalls === undefined ? {} : { maxToolCalls: ask.maxToolCalls }),
|
|
72
74
|
...(ask?.maxDurationMs === undefined ? {} : { maxDurationMs: ask.maxDurationMs }),
|
|
73
75
|
...(ask?.idleMs === undefined ? {} : { idleMs: ask.idleMs }),
|
|
76
|
+
...(ask?.stallMs === undefined ? {} : { stallMs: ask.stallMs }),
|
|
74
77
|
...(ask?.wrapUpPrompt === undefined ? {} : { wrapUpPrompt: ask.wrapUpPrompt }),
|
|
75
78
|
...(outputContract === undefined ? {} : outputContract),
|
|
76
79
|
},
|
|
@@ -80,7 +83,8 @@ export function askMarkerContext({
|
|
|
80
83
|
/** Creates structured identity only when the Ask explicitly carries a schema. */
|
|
81
84
|
export function structuredOutputContract(
|
|
82
85
|
ask: EffectiveAskOptions | undefined,
|
|
86
|
+
policy?: AskOutputExtractionPolicy,
|
|
83
87
|
): AskOutputContract | undefined {
|
|
84
88
|
if (ask === undefined || !("outputSchema" in ask)) return undefined;
|
|
85
|
-
return createAskOutputContract(ask.outputSchema, ask.maxSteers);
|
|
89
|
+
return createAskOutputContract(ask.outputSchema, ask.maxSteers, policy);
|
|
86
90
|
}
|