@datalayer/agent-runtimes 1.3.74 → 1.3.75
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/lib/chat/ChatFloating.d.ts +17 -1
- package/lib/chat/ChatFloating.js +52 -5
- package/lib/chat/assistant/AssistantContextMenu.d.ts +67 -0
- package/lib/chat/assistant/AssistantContextMenu.js +68 -0
- package/lib/chat/assistant/AssistantStage.d.ts +95 -1
- package/lib/chat/assistant/AssistantStage.js +227 -7
- package/lib/chat/assistant/BalloonParts.d.ts +15 -1
- package/lib/chat/assistant/BalloonParts.js +26 -9
- package/lib/chat/assistant/BalloonVisual.d.ts +10 -2
- package/lib/chat/assistant/BalloonVisual.js +3 -3
- package/lib/chat/assistant/SpeechBalloon.d.ts +47 -1
- package/lib/chat/assistant/SpeechBalloon.js +60 -8
- package/lib/chat/base/ChatBase.js +51 -1
- package/lib/chat/indicators/TypingDots.d.ts +13 -0
- package/lib/chat/indicators/TypingDots.js +21 -0
- package/lib/chat/messages/ChatMessageList.js +1 -15
- package/lib/components/inspector/AgentInspector.d.ts +59 -0
- package/lib/components/inspector/AgentInspector.js +220 -0
- package/lib/components/inspector/AgentInspectorDialog.d.ts +19 -0
- package/lib/components/inspector/AgentInspectorDialog.js +9 -0
- package/lib/components/inspector/JsonTree.d.ts +24 -0
- package/lib/components/inspector/JsonTree.js +89 -0
- package/lib/components/inspector/a2aInspect.d.ts +56 -0
- package/lib/components/inspector/a2aInspect.js +478 -0
- package/lib/components/inspector/agentInspector.d.ts +179 -0
- package/lib/components/inspector/agentInspector.js +267 -0
- package/lib/components/inspector/chatInspect.d.ts +38 -0
- package/lib/components/inspector/chatInspect.js +65 -0
- package/lib/components/inspector/classify.d.ts +19 -0
- package/lib/components/inspector/classify.js +42 -0
- package/lib/components/inspector/index.d.ts +14 -0
- package/lib/components/inspector/index.js +18 -0
- package/lib/components/teams/A2ATeamGraph.d.ts +42 -1
- package/lib/components/teams/A2ATeamGraph.js +24 -7
- package/lib/components/teams/NotebookPreviewView.js +9 -1
- package/lib/components/teams/TeamNotebook.d.ts +3 -1
- package/lib/components/teams/TeamNotebook.js +3 -3
- package/lib/components/teams/jupyterThemeVariables.d.ts +9 -0
- package/lib/components/teams/jupyterThemeVariables.js +441 -0
- package/lib/components/teams/teamConnections.d.ts +9 -1
- package/lib/components/teams/teamConnections.js +20 -0
- package/lib/components/teams/useA2ATeam.d.ts +17 -4
- package/lib/components/teams/useA2ATeam.js +87 -23
- package/lib/examples/AgentA2ATeamExample.d.ts +13 -0
- package/lib/examples/AgentA2ATeamExample.js +61 -13
- package/lib/examples/AssistantExample.js +62 -2
- package/lib/loop/apps/AppRenderer.js +1 -1
- package/lib/loop/core/index.d.ts +17 -0
- package/lib/loop/core/index.js +1 -0
- package/lib/loop/plugins/assistant/index.d.ts +1 -1
- package/lib/loop/plugins/assistant/index.js +8 -3
- package/lib/runtimes/browser/a2aPeer.d.ts +4 -1
- package/lib/types/chat.d.ts +7 -0
- package/package.json +1 -1
- package/scripts/codegen/generate_jupyter_theme_variables.mjs +57 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The A2A traffic between two agents, as the Agent Inspector records it: a
|
|
3
|
+
* fetch that hands every request on and records what went (the JSON-RPC
|
|
4
|
+
* request: its method, its message) and what came back — a JSON-RPC
|
|
5
|
+
* response, or each server-sent event of a stream (the task, its status
|
|
6
|
+
* updates, its artifact updates, a message) — with its task, context and
|
|
7
|
+
* state, whole.
|
|
8
|
+
*
|
|
9
|
+
* It sits under the A2A SDK (`ClientFactory`'s `JsonRpcTransportFactory`
|
|
10
|
+
* takes a `fetchImpl`): the SDK reads the response it is handed, and the
|
|
11
|
+
* inspector reads a copy of its body. Nothing it records changes what the
|
|
12
|
+
* SDK sees, and a key sent as a header is never recorded.
|
|
13
|
+
*
|
|
14
|
+
* A peer served by agent-runtimes tells each tool call of its agent on a
|
|
15
|
+
* `working` status (`{tool_call}`, then `{tool_result}`): each call is
|
|
16
|
+
* recorded besides as one entry of its own, the peer's, from its start to
|
|
17
|
+
* its end, linked to the two statuses — an MCP call, a skill, or a tool its
|
|
18
|
+
* runtime runs, by who claims its name. The text chunks of one artifact are
|
|
19
|
+
* one entry, their count kept.
|
|
20
|
+
*
|
|
21
|
+
* {@link linkA2APeerEvent} attaches what the asker made of an event (an
|
|
22
|
+
* `A2APeerEvent`) to the entry it came from.
|
|
23
|
+
*
|
|
24
|
+
* @module components/inspector/a2aInspect
|
|
25
|
+
*/
|
|
26
|
+
import type { A2APeerEvent } from '../../runtimes/browser/a2aPeer';
|
|
27
|
+
import { type AgentInspectorEntry, type AgentInspectorSink, type ToolClass } from './agentInspector';
|
|
28
|
+
export type A2AInspectOptions = {
|
|
29
|
+
sink: AgentInspectorSink;
|
|
30
|
+
/** The agent that asks: `Sales`. */
|
|
31
|
+
asker: string;
|
|
32
|
+
/** The agent asked: `Accounting`. */
|
|
33
|
+
peer: string;
|
|
34
|
+
/**
|
|
35
|
+
* Where a peer's tool call comes from — an MCP server's, a skill, … — and
|
|
36
|
+
* its mark (`classifyToolCall`, or a team's `teamToolClassifier`).
|
|
37
|
+
* Without it, a tool the peer's runtime runs (`backend-tool`).
|
|
38
|
+
*/
|
|
39
|
+
classifyTool?: (name: string, args?: Record<string, unknown>) => ToolClass;
|
|
40
|
+
};
|
|
41
|
+
/** The text of an A2A 1.0 message's or artifact's parts, as JSON carries them. */
|
|
42
|
+
export declare function partsText(parts: unknown): string;
|
|
43
|
+
/** `TASK_STATE_WORKING` is `working`. */
|
|
44
|
+
export declare function stateWord(state: unknown): string;
|
|
45
|
+
/**
|
|
46
|
+
* A fetch that records the A2A traffic it carries into `options.sink`, and
|
|
47
|
+
* hands the SDK what the network gave.
|
|
48
|
+
*/
|
|
49
|
+
export declare function inspectA2AFetch(base: typeof globalThis.fetch, options: A2AInspectOptions): typeof globalThis.fetch;
|
|
50
|
+
/**
|
|
51
|
+
* The linker the A2A capture adds to its sink: an `A2APeerEvent` noted
|
|
52
|
+
* (`sink.note`) is attached to the entry it came from.
|
|
53
|
+
*/
|
|
54
|
+
export declare function a2aEventLinker(event: unknown): ((entry: AgentInspectorEntry) => boolean) | undefined;
|
|
55
|
+
/** Attach what the asker made of an event to the A2A entry it came from. */
|
|
56
|
+
export declare function linkA2APeerEvent(sink: AgentInspectorSink, event: A2APeerEvent): void;
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
import { argumentsLine, formatBytes, jsonBytes, shortLine, } from './agentInspector';
|
|
6
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
7
|
+
/** The text of an A2A 1.0 message's or artifact's parts, as JSON carries them. */
|
|
8
|
+
export function partsText(parts) {
|
|
9
|
+
return Array.isArray(parts)
|
|
10
|
+
? parts
|
|
11
|
+
.map(part => isObject(part) && typeof part.text === 'string' ? part.text : '')
|
|
12
|
+
.join('')
|
|
13
|
+
: '';
|
|
14
|
+
}
|
|
15
|
+
/** `TASK_STATE_WORKING` is `working`. */
|
|
16
|
+
export function stateWord(state) {
|
|
17
|
+
return typeof state === 'string'
|
|
18
|
+
? state
|
|
19
|
+
.replace(/^TASK_STATE_/, '')
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/_/g, ' ')
|
|
22
|
+
: '';
|
|
23
|
+
}
|
|
24
|
+
/** A JSON-RPC request's body, if the request carries one. */
|
|
25
|
+
function rpcOf(body) {
|
|
26
|
+
if (typeof body !== 'string') {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(body);
|
|
31
|
+
return isObject(parsed) && parsed.jsonrpc === '2.0' ? parsed : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** What a request asks, in a line. */
|
|
38
|
+
function requestSummary(method, params) {
|
|
39
|
+
const message = isObject(params) ? params.message : undefined;
|
|
40
|
+
const text = isObject(message) ? partsText(message.parts) : '';
|
|
41
|
+
if (text) {
|
|
42
|
+
return shortLine(text);
|
|
43
|
+
}
|
|
44
|
+
if (isObject(params) && typeof params.id === 'string') {
|
|
45
|
+
return `${method} ${params.id}`;
|
|
46
|
+
}
|
|
47
|
+
return method;
|
|
48
|
+
}
|
|
49
|
+
/** The step a status message carries: a tool call or its end. */
|
|
50
|
+
function stepOf(message) {
|
|
51
|
+
if (!isObject(message) || !Array.isArray(message.parts)) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
for (const part of message.parts) {
|
|
55
|
+
const data = isObject(part) ? part.data : undefined;
|
|
56
|
+
if (!isObject(data)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const call = isObject(data.tool_call) ? data.tool_call : undefined;
|
|
60
|
+
const done = isObject(data.tool_result) ? data.tool_result : undefined;
|
|
61
|
+
const told = call ?? done;
|
|
62
|
+
if (told && typeof told.name === 'string') {
|
|
63
|
+
return {
|
|
64
|
+
...(typeof told.id === 'string' ? { toolId: told.id } : {}),
|
|
65
|
+
name: told.name,
|
|
66
|
+
ended: !call,
|
|
67
|
+
...(call && isObject(call.arguments)
|
|
68
|
+
? { args: call.arguments }
|
|
69
|
+
: {}),
|
|
70
|
+
...(done && 'result' in done ? { result: done.result } : {}),
|
|
71
|
+
...(done && done.error ? { error: String(done.error) } : {}),
|
|
72
|
+
...(typeof told.note === 'string' && told.note
|
|
73
|
+
? { note: told.note }
|
|
74
|
+
: {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
/** Records the stream of one request, event by event. */
|
|
81
|
+
class StreamRecorder {
|
|
82
|
+
options;
|
|
83
|
+
method;
|
|
84
|
+
rpcId;
|
|
85
|
+
/** The entry of each artifact being streamed, by its id. */
|
|
86
|
+
artifacts = new Map();
|
|
87
|
+
constructor(options, method, rpcId) {
|
|
88
|
+
this.options = options;
|
|
89
|
+
this.method = method;
|
|
90
|
+
this.rpcId = rpcId;
|
|
91
|
+
}
|
|
92
|
+
classify(name, args) {
|
|
93
|
+
return (this.options.classifyTool?.(name, args) ?? { source: 'backend-tool' });
|
|
94
|
+
}
|
|
95
|
+
/** One JSON-RPC message of the response: a response, or an event of a stream. */
|
|
96
|
+
record(message, streamed) {
|
|
97
|
+
const { sink, asker, peer } = this.options;
|
|
98
|
+
const direction = { from: peer, to: asker };
|
|
99
|
+
const base = { actor: peer, source: 'a2a', direction };
|
|
100
|
+
if (!isObject(message)) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (isObject(message.error)) {
|
|
104
|
+
const error = message.error;
|
|
105
|
+
sink.push({
|
|
106
|
+
...base,
|
|
107
|
+
kind: 'error',
|
|
108
|
+
name: this.method,
|
|
109
|
+
status: 'failed',
|
|
110
|
+
summary: shortLine(`${String(error.code ?? '')} ${String(error.message ?? '')}`),
|
|
111
|
+
error: String(error.message ?? ''),
|
|
112
|
+
a2a: { method: this.method, rpcId: this.rpcId },
|
|
113
|
+
payload: message,
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const result = isObject(message.result) ? message.result : undefined;
|
|
118
|
+
if (!result) {
|
|
119
|
+
sink.push({
|
|
120
|
+
...base,
|
|
121
|
+
kind: 'response',
|
|
122
|
+
name: this.method,
|
|
123
|
+
summary: this.method,
|
|
124
|
+
a2a: { method: this.method, rpcId: this.rpcId },
|
|
125
|
+
payload: message,
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const a2a = { method: this.method, rpcId: this.rpcId };
|
|
130
|
+
if (isObject(result.task)) {
|
|
131
|
+
const task = result.task;
|
|
132
|
+
const status = isObject(task.status) ? task.status : {};
|
|
133
|
+
sink.push({
|
|
134
|
+
...base,
|
|
135
|
+
kind: 'task',
|
|
136
|
+
summary: `Task ${stateWord(status.state)}`,
|
|
137
|
+
a2a: {
|
|
138
|
+
...a2a,
|
|
139
|
+
taskId: String(task.id ?? ''),
|
|
140
|
+
contextId: String(task.contextId ?? ''),
|
|
141
|
+
state: String(status.state ?? ''),
|
|
142
|
+
},
|
|
143
|
+
payload: message,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
else if (isObject(result.statusUpdate)) {
|
|
147
|
+
this.status(result.statusUpdate, message, a2a);
|
|
148
|
+
}
|
|
149
|
+
else if (isObject(result.artifactUpdate)) {
|
|
150
|
+
this.artifact(result.artifactUpdate, message, a2a);
|
|
151
|
+
}
|
|
152
|
+
else if (isObject(result.message)) {
|
|
153
|
+
const reply = result.message;
|
|
154
|
+
sink.push({
|
|
155
|
+
...base,
|
|
156
|
+
kind: 'message',
|
|
157
|
+
summary: shortLine(partsText(reply.parts)) || 'A message',
|
|
158
|
+
a2a: {
|
|
159
|
+
...a2a,
|
|
160
|
+
taskId: String(reply.taskId ?? ''),
|
|
161
|
+
contextId: String(reply.contextId ?? ''),
|
|
162
|
+
},
|
|
163
|
+
payload: message,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
sink.push({
|
|
168
|
+
...base,
|
|
169
|
+
kind: streamed ? 'event' : 'response',
|
|
170
|
+
summary: this.method,
|
|
171
|
+
a2a,
|
|
172
|
+
payload: message,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
status(update, message, a2a) {
|
|
177
|
+
const { sink, asker, peer } = this.options;
|
|
178
|
+
const status = isObject(update.status) ? update.status : {};
|
|
179
|
+
const taskId = String(update.taskId ?? '');
|
|
180
|
+
const state = String(status.state ?? '');
|
|
181
|
+
const step = stepOf(status.message);
|
|
182
|
+
const text = partsText(isObject(status.message) ? status.message.parts : undefined);
|
|
183
|
+
const summary = step
|
|
184
|
+
? step.ended
|
|
185
|
+
? `${stateWord(state)} · ${step.error ? `${step.name} failed` : `${step.name} returned`}`
|
|
186
|
+
: `${stateWord(state)} · calls ${step.name}(${argumentsLine(step.args)})`
|
|
187
|
+
: `${stateWord(state)}${text ? ` · ${shortLine(text)}` : ''}`;
|
|
188
|
+
const failed = /FAILED|REJECTED|CANCELED/.test(state);
|
|
189
|
+
const id = sink.push({
|
|
190
|
+
actor: peer,
|
|
191
|
+
source: 'a2a',
|
|
192
|
+
direction: { from: peer, to: asker },
|
|
193
|
+
kind: 'status-update',
|
|
194
|
+
summary,
|
|
195
|
+
...(failed ? { status: 'failed' } : {}),
|
|
196
|
+
a2a: {
|
|
197
|
+
...a2a,
|
|
198
|
+
taskId,
|
|
199
|
+
contextId: String(update.contextId ?? ''),
|
|
200
|
+
state,
|
|
201
|
+
...(step
|
|
202
|
+
? {
|
|
203
|
+
step: { toolId: step.toolId, name: step.name, ended: step.ended },
|
|
204
|
+
}
|
|
205
|
+
: {}),
|
|
206
|
+
},
|
|
207
|
+
payload: message,
|
|
208
|
+
});
|
|
209
|
+
if (!step) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// The call, as one entry of its own, the peer's: from its start to its end.
|
|
213
|
+
const key = `a2a:${taskId}:${step.toolId ?? step.name}`;
|
|
214
|
+
if (!step.ended) {
|
|
215
|
+
const kind = this.classify(step.name, step.args);
|
|
216
|
+
sink.start({
|
|
217
|
+
key,
|
|
218
|
+
actor: peer,
|
|
219
|
+
source: kind.source,
|
|
220
|
+
...(kind.icon ? { icon: kind.icon } : {}),
|
|
221
|
+
...(kind.emoji ? { emoji: kind.emoji } : {}),
|
|
222
|
+
kind: 'tool-call',
|
|
223
|
+
name: step.name,
|
|
224
|
+
summary: step.note ?? argumentsLine(step.args),
|
|
225
|
+
payload: step.args ?? {},
|
|
226
|
+
links: [id],
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
else if (sink.running(key)) {
|
|
230
|
+
sink.end(key, {
|
|
231
|
+
status: step.error ? 'failed' : 'done',
|
|
232
|
+
result: step.result,
|
|
233
|
+
...(step.error ? { error: step.error } : {}),
|
|
234
|
+
links: [id],
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
artifact(update, message, a2a) {
|
|
239
|
+
const { sink, asker, peer } = this.options;
|
|
240
|
+
const artifact = isObject(update.artifact) ? update.artifact : {};
|
|
241
|
+
const artifactId = String(artifact.artifactId ?? '');
|
|
242
|
+
const text = partsText(artifact.parts);
|
|
243
|
+
const parts = Array.isArray(artifact.parts) ? artifact.parts : [];
|
|
244
|
+
const media = parts
|
|
245
|
+
.map(part => (isObject(part) ? part.mediaType : undefined))
|
|
246
|
+
.find(type => typeof type === 'string' && type && !type.startsWith('text/'));
|
|
247
|
+
const known = this.artifacts.get(artifactId);
|
|
248
|
+
if (known && update.append && !update.lastChunk) {
|
|
249
|
+
// A chunk of an artifact being streamed: one entry, its text growing.
|
|
250
|
+
known.text += text;
|
|
251
|
+
known.events.push(message);
|
|
252
|
+
const chunks = known.events.length;
|
|
253
|
+
const words = known.text;
|
|
254
|
+
sink.update(known.id, entry => ({
|
|
255
|
+
summary: `${String(artifact.name ?? 'artifact')} · ${shortLine(words)}`,
|
|
256
|
+
payload: [...known.events],
|
|
257
|
+
a2a: { ...entry.a2a, chunks },
|
|
258
|
+
}));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const name = String(artifact.name ?? (artifactId || 'artifact'));
|
|
262
|
+
const summary = media
|
|
263
|
+
? `${name} · ${String(media)}, ${formatBytes(jsonBytes(message))}`
|
|
264
|
+
: `${name} · ${shortLine(text)}`;
|
|
265
|
+
const id = sink.push({
|
|
266
|
+
actor: peer,
|
|
267
|
+
source: 'a2a',
|
|
268
|
+
direction: { from: peer, to: asker },
|
|
269
|
+
kind: 'artifact-update',
|
|
270
|
+
name,
|
|
271
|
+
summary,
|
|
272
|
+
a2a: {
|
|
273
|
+
...a2a,
|
|
274
|
+
taskId: String(update.taskId ?? ''),
|
|
275
|
+
contextId: String(update.contextId ?? ''),
|
|
276
|
+
artifactId,
|
|
277
|
+
chunks: 1,
|
|
278
|
+
},
|
|
279
|
+
payload: message,
|
|
280
|
+
});
|
|
281
|
+
if (update.append && !update.lastChunk) {
|
|
282
|
+
this.artifacts.set(artifactId, { id, text, events: [message] });
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
this.artifacts.delete(artifactId);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/** The JSON of each event of a server-sent stream, as it arrives. */
|
|
290
|
+
async function readEvents(body, onEvent) {
|
|
291
|
+
const reader = body.getReader();
|
|
292
|
+
const decoder = new TextDecoder();
|
|
293
|
+
let buffer = '';
|
|
294
|
+
const flush = (block) => {
|
|
295
|
+
const data = block
|
|
296
|
+
.split(/\r?\n/)
|
|
297
|
+
.filter(line => line.startsWith('data:'))
|
|
298
|
+
.map(line => line.slice(5).replace(/^ /, ''))
|
|
299
|
+
.join('\n');
|
|
300
|
+
if (!data) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
onEvent(JSON.parse(data));
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
onEvent({ unparsed: data });
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
for (;;) {
|
|
311
|
+
const { done, value } = await reader.read();
|
|
312
|
+
if (done) {
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
buffer += decoder.decode(value, { stream: true });
|
|
316
|
+
let at;
|
|
317
|
+
while ((at = buffer.search(/\r?\n\r?\n/)) >= 0) {
|
|
318
|
+
const block = buffer.slice(0, at);
|
|
319
|
+
buffer = buffer.slice(at).replace(/^\r?\n\r?\n/, '');
|
|
320
|
+
flush(block);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (buffer.trim()) {
|
|
324
|
+
flush(buffer);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* A fetch that records the A2A traffic it carries into `options.sink`, and
|
|
329
|
+
* hands the SDK what the network gave.
|
|
330
|
+
*/
|
|
331
|
+
export function inspectA2AFetch(base, options) {
|
|
332
|
+
const { sink, asker, peer } = options;
|
|
333
|
+
// What the asker makes of the events (`A2APeerEvent`) is linked to them.
|
|
334
|
+
sink.addLinker(a2aEventLinker);
|
|
335
|
+
return async (input, init) => {
|
|
336
|
+
const rpc = rpcOf(init?.body);
|
|
337
|
+
const method = rpc ? String(rpc.method ?? '') : '';
|
|
338
|
+
const rpcId = rpc ? rpc.id : undefined;
|
|
339
|
+
const url = typeof input === 'string'
|
|
340
|
+
? input
|
|
341
|
+
: input instanceof URL
|
|
342
|
+
? input.href
|
|
343
|
+
: input.url;
|
|
344
|
+
if (rpc) {
|
|
345
|
+
sink.push({
|
|
346
|
+
actor: asker,
|
|
347
|
+
source: 'a2a',
|
|
348
|
+
direction: { from: asker, to: peer },
|
|
349
|
+
kind: 'request',
|
|
350
|
+
name: method,
|
|
351
|
+
summary: requestSummary(method, rpc.params),
|
|
352
|
+
a2a: { method, rpcId },
|
|
353
|
+
payload: rpc,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
let response;
|
|
357
|
+
try {
|
|
358
|
+
response = await base(input, init);
|
|
359
|
+
}
|
|
360
|
+
catch (reason) {
|
|
361
|
+
sink.push({
|
|
362
|
+
actor: asker,
|
|
363
|
+
source: 'a2a',
|
|
364
|
+
direction: { from: asker, to: peer },
|
|
365
|
+
kind: 'error',
|
|
366
|
+
name: method || undefined,
|
|
367
|
+
status: 'failed',
|
|
368
|
+
summary: shortLine(`${method || url}: ${reason instanceof Error ? reason.message : String(reason)}`),
|
|
369
|
+
error: reason instanceof Error ? reason.message : String(reason),
|
|
370
|
+
a2a: { method, rpcId },
|
|
371
|
+
});
|
|
372
|
+
throw reason;
|
|
373
|
+
}
|
|
374
|
+
const type = response.headers.get('content-type') ?? '';
|
|
375
|
+
const recorder = new StreamRecorder(options, method, rpcId);
|
|
376
|
+
if (!response.ok) {
|
|
377
|
+
const copy = response.clone();
|
|
378
|
+
void copy.text().then(text => sink.push({
|
|
379
|
+
actor: peer,
|
|
380
|
+
source: 'a2a',
|
|
381
|
+
direction: { from: peer, to: asker },
|
|
382
|
+
kind: 'error',
|
|
383
|
+
name: method || undefined,
|
|
384
|
+
status: 'failed',
|
|
385
|
+
summary: shortLine(`${response.status} ${response.statusText} ${text}`),
|
|
386
|
+
error: `${response.status} ${response.statusText}`,
|
|
387
|
+
a2a: { method, rpcId },
|
|
388
|
+
payload: { status: response.status, body: text },
|
|
389
|
+
}));
|
|
390
|
+
return response;
|
|
391
|
+
}
|
|
392
|
+
if (type.includes('text/event-stream') && response.body) {
|
|
393
|
+
const [mine, theirs] = response.body.tee();
|
|
394
|
+
void readEvents(mine, data => recorder.record(data, true)).catch(() => undefined);
|
|
395
|
+
return new Response(theirs, {
|
|
396
|
+
status: response.status,
|
|
397
|
+
statusText: response.statusText,
|
|
398
|
+
headers: response.headers,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (type.includes('json')) {
|
|
402
|
+
const copy = response.clone();
|
|
403
|
+
void copy
|
|
404
|
+
.json()
|
|
405
|
+
.then(data => {
|
|
406
|
+
if (rpc) {
|
|
407
|
+
recorder.record(data, false);
|
|
408
|
+
}
|
|
409
|
+
else if (isObject(data) && Array.isArray(data.skills)) {
|
|
410
|
+
// The agent card.
|
|
411
|
+
sink.push({
|
|
412
|
+
actor: peer,
|
|
413
|
+
source: 'a2a',
|
|
414
|
+
direction: { from: peer, to: asker },
|
|
415
|
+
kind: 'agent-card',
|
|
416
|
+
name: String(data.name ?? peer),
|
|
417
|
+
summary: shortLine(`${String(data.name ?? peer)} · ${data.skills
|
|
418
|
+
.map(skill => isObject(skill) ? String(skill.name ?? skill.id) : '')
|
|
419
|
+
.join(', ')}`),
|
|
420
|
+
payload: data,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
})
|
|
424
|
+
.catch(() => undefined);
|
|
425
|
+
}
|
|
426
|
+
return response;
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
/** The entry an event of the asker's came from. */
|
|
430
|
+
function matchOf(event) {
|
|
431
|
+
switch (event.phase) {
|
|
432
|
+
case 'asked':
|
|
433
|
+
return entry => entry.source === 'a2a' &&
|
|
434
|
+
entry.kind === 'request' &&
|
|
435
|
+
/Message/.test(entry.a2a?.method ?? '') &&
|
|
436
|
+
entry.events.length === 0;
|
|
437
|
+
case 'working':
|
|
438
|
+
if (event.tool) {
|
|
439
|
+
const tool = event.tool;
|
|
440
|
+
return entry => entry.kind === 'status-update' &&
|
|
441
|
+
entry.a2a?.taskId === event.taskId &&
|
|
442
|
+
entry.a2a?.step?.name === tool.name &&
|
|
443
|
+
entry.a2a?.step?.ended === tool.ended &&
|
|
444
|
+
(tool.id === undefined || entry.a2a?.step?.toolId === tool.id);
|
|
445
|
+
}
|
|
446
|
+
if (event.note) {
|
|
447
|
+
return entry => entry.kind === 'status-update' &&
|
|
448
|
+
entry.a2a?.taskId === event.taskId &&
|
|
449
|
+
!entry.a2a?.step &&
|
|
450
|
+
entry.events.length === 0;
|
|
451
|
+
}
|
|
452
|
+
return entry => (entry.kind === 'task' || entry.kind === 'status-update') &&
|
|
453
|
+
entry.a2a?.taskId === event.taskId &&
|
|
454
|
+
entry.events.length === 0;
|
|
455
|
+
case 'answered':
|
|
456
|
+
return entry => entry.kind === 'status-update' &&
|
|
457
|
+
entry.a2a?.taskId === event.taskId &&
|
|
458
|
+
entry.a2a?.state === 'TASK_STATE_COMPLETED';
|
|
459
|
+
default:
|
|
460
|
+
return entry => entry.source === 'a2a' && entry.direction?.to !== undefined;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/** Whether a value is an `A2APeerEvent`. */
|
|
464
|
+
function isPeerEvent(event) {
|
|
465
|
+
return (isObject(event) &&
|
|
466
|
+
['asked', 'working', 'answered', 'failed'].includes(String(event.phase)));
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* The linker the A2A capture adds to its sink: an `A2APeerEvent` noted
|
|
470
|
+
* (`sink.note`) is attached to the entry it came from.
|
|
471
|
+
*/
|
|
472
|
+
export function a2aEventLinker(event) {
|
|
473
|
+
return isPeerEvent(event) ? matchOf(event) : undefined;
|
|
474
|
+
}
|
|
475
|
+
/** Attach what the asker made of an event to the A2A entry it came from. */
|
|
476
|
+
export function linkA2APeerEvent(sink, event) {
|
|
477
|
+
sink.attach(matchOf(event), event);
|
|
478
|
+
}
|