@skrr-ai/cli 0.1.84 → 0.1.86
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/dist/commands/agents/chat.js +10 -3
- package/dist/commands/agents/endpoint/enable.js +6 -0
- package/dist/commands/store/acquisitions/approve.js +3 -0
- package/dist/lib/agent-endpoints.d.ts +19 -5
- package/dist/lib/agent-endpoints.js +11 -0
- package/dist/lib/agentic-stream.d.ts +84 -17
- package/dist/lib/agentic-stream.js +207 -36
- package/dist/lib/store-acquisition.js +11 -3
- package/dist/lib/workspaces.js +11 -13
- package/dist/node_modules/@skrr-ai/data-provider/index.js +5139 -5029
- package/oclif.manifest.json +11786 -11786
- package/package.json +5 -2
|
@@ -265,8 +265,13 @@ class AgentsChat extends base_command_1.BaseCommand {
|
|
|
265
265
|
'so folding both doubles the answer: pick ONE lane. Inside a `session.output`, ' +
|
|
266
266
|
'`envelopes[].__seq` is the dedupe key and the ordering key — the same envelope is ' +
|
|
267
267
|
'delivered by more than one room, and a re-delivery repeats its `__seq`. A ' +
|
|
268
|
-
'`stream.gap` event means envelopes in that seq range never arrived and
|
|
269
|
-
'
|
|
268
|
+
'`stream.gap` event means envelopes in that seq range never arrived and replay from ' +
|
|
269
|
+
'the server could not recover them; a hole replay fills, or one that held only ' +
|
|
270
|
+
'invisible envelopes (`service`, `status`), emits nothing. When the persisted message ' +
|
|
271
|
+
'could be checked, `verified` is true and `missing` names what the stream is short of ' +
|
|
272
|
+
'(`text`, `tool`), and the event arrives just before the terminal event; `verified: ' +
|
|
273
|
+
'false` means it could not be checked and the text MAY be short. `run.result` and the ' +
|
|
274
|
+
'persisted message remain authoritative. Tool ' +
|
|
270
275
|
'calls arrive as `tool.started` / `tool.completed`, once per `toolUseId`, on cloud ' +
|
|
271
276
|
'and local-harness runs alike. A `session.notice` with `notice: ' +
|
|
272
277
|
'"harness-session-reset"` means the local harness could not resume its previous ' +
|
|
@@ -376,7 +381,9 @@ class AgentsChat extends base_command_1.BaseCommand {
|
|
|
376
381
|
this.requireAuth();
|
|
377
382
|
const { args, flags } = await this.parse(AgentsChat);
|
|
378
383
|
if (flags.quick && flags.task)
|
|
379
|
-
this.error('--task requires a runtime session; remove --quick to bind this turn.', {
|
|
384
|
+
this.error('--task requires a runtime session; remove --quick to bind this turn.', {
|
|
385
|
+
exit: 2,
|
|
386
|
+
});
|
|
380
387
|
if (flags.json && !flags['no-stream'] && !flags.quick) {
|
|
381
388
|
this.error('--json is only supported with --no-stream or --quick; use --jsonl for streaming.', {
|
|
382
389
|
exit: 2,
|
|
@@ -40,6 +40,12 @@ class AgentsEndpointEnable extends base_command_1.BaseCommand {
|
|
|
40
40
|
}
|
|
41
41
|
(0, agent_endpoints_1.printAgentEndpoint)(this, endpoint);
|
|
42
42
|
this.log('');
|
|
43
|
+
if (!(0, agent_endpoints_1.endpointWillRun)(endpoint)) {
|
|
44
|
+
// On is not the same as runnable: say so rather than end on a connect
|
|
45
|
+
// line that leads to a refused first call (OSK-13521).
|
|
46
|
+
this.log(endpoint.notice ||
|
|
47
|
+
`The endpoint is on, but it will not run a turn until ${endpoint.blocked_by?.code ?? 'its blocker'} clears.`);
|
|
48
|
+
}
|
|
43
49
|
this.log('Connect a client with: skrr agents endpoint connect ' + args.id + ' --space <space-id>');
|
|
44
50
|
}
|
|
45
51
|
}
|
|
@@ -66,6 +66,9 @@ class StoreAcquisitionsApprove extends base_command_1.BaseCommand {
|
|
|
66
66
|
exit: 1,
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
|
+
if (request.invalidReason === 'buyer_terms_missing') {
|
|
70
|
+
this.error(`This request was recorded without buyer terms, so it cannot be approved. Decline it with \`skrr store acquisitions decline ${request.id}\`; the agent's next attempt opens a new request that carries the terms.`, { exit: 1 });
|
|
71
|
+
}
|
|
69
72
|
if (!request.buyerTerms) {
|
|
70
73
|
this.error('Buyer terms are not configured, so this purchase cannot be approved.', {
|
|
71
74
|
exit: 1,
|
|
@@ -23,11 +23,14 @@ export interface AgentEndpoint {
|
|
|
23
23
|
enabled_at?: string | null;
|
|
24
24
|
switchable: boolean;
|
|
25
25
|
ready: boolean;
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The gate that would refuse a turn right now (see docs/cli/SKRR_AGENT_ENDPOINTS.md).
|
|
28
|
+
* Includes the agent-worker runtime admission endpoint turns run on — e.g.
|
|
29
|
+
* `RUNTIME_METERING_BACKLOG_BLOCKED` — with the next step in `remedy`.
|
|
30
|
+
*/
|
|
31
|
+
blocked_by: AgentEndpointBlocker | null;
|
|
32
|
+
/** Set by `enable` when the endpoint is on but a turn would still be refused. */
|
|
33
|
+
notice?: string;
|
|
31
34
|
release?: {
|
|
32
35
|
id: string;
|
|
33
36
|
version_label: string | null;
|
|
@@ -44,6 +47,12 @@ export interface AgentEndpoint {
|
|
|
44
47
|
};
|
|
45
48
|
workspace_id?: string | null;
|
|
46
49
|
}
|
|
50
|
+
export interface AgentEndpointBlocker {
|
|
51
|
+
code: string;
|
|
52
|
+
message: string;
|
|
53
|
+
remedy?: string | null;
|
|
54
|
+
details?: Record<string, unknown>;
|
|
55
|
+
}
|
|
47
56
|
export declare function getAgentEndpoint(agentId: string): Promise<AgentEndpoint>;
|
|
48
57
|
/** Turn a Store instance's endpoint on or off. Owner only, enforced server-side. */
|
|
49
58
|
export declare function setAgentEndpointEnabled(agentId: string, enabled: boolean): Promise<AgentEndpoint>;
|
|
@@ -56,6 +65,11 @@ export declare const AGENT_ENDPOINT_SCOPE = "agents:invoke";
|
|
|
56
65
|
export declare function mcpClientCommand(endpoint: AgentEndpoint, secret: string): string;
|
|
57
66
|
/** One human line for the state an endpoint is in. */
|
|
58
67
|
export declare function describeEndpointState(endpoint: AgentEndpoint): string;
|
|
68
|
+
/**
|
|
69
|
+
* Whether the endpoint would run a turn now. `ready` alone is the answer; this
|
|
70
|
+
* exists so a caller never reads `enabled` as "it works" (OSK-13521).
|
|
71
|
+
*/
|
|
72
|
+
export declare function endpointWillRun(endpoint: AgentEndpoint): boolean;
|
|
59
73
|
/** The human rendering every `agents endpoint` command prints. */
|
|
60
74
|
export declare function printAgentEndpoint(cmd: {
|
|
61
75
|
log: (line: string) => void;
|
|
@@ -5,6 +5,7 @@ exports.getAgentEndpoint = getAgentEndpoint;
|
|
|
5
5
|
exports.setAgentEndpointEnabled = setAgentEndpointEnabled;
|
|
6
6
|
exports.mcpClientCommand = mcpClientCommand;
|
|
7
7
|
exports.describeEndpointState = describeEndpointState;
|
|
8
|
+
exports.endpointWillRun = endpointWillRun;
|
|
8
9
|
exports.printAgentEndpoint = printAgentEndpoint;
|
|
9
10
|
const api_fetch_1 = require("./api-fetch");
|
|
10
11
|
async function getAgentEndpoint(agentId) {
|
|
@@ -40,11 +41,21 @@ function describeEndpointState(endpoint) {
|
|
|
40
41
|
}
|
|
41
42
|
return 'on';
|
|
42
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether the endpoint would run a turn now. `ready` alone is the answer; this
|
|
46
|
+
* exists so a caller never reads `enabled` as "it works" (OSK-13521).
|
|
47
|
+
*/
|
|
48
|
+
function endpointWillRun(endpoint) {
|
|
49
|
+
return endpoint.enabled && endpoint.ready && !endpoint.blocked_by;
|
|
50
|
+
}
|
|
43
51
|
/** The human rendering every `agents endpoint` command prints. */
|
|
44
52
|
function printAgentEndpoint(cmd, endpoint) {
|
|
45
53
|
cmd.log(`Agent ${endpoint.agent_id}${endpoint.agent_name ? ` (${endpoint.agent_name})` : ''}`);
|
|
46
54
|
cmd.log(`Kind ${endpoint.kind.replace(/_/g, ' ')}`);
|
|
47
55
|
cmd.log(`Endpoint ${describeEndpointState(endpoint)}`);
|
|
56
|
+
if (endpoint.blocked_by?.remedy) {
|
|
57
|
+
cmd.log(`Next step ${endpoint.blocked_by.remedy}`);
|
|
58
|
+
}
|
|
48
59
|
if (endpoint.url) {
|
|
49
60
|
cmd.log(`URL ${endpoint.url} (MCP, ${endpoint.transport})`);
|
|
50
61
|
cmd.log(`Key scope ${endpoint.scope}`);
|
|
@@ -99,6 +99,13 @@ export type AgenticStreamOptions = {
|
|
|
99
99
|
replayEnvelopes?: (from: number) => Promise<EnvelopeReplayPage>;
|
|
100
100
|
/** Bounded live catch-up cadence while a run is active. Default 2s. */
|
|
101
101
|
envelopeReplayIntervalMs?: number;
|
|
102
|
+
/**
|
|
103
|
+
* Where debug-level records go — a seq hole that replay filled, or one that
|
|
104
|
+
* was never filled but provably carried nothing the reader sees. Default:
|
|
105
|
+
* stderr when `DEBUG` is set, otherwise nowhere. Never stdout: `--jsonl`
|
|
106
|
+
* owns that stream and its contract does not include diagnostics.
|
|
107
|
+
*/
|
|
108
|
+
debug?: (line: string) => void;
|
|
102
109
|
/** Silence before the first durable-record probe. Default 20s. */
|
|
103
110
|
idleTerminalProbeMs?: number;
|
|
104
111
|
/** How long to keep trying to attach before reporting a detach. Default 30s. */
|
|
@@ -335,16 +342,19 @@ export declare function jitteredRetryDelay(retryAfterMs: number, random?: () =>
|
|
|
335
342
|
* is the more dangerous of the two to leave silent, because a truncated live
|
|
336
343
|
* answer looks like a complete short answer.
|
|
337
344
|
*
|
|
338
|
-
* The web client
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
* WHILE READING, that the text on screen is not all of it.
|
|
345
|
+
* The web client detects this (`client_envelope_seq_gap_detected_total`) and
|
|
346
|
+
* rehydrates the missing range from the envelope ring; the CLI now does the
|
|
347
|
+
* same through `replayEnvelopes` (the canonical
|
|
348
|
+
* `GET /api/agentic/sessions/:id/envelopes`), holding later frames behind a
|
|
349
|
+
* hole until replay fills it.
|
|
344
350
|
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
351
|
+
* A hole replay fills is not a gap at all. One it cannot fill is judged at the
|
|
352
|
+
* terminal against the persisted message, and is warned about only when the
|
|
353
|
+
* stream is proven short of text or of a tool call (OSK-13518): the commonest
|
|
354
|
+
* hole — `service` / `slash-commands-available` envelopes a daemon published
|
|
355
|
+
* before the viewer joined the room — carries nothing the reader sees. Still
|
|
356
|
+
* never reconstructs: a client that guesses at missing text is worse than one
|
|
357
|
+
* that says it is missing.
|
|
348
358
|
*/
|
|
349
359
|
export type SeqGap = {
|
|
350
360
|
from: number;
|
|
@@ -358,8 +368,23 @@ export type SeqGap = {
|
|
|
358
368
|
*/
|
|
359
369
|
export declare function projectSessionNotices(envelopes: unknown[]): AgenticStreamEvent[];
|
|
360
370
|
export declare function detectSeqGap(highestApplied: number, incoming: number): SeqGap | null;
|
|
371
|
+
/** What a lost range is proven to have cost the reader. */
|
|
372
|
+
export type SeqGapLoss = 'text' | 'tool';
|
|
361
373
|
/**
|
|
362
|
-
* The notice a reader sees when chunks went missing.
|
|
374
|
+
* The notice a reader sees when chunks went missing AND stayed missing.
|
|
375
|
+
*
|
|
376
|
+
* A hole in `__seq` is not by itself a loss: most of the envelopes a daemon
|
|
377
|
+
* publishes before the viewer is admitted to the room — `service`
|
|
378
|
+
* ("Spawning Codex session"), `slash-commands-available`, `status` — carry
|
|
379
|
+
* nothing the reader sees, and the replay endpoint returns them anyway
|
|
380
|
+
* (OSK-13518). So this sentence is only ever built for a range that replay
|
|
381
|
+
* could NOT fill, and it says exactly as much as is known about it:
|
|
382
|
+
*
|
|
383
|
+
* - `missing` given: the range was checked against the persisted message and
|
|
384
|
+
* the stream is proven short of `text`, or a `tool` call's start never
|
|
385
|
+
* arrived (its end did). The sentence says which.
|
|
386
|
+
* - `missing` absent: nothing could check it — no record, or the record could
|
|
387
|
+
* not be read — so it says "may", never "is".
|
|
363
388
|
*
|
|
364
389
|
* It names `run.result` only when this client will actually fill `run.result`
|
|
365
390
|
* from the persisted message (`recordBackfill`). The sentence used to promise
|
|
@@ -367,9 +392,20 @@ export declare function detectSeqGap(highestApplied: number, incoming: number):
|
|
|
367
392
|
* that DID arrive — so the one run told to trust `run.result` was the run whose
|
|
368
393
|
* `run.result.text` was empty, with the answer only in the record (OSK-12132).
|
|
369
394
|
*/
|
|
370
|
-
export declare function seqGapNotice(
|
|
395
|
+
export declare function seqGapNotice(gaps: SeqGap | readonly SeqGap[], options?: {
|
|
371
396
|
recordBackfill?: boolean;
|
|
397
|
+
missing?: readonly SeqGapLoss[];
|
|
372
398
|
}): string;
|
|
399
|
+
/**
|
|
400
|
+
* Did the stream show everything the record holds?
|
|
401
|
+
*
|
|
402
|
+
* Whitespace-insensitive containment, not equality: the record may join text
|
|
403
|
+
* blocks with blank lines the envelopes never carried, and on some paths it
|
|
404
|
+
* holds only the last block of an answer the stream showed in full (OSK-7703).
|
|
405
|
+
* Both of those are "nothing missing". What is missing is persisted text the
|
|
406
|
+
* stream never showed — and that is the only thing this answers yes to.
|
|
407
|
+
*/
|
|
408
|
+
export declare function streamShowsRecordedText(streamed: string, recorded: string): boolean;
|
|
373
409
|
/**
|
|
374
410
|
* The permission mode a `run.started` reports, and where it came from
|
|
375
411
|
* (OSK-12089). `null` when the event carries no mode report at all — a cloud
|
|
@@ -392,7 +428,19 @@ export declare class AgenticStreamClient {
|
|
|
392
428
|
private replayGeneration;
|
|
393
429
|
private replayFailures;
|
|
394
430
|
private finishingReplay;
|
|
431
|
+
/** A frame was held while a replay read was already in flight (see catchUpEnvelopes). */
|
|
432
|
+
private heldDuringFlight;
|
|
433
|
+
/** A user-visible `stream.gap` was emitted; at most one per stream. */
|
|
395
434
|
private reportedGap;
|
|
435
|
+
/**
|
|
436
|
+
* Seq ranges passed over WITHOUT recovery. Not yet a loss: whether they cost
|
|
437
|
+
* the reader anything is decided against the record at the terminal.
|
|
438
|
+
*/
|
|
439
|
+
private readonly lostRanges;
|
|
440
|
+
/** Daemon tool calls whose `tool-call-start` was applied. */
|
|
441
|
+
private readonly startedDaemonCalls;
|
|
442
|
+
/** A `tool-call-end` arrived whose start never did — proof a tool envelope was lost. */
|
|
443
|
+
private orphanToolEnd;
|
|
396
444
|
private readonly seenEnvelopes;
|
|
397
445
|
/**
|
|
398
446
|
* Lifecycle events already emitted, by their own identity (OSK-7632/7689/7709).
|
|
@@ -494,15 +542,34 @@ export declare class AgenticStreamClient {
|
|
|
494
542
|
probeTerminal(reason: string): Promise<boolean>;
|
|
495
543
|
cancel(reason?: string): void;
|
|
496
544
|
close(): void;
|
|
545
|
+
private debug;
|
|
546
|
+
/**
|
|
547
|
+
* Record a hole being passed over — the moment replay has given up on it.
|
|
548
|
+
*
|
|
549
|
+
* Reaching here means the range is NOT recoverable: with a replay path every
|
|
550
|
+
* held frame waits behind the hole until the canonical endpoint fills it, and
|
|
551
|
+
* is only released past it on an explicit retention gap, repeated replay
|
|
552
|
+
* failure, or the terminal. What it is not yet is a LOSS. A hole made of
|
|
553
|
+
* `service` / `slash-commands-available` / `status` envelopes costs the
|
|
554
|
+
* reader nothing, and a warning about it is false (OSK-13518). So when the
|
|
555
|
+
* record can be read at the terminal, the verdict waits for it
|
|
556
|
+
* ({@link settleGapVerdict}). Only a client with no record to consult says
|
|
557
|
+
* something now, and then only what it knows: "may be incomplete".
|
|
558
|
+
*/
|
|
559
|
+
private noteSeqGap;
|
|
560
|
+
private emitGap;
|
|
561
|
+
/** Track daemon tool starts, and notice an end whose start never arrived. */
|
|
562
|
+
private noteDaemonToolEnvelopes;
|
|
497
563
|
/**
|
|
498
|
-
*
|
|
564
|
+
* Decide, against the persisted message, whether unrecovered ranges cost the
|
|
565
|
+
* reader anything — and say so only if they did.
|
|
499
566
|
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
567
|
+
* `recorded` undefined means the record could not be read: unverifiable, so
|
|
568
|
+
* the hedged notice. Otherwise the stream is short of text exactly when the
|
|
569
|
+
* record holds text the stream never showed, and short of a tool call exactly
|
|
570
|
+
* when an end arrived without its start. Neither → a debug record and silence.
|
|
504
571
|
*/
|
|
505
|
-
private
|
|
572
|
+
private settleGapVerdict;
|
|
506
573
|
/** Keep later live frames behind missing earlier frames while canonical replay catches up. */
|
|
507
574
|
private receiveSessionOutput;
|
|
508
575
|
private flushPendingEnvelopes;
|
|
@@ -19,6 +19,7 @@ exports.jitteredRetryDelay = jitteredRetryDelay;
|
|
|
19
19
|
exports.projectSessionNotices = projectSessionNotices;
|
|
20
20
|
exports.detectSeqGap = detectSeqGap;
|
|
21
21
|
exports.seqGapNotice = seqGapNotice;
|
|
22
|
+
exports.streamShowsRecordedText = streamShowsRecordedText;
|
|
22
23
|
exports.describeRunPermission = describeRunPermission;
|
|
23
24
|
exports.toWebSocketOrigin = toWebSocketOrigin;
|
|
24
25
|
const node_readline_1 = require("node:readline");
|
|
@@ -571,7 +572,20 @@ function detectSeqGap(highestApplied, incoming) {
|
|
|
571
572
|
return { from: highestApplied + 1, to: incoming - 1 };
|
|
572
573
|
}
|
|
573
574
|
/**
|
|
574
|
-
* The notice a reader sees when chunks went missing.
|
|
575
|
+
* The notice a reader sees when chunks went missing AND stayed missing.
|
|
576
|
+
*
|
|
577
|
+
* A hole in `__seq` is not by itself a loss: most of the envelopes a daemon
|
|
578
|
+
* publishes before the viewer is admitted to the room — `service`
|
|
579
|
+
* ("Spawning Codex session"), `slash-commands-available`, `status` — carry
|
|
580
|
+
* nothing the reader sees, and the replay endpoint returns them anyway
|
|
581
|
+
* (OSK-13518). So this sentence is only ever built for a range that replay
|
|
582
|
+
* could NOT fill, and it says exactly as much as is known about it:
|
|
583
|
+
*
|
|
584
|
+
* - `missing` given: the range was checked against the persisted message and
|
|
585
|
+
* the stream is proven short of `text`, or a `tool` call's start never
|
|
586
|
+
* arrived (its end did). The sentence says which.
|
|
587
|
+
* - `missing` absent: nothing could check it — no record, or the record could
|
|
588
|
+
* not be read — so it says "may", never "is".
|
|
575
589
|
*
|
|
576
590
|
* It names `run.result` only when this client will actually fill `run.result`
|
|
577
591
|
* from the persisted message (`recordBackfill`). The sentence used to promise
|
|
@@ -579,15 +593,44 @@ function detectSeqGap(highestApplied, incoming) {
|
|
|
579
593
|
* that DID arrive — so the one run told to trust `run.result` was the run whose
|
|
580
594
|
* `run.result.text` was empty, with the answer only in the record (OSK-12132).
|
|
581
595
|
*/
|
|
582
|
-
function seqGapNotice(
|
|
583
|
-
const
|
|
584
|
-
const
|
|
596
|
+
function seqGapNotice(gaps, options = {}) {
|
|
597
|
+
const ranges = Array.isArray(gaps) ? gaps : [gaps];
|
|
598
|
+
const count = ranges.reduce((n, g) => n + (g.to - g.from + 1), 0);
|
|
599
|
+
const where = ranges.map((g) => (g.from === g.to ? `${g.from}` : `${g.from}-${g.to}`)).join(', ');
|
|
600
|
+
const head = `[${count} stream chunk${count === 1 ? '' : 's'} (seq ${where}) never arrived ` +
|
|
601
|
+
'and could not be recovered';
|
|
602
|
+
const record = options.recordBackfill === false
|
|
585
603
|
? 'The full answer is in the persisted message.'
|
|
586
604
|
: 'The full answer is in the persisted message and in run.result.';
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
`${
|
|
605
|
+
const missing = options.missing;
|
|
606
|
+
if (!missing) {
|
|
607
|
+
return `${head} — the text above may be incomplete. ${record}]`;
|
|
608
|
+
}
|
|
609
|
+
const lostText = missing.includes('text');
|
|
610
|
+
const lostTool = missing.includes('tool');
|
|
611
|
+
if (!lostText) {
|
|
612
|
+
return `${head} — a tool call above is missing its start; the answer text is complete. The persisted message has the whole turn.]`;
|
|
613
|
+
}
|
|
614
|
+
const what = lostTool ? 'text and tool calls above are' : 'text above is';
|
|
615
|
+
return `${head} — the streamed ${what} incomplete. ${record}]`;
|
|
590
616
|
}
|
|
617
|
+
/**
|
|
618
|
+
* Did the stream show everything the record holds?
|
|
619
|
+
*
|
|
620
|
+
* Whitespace-insensitive containment, not equality: the record may join text
|
|
621
|
+
* blocks with blank lines the envelopes never carried, and on some paths it
|
|
622
|
+
* holds only the last block of an answer the stream showed in full (OSK-7703).
|
|
623
|
+
* Both of those are "nothing missing". What is missing is persisted text the
|
|
624
|
+
* stream never showed — and that is the only thing this answers yes to.
|
|
625
|
+
*/
|
|
626
|
+
function streamShowsRecordedText(streamed, recorded) {
|
|
627
|
+
const squash = (s) => s.replace(/\s+/g, '');
|
|
628
|
+
return squash(streamed).includes(squash(recorded));
|
|
629
|
+
}
|
|
630
|
+
const defaultStreamDebug = (line) => {
|
|
631
|
+
if (process.env.DEBUG)
|
|
632
|
+
process.stderr.write(`[agentic-stream debug] ${line}\n`);
|
|
633
|
+
};
|
|
591
634
|
/** How long, at most, a gapped run waits for the record before settling anyway. */
|
|
592
635
|
const GAP_BACKFILL_ATTEMPTS = 3;
|
|
593
636
|
const GAP_BACKFILL_RETRY_MS = 400;
|
|
@@ -635,7 +678,19 @@ class AgenticStreamClient {
|
|
|
635
678
|
replayGeneration = 0;
|
|
636
679
|
replayFailures = 0;
|
|
637
680
|
finishingReplay = false;
|
|
681
|
+
/** A frame was held while a replay read was already in flight (see catchUpEnvelopes). */
|
|
682
|
+
heldDuringFlight = false;
|
|
683
|
+
/** A user-visible `stream.gap` was emitted; at most one per stream. */
|
|
638
684
|
reportedGap = false;
|
|
685
|
+
/**
|
|
686
|
+
* Seq ranges passed over WITHOUT recovery. Not yet a loss: whether they cost
|
|
687
|
+
* the reader anything is decided against the record at the terminal.
|
|
688
|
+
*/
|
|
689
|
+
lostRanges = [];
|
|
690
|
+
/** Daemon tool calls whose `tool-call-start` was applied. */
|
|
691
|
+
startedDaemonCalls = new Set();
|
|
692
|
+
/** A `tool-call-end` arrived whose start never did — proof a tool envelope was lost. */
|
|
693
|
+
orphanToolEnd = false;
|
|
639
694
|
// Envelope keys already applied, so a re-delivered `session:output` frame is
|
|
640
695
|
// not appended twice (OSK-4834). Cleared on `execution:stream-reset`.
|
|
641
696
|
seenEnvelopes = new Set();
|
|
@@ -868,34 +923,113 @@ class AgenticStreamClient {
|
|
|
868
923
|
this.readline?.close();
|
|
869
924
|
this.readline = null;
|
|
870
925
|
}
|
|
926
|
+
debug(line) {
|
|
927
|
+
try {
|
|
928
|
+
(this.options.debug ?? defaultStreamDebug)(`session ${this.options.sessionId}: ${line}`);
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
// A diagnostic sink must never break the stream it describes.
|
|
932
|
+
}
|
|
933
|
+
}
|
|
871
934
|
/**
|
|
872
|
-
*
|
|
935
|
+
* Record a hole being passed over — the moment replay has given up on it.
|
|
873
936
|
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
937
|
+
* Reaching here means the range is NOT recoverable: with a replay path every
|
|
938
|
+
* held frame waits behind the hole until the canonical endpoint fills it, and
|
|
939
|
+
* is only released past it on an explicit retention gap, repeated replay
|
|
940
|
+
* failure, or the terminal. What it is not yet is a LOSS. A hole made of
|
|
941
|
+
* `service` / `slash-commands-available` / `status` envelopes costs the
|
|
942
|
+
* reader nothing, and a warning about it is false (OSK-13518). So when the
|
|
943
|
+
* record can be read at the terminal, the verdict waits for it
|
|
944
|
+
* ({@link settleGapVerdict}). Only a client with no record to consult says
|
|
945
|
+
* something now, and then only what it knows: "may be incomplete".
|
|
878
946
|
*/
|
|
879
|
-
|
|
947
|
+
noteSeqGap(envelopes) {
|
|
880
948
|
for (const envelope of envelopes) {
|
|
881
949
|
const seq = Number(envelope?.__seq);
|
|
882
950
|
if (!Number.isFinite(seq) || seq <= 0)
|
|
883
951
|
continue;
|
|
884
952
|
const gap = detectSeqGap(this.highestSeq, seq);
|
|
885
|
-
if (gap
|
|
886
|
-
this.
|
|
887
|
-
this.
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
});
|
|
953
|
+
if (gap) {
|
|
954
|
+
this.lostRanges.push(gap);
|
|
955
|
+
this.debug(`seq ${gap.from}-${gap.to} passed over unrecovered`);
|
|
956
|
+
if (!this.options.reconcileTerminal && !this.reportedGap) {
|
|
957
|
+
// Said WHILE READING, which is the only moment it helps (OSK-7631)
|
|
958
|
+
// — and hedged, because nothing will ever check it.
|
|
959
|
+
this.emitGap([gap]);
|
|
960
|
+
}
|
|
894
961
|
}
|
|
895
962
|
if (seq > this.highestSeq)
|
|
896
963
|
this.highestSeq = seq;
|
|
897
964
|
}
|
|
898
965
|
}
|
|
966
|
+
emitGap(ranges, missing) {
|
|
967
|
+
if (this.reportedGap || ranges.length === 0)
|
|
968
|
+
return;
|
|
969
|
+
this.reportedGap = true;
|
|
970
|
+
this.emit({
|
|
971
|
+
type: 'stream.gap',
|
|
972
|
+
sessionId: this.options.sessionId,
|
|
973
|
+
fromSeq: ranges[0].from,
|
|
974
|
+
toSeq: ranges[ranges.length - 1].to,
|
|
975
|
+
...(ranges.length > 1
|
|
976
|
+
? { ranges: ranges.map((g) => ({ fromSeq: g.from, toSeq: g.to })) }
|
|
977
|
+
: {}),
|
|
978
|
+
verified: Boolean(missing),
|
|
979
|
+
...(missing ? { missing } : {}),
|
|
980
|
+
message: seqGapNotice(ranges, {
|
|
981
|
+
recordBackfill: Boolean(this.options.reconcileTerminal),
|
|
982
|
+
...(missing ? { missing } : {}),
|
|
983
|
+
}),
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
/** Track daemon tool starts, and notice an end whose start never arrived. */
|
|
987
|
+
noteDaemonToolEnvelopes(envelopes) {
|
|
988
|
+
for (const envelope of envelopes) {
|
|
989
|
+
const ev = envelope?.ev;
|
|
990
|
+
if (!ev || typeof ev.call !== 'string')
|
|
991
|
+
continue;
|
|
992
|
+
if (ev.t === 'tool-call-start')
|
|
993
|
+
this.startedDaemonCalls.add(ev.call);
|
|
994
|
+
else if (ev.t === 'tool-call-end' && !this.startedDaemonCalls.has(ev.call)) {
|
|
995
|
+
this.orphanToolEnd = true;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Decide, against the persisted message, whether unrecovered ranges cost the
|
|
1001
|
+
* reader anything — and say so only if they did.
|
|
1002
|
+
*
|
|
1003
|
+
* `recorded` undefined means the record could not be read: unverifiable, so
|
|
1004
|
+
* the hedged notice. Otherwise the stream is short of text exactly when the
|
|
1005
|
+
* record holds text the stream never showed, and short of a tool call exactly
|
|
1006
|
+
* when an end arrived without its start. Neither → a debug record and silence.
|
|
1007
|
+
*/
|
|
1008
|
+
settleGapVerdict(streamedText, recorded) {
|
|
1009
|
+
if (this.lostRanges.length === 0 || this.reportedGap)
|
|
1010
|
+
return;
|
|
1011
|
+
const ranges = this.lostRanges.map((g) => `${g.from}-${g.to}`).join(', ');
|
|
1012
|
+
if (!recorded) {
|
|
1013
|
+
this.debug(`seq ${ranges} unrecovered and the record was unreadable; warning unverified`);
|
|
1014
|
+
this.emitGap(this.lostRanges);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const missing = [];
|
|
1018
|
+
if (recorded.text && !streamShowsRecordedText(streamedText, recorded.text))
|
|
1019
|
+
missing.push('text');
|
|
1020
|
+
if (this.orphanToolEnd)
|
|
1021
|
+
missing.push('tool');
|
|
1022
|
+
if (missing.length === 0) {
|
|
1023
|
+
this.debug(`seq ${ranges} unrecovered but carried nothing visible (record matches stream)`);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
this.emitGap(this.lostRanges, missing);
|
|
1027
|
+
// The notice says the text above is short; what follows it is the whole
|
|
1028
|
+
// answer from the record, printed in full rather than as a tail appended
|
|
1029
|
+
// to a fragment the reader now knows is wrong.
|
|
1030
|
+
if (missing.includes('text'))
|
|
1031
|
+
this.renderedText = '';
|
|
1032
|
+
}
|
|
899
1033
|
/** Keep later live frames behind missing earlier frames while canonical replay catches up. */
|
|
900
1034
|
receiveSessionOutput(event) {
|
|
901
1035
|
if (this.closed || this.settled)
|
|
@@ -912,6 +1046,11 @@ class AgenticStreamClient {
|
|
|
912
1046
|
}
|
|
913
1047
|
else if (seq > this.highestSeq && !this.pendingEnvelopes.has(seq)) {
|
|
914
1048
|
this.pendingEnvelopes.set(seq, { envelope, event });
|
|
1049
|
+
// A read already in flight may have been issued BEFORE this frame was
|
|
1050
|
+
// published, and so before the earlier seqs it is waiting on were
|
|
1051
|
+
// stamped. Its answer proves nothing about them; see catchUpEnvelopes.
|
|
1052
|
+
if (this.replayFlight && event.source !== 'replay')
|
|
1053
|
+
this.heldDuringFlight = true;
|
|
915
1054
|
}
|
|
916
1055
|
}
|
|
917
1056
|
this.flushPendingEnvelopes(false);
|
|
@@ -942,6 +1081,7 @@ class AgenticStreamClient {
|
|
|
942
1081
|
clearTimeout(this.replayTimer);
|
|
943
1082
|
this.replayTimer = null;
|
|
944
1083
|
const generation = this.replayGeneration;
|
|
1084
|
+
this.heldDuringFlight = false;
|
|
945
1085
|
this.replayFlight = (async () => {
|
|
946
1086
|
try {
|
|
947
1087
|
for (let page = 0; page < 4; page += 1) {
|
|
@@ -950,6 +1090,14 @@ class AgenticStreamClient {
|
|
|
950
1090
|
if (this.closed || this.settled || generation !== this.replayGeneration)
|
|
951
1091
|
return;
|
|
952
1092
|
this.replayFailures = 0;
|
|
1093
|
+
if (this.pendingEnvelopes.size && result.envelopes.length) {
|
|
1094
|
+
const seqs = result.envelopes
|
|
1095
|
+
.map((e) => Number(e?.__seq))
|
|
1096
|
+
.filter((n) => Number.isSafeInteger(n) && n > from);
|
|
1097
|
+
if (seqs.length) {
|
|
1098
|
+
this.debug(`seq ${Math.min(...seqs)}-${Math.max(...seqs)} backfilled by replay`);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
953
1101
|
this.receiveSessionOutput({
|
|
954
1102
|
type: 'session.output',
|
|
955
1103
|
sessionId: this.options.sessionId,
|
|
@@ -981,7 +1129,14 @@ class AgenticStreamClient {
|
|
|
981
1129
|
})().finally(() => {
|
|
982
1130
|
this.replayFlight = null;
|
|
983
1131
|
if (!this.closed && !this.settled && !this.finishingReplay) {
|
|
984
|
-
|
|
1132
|
+
// The server stamps and persists an envelope BEFORE it fans it out, so
|
|
1133
|
+
// a read issued after a live frame arrived is guaranteed to see every
|
|
1134
|
+
// earlier seq. A frame held while a stale read was in flight therefore
|
|
1135
|
+
// gets one immediate re-read, instead of sitting behind a pre-join
|
|
1136
|
+
// hole for a whole poll interval (OSK-13518). Bounded: the flag is
|
|
1137
|
+
// cleared when that read starts and set again only by a NEW live frame.
|
|
1138
|
+
const again = this.heldDuringFlight && this.pendingEnvelopes.size > 0;
|
|
1139
|
+
this.replayTimer = setTimeout(() => void this.catchUpEnvelopes(), again ? 0 : (this.options.envelopeReplayIntervalMs ?? 2000));
|
|
985
1140
|
}
|
|
986
1141
|
});
|
|
987
1142
|
return this.replayFlight;
|
|
@@ -1004,10 +1159,11 @@ class AgenticStreamClient {
|
|
|
1004
1159
|
if (fresh.length === 0)
|
|
1005
1160
|
return; // a pure re-delivery — drop it whole
|
|
1006
1161
|
const scoped = { ...event, envelopes: fresh };
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1162
|
+
// Note what never arrived before folding this batch in (OSK-7631,
|
|
1163
|
+
// OSK-13518). Checked against the FRESH set: a re-delivery carries old
|
|
1009
1164
|
// seqs and would read as a gap that never happened.
|
|
1010
|
-
this.
|
|
1165
|
+
this.noteSeqGap(fresh);
|
|
1166
|
+
this.noteDaemonToolEnvelopes(fresh);
|
|
1011
1167
|
const envelopeText = envelopesText(scoped);
|
|
1012
1168
|
if (envelopeText) {
|
|
1013
1169
|
this.accumulated += envelopeText;
|
|
@@ -1120,6 +1276,9 @@ class AgenticStreamClient {
|
|
|
1120
1276
|
this.replayGeneration += 1;
|
|
1121
1277
|
this.pendingEnvelopes.clear();
|
|
1122
1278
|
this.reportedGap = false;
|
|
1279
|
+
this.lostRanges.length = 0;
|
|
1280
|
+
this.startedDaemonCalls.clear();
|
|
1281
|
+
this.orphanToolEnd = false;
|
|
1123
1282
|
// A reset restarts the stream, so envelope seqs may repeat; forget the
|
|
1124
1283
|
// seen keys or the fresh post-reset text would be deduped away (OSK-4834).
|
|
1125
1284
|
this.seenEnvelopes.clear();
|
|
@@ -1562,13 +1721,19 @@ class AgenticStreamClient {
|
|
|
1562
1721
|
return;
|
|
1563
1722
|
this.settled = true;
|
|
1564
1723
|
this.lastEvent = event;
|
|
1565
|
-
// A stream that
|
|
1566
|
-
//
|
|
1567
|
-
//
|
|
1568
|
-
//
|
|
1569
|
-
//
|
|
1570
|
-
|
|
1571
|
-
|
|
1724
|
+
// A stream that passed over unrecovered chunks reads the record before
|
|
1725
|
+
// settling, for two reasons. The verdict: whether those chunks cost the
|
|
1726
|
+
// reader anything is a comparison with the persisted message, and only a
|
|
1727
|
+
// proven loss is said out loud (OSK-13518). And the answer: the terminal
|
|
1728
|
+
// event does not always carry it — a failed turn's `execution:error` has
|
|
1729
|
+
// no `result` — so `run.result` is filled from the record, or the notice's
|
|
1730
|
+
// promise is false exactly when it was made (OSK-12132).
|
|
1731
|
+
if (this.lostRanges.length && this.options.reconcileTerminal) {
|
|
1732
|
+
const streamedText = this.accumulated;
|
|
1733
|
+
void this.readRecordedText().then((recorded) => {
|
|
1734
|
+
this.settleGapVerdict(streamedText, recorded);
|
|
1735
|
+
this.settle(status, event, recorded?.text || undefined);
|
|
1736
|
+
});
|
|
1572
1737
|
return;
|
|
1573
1738
|
}
|
|
1574
1739
|
this.settle(status, event, undefined);
|
|
@@ -1583,19 +1748,25 @@ class AgenticStreamClient {
|
|
|
1583
1748
|
const reconcile = this.options.reconcileTerminal;
|
|
1584
1749
|
if (!reconcile)
|
|
1585
1750
|
return undefined;
|
|
1751
|
+
// A terminal record with no text is still a reading — "the turn persisted
|
|
1752
|
+
// no answer" — and is kept in case a later attempt finds none either.
|
|
1753
|
+
let empty;
|
|
1586
1754
|
for (let attempt = 0; attempt < GAP_BACKFILL_ATTEMPTS; attempt += 1) {
|
|
1587
1755
|
if (attempt > 0)
|
|
1588
1756
|
await new Promise((r) => setTimeout(r, GAP_BACKFILL_RETRY_MS));
|
|
1589
1757
|
try {
|
|
1590
1758
|
const terminal = await reconcile();
|
|
1591
|
-
if (terminal && typeof terminal.text === 'string' && terminal.text)
|
|
1592
|
-
return terminal.text;
|
|
1759
|
+
if (terminal && typeof terminal.text === 'string' && terminal.text) {
|
|
1760
|
+
return { text: terminal.text };
|
|
1761
|
+
}
|
|
1762
|
+
if (terminal)
|
|
1763
|
+
empty = { text: '' };
|
|
1593
1764
|
}
|
|
1594
1765
|
catch {
|
|
1595
1766
|
// No evidence either way; try again, then settle on the stream.
|
|
1596
1767
|
}
|
|
1597
1768
|
}
|
|
1598
|
-
return
|
|
1769
|
+
return empty;
|
|
1599
1770
|
}
|
|
1600
1771
|
settle(status, event, recordedText) {
|
|
1601
1772
|
// `execution:complete` carries the final answer as `result`, and it is
|