@robota-sdk/agent-transport 3.0.0-beta.100
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 +1137 -0
- package/LICENSE +661 -0
- package/README.md +121 -0
- package/dist/browser/client.d.ts +2 -0
- package/dist/browser/client.js +1 -0
- package/dist/browser/index.d.ts +624 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/observer-messages-4fRQZU2D.d.ts +499 -0
- package/dist/browser/observer-messages-4fRQZU2D.d.ts.map +1 -0
- package/dist/browser/observer-messages-O8jTPEQO.js +2 -0
- package/dist/browser/observer-messages-O8jTPEQO.js.map +1 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +1115 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1115 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/node.cjs +1 -0
- package/dist/node/node.d.cts +317 -0
- package/dist/node/node.d.cts.map +1 -0
- package/dist/node/node.d.ts +317 -0
- package/dist/node/node.d.ts.map +1 -0
- package/dist/node/node.js +2 -0
- package/dist/node/node.js.map +1 -0
- package/package.json +122 -0
- package/src/__tests__/__fixtures__/korean-text.json +6 -0
- package/src/__tests__/access-token-verifier.test.ts +672 -0
- package/src/__tests__/admission.test.ts +130 -0
- package/src/__tests__/app-tool-messages.test.ts +463 -0
- package/src/__tests__/bearer-resource-gate.test.ts +120 -0
- package/src/__tests__/channel-frames.test.ts +215 -0
- package/src/__tests__/driver-attribution-capability.test.ts +83 -0
- package/src/__tests__/file-transfer.test.ts +281 -0
- package/src/__tests__/handoff-chunking.test.ts +261 -0
- package/src/__tests__/handoff-manifest.test.ts +52 -0
- package/src/__tests__/history-attached-budget.test.ts +216 -0
- package/src/__tests__/message-decoders.test.ts +870 -0
- package/src/__tests__/outbound-delivery.test.ts +558 -0
- package/src/__tests__/peer-message-ledger.test.ts +200 -0
- package/src/__tests__/permission-summary.test.ts +18 -0
- package/src/__tests__/personal-usage-report.test.ts +193 -0
- package/src/__tests__/project-read-messages.test.ts +118 -0
- package/src/__tests__/resume-buffer.test.ts +93 -0
- package/src/__tests__/session-detail-loop-status.test.ts +169 -0
- package/src/__tests__/session-directory-messages.test.ts +322 -0
- package/src/__tests__/session-error-classification.test.ts +113 -0
- package/src/__tests__/session-event-delivery.test.ts +101 -0
- package/src/__tests__/session-history-wire.test.ts +364 -0
- package/src/__tests__/session-message-handler-e5.test.ts +99 -0
- package/src/__tests__/session-message-handler-observe.test.ts +223 -0
- package/src/__tests__/session-message-handler.test.ts +726 -0
- package/src/__tests__/session-resume-bridge.test.ts +472 -0
- package/src/__tests__/settings-messages.test.ts +193 -0
- package/src/__tests__/stdio-carrier.test.ts +85 -0
- package/src/__tests__/submit-prompt-shape.test.ts +78 -0
- package/src/__tests__/usage-report-carrier.test.ts +98 -0
- package/src/__tests__/ws-broadcast-events.test.ts +94 -0
- package/src/app-tool-messages.ts +140 -0
- package/src/background-messages.ts +169 -0
- package/src/channel-frames.ts +182 -0
- package/src/client.ts +17 -0
- package/src/handoff-chunking.ts +275 -0
- package/src/index.ts +135 -0
- package/src/loop-control-messages.ts +34 -0
- package/src/message-decoders.ts +485 -0
- package/src/message-parser.ts +33 -0
- package/src/node/access-token-verifier.ts +363 -0
- package/src/node/admission.ts +153 -0
- package/src/node/bearer-resource-gate.ts +331 -0
- package/src/node/file-transfer.ts +530 -0
- package/src/node/handoff-manifest.ts +77 -0
- package/src/node/index.ts +45 -0
- package/src/node/stdio-carrier.ts +59 -0
- package/src/observer-messages.ts +47 -0
- package/src/open-prompts.ts +76 -0
- package/src/outbound-delivery.ts +240 -0
- package/src/peer-message-ledger.ts +193 -0
- package/src/permission-summary.ts +18 -0
- package/src/project-read-messages.ts +74 -0
- package/src/protocol-session.ts +56 -0
- package/src/resume-buffer.ts +123 -0
- package/src/session-directory-messages.ts +162 -0
- package/src/session-error-classification.ts +91 -0
- package/src/session-events.ts +290 -0
- package/src/session-message-handler.ts +413 -0
- package/src/session-query-messages.ts +160 -0
- package/src/session-resume-bridge.ts +275 -0
- package/src/settings-messages.ts +103 -0
- package/src/usage-messages.ts +195 -0
- package/src/wire-messages.ts +436 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The input bridge and close observation a stdio protocol carrier shares.
|
|
3
|
+
*
|
|
4
|
+
* A protocol SDK that reads a stream owns what it reads: closing the SDK connection destroys its
|
|
5
|
+
* source. Handing it the process input directly would let one carrier's `stop()` destroy `process.stdin`
|
|
6
|
+
* for the whole process, so the carrier reads a `PassThrough` bridge fed by the input instead. Piping
|
|
7
|
+
* also makes EOF observable before the SDK starts reading and keeps early frames, with backpressure,
|
|
8
|
+
* until it does.
|
|
9
|
+
*
|
|
10
|
+
* This module only attaches and detaches. What a close or failure means — settle a promise, cancel a
|
|
11
|
+
* startup, end the process — stays with each carrier.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { PassThrough } from 'node:stream';
|
|
15
|
+
|
|
16
|
+
import type { Readable, Writable } from 'node:stream';
|
|
17
|
+
|
|
18
|
+
export interface IStdioCarrierHandlers {
|
|
19
|
+
/** The peer ended its side: `end` or `close` on the input. May fire more than once. */
|
|
20
|
+
readonly onInputClose: () => void;
|
|
21
|
+
/** The output closed. */
|
|
22
|
+
readonly onOutputClose: () => void;
|
|
23
|
+
/** The output failed to accept a write. */
|
|
24
|
+
readonly onOutputError: (error: Error) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface IStdioCarrier {
|
|
28
|
+
/** Hand this to the protocol SDK in place of the input; destroying it never destroys the input. */
|
|
29
|
+
readonly bridge: PassThrough;
|
|
30
|
+
/** Remove the listeners, unpipe and destroy the bridge. Safe to call again; the streams stay open. */
|
|
31
|
+
release(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function attachStdioCarrier(
|
|
35
|
+
input: Readable,
|
|
36
|
+
output: Writable,
|
|
37
|
+
handlers: IStdioCarrierHandlers,
|
|
38
|
+
): IStdioCarrier {
|
|
39
|
+
const bridge = new PassThrough();
|
|
40
|
+
let released = false;
|
|
41
|
+
input.once('end', handlers.onInputClose);
|
|
42
|
+
input.once('close', handlers.onInputClose);
|
|
43
|
+
output.once('close', handlers.onOutputClose);
|
|
44
|
+
output.on('error', handlers.onOutputError);
|
|
45
|
+
input.pipe(bridge);
|
|
46
|
+
return {
|
|
47
|
+
bridge,
|
|
48
|
+
release() {
|
|
49
|
+
if (released) return;
|
|
50
|
+
released = true;
|
|
51
|
+
input.off('end', handlers.onInputClose);
|
|
52
|
+
input.off('close', handlers.onInputClose);
|
|
53
|
+
output.off('close', handlers.onOutputClose);
|
|
54
|
+
output.off('error', handlers.onOutputError);
|
|
55
|
+
input.unpipe(bridge);
|
|
56
|
+
bridge.destroy();
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { TClientMessage } from './wire-messages.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The only inbound messages an observer may send: reads of this session. An allowlist, so a message
|
|
5
|
+
* type added later is refused to observers until someone decides it is a read. `get-prompts` is left
|
|
6
|
+
* out: an observer never receives prompts, open ones included.
|
|
7
|
+
*
|
|
8
|
+
* The host refuses anything else from an observer, and a client in observe mode sends only these, so
|
|
9
|
+
* both sides read this one list.
|
|
10
|
+
*/
|
|
11
|
+
export const OBSERVER_MESSAGES: ReadonlySet<TClientMessage['type']> = new Set<
|
|
12
|
+
TClientMessage['type']
|
|
13
|
+
>([
|
|
14
|
+
'get-messages',
|
|
15
|
+
'get-history',
|
|
16
|
+
'get-context',
|
|
17
|
+
'get-status',
|
|
18
|
+
'get-commands',
|
|
19
|
+
'list-models',
|
|
20
|
+
'get-executing',
|
|
21
|
+
'get-pending',
|
|
22
|
+
'get-execution-workspace',
|
|
23
|
+
'read-execution-detail',
|
|
24
|
+
'get-agent-definitions',
|
|
25
|
+
'get-usage-report',
|
|
26
|
+
'list-sessions',
|
|
27
|
+
'get-background-tasks',
|
|
28
|
+
'get-background-task',
|
|
29
|
+
'get-background-job-groups',
|
|
30
|
+
'get-background-job-group',
|
|
31
|
+
'wait-background-job-group',
|
|
32
|
+
'read-background-task-log',
|
|
33
|
+
// #3282 §4a: reading the Settings snapshot is a pure read of this session's own configuration.
|
|
34
|
+
// `update-settings` stays out — an observer never writes.
|
|
35
|
+
'get-settings',
|
|
36
|
+
// #3282 §4c: the Project panel's reads — git status, one file's diff, project memory. All three are
|
|
37
|
+
// read-only (there is no "restore"/write counterpart in this feature — see the SPEC.md design
|
|
38
|
+
// decision on why checkpoints/rewind are excluded entirely), so all three are observer-safe.
|
|
39
|
+
'project-status',
|
|
40
|
+
'project-diff',
|
|
41
|
+
'project-memory',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** Whether an observer may send a message of this type. */
|
|
45
|
+
export function isObserverMessageType(type: string): boolean {
|
|
46
|
+
return (OBSERVER_MESSAGES as ReadonlySet<string>).has(type);
|
|
47
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3189: the permission and ask prompts a session has open, as the frames that asked them.
|
|
3
|
+
*
|
|
4
|
+
* The session keeps only the promise each prompt waits on, and emits the prompt once, when it is
|
|
5
|
+
* asked. A client that attaches later would never see a prompt already open. This keeps what the
|
|
6
|
+
* transport forwarded, per session, for a `get-prompts` reply.
|
|
7
|
+
*
|
|
8
|
+
* It is held by the connections that receive prompts and learns everything through their own
|
|
9
|
+
* subscriptions, so it never listens to the session itself. That matters: a session asks only while
|
|
10
|
+
* someone listens for prompts, and a listener of its own here would keep an unattended session's
|
|
11
|
+
* prompt open instead of failing it closed. When the last such connection goes, the session settles
|
|
12
|
+
* every open prompt, so nothing is kept.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { IProtocolSession } from './protocol-session.js';
|
|
16
|
+
import type { TServerMessage } from './wire-messages.js';
|
|
17
|
+
|
|
18
|
+
export type TOpenPromptFrame = Extract<
|
|
19
|
+
TServerMessage,
|
|
20
|
+
{ type: 'permission_request' | 'ask_request' }
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
interface IOpenPrompts {
|
|
24
|
+
readonly frames: Map<string, TOpenPromptFrame>;
|
|
25
|
+
holders: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const openPromptsBySession = new WeakMap<IProtocolSession, IOpenPrompts>();
|
|
29
|
+
|
|
30
|
+
/** One connection's hold on its session's open prompts; every holder reports what it forwards. */
|
|
31
|
+
export interface IOpenPromptsHold {
|
|
32
|
+
/** A prompt was asked. */
|
|
33
|
+
record(frame: TOpenPromptFrame): void;
|
|
34
|
+
/** A prompt was settled, by an answer or by the session. */
|
|
35
|
+
forget(id: string): void;
|
|
36
|
+
/** The host made another session current; the previous session's prompts are settled. */
|
|
37
|
+
clear(): void;
|
|
38
|
+
/** This connection no longer receives prompts. */
|
|
39
|
+
release(): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Called by a connection that receives prompts, before it subscribes to them. */
|
|
43
|
+
export function holdOpenPrompts(session: IProtocolSession): IOpenPromptsHold {
|
|
44
|
+
let open = openPromptsBySession.get(session);
|
|
45
|
+
if (open === undefined) {
|
|
46
|
+
open = { frames: new Map(), holders: 0 };
|
|
47
|
+
openPromptsBySession.set(session, open);
|
|
48
|
+
}
|
|
49
|
+
const held = open;
|
|
50
|
+
held.holders += 1;
|
|
51
|
+
let released = false;
|
|
52
|
+
return {
|
|
53
|
+
record: (frame) => {
|
|
54
|
+
if (!released) held.frames.set(frame.event.id, frame);
|
|
55
|
+
},
|
|
56
|
+
forget: (id) => {
|
|
57
|
+
held.frames.delete(id);
|
|
58
|
+
},
|
|
59
|
+
clear: () => {
|
|
60
|
+
held.frames.clear();
|
|
61
|
+
},
|
|
62
|
+
release: () => {
|
|
63
|
+
if (released) return;
|
|
64
|
+
released = true;
|
|
65
|
+
held.holders -= 1;
|
|
66
|
+
if (held.holders === 0 && openPromptsBySession.get(session) === held) {
|
|
67
|
+
openPromptsBySession.delete(session);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The prompts open now, oldest first; none when no connection that receives prompts is attached. */
|
|
74
|
+
export function listOpenPrompts(session: IProtocolSession): readonly TOpenPromptFrame[] {
|
|
75
|
+
return [...(openPromptsBySession.get(session)?.frames.values() ?? [])];
|
|
76
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The connection-scoped outbound delivery boundary (ARCH-030).
|
|
3
|
+
*
|
|
4
|
+
* Every outbound `TServerMessage` on one connection — session-event fan-out AND every reply to an
|
|
5
|
+
* inbound frame — goes through ONE boundary that owns carrier-failure containment. Before this
|
|
6
|
+
* existed, `subscribeSessionEvents` guarded its sends while the reply paths received the raw carrier
|
|
7
|
+
* `send`, so a reply resolving after a disconnect threw from a Promise continuation, escaped as an
|
|
8
|
+
* unhandled rejection, and left the carrier's cleanup — written, idempotent, and waiting — unnotified.
|
|
9
|
+
*
|
|
10
|
+
* WHO BUILDS IT. The **carrier**, from its own private sink and its own failure policy, and passes the
|
|
11
|
+
* result down into the protocol handler. Not the other way round: the raw sink and the "what does a
|
|
12
|
+
* failed send mean for this connection" decision both belong to the carrier, and handing the protocol
|
|
13
|
+
* layer a raw sink so it can hand a wrapper back leaves the raw sink reachable — which is the hole a
|
|
14
|
+
* twelfth reply family walks through next year.
|
|
15
|
+
*
|
|
16
|
+
* WHY IT IS BRANDED. {@link createOutboundDelivery} is the only producer of {@link TOutboundDeliver}, so
|
|
17
|
+
* a plain `(message: TServerMessage) => void` is **refused by the compiler** wherever a boundary is
|
|
18
|
+
* required. That turns "a new raw-send continuation cannot be added silently" from a scan that a rename
|
|
19
|
+
* defeats into a type error at the call site. `src/__tests__` is inside this package's `tsconfig.json`
|
|
20
|
+
* `include`, so the `@ts-expect-error` case pinning it is checked by `tsgo --noEmit`.
|
|
21
|
+
*
|
|
22
|
+
* IT LATCHES. A boundary reports AT MOST ONE delivery failure; after the first, it is closed and every
|
|
23
|
+
* subsequent frame is dropped without a further report. All three carriers already treat a delivery
|
|
24
|
+
* failure as terminal (`WsSessionDelivery.close` → `socket.close(1011)`, `WebRtcDeliveryLifecycle` →
|
|
25
|
+
* `channel.close()`, `PairingGate` → `pairingChannel.close`) and each had grown its own latch to
|
|
26
|
+
* suppress the repeats; the latch belongs upstream of all three. `SessionResumeBridge` already behaved
|
|
27
|
+
* this way, so the handler path converges onto the bridge's semantics rather than the reverse.
|
|
28
|
+
*
|
|
29
|
+
* A boundary is not reusable across carriers: a latched one stays latched. `SessionResumeBridge` builds
|
|
30
|
+
* a fresh boundary per `attach`, which is what un-latches the session after a reconnect.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { TServerMessage } from './wire-messages.js';
|
|
34
|
+
|
|
35
|
+
declare const outboundDeliveryBrand: unique symbol;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Deliver one outbound frame on a connection. A carrier failure is REPORTED through the boundary's
|
|
39
|
+
* error handler, never thrown at the caller — so a reply resolving after a disconnect cannot escape as
|
|
40
|
+
* an unhandled rejection or out of the carrier's inbound listener.
|
|
41
|
+
*
|
|
42
|
+
* Only {@link createOutboundDelivery} produces one. The brand is the mechanism, not decoration: it is
|
|
43
|
+
* what makes a raw carrier `send` unusable where a boundary is required.
|
|
44
|
+
*/
|
|
45
|
+
export type TOutboundDeliver = ((message: TServerMessage) => void) & {
|
|
46
|
+
readonly [outboundDeliveryBrand]: true;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Bytes this carrier has ACCEPTED and not yet written to the peer — or `undefined` when it cannot
|
|
50
|
+
* say.
|
|
51
|
+
*
|
|
52
|
+
* `deliver` returning is not delivery. Both carriers hand this boundary a fire-and-forget sink, so
|
|
53
|
+
* a frame that has "been sent" may be sitting in a socket buffer the boundary cannot see. Anything
|
|
54
|
+
* this boundary counted itself would be a count of what it HANDED OVER, which is a different
|
|
55
|
+
* quantity from what the peer has not read — and a budget over the wrong quantity reports a
|
|
56
|
+
* healthy connection for a peer that stopped reading (ARCH-030 / issue #1734).
|
|
57
|
+
*
|
|
58
|
+
* `undefined` is deliberately not `0`. A carrier that cannot report backpressure and a carrier with
|
|
59
|
+
* nothing pending are different states, and collapsing them would let "unknown" satisfy any
|
|
60
|
+
* threshold placed on this number.
|
|
61
|
+
*/
|
|
62
|
+
readonly pendingBytes: () => number | undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Observe THIS connection's first outbound delivery failure. `event` is the `type` of the frame that
|
|
67
|
+
* could not be delivered — for the fan-out, the frame a session event was forwarded as (usually, not
|
|
68
|
+
* always, the event's own name), and the reply's type for a reply (`command_result`, `protocol_error`, …).
|
|
69
|
+
*
|
|
70
|
+
* Required, never optional: a carrier that could opt out of observing its own delivery failures is the
|
|
71
|
+
* silent-failure shape this boundary exists to remove.
|
|
72
|
+
*/
|
|
73
|
+
export type TDeliveryErrorHandler = (error: Error, event: TServerMessage['type']) => void;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the outbound delivery boundary for ONE connection from that connection's raw sink and its
|
|
77
|
+
* failure policy.
|
|
78
|
+
*
|
|
79
|
+
* @param send - the carrier's raw sink. May throw; that is what makes it raw.
|
|
80
|
+
* @param onDeliveryError - the carrier's failure policy, invoked at most once. A handler that itself
|
|
81
|
+
* throws is isolated: a diagnostic cannot reverse an already-committed session operation.
|
|
82
|
+
* @param pendingBytes - the carrier's own backpressure reading. Optional because not every carrier
|
|
83
|
+
* has one; omitted, the boundary answers `undefined` rather than inventing a number.
|
|
84
|
+
*/
|
|
85
|
+
/**
|
|
86
|
+
* Default outbound backpressure budget, in bytes the carrier has accepted and not yet written.
|
|
87
|
+
*
|
|
88
|
+
* 8 MiB. The number is a policy, not a measurement, and it is stated here rather than inlined so a
|
|
89
|
+
* reader can find the one place that decides it. It is well above any single frame this protocol
|
|
90
|
+
* produces and well below a figure at which a non-reading peer could exhaust the host — the property
|
|
91
|
+
* that matters is that SOME finite budget exists, because the previous behaviour was unbounded.
|
|
92
|
+
*
|
|
93
|
+
* A carrier that cannot report `pendingBytes` is not subject to it: `undefined` is unknown, not zero,
|
|
94
|
+
* so no threshold applies to it. That is deliberate and it is a gap — a carrier with no backpressure
|
|
95
|
+
* reading has no budget — recorded rather than hidden behind a default of `0`, which would refuse
|
|
96
|
+
* every frame on such a carrier.
|
|
97
|
+
*/
|
|
98
|
+
const BYTES_PER_MIB = Number('1024') * Number('1024');
|
|
99
|
+
export const DEFAULT_MAX_PENDING_BYTES = Number('8') * BYTES_PER_MIB;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Backpressure budget for a terminal attached to a session on the same host, in either role.
|
|
103
|
+
*
|
|
104
|
+
* 1 MiB, tighter than the default: an attached terminal reads a local socket, so a backlog this large
|
|
105
|
+
* means it stopped reading rather than that a network is slow. Exceeding it closes that connection
|
|
106
|
+
* only; the session never waits for a reader, so other surfaces keep streaming.
|
|
107
|
+
*/
|
|
108
|
+
export const ATTACHED_SURFACE_MAX_PENDING_BYTES = BYTES_PER_MIB;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Is this carrier holding more than the budget allows?
|
|
112
|
+
*
|
|
113
|
+
* Exported because a WebSocket carries TWO kinds of outbound frame over one socket — the text
|
|
114
|
+
* protocol and TRANS-001's payload channels — and only one of them goes through
|
|
115
|
+
* {@link createOutboundDelivery}. A budget the binary path re-implemented would be a second opinion
|
|
116
|
+
* about one socket's backpressure; a budget it skipped would leave the same non-reading peer
|
|
117
|
+
* unbounded on the other half of the connection.
|
|
118
|
+
*
|
|
119
|
+
* `undefined` is unknown, not zero: a carrier that cannot report backpressure is never over budget.
|
|
120
|
+
*/
|
|
121
|
+
export function isOverPendingBudget(
|
|
122
|
+
pending: number | undefined,
|
|
123
|
+
limit: number = DEFAULT_MAX_PENDING_BYTES,
|
|
124
|
+
): boolean {
|
|
125
|
+
return pending !== undefined && pending > limit;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Default outbound DRAIN budget (issue #2306): how long a carrier may stay non-empty before the peer
|
|
130
|
+
* is treated as not reading.
|
|
131
|
+
*
|
|
132
|
+
* 120 seconds. The byte budget bounds HOW MUCH a peer holds; it cannot bound HOW LONG, because
|
|
133
|
+
* `bufferedAmount` is a quantity with no history — a peer draining just enough to stay under 8 MiB
|
|
134
|
+
* forever is never over it. The figure is generous on purpose: a legitimately slow link drains to
|
|
135
|
+
* zero between bursts, so the clock resets, whereas a peer that keeps SOMETHING pending for two
|
|
136
|
+
* minutes straight is holding a session, its resume buffer, and a socket it does not intend to read.
|
|
137
|
+
* A badly chosen figure here disconnects real users, which is why it is one named constant and an
|
|
138
|
+
* argument, never an inline number. `Infinity` disables the budget.
|
|
139
|
+
*/
|
|
140
|
+
export const DEFAULT_MAX_PENDING_MS = Number('120') * Number('1000');
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The drain clock the boundary keeps for a carrier (issue #2306). One timestamp, not per-frame
|
|
144
|
+
* bookkeeping: it starts on the first reading that finds the carrier non-empty, RESETS only when a
|
|
145
|
+
* reading finds it empty (a peer that drains to one byte and stalls again has not drained), and is
|
|
146
|
+
* consulted synchronously at each send — the boundary has no timer and gains none here.
|
|
147
|
+
*/
|
|
148
|
+
export interface IPendingStallClock {
|
|
149
|
+
readonly maxPendingMs: number;
|
|
150
|
+
/**
|
|
151
|
+
* Record one reading of the carrier's pending bytes. Returns how long the carrier has been
|
|
152
|
+
* continuously non-empty when that exceeds the budget; `undefined` otherwise. An `undefined`
|
|
153
|
+
* reading is unknown, not zero: it neither starts nor resets the clock.
|
|
154
|
+
*/
|
|
155
|
+
observe: (pending: number | undefined) => number | undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function createPendingStallClock(
|
|
159
|
+
maxPendingMs: number = DEFAULT_MAX_PENDING_MS,
|
|
160
|
+
now: () => number = Date.now,
|
|
161
|
+
): IPendingStallClock {
|
|
162
|
+
let nonEmptySince: number | undefined;
|
|
163
|
+
return {
|
|
164
|
+
maxPendingMs,
|
|
165
|
+
observe(pending) {
|
|
166
|
+
if (pending === undefined) return undefined;
|
|
167
|
+
if (pending === 0) {
|
|
168
|
+
nonEmptySince = undefined;
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
const at = now();
|
|
172
|
+
nonEmptySince ??= at;
|
|
173
|
+
const stalledMs = at - nonEmptySince;
|
|
174
|
+
return stalledMs > maxPendingMs ? stalledMs : undefined;
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function createOutboundDelivery(
|
|
180
|
+
send: (message: TServerMessage) => void,
|
|
181
|
+
onDeliveryError: TDeliveryErrorHandler,
|
|
182
|
+
pendingBytes?: () => number | undefined,
|
|
183
|
+
maxPendingBytes: number = DEFAULT_MAX_PENDING_BYTES,
|
|
184
|
+
stallClock: IPendingStallClock = createPendingStallClock(),
|
|
185
|
+
): TOutboundDeliver {
|
|
186
|
+
let closed = false;
|
|
187
|
+
const deliver = (message: TServerMessage): void => {
|
|
188
|
+
if (closed) return;
|
|
189
|
+
// The budget is checked BEFORE the send, not after: a peer that has stopped reading is detected
|
|
190
|
+
// by what the carrier is still holding, and adding one more frame first would make the boundary
|
|
191
|
+
// the last contributor to the overflow it is reporting.
|
|
192
|
+
const pending = pendingBytes?.();
|
|
193
|
+
if (isOverPendingBudget(pending, maxPendingBytes)) {
|
|
194
|
+
closed = true;
|
|
195
|
+
reportFailure(
|
|
196
|
+
new Error(
|
|
197
|
+
`outbound backpressure budget exceeded: ${pending} byte(s) pending, limit ${maxPendingBytes}. ` +
|
|
198
|
+
'The peer has accepted frames it is not reading.',
|
|
199
|
+
),
|
|
200
|
+
message.type,
|
|
201
|
+
);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// Issue #2306: the same reading, judged by duration. A peer under the byte budget that has kept
|
|
205
|
+
// the carrier non-empty for longer than the drain budget is not reading either.
|
|
206
|
+
const stalledMs = stallClock.observe(pending);
|
|
207
|
+
if (stalledMs !== undefined) {
|
|
208
|
+
closed = true;
|
|
209
|
+
reportFailure(
|
|
210
|
+
new Error(
|
|
211
|
+
`outbound drain budget exceeded: ${pending} byte(s) pending for ${stalledMs} ms, limit ${stallClock.maxPendingMs} ms. ` +
|
|
212
|
+
'The peer is reading too slowly to ever drain what it accepted.',
|
|
213
|
+
),
|
|
214
|
+
message.type,
|
|
215
|
+
);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
send(message);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
// allow-fallback: this IS the boundary — a carrier send failure is a CARRIER lifecycle failure,
|
|
222
|
+
// reported to the carrier's own policy (which closes the connection). It is never swallowed, and
|
|
223
|
+
// re-throwing it would fail a session operation that has already committed.
|
|
224
|
+
closed = true;
|
|
225
|
+
reportFailure(error instanceof Error ? error : new Error(String(error)), message.type);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
function reportFailure(error: Error, event: TServerMessage['type']): void {
|
|
229
|
+
try {
|
|
230
|
+
onDeliveryError(error, event);
|
|
231
|
+
} catch {
|
|
232
|
+
// allow-fallback: the carrier-owned diagnostic is the LAST step of a failure already being
|
|
233
|
+
// reported. A throw from it has nowhere left to go, and letting it out would make a committed
|
|
234
|
+
// session operation fail for the sake of a diagnostic.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return Object.assign(deliver, {
|
|
238
|
+
pendingBytes: (): number | undefined => pendingBytes?.(),
|
|
239
|
+
}) as TOutboundDeliver;
|
|
240
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PEER-001 (#1809): the receiver's record of what it has already taken responsibility for.
|
|
3
|
+
*
|
|
4
|
+
* ## What this decides, and why it is not the carrier's job
|
|
5
|
+
*
|
|
6
|
+
* The issue requires delivery, acknowledgement, duplicate, retry and shutdown to produce
|
|
7
|
+
* "deterministic, documented outcomes". Every one of those is a question about what the receiver has
|
|
8
|
+
* SEEN BEFORE, which no carrier can answer: a WebRTC data channel redelivers on reconnect, a retry
|
|
9
|
+
* repeats a message the sender already sent, and neither the socket nor the frame codec has any
|
|
10
|
+
* memory to consult. So the decision lives here, once, and the carriers stay dumb.
|
|
11
|
+
*
|
|
12
|
+
* ## The rules, stated rather than emergent
|
|
13
|
+
*
|
|
14
|
+
* - **A repeated `id` is a duplicate**, and the ledger returns the SAME ack it returned the first
|
|
15
|
+
* time. That is what makes a retry safe: the sender learns the message landed, and the receiver
|
|
16
|
+
* does not deliver it twice. An ack that merely said "duplicate" without the original state would
|
|
17
|
+
* force the sender to guess whether the first attempt was accepted or refused.
|
|
18
|
+
* - **Sequence is per origin.** Two peers are independent senders; a shared counter would make one
|
|
19
|
+
* peer's traffic look like the other's gap.
|
|
20
|
+
* - **A gap is REPORTED, never silently reordered.** If sequence 5 arrives while 4 has not, the
|
|
21
|
+
* ledger says so. Buffering-and-reordering is a policy the session layer may choose; inventing it
|
|
22
|
+
* here would hide a lost message behind an apparent success, which is the failure mode this whole
|
|
23
|
+
* contract exists to avoid.
|
|
24
|
+
* - **Nothing is remembered across shutdown.** The ledger is per-connection state by construction —
|
|
25
|
+
* a peer that reconnects gets a fresh sequence space, and the session layer decides what to do
|
|
26
|
+
* with a peer that restarts mid-conversation.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type {
|
|
30
|
+
IPeerMessage,
|
|
31
|
+
IPeerMessageAck,
|
|
32
|
+
TPeerDeliveryState,
|
|
33
|
+
} from '@robota-sdk/agent-interface-session-mobility';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What the receiver has seen from one origin.
|
|
37
|
+
*
|
|
38
|
+
* `delivered` is a SET, not just a high-water mark, and that distinction is the whole of review
|
|
39
|
+
* finding #1. A high-water mark cannot tell "sequence 2 was already used" from "sequence 2 is the
|
|
40
|
+
* gap we reported and it has just arrived" — and answering the second as if it were the first
|
|
41
|
+
* refuses a message that was never delivered, permanently. `highestSequence` is kept alongside it
|
|
42
|
+
* because reporting a gap needs the frontier, not the membership.
|
|
43
|
+
*
|
|
44
|
+
* Exported because it is reachable from `IPeerMessageLedger`. A public interface whose field type is
|
|
45
|
+
* unexported is a name a consumer cannot write down (review finding #2). Measured: `tsdown` inlines
|
|
46
|
+
* it into the bundled declaration so no TS4033 is raised today — the finding's build-breakage claim
|
|
47
|
+
* does not reproduce — but a type nobody can name is still a bad public surface.
|
|
48
|
+
*/
|
|
49
|
+
export interface IOriginRecord {
|
|
50
|
+
/** The highest sequence delivered from this origin — the frontier a gap is measured against. */
|
|
51
|
+
highestSequence: number;
|
|
52
|
+
/** Every sequence actually delivered. Membership, not a range: gaps get filled out of order. */
|
|
53
|
+
delivered: Set<number>;
|
|
54
|
+
/** Acks already issued, by message id, so a retry gets the original answer back. */
|
|
55
|
+
issued: Map<string, IPeerMessageAck>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Per-connection receive state. Created empty; discarded when the connection ends. */
|
|
59
|
+
export interface IPeerMessageLedger {
|
|
60
|
+
readonly origins: Map<string, IOriginRecord>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createPeerMessageLedger(): IPeerMessageLedger {
|
|
64
|
+
return { origins: new Map() };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Why a message was not accepted, when it was not. */
|
|
68
|
+
export interface IPeerMessageRejection {
|
|
69
|
+
readonly state: Extract<TPeerDeliveryState, 'refused' | 'failed'>;
|
|
70
|
+
readonly reason: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface IPeerMessageVerdict {
|
|
74
|
+
readonly ack: IPeerMessageAck;
|
|
75
|
+
/**
|
|
76
|
+
* Should the session deliver this to the agent?
|
|
77
|
+
*
|
|
78
|
+
* False for a duplicate and for a refusal. Separate from `ack.state` because a caller that
|
|
79
|
+
* re-derived it from the state string would have to re-list which states mean "deliver", and that
|
|
80
|
+
* list is exactly what drifts.
|
|
81
|
+
*/
|
|
82
|
+
readonly deliver: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Set when this message's sequence is ahead of the last one delivered from its origin.
|
|
85
|
+
*
|
|
86
|
+
* Reported, not resolved. The session layer decides whether a gap is worth waiting for; the
|
|
87
|
+
* ledger's job is to make sure it is never invisible.
|
|
88
|
+
*/
|
|
89
|
+
readonly gapBefore?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Record a message and say what should happen to it.
|
|
94
|
+
*
|
|
95
|
+
* Pure with respect to its inputs except for the ledger it is handed, which it updates in place —
|
|
96
|
+
* the caller owns the ledger's lifetime, so a connection ending is a reference going away rather
|
|
97
|
+
* than a cleanup step someone can forget.
|
|
98
|
+
*/
|
|
99
|
+
export function admitPeerMessage(
|
|
100
|
+
ledger: IPeerMessageLedger,
|
|
101
|
+
message: IPeerMessage,
|
|
102
|
+
rejection?: IPeerMessageRejection,
|
|
103
|
+
): IPeerMessageVerdict {
|
|
104
|
+
const originId = message.origin.sessionId;
|
|
105
|
+
let record = ledger.origins.get(originId);
|
|
106
|
+
if (record === undefined) {
|
|
107
|
+
record = { highestSequence: 0, delivered: new Set(), issued: new Map() };
|
|
108
|
+
ledger.origins.set(originId, record);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A repeated id is answered with the ORIGINAL verdict, before anything else is considered: a
|
|
112
|
+
// retry must not be re-judged, or a message accepted once could be refused on its second arrival
|
|
113
|
+
// and the sender would have two contradictory answers for one message.
|
|
114
|
+
const previous = record.issued.get(message.id);
|
|
115
|
+
if (previous !== undefined) {
|
|
116
|
+
return { ack: { ...previous, state: 'duplicate' }, deliver: false };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (rejection !== undefined) {
|
|
120
|
+
const ack: IPeerMessageAck = {
|
|
121
|
+
id: message.id,
|
|
122
|
+
sequence: message.sequence,
|
|
123
|
+
state: rejection.state,
|
|
124
|
+
reason: rejection.reason,
|
|
125
|
+
};
|
|
126
|
+
record.issued.set(message.id, ack);
|
|
127
|
+
return { ack, deliver: false };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// A sequence this origin ACTUALLY delivered before, under a different id: a sender reusing a
|
|
131
|
+
// sequence, which is a protocol error rather than a duplicate.
|
|
132
|
+
//
|
|
133
|
+
// Membership, not `<= highestSequence`. The high-water form refused the late arrival of a gap it
|
|
134
|
+
// had itself reported — deliver 1, receive 3 (gap at 2 reported), then 2 arrives and is rejected
|
|
135
|
+
// as "already used" although it was never delivered. That contradicted this module's own stated
|
|
136
|
+
// rule that a gap is reported so the SESSION layer can decide, since the session never got the
|
|
137
|
+
// chance. Found in review.
|
|
138
|
+
if (record.delivered.has(message.sequence)) {
|
|
139
|
+
const ack: IPeerMessageAck = {
|
|
140
|
+
id: message.id,
|
|
141
|
+
sequence: message.sequence,
|
|
142
|
+
state: 'refused',
|
|
143
|
+
reason:
|
|
144
|
+
`sequence ${message.sequence} was already delivered by this origin under a different ` +
|
|
145
|
+
'message id. A retry must repeat its id; a new id on a delivered sequence cannot be ' +
|
|
146
|
+
'ordered against what the receiver already has.',
|
|
147
|
+
};
|
|
148
|
+
record.issued.set(message.id, ack);
|
|
149
|
+
return { ack, deliver: false };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const expected = record.highestSequence + 1;
|
|
153
|
+
const gapBefore = message.sequence > expected ? expected : undefined;
|
|
154
|
+
|
|
155
|
+
const ack: IPeerMessageAck = {
|
|
156
|
+
id: message.id,
|
|
157
|
+
sequence: message.sequence,
|
|
158
|
+
state: 'delivered',
|
|
159
|
+
};
|
|
160
|
+
record.issued.set(message.id, ack);
|
|
161
|
+
record.delivered.add(message.sequence);
|
|
162
|
+
// The frontier only moves FORWARD. A gap arriving late fills membership without rewinding what a
|
|
163
|
+
// later gap is measured against.
|
|
164
|
+
record.highestSequence = Math.max(record.highestSequence, message.sequence);
|
|
165
|
+
|
|
166
|
+
return gapBefore === undefined ? { ack, deliver: true } : { ack, deliver: true, gapBefore };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Mark a delivered message as acknowledged by the agent side.
|
|
171
|
+
*
|
|
172
|
+
* Separate from `admitPeerMessage` because delivery and acknowledgement answer different questions —
|
|
173
|
+
* "did the receiver take it" versus "did the agent act on it" — and a sender waiting for the second
|
|
174
|
+
* must not be told the first.
|
|
175
|
+
*/
|
|
176
|
+
export function acknowledgePeerMessage(
|
|
177
|
+
ledger: IPeerMessageLedger,
|
|
178
|
+
originSessionId: string,
|
|
179
|
+
id: string,
|
|
180
|
+
): IPeerMessageAck | undefined {
|
|
181
|
+
const record = ledger.origins.get(originSessionId);
|
|
182
|
+
const issued = record?.issued.get(id);
|
|
183
|
+
if (record === undefined || issued === undefined || issued.state !== 'delivered')
|
|
184
|
+
return undefined;
|
|
185
|
+
const acknowledged: IPeerMessageAck = { ...issued, state: 'acknowledged' };
|
|
186
|
+
record.issued.set(id, acknowledged);
|
|
187
|
+
return acknowledged;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Forget one origin — used when a peer disconnects and its sequence space is no longer meaningful. */
|
|
191
|
+
export function forgetPeerOrigin(ledger: IPeerMessageLedger, originSessionId: string): void {
|
|
192
|
+
ledger.origins.delete(originSessionId);
|
|
193
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one argument a person recognises a tool call by, for every transport that puts a permission
|
|
3
|
+
* request to a person in its own wire form. Shared so the transports name the same call the same
|
|
4
|
+
* way; the built-in file tools take `filePath`, other tools `file_path` or `path`.
|
|
5
|
+
*/
|
|
6
|
+
import type { TToolArgs } from '@robota-sdk/agent-core';
|
|
7
|
+
|
|
8
|
+
/** In the order the tools put them first. */
|
|
9
|
+
const SUMMARY_ARGUMENTS = ['command', 'filePath', 'file_path', 'path', 'url', 'pattern', 'query'];
|
|
10
|
+
|
|
11
|
+
/** The call's identifying argument, or `undefined` when it has none worth showing. */
|
|
12
|
+
export function permissionSummaryArgument(toolArgs: TToolArgs): string | undefined {
|
|
13
|
+
for (const key of SUMMARY_ARGUMENTS) {
|
|
14
|
+
const value = toolArgs[key];
|
|
15
|
+
if (typeof value === 'string' && value !== '') return value;
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|