@robota-sdk/agent-transport 3.0.0-beta.93 → 3.0.0-beta.95
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 +151 -0
- package/README.md +2 -1
- package/dist/browser/client.d.ts +1 -1
- package/dist/browser/client.js +1 -1
- package/dist/browser/index.d.ts +34 -4
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/{observer-messages-Dnff2Tdb.d.ts → observer-messages-DoWb6RNv.d.ts} +23 -1
- package/dist/browser/observer-messages-DoWb6RNv.d.ts.map +1 -0
- package/dist/browser/observer-messages-UG77sTYK.js +2 -0
- package/dist/browser/observer-messages-UG77sTYK.js.map +1 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +54 -3
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +54 -3
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/dist/node/node.cjs +1 -1
- package/dist/node/node.d.cts +19 -1
- package/dist/node/node.d.cts.map +1 -1
- package/dist/node/node.d.ts +19 -1
- package/dist/node/node.d.ts.map +1 -1
- package/dist/node/node.js +1 -1
- package/dist/node/node.js.map +1 -1
- package/package.json +8 -8
- package/src/__tests__/__fixtures__/korean-text.json +6 -0
- package/src/__tests__/app-tool-messages.test.ts +463 -0
- package/src/__tests__/channel-frames.test.ts +3 -2
- package/src/__tests__/handoff-chunking.test.ts +6 -3
- package/src/__tests__/message-decoders.test.ts +101 -0
- package/src/__tests__/permission-summary.test.ts +18 -0
- package/src/__tests__/session-message-handler-observe.test.ts +6 -0
- package/src/__tests__/session-message-handler.test.ts +3 -2
- package/src/__tests__/session-resume-bridge.test.ts +66 -0
- package/src/__tests__/stdio-carrier.test.ts +85 -0
- package/src/app-tool-messages.ts +140 -0
- package/src/index.ts +7 -0
- package/src/message-decoders.ts +16 -0
- package/src/node/index.ts +2 -0
- package/src/node/stdio-carrier.ts +59 -0
- package/src/permission-summary.ts +18 -0
- package/src/protocol-session.ts +7 -1
- package/src/session-events.ts +1 -0
- package/src/session-message-handler.ts +37 -1
- package/src/session-resume-bridge.ts +15 -0
- package/src/wire-messages.ts +35 -0
- package/dist/browser/observer-messages-CEeisRaF.js +0 -2
- package/dist/browser/observer-messages-CEeisRaF.js.map +0 -1
- package/dist/browser/observer-messages-Dnff2Tdb.d.ts.map +0 -1
|
@@ -35,6 +35,8 @@ function createSession(): IInteractiveSession & {
|
|
|
35
35
|
cancelBackgroundTask: vi.fn(),
|
|
36
36
|
closeBackgroundTask: vi.fn(),
|
|
37
37
|
sendBackgroundTask: vi.fn(),
|
|
38
|
+
invokeRuntimeTool: vi.fn(),
|
|
39
|
+
readToolAppView: vi.fn(),
|
|
38
40
|
getMessages: vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]),
|
|
39
41
|
isExecuting: vi.fn().mockReturnValue(true),
|
|
40
42
|
on: ((event: string, handler: TListener) => {
|
|
@@ -80,6 +82,8 @@ const MUTATING: readonly TClientMessage[] = [
|
|
|
80
82
|
{ type: 'ask-response', id: 'a1', response: { type: 'answer', values: ['React'] } } as TClientMessage,
|
|
81
83
|
{ type: 'get-personal-usage-report', requestId: 'r1', period: '7d', timezone: 'UTC' },
|
|
82
84
|
{ type: 'get-stored-session-usage-report', requestId: 'r2', sessionId: 'other' },
|
|
85
|
+
{ type: 'call-app-tool', requestId: 'r3', viewId: 'view-1', name: 'Write', arguments: {} },
|
|
86
|
+
{ type: 'read-app-view', requestId: 'r4', viewId: 'view-1' },
|
|
83
87
|
];
|
|
84
88
|
|
|
85
89
|
describe('observe role', () => {
|
|
@@ -102,6 +106,8 @@ describe('observe role', () => {
|
|
|
102
106
|
expect(session.sendBackgroundTask).not.toHaveBeenCalled();
|
|
103
107
|
expect(session.resolvePermission).not.toHaveBeenCalled();
|
|
104
108
|
expect(session.resolveAsk).not.toHaveBeenCalled();
|
|
109
|
+
expect(session.invokeRuntimeTool).not.toHaveBeenCalled();
|
|
110
|
+
expect(session.readToolAppView).not.toHaveBeenCalled();
|
|
105
111
|
});
|
|
106
112
|
|
|
107
113
|
it('keeps the read queries for this session', () => {
|
|
@@ -667,17 +667,18 @@ describe('WebSocket Transport Handler', () => {
|
|
|
667
667
|
|
|
668
668
|
// ── REMOTE-007: transport-neutral permission/ask over the wire (TC-06) ──
|
|
669
669
|
|
|
670
|
-
it('forwards a permission_request session event to the client', () => {
|
|
670
|
+
it('forwards a permission_request session event to the client, with the call id it names', () => {
|
|
671
671
|
const { session, sent } = setup();
|
|
672
672
|
session._emit('permission_request', {
|
|
673
673
|
id: 'p1',
|
|
674
674
|
toolName: 'Bash',
|
|
675
675
|
toolArgs: { command: 'ls' },
|
|
676
|
+
executionId: 'call-1',
|
|
676
677
|
});
|
|
677
678
|
const msg = sent.find((m) => m.type === 'permission_request');
|
|
678
679
|
expect(msg).toEqual({
|
|
679
680
|
type: 'permission_request',
|
|
680
|
-
event: { id: 'p1', toolName: 'Bash', toolArgs: { command: 'ls' } },
|
|
681
|
+
event: { id: 'p1', toolName: 'Bash', toolArgs: { command: 'ls' }, executionId: 'call-1' },
|
|
681
682
|
});
|
|
682
683
|
});
|
|
683
684
|
|
|
@@ -404,3 +404,69 @@ describe('SessionResumeBridge (REMOTE-013 TC-02)', () => {
|
|
|
404
404
|
});
|
|
405
405
|
});
|
|
406
406
|
});
|
|
407
|
+
|
|
408
|
+
describe('app view messages through the resume bridge', () => {
|
|
409
|
+
const view = {
|
|
410
|
+
sourceId: 'files',
|
|
411
|
+
resourceUri: 'ui://files/list',
|
|
412
|
+
mimeType: 'text/html;profile=mcp-app',
|
|
413
|
+
html: '<main>rows</main>',
|
|
414
|
+
callableTools: { list_files: 'mcp__files__list_files' },
|
|
415
|
+
};
|
|
416
|
+
const call = {
|
|
417
|
+
type: 'call-app-tool',
|
|
418
|
+
requestId: 'c1',
|
|
419
|
+
viewId: 'view-1',
|
|
420
|
+
name: 'mcp__files__list_files',
|
|
421
|
+
arguments: {},
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
it('cancels a call waiting on its prompt when the host gives up on the bridge, not when a channel drops', async () => {
|
|
425
|
+
let signal: AbortSignal | undefined;
|
|
426
|
+
const base = fakeSession().session;
|
|
427
|
+
const session = Object.assign(base, {
|
|
428
|
+
readToolAppView: vi.fn().mockReturnValue(view),
|
|
429
|
+
invokeRuntimeTool: vi.fn(
|
|
430
|
+
(_name: string, _arguments: unknown, options?: { signal?: AbortSignal }) => {
|
|
431
|
+
signal = options?.signal;
|
|
432
|
+
return new Promise(() => undefined);
|
|
433
|
+
},
|
|
434
|
+
),
|
|
435
|
+
});
|
|
436
|
+
const bridge = new SessionResumeBridge({ session });
|
|
437
|
+
bridge.attach(sink(), TEST_ATTACH_OPTIONS);
|
|
438
|
+
|
|
439
|
+
bridge.onClientMessage(JSON.stringify(call));
|
|
440
|
+
await vi.waitFor(() => expect(signal).toBeDefined());
|
|
441
|
+
|
|
442
|
+
// A channel drop leaves the session reconnectable: the call keeps waiting for its prompt.
|
|
443
|
+
bridge.detach();
|
|
444
|
+
expect(signal?.aborted).toBe(false);
|
|
445
|
+
|
|
446
|
+
bridge.dispose();
|
|
447
|
+
expect(signal?.aborted).toBe(true);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('holds an app view to the channel limit before the frame reaches the buffer', () => {
|
|
451
|
+
const base = fakeSession().session;
|
|
452
|
+
const session = Object.assign(base, {
|
|
453
|
+
readToolAppView: vi.fn().mockReturnValue({ ...view, html: 'x'.repeat(5_000) }),
|
|
454
|
+
});
|
|
455
|
+
const bridge = new SessionResumeBridge({ session, maxFrameBytes: 2_000 });
|
|
456
|
+
const output = sink();
|
|
457
|
+
bridge.attach(output, TEST_ATTACH_OPTIONS);
|
|
458
|
+
|
|
459
|
+
bridge.onClientMessage(JSON.stringify({ type: 'read-app-view', requestId: 'v1', viewId: 'view-1' }));
|
|
460
|
+
|
|
461
|
+
expect(frames(output).map((frame) => frame.type)).toEqual(['protocol_error']);
|
|
462
|
+
// Nothing large was retained for a later `resume` either.
|
|
463
|
+
const replay = sink();
|
|
464
|
+
bridge.detach();
|
|
465
|
+
bridge.attach(replay, TEST_ATTACH_OPTIONS);
|
|
466
|
+
bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
|
|
467
|
+
expect(frames(replay).map((frame) => frame.type)).toEqual(['protocol_error']);
|
|
468
|
+
expect(replay.calls.join('').length).toBeLessThan(2_000);
|
|
469
|
+
bridge.dispose();
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stdio carrier bridge: what a protocol carrier hands its SDK in place of the process input, and
|
|
3
|
+
* how it learns that either end went away. Two carriers share it, so the cases here are the contract
|
|
4
|
+
* both rely on rather than either carrier's own behavior.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { PassThrough } from 'node:stream';
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import { attachStdioCarrier } from '../node/stdio-carrier.js';
|
|
12
|
+
|
|
13
|
+
function handlers() {
|
|
14
|
+
return { onInputClose: vi.fn(), onOutputClose: vi.fn(), onOutputError: vi.fn() };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('attachStdioCarrier', () => {
|
|
18
|
+
it('forwards input to the bridge and keeps bytes written before anyone reads', async () => {
|
|
19
|
+
const input = new PassThrough();
|
|
20
|
+
const carrier = attachStdioCarrier(input, new PassThrough(), handlers());
|
|
21
|
+
input.write('early\n');
|
|
22
|
+
const chunks: string[] = [];
|
|
23
|
+
carrier.bridge.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
|
|
24
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
25
|
+
expect(chunks.join('')).toBe('early\n');
|
|
26
|
+
carrier.release();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('reports the input ending, and the bridge ends with it', async () => {
|
|
30
|
+
const input = new PassThrough();
|
|
31
|
+
const h = handlers();
|
|
32
|
+
const carrier = attachStdioCarrier(input, new PassThrough(), h);
|
|
33
|
+
const ended = new Promise<void>((resolve) => carrier.bridge.once('end', resolve));
|
|
34
|
+
carrier.bridge.resume();
|
|
35
|
+
input.end();
|
|
36
|
+
await ended;
|
|
37
|
+
expect(h.onInputClose).toHaveBeenCalled();
|
|
38
|
+
carrier.release();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('reports the input closing without an end', () => {
|
|
42
|
+
const input = new PassThrough();
|
|
43
|
+
const h = handlers();
|
|
44
|
+
const carrier = attachStdioCarrier(input, new PassThrough(), h);
|
|
45
|
+
input.emit('close');
|
|
46
|
+
expect(h.onInputClose).toHaveBeenCalledTimes(1);
|
|
47
|
+
carrier.release();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('reports the output closing and failing', () => {
|
|
51
|
+
const output = new PassThrough();
|
|
52
|
+
const h = handlers();
|
|
53
|
+
const carrier = attachStdioCarrier(new PassThrough(), output, h);
|
|
54
|
+
const failure = new Error('EPIPE');
|
|
55
|
+
output.emit('error', failure);
|
|
56
|
+
output.emit('close');
|
|
57
|
+
expect(h.onOutputError).toHaveBeenCalledWith(failure);
|
|
58
|
+
expect(h.onOutputClose).toHaveBeenCalledTimes(1);
|
|
59
|
+
carrier.release();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('destroys the bridge on release but leaves the real input alone', () => {
|
|
63
|
+
const input = new PassThrough();
|
|
64
|
+
const carrier = attachStdioCarrier(input, new PassThrough(), handlers());
|
|
65
|
+
carrier.release();
|
|
66
|
+
expect(carrier.bridge.destroyed).toBe(true);
|
|
67
|
+
expect(input.destroyed).toBe(false);
|
|
68
|
+
expect(input.listenerCount('end')).toBe(0);
|
|
69
|
+
expect(input.listenerCount('close')).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('stops reporting after release, and release is idempotent', () => {
|
|
73
|
+
const input = new PassThrough();
|
|
74
|
+
const output = new PassThrough();
|
|
75
|
+
const h = handlers();
|
|
76
|
+
const carrier = attachStdioCarrier(input, output, h);
|
|
77
|
+
carrier.release();
|
|
78
|
+
carrier.release();
|
|
79
|
+
input.emit('close');
|
|
80
|
+
output.emit('close');
|
|
81
|
+
expect(h.onInputClose).not.toHaveBeenCalled();
|
|
82
|
+
expect(h.onOutputClose).not.toHaveBeenCalled();
|
|
83
|
+
expect(h.onOutputError).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A driving surface that hosts an MCP app view acts for it here: it invokes one runtime tool
|
|
3
|
+
* through the session's own permission gate, and it reads a finished call's view on demand.
|
|
4
|
+
*
|
|
5
|
+
* Both are drive-only (neither is in `OBSERVER_MESSAGES`): an observer neither runs a tool nor
|
|
6
|
+
* receives a view's document. A message that decoded but cannot be served answers `protocol_error`
|
|
7
|
+
* echoing `requestId`, so the view that asked can tell which call it was.
|
|
8
|
+
*/
|
|
9
|
+
import { MAX_INBOUND_FRAME_BYTES } from './message-decoders.js';
|
|
10
|
+
|
|
11
|
+
import type { TOutboundDeliver } from './outbound-delivery.js';
|
|
12
|
+
import type { TClientMessage, TServerMessage } from './wire-messages.js';
|
|
13
|
+
import type {
|
|
14
|
+
ISessionRuntimeTools,
|
|
15
|
+
ISessionToolAppViews,
|
|
16
|
+
TDriverId,
|
|
17
|
+
} from '@robota-sdk/agent-interface-session';
|
|
18
|
+
|
|
19
|
+
type TCallAppToolMessage = Extract<TClientMessage, { type: 'call-app-tool' }>;
|
|
20
|
+
type TReadAppViewMessage = Extract<TClientMessage, { type: 'read-app-view' }>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Room a carrier that stamps frames after they are produced (the resume bridge adds `seq`) needs
|
|
24
|
+
* beneath the inbound limit, so a frame sent within the limit is still received within it.
|
|
25
|
+
*/
|
|
26
|
+
const FRAME_STAMP_MARGIN_BYTES = 1024;
|
|
27
|
+
|
|
28
|
+
/** Where a frame answering an app message is measured, and what ends the work behind it. */
|
|
29
|
+
export interface IAppMessageOptions {
|
|
30
|
+
/** The most bytes one answering frame may take; defaults to the inbound limit less a margin. */
|
|
31
|
+
readonly maxFrameBytes?: number;
|
|
32
|
+
/** Ends the connection's work: aborting it cancels a call still waiting on its prompt. */
|
|
33
|
+
readonly signal?: AbortSignal;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isAppToolMessage(msg: TClientMessage): msg is TCallAppToolMessage {
|
|
37
|
+
return msg.type === 'call-app-tool';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isAppViewMessage(msg: TClientMessage): msg is TReadAppViewMessage {
|
|
41
|
+
return msg.type === 'read-app-view';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function frameLimit(options: IAppMessageOptions | undefined): number {
|
|
45
|
+
return options?.maxFrameBytes ?? MAX_INBOUND_FRAME_BYTES - FRAME_STAMP_MARGIN_BYTES;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Checked before `deliver`, so a frame the channel cannot carry never enters a buffer. */
|
|
49
|
+
function fitsFrame(frame: TServerMessage, limit: number): boolean {
|
|
50
|
+
return new TextEncoder().encode(JSON.stringify(frame)).byteLength <= limit;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Runs the tool as an interactive call acting for this connection's driver, so a permission prompt
|
|
55
|
+
* it raises is shown to the surfaces that can answer and is attributed to that driver. The call is
|
|
56
|
+
* limited to the tools the named view's own source declared: the surface says which view it acts
|
|
57
|
+
* for, and the session, not the surface, says what that view may reach.
|
|
58
|
+
*/
|
|
59
|
+
export function handleAppToolMessage(
|
|
60
|
+
session: ISessionRuntimeTools & ISessionToolAppViews,
|
|
61
|
+
deliver: TOutboundDeliver,
|
|
62
|
+
msg: TCallAppToolMessage,
|
|
63
|
+
driverId?: TDriverId,
|
|
64
|
+
options?: IAppMessageOptions,
|
|
65
|
+
): void {
|
|
66
|
+
const { requestId } = msg;
|
|
67
|
+
const view = session.readToolAppView(msg.viewId);
|
|
68
|
+
if (view === undefined) {
|
|
69
|
+
deliver({ type: 'protocol_error', message: 'This app view is no longer available.', requestId });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!Object.values(view.callableTools).includes(msg.name)) {
|
|
73
|
+
deliver({
|
|
74
|
+
type: 'protocol_error',
|
|
75
|
+
message: 'This tool is not available to this app view.',
|
|
76
|
+
requestId,
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const { signal } = options ?? {};
|
|
81
|
+
const limit = frameLimit(options);
|
|
82
|
+
session
|
|
83
|
+
.invokeRuntimeTool(msg.name, msg.arguments, {
|
|
84
|
+
permissionInteraction: 'interactive',
|
|
85
|
+
...(driverId ? { driverId } : {}),
|
|
86
|
+
...(signal ? { signal } : {}),
|
|
87
|
+
})
|
|
88
|
+
.then(
|
|
89
|
+
(result) => {
|
|
90
|
+
// The connection that asked is gone: there is nobody to answer.
|
|
91
|
+
if (signal?.aborted) return;
|
|
92
|
+
const frame: TServerMessage = {
|
|
93
|
+
type: 'app_tool_result',
|
|
94
|
+
requestId,
|
|
95
|
+
success: result.success,
|
|
96
|
+
...(result.result !== undefined ? { data: result.result } : {}),
|
|
97
|
+
...(result.parts !== undefined ? { parts: result.parts } : {}),
|
|
98
|
+
...(result.error !== undefined ? { error: result.error } : {}),
|
|
99
|
+
};
|
|
100
|
+
if (fitsFrame(frame, limit)) {
|
|
101
|
+
deliver(frame);
|
|
102
|
+
} else {
|
|
103
|
+
deliver({
|
|
104
|
+
type: 'protocol_error',
|
|
105
|
+
message: 'This app tool result is too large to send.',
|
|
106
|
+
requestId,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
(error: unknown) => {
|
|
111
|
+
if (signal?.aborted) return;
|
|
112
|
+
deliver({
|
|
113
|
+
type: 'protocol_error',
|
|
114
|
+
message: error instanceof Error ? error.message : String(error),
|
|
115
|
+
requestId,
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Answers with the whole document, or says why not: gone, or too large for a frame. */
|
|
122
|
+
export function handleAppViewMessage(
|
|
123
|
+
session: ISessionToolAppViews,
|
|
124
|
+
deliver: TOutboundDeliver,
|
|
125
|
+
msg: TReadAppViewMessage,
|
|
126
|
+
options?: IAppMessageOptions,
|
|
127
|
+
): void {
|
|
128
|
+
const { requestId } = msg;
|
|
129
|
+
const view = session.readToolAppView(msg.viewId);
|
|
130
|
+
if (view === undefined) {
|
|
131
|
+
deliver({ type: 'protocol_error', message: 'This app view is no longer available.', requestId });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const frame: TServerMessage = { type: 'app_view', requestId, view };
|
|
135
|
+
if (!fitsFrame(frame, frameLimit(options))) {
|
|
136
|
+
deliver({ type: 'protocol_error', message: 'This app view is too large to send.', requestId });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
deliver(frame);
|
|
140
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -54,6 +54,9 @@ export type {
|
|
|
54
54
|
export { PROTOCOL_SESSION_EVENT_CLASSIFICATION } from './session-events.js';
|
|
55
55
|
// The one list of messages an observer may send: the host refuses the rest, a client sends no others.
|
|
56
56
|
export { isObserverMessageType } from './observer-messages.js';
|
|
57
|
+
// The most history one frame carries, sized for the tightest channel; a carrier whose channel is
|
|
58
|
+
// small (a WebRTC data channel) bounds an app view's frame by the same number.
|
|
59
|
+
export { HISTORY_PAGE_MAX_BYTES } from './session-query-messages.js';
|
|
57
60
|
export type { TProtocolSessionEventClassification } from './session-events.js';
|
|
58
61
|
export type { IProtocolSession } from './protocol-session.js';
|
|
59
62
|
export type {
|
|
@@ -112,6 +115,10 @@ export type {
|
|
|
112
115
|
IPeerMessageVerdict,
|
|
113
116
|
} from './peer-message-ledger.js';
|
|
114
117
|
|
|
118
|
+
// The argument a person recognises a tool call by, shared by every transport that asks a person
|
|
119
|
+
// about a permission in its own wire form, so they name the same call the same way.
|
|
120
|
+
export { permissionSummaryArgument } from './permission-summary.js';
|
|
121
|
+
|
|
115
122
|
// The handoff integrity helpers (`sealHandoffRecord`, `verifyHandoffPayload`) are NOT
|
|
116
123
|
// here: it hashes with `node:crypto`. Chunking is, because it never hashes anything.
|
|
117
124
|
export {
|
package/src/message-decoders.ts
CHANGED
|
@@ -269,6 +269,13 @@ export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVar
|
|
|
269
269
|
'project-status': { requestId: isNonEmptyString },
|
|
270
270
|
'project-diff': { requestId: isNonEmptyString, path: isNonEmptyString },
|
|
271
271
|
'project-memory': { requestId: isNonEmptyString },
|
|
272
|
+
'call-app-tool': {
|
|
273
|
+
requestId: isNonEmptyString,
|
|
274
|
+
viewId: isNonEmptyString,
|
|
275
|
+
name: isNonEmptyString,
|
|
276
|
+
arguments: isRecord,
|
|
277
|
+
},
|
|
278
|
+
'read-app-view': { requestId: isNonEmptyString, viewId: isNonEmptyString },
|
|
272
279
|
'get-background-tasks': { filter: isOptional(isBackgroundTaskListFilter) },
|
|
273
280
|
'get-background-task': { taskId: isNonEmptyString },
|
|
274
281
|
'get-background-job-groups': {},
|
|
@@ -380,6 +387,15 @@ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVar
|
|
|
380
387
|
project_status: { requestId: isNonEmptyString, result: isProjectStatusRead },
|
|
381
388
|
project_diff: { requestId: isNonEmptyString, result: isProjectDiffRead },
|
|
382
389
|
project_memory: { requestId: isNonEmptyString, result: isProjectMemoryRead },
|
|
390
|
+
// `data` is whatever JSON the tool returned, so any value passes, null included; absent is fine.
|
|
391
|
+
app_tool_result: {
|
|
392
|
+
requestId: isNonEmptyString,
|
|
393
|
+
success: isBoolean,
|
|
394
|
+
data: () => true,
|
|
395
|
+
parts: isOptional(isRecordArray),
|
|
396
|
+
error: isOptional(isString),
|
|
397
|
+
},
|
|
398
|
+
app_view: { requestId: isNonEmptyString, view: isRecord },
|
|
383
399
|
background_task_event: { event: isRecord },
|
|
384
400
|
background_job_group_event: { event: isRecord },
|
|
385
401
|
plan_event: { event: isRecord },
|
package/src/node/index.ts
CHANGED
|
@@ -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,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
|
+
}
|
package/src/protocol-session.ts
CHANGED
|
@@ -11,8 +11,10 @@ import type {
|
|
|
11
11
|
ISessionExecutionWorkspace,
|
|
12
12
|
ISessionProjectRead,
|
|
13
13
|
ISessionPromptResolution,
|
|
14
|
+
ISessionRuntimeTools,
|
|
14
15
|
ISessionSelfPacedLoopControl,
|
|
15
16
|
ISessionStatusRead,
|
|
17
|
+
ISessionToolAppViews,
|
|
16
18
|
ISessionTurnControl,
|
|
17
19
|
ISessionTurnSubmission,
|
|
18
20
|
} from '@robota-sdk/agent-interface-session';
|
|
@@ -37,7 +39,11 @@ export interface IProtocolSession
|
|
|
37
39
|
// #3282 §4: the agent switcher's read (`listAgentDefinitions`/`getDefaultAgentType`) — the
|
|
38
40
|
// group's write members (`spawnAgentJob` and friends) stay unused by any wire handler; they run
|
|
39
41
|
// only through `executeCommand`, same as every other command-only write.
|
|
40
|
-
ISessionAgentJobs
|
|
42
|
+
ISessionAgentJobs,
|
|
43
|
+
// A driving surface that hosts an app view runs one runtime tool for it (`call-app-tool`) and
|
|
44
|
+
// reads a finished call's view (`read-app-view`).
|
|
45
|
+
ISessionRuntimeTools,
|
|
46
|
+
ISessionToolAppViews {}
|
|
41
47
|
|
|
42
48
|
/**
|
|
43
49
|
* #3282 §4c: a protocol session that ALSO answers the Project panel's reads. `Partial` (not a fourth
|
package/src/session-events.ts
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
* Server pushes IProtocolSession events to client in real-time.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import {
|
|
12
|
+
handleAppToolMessage,
|
|
13
|
+
handleAppViewMessage,
|
|
14
|
+
isAppToolMessage,
|
|
15
|
+
isAppViewMessage,
|
|
16
|
+
type IAppMessageOptions,
|
|
17
|
+
} from './app-tool-messages.js';
|
|
11
18
|
import {
|
|
12
19
|
handleBackgroundControlMessage,
|
|
13
20
|
handleBackgroundQueryMessage,
|
|
@@ -93,6 +100,13 @@ export interface ISessionMessageHandlerOptions {
|
|
|
93
100
|
* command that runs code from outside the session (installing a plugin) can refuse a remote device.
|
|
94
101
|
*/
|
|
95
102
|
commandSurfaceLocality?: TCommandSurfaceLocality;
|
|
103
|
+
/**
|
|
104
|
+
* The most bytes one frame answering an app tool call or view read may take, for a carrier whose
|
|
105
|
+
* channel carries less than the inbound limit (a WebRTC data channel). An answer over it is
|
|
106
|
+
* replaced by a `protocol_error` before it reaches any buffer. Defaults to the inbound limit less
|
|
107
|
+
* a margin for a carrier that stamps frames afterwards.
|
|
108
|
+
*/
|
|
109
|
+
maxFrameBytes?: number;
|
|
96
110
|
}
|
|
97
111
|
|
|
98
112
|
/**
|
|
@@ -119,10 +133,17 @@ export function createSessionMessageHandler(options: ISessionMessageHandlerOptio
|
|
|
119
133
|
cleanup: () => void;
|
|
120
134
|
} {
|
|
121
135
|
const role = options.role ?? 'drive';
|
|
122
|
-
const
|
|
136
|
+
const unsubscribe = subscribeSessionEvents(options.session, options.deliver, {
|
|
123
137
|
getSurfaceDriverId: () => options.driverId,
|
|
124
138
|
receivePrompts: role === 'drive',
|
|
125
139
|
});
|
|
140
|
+
// One per connection: when the connection goes, work it started for a hosted app view (a tool call
|
|
141
|
+
// waiting on its permission prompt) is cancelled, so the prompt is withdrawn instead of left open.
|
|
142
|
+
const connection = new AbortController();
|
|
143
|
+
const cleanup = (): void => {
|
|
144
|
+
connection.abort();
|
|
145
|
+
unsubscribe();
|
|
146
|
+
};
|
|
126
147
|
const onMessage = createMessageHandler(
|
|
127
148
|
options.session,
|
|
128
149
|
options.deliver,
|
|
@@ -137,6 +158,10 @@ export function createSessionMessageHandler(options: ISessionMessageHandlerOptio
|
|
|
137
158
|
options.sessionDirectory,
|
|
138
159
|
options.settingsReporter,
|
|
139
160
|
options.commandSurfaceLocality,
|
|
161
|
+
{
|
|
162
|
+
signal: connection.signal,
|
|
163
|
+
...(options.maxFrameBytes !== undefined ? { maxFrameBytes: options.maxFrameBytes } : {}),
|
|
164
|
+
},
|
|
140
165
|
);
|
|
141
166
|
|
|
142
167
|
return { onMessage, cleanup };
|
|
@@ -152,6 +177,7 @@ function createMessageHandler(
|
|
|
152
177
|
sessionDirectory?: ISessionDirectory,
|
|
153
178
|
settingsReporter?: ISettingsReporter,
|
|
154
179
|
commandSurfaceLocality?: TCommandSurfaceLocality,
|
|
180
|
+
appMessages?: IAppMessageOptions,
|
|
155
181
|
): (data: string) => void {
|
|
156
182
|
return (data: string): void => {
|
|
157
183
|
const msg = parseClientMessage(data, deliver);
|
|
@@ -170,6 +196,7 @@ function createMessageHandler(
|
|
|
170
196
|
sessionDirectory,
|
|
171
197
|
settingsReporter,
|
|
172
198
|
commandSurfaceLocality,
|
|
199
|
+
appMessages,
|
|
173
200
|
);
|
|
174
201
|
};
|
|
175
202
|
}
|
|
@@ -194,6 +221,7 @@ export function handleClientMessage(
|
|
|
194
221
|
sessionDirectory?: ISessionDirectory,
|
|
195
222
|
settingsReporter?: ISettingsReporter,
|
|
196
223
|
commandSurfaceLocality?: TCommandSurfaceLocality,
|
|
224
|
+
appMessages?: IAppMessageOptions,
|
|
197
225
|
): void {
|
|
198
226
|
if (handleUsageQueryMessage(session, deliver, msg, reporters)) {
|
|
199
227
|
return;
|
|
@@ -230,6 +258,14 @@ export function handleClientMessage(
|
|
|
230
258
|
handleProjectReadMessage(session, deliver, msg);
|
|
231
259
|
return;
|
|
232
260
|
}
|
|
261
|
+
if (isAppToolMessage(msg)) {
|
|
262
|
+
handleAppToolMessage(session, deliver, msg, driverId, appMessages);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (isAppViewMessage(msg)) {
|
|
266
|
+
handleAppViewMessage(session, deliver, msg, appMessages);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
233
269
|
if (isBackgroundQueryMessage(msg)) {
|
|
234
270
|
handleBackgroundQueryMessage(session, deliver, msg);
|
|
235
271
|
return;
|
|
@@ -68,6 +68,8 @@ export interface ISessionResumeBridgeOptions {
|
|
|
68
68
|
readonly storedSessionUsageReporter?: IUsageQueryReporters['storedSessionUsageReporter'];
|
|
69
69
|
/** #3282 §4 part b-2: this bridge is the reconnectable WebRTC path, so it is always `'remote'`. */
|
|
70
70
|
readonly commandSurfaceLocality?: TCommandSurfaceLocality;
|
|
71
|
+
/** See {@link ISessionMessageHandlerOptions.maxFrameBytes}: the channel's limit, not the inbound one. */
|
|
72
|
+
readonly maxFrameBytes?: number;
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
export class SessionResumeBridge {
|
|
@@ -77,6 +79,13 @@ export class SessionResumeBridge {
|
|
|
77
79
|
private readonly reporters: IUsageQueryReporters;
|
|
78
80
|
private readonly surface?: TUsageSurface;
|
|
79
81
|
private readonly commandSurfaceLocality?: TCommandSurfaceLocality;
|
|
82
|
+
private readonly maxFrameBytes?: number;
|
|
83
|
+
/**
|
|
84
|
+
* Ends with the bridge, not with a channel: a channel drop keeps the session reconnectable, so work
|
|
85
|
+
* started for a hosted app view (a call waiting on its prompt) survives it and is cancelled only when
|
|
86
|
+
* the host gives up on the session.
|
|
87
|
+
*/
|
|
88
|
+
private readonly lifetime = new AbortController();
|
|
80
89
|
private readonly unsubscribe: () => void;
|
|
81
90
|
private onDeliveryError?: IAttachOptions['onDeliveryError'];
|
|
82
91
|
/**
|
|
@@ -110,6 +119,7 @@ export class SessionResumeBridge {
|
|
|
110
119
|
this.driverId = options.driverId;
|
|
111
120
|
this.surface = options.surface;
|
|
112
121
|
this.commandSurfaceLocality = options.commandSurfaceLocality;
|
|
122
|
+
this.maxFrameBytes = options.maxFrameBytes;
|
|
113
123
|
this.reporters = {
|
|
114
124
|
personalUsageReporter: options.personalUsageReporter,
|
|
115
125
|
usageReporter: options.usageReporter,
|
|
@@ -186,6 +196,10 @@ export class SessionResumeBridge {
|
|
|
186
196
|
undefined,
|
|
187
197
|
undefined,
|
|
188
198
|
this.commandSurfaceLocality,
|
|
199
|
+
{
|
|
200
|
+
signal: this.lifetime.signal,
|
|
201
|
+
...(this.maxFrameBytes !== undefined ? { maxFrameBytes: this.maxFrameBytes } : {}),
|
|
202
|
+
},
|
|
189
203
|
);
|
|
190
204
|
}
|
|
191
205
|
|
|
@@ -193,6 +207,7 @@ export class SessionResumeBridge {
|
|
|
193
207
|
public dispose(): void {
|
|
194
208
|
if (this.disposed) return;
|
|
195
209
|
this.disposed = true;
|
|
210
|
+
this.lifetime.abort();
|
|
196
211
|
// Through `detach`, not a field assignment: the boundary's closure is what holds the channel, so
|
|
197
212
|
// clearing anything else released nothing and left teardown documented but not performed.
|
|
198
213
|
this.detach();
|