@datalayer/agent-runtimes 1.3.85 → 1.3.87

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.
@@ -3,28 +3,34 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
  /**
6
- * A team of two applications over A2A, as a page runs it: the entry's loop
7
- * turns in the page and asks its peer over A2A with one tool.
6
+ * A team of applications over A2A, as a page runs it: the entry's loop
7
+ * turns in the page and asks each of its peers over A2A with a tool of its
8
+ * own.
8
9
  *
9
10
  * The entry (Sales) talks to the person: its instructions and its model run
10
11
  * here (the Vercel AI SDK's loop), its model reached through ai-inference
11
12
  * with whatever `inference` holds — a signed-in person's token, or a
12
- * visitor's trial key. Its one tool asks the peer (Accounting) with
13
- * {@link a2aPeerTool}. What each of them does is kept as a persona — the
14
- * state its character acts and what its balloon says — the peer's last
15
- * answer as the report, and the way
16
- * the link carries a message as the flow ({@link flowAfter}). What the peer
17
- * gives besides words — the formats the page accepts (`accept`), such as a
18
- * Jupyter notebook — is kept as its artifacts, and its latest notebook kept
19
- * until another one comes ({@link A2ATeam.notebook}). The tools the
20
- * peer calls on its connections (its MCP servers), told over A2A as it calls
21
- * them, are kept as the calls running now ({@link callsAfter}).
13
+ * visitor's trial key. Its tools ask its peers (Accounting; Disaster
14
+ * assessment and Change detection), one `ask_<peer>` tool per peer, with
15
+ * {@link a2aPeerTool}. A team of two names its peer as `peerApp`; a team of
16
+ * N names them as `peers` (LOOP A-08). An entry that runs on a runtime
17
+ * itself (a scene of one member: Month-end close, Crop monitoring) is asked
18
+ * over A2A from the page, with no agent in the browser: `entryPeer`.
19
+ *
20
+ * What each of them does is kept as a persona — the state its character
21
+ * acts and what its balloon says — each peer's last answer as the report,
22
+ * and the way each link carries a message as its flow ({@link flowAfter}).
23
+ * What a peer gives besides words — the formats the page accepts
24
+ * (`accept`), such as a Jupyter notebook — is kept as its artifacts, and
25
+ * the latest notebook kept until another one comes ({@link A2ATeam.notebook}).
26
+ * The tools a peer calls on its connections (its MCP servers), told over
27
+ * A2A as it calls them, are kept as the calls running now ({@link callsAfter}).
22
28
  *
23
29
  * Given the Agent Inspector's tracer (`inspector`), the entry's turns (an
24
- * `invoke_agent` span: its model, its tokens) and its call to the peer (an
25
- * `execute_tool` span under it) are recorded there as OpenTelemetry spans;
26
- * the A2A traffic itself, under that call, is recorded by the peer's fetch
27
- * (`traceA2AFetch`).
30
+ * `invoke_agent` span: its model, its tokens) and its calls to its peers
31
+ * (`execute_tool` spans under it) are recorded there as OpenTelemetry
32
+ * spans; the A2A traffic itself, under those calls, is recorded by each
33
+ * peer's fetch (`traceA2AFetch`).
28
34
  *
29
35
  * What `A2ATeamGraph` draws, and what a page's composer sends.
30
36
  *
@@ -33,11 +39,12 @@
33
39
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
34
40
  import { stepCountIs, ToolLoopAgent } from 'ai';
35
41
  import { createBrowserModel, } from '../../runtimes/browser/model';
36
- import { a2aPeerTool, NOTEBOOK_MEDIA_TYPE, } from '../../runtimes/browser/a2aPeer';
42
+ import { a2aPeerTool, askA2APeer, NOTEBOOK_MEDIA_TYPE, } from '../../runtimes/browser/a2aPeer';
37
43
  import { endAgentTurn, endToolCall, startAgentTurn, startToolCall, } from '../inspector/agentSpans';
38
44
  import { callsAfter, flowAfter, toolOwnName, pruneCalls, } from './a2aTeamFlow';
39
45
  import { toolLineOfStep, } from '../../chat/assistant/toolLine';
40
46
  const NO_CONNECTIONS = [];
47
+ const NO_PEERS = [];
41
48
  export const AT_REST = {
42
49
  state: 'idle',
43
50
  insist: false,
@@ -66,14 +73,84 @@ export function balloonLine(text, length = 120) {
66
73
  const flat = text.replace(/\s+/g, ' ').trim();
67
74
  return flat.length > length ? `${flat.slice(0, length - 1)}…` : flat;
68
75
  }
69
- /** Run a team of two over A2A in the page. */
76
+ /** The name of the entry's tool for a peer, as its instructions call it: `ask_<peer id>`. */
77
+ export function askToolOf(peerId) {
78
+ return `ask_${peerId.replace(/-/g, '_')}`;
79
+ }
80
+ /**
81
+ * What a member says when it is asked, before it works: the books when it
82
+ * reaches Odoo, the system it reaches otherwise, or just that it is on it.
83
+ */
84
+ export function askedLine(connections) {
85
+ if (connections.some(connection => connection.id.startsWith('odoo'))) {
86
+ return 'On it. Let me read the books.';
87
+ }
88
+ const systems = [...new Set(connections.map(connection => connection.label))];
89
+ return systems.length
90
+ ? `On it. Let me look in ${systems.join(' and ')}.`
91
+ : 'On it.';
92
+ }
93
+ /**
94
+ * The entry's instructions for a team of more than one peer: its own, and
95
+ * which tool asks whom, so that it names each. A team of two keeps the
96
+ * entry's instructions as they are.
97
+ */
98
+ export function teamInstructions(entry, peers) {
99
+ if (peers.length < 2) {
100
+ return entry.instructions;
101
+ }
102
+ const who = peers
103
+ .map(peer => `${peer.app.name} with \`${peer.askTool}\``)
104
+ .join(', ');
105
+ return `${entry.instructions}\n\nYour team, over A2A: ask ${who}. Each answers one request on its own.`;
106
+ }
107
+ /** A persona at rest, or greeting with a line when it has one. */
108
+ function restingPersona(greeting) {
109
+ return greeting
110
+ ? {
111
+ ...AT_REST,
112
+ state: 'greeting',
113
+ saying: balloonLine(greeting),
114
+ full: greeting,
115
+ insist: true,
116
+ }
117
+ : AT_REST;
118
+ }
119
+ /** Run a team over A2A in the page. */
70
120
  export function useA2ATeam(options) {
71
- const { entry, peerApp, peer, inference, maxSteps = 6, peerConnections = NO_CONNECTIONS, inspector = null, } = options;
121
+ const { entry, peerApp, peer = null, peerConnections = NO_CONNECTIONS, peers: given = NO_PEERS, entryPeer = null, inference, maxSteps = 6, inspector = null, } = options;
72
122
  // By what it names, so that a page passing a new array of the same media
73
123
  // types does not make the agent again.
74
124
  const acceptKey = options.accept?.join('\n');
75
125
  const accept = useMemo(() => (acceptKey ? acceptKey.split('\n') : undefined), [acceptKey]);
76
- const askTool = options.askTool ?? `ask_${peerApp.id.replace(/-/g, '_')}`;
126
+ const askTool = options.askTool ?? (peerApp ? askToolOf(peerApp.id) : '');
127
+ // The peers, resolved: the one of a team of two, then those of a team of N.
128
+ const peerList = useMemo(() => [
129
+ ...(peerApp
130
+ ? [
131
+ {
132
+ id: peerApp.id,
133
+ app: peerApp,
134
+ peer,
135
+ askTool,
136
+ connections: peerConnections,
137
+ },
138
+ ]
139
+ : []),
140
+ ...given.map(one => ({
141
+ id: one.app.id,
142
+ app: one.app,
143
+ peer: one.peer,
144
+ askTool: one.askTool ?? askToolOf(one.app.id),
145
+ connections: one.connections ?? NO_CONNECTIONS,
146
+ ...(one.greeting ? { greeting: one.greeting } : {}),
147
+ })),
148
+ ], [peerApp, peer, askTool, peerConnections, given]);
149
+ // Read by the event handlers, so that they need not be made again when
150
+ // a peer connects.
151
+ const peersNow = useRef(peerList);
152
+ peersNow.current = peerList;
153
+ const peerOf = useCallback((id) => peersNow.current.find(one => one.id === id), []);
77
154
  const [entryPersona, setEntryPersona] = useState(() => ({
78
155
  ...AT_REST,
79
156
  state: 'greeting',
@@ -81,11 +158,28 @@ export function useA2ATeam(options) {
81
158
  full: entry.interface.welcome ?? '',
82
159
  insist: Boolean(entry.interface.welcome),
83
160
  }));
84
- const [peerPersona, setPeerPersona] = useState(AT_REST);
161
+ const [personas, setPersonas] = useState(() => Object.fromEntries(peerList.map(one => [one.id, restingPersona(one.greeting)])));
162
+ const personaOf = useCallback((id) => personas[id] ?? restingPersona(peerOf(id)?.greeting), [personas, peerOf]);
163
+ /** Change a member's persona: the entry's, or a peer's by id. */
164
+ const setPersona = useCallback((id, next) => {
165
+ if (id === entry.id) {
166
+ setEntryPersona(next);
167
+ }
168
+ else {
169
+ setPersonas(prev => ({
170
+ ...prev,
171
+ [id]: next(prev[id] ?? restingPersona(peerOf(id)?.greeting)),
172
+ }));
173
+ }
174
+ }, [entry.id, peerOf]);
85
175
  const [turns, setTurns] = useState([]);
86
- const [peerHistory, setPeerHistory] = useState([]);
176
+ const [histories, setHistories] = useState({});
87
177
  const told = useRef(0);
88
- const tell = useCallback((role, text) => {
178
+ const tell = useCallback((id, role, text) => {
179
+ if (id === entry.id) {
180
+ // The entry's history is its turns.
181
+ return;
182
+ }
89
183
  told.current += 1;
90
184
  const message = {
91
185
  id: `peer-${told.current}`,
@@ -93,62 +187,82 @@ export function useA2ATeam(options) {
93
187
  content: text,
94
188
  createdAt: new Date(),
95
189
  };
96
- setPeerHistory(prev => [...prev, message]);
97
- }, []);
98
- // A tool the peer calls, as the chat holds one: added when it starts,
190
+ setHistories(prev => ({ ...prev, [id]: [...(prev[id] ?? []), message] }));
191
+ }, [entry.id]);
192
+ // A tool a peer calls, as the chat holds one: added when it starts,
99
193
  // updated when it ends.
100
- const toolCalled = useCallback((step) => {
101
- const id = step.id ?? step.name;
194
+ const toolCalled = useCallback((id, step) => {
195
+ if (id === entry.id) {
196
+ return;
197
+ }
198
+ const connections = peerOf(id)?.connections ?? NO_CONNECTIONS;
199
+ const callId = step.id ?? step.name;
102
200
  const call = {
103
- id: `peer-tool:${id}`,
201
+ id: `peer-tool:${callId}`,
104
202
  type: 'tool-call',
105
- toolCallId: id,
106
- toolName: toolOwnName(step.name, peerConnections),
203
+ toolCallId: callId,
204
+ toolName: toolOwnName(step.name, connections),
107
205
  args: {},
108
206
  status: step.error ? 'error' : step.ended ? 'complete' : 'executing',
109
207
  ...(step.error ? { error: step.error } : {}),
110
208
  };
111
- setPeerHistory(prev => prev.some(item => item.id === call.id)
112
- ? prev.map(item => (item.id === call.id ? call : item))
113
- : [...prev, call]);
114
- }, [peerConnections]);
209
+ setHistories(prev => {
210
+ const history = prev[id] ?? [];
211
+ return {
212
+ ...prev,
213
+ [id]: history.some(item => item.id === call.id)
214
+ ? history.map(item => (item.id === call.id ? call : item))
215
+ : [...history, call],
216
+ };
217
+ });
218
+ }, [entry.id, peerOf]);
115
219
  const [report, setReport] = useState(null);
220
+ const [reports, setReports] = useState({});
116
221
  const [artifacts, setArtifacts] = useState([]);
222
+ const [givens, setGivens] = useState({});
117
223
  const [notebook, setNotebook] = useState(null);
118
- const [flow, setFlow] = useState('still');
224
+ const [flows, setFlows] = useState({});
119
225
  const [calls, setCalls] = useState([]);
120
226
  const callsNow = useRef([]);
121
227
  const callTimer = useRef(undefined);
122
228
  const [busy, setBusy] = useState(false);
123
229
  const history = useRef([]);
124
230
  const abort = useRef(null);
125
- const flowTimer = useRef(undefined);
126
- // What the peer does, as it does it: its own state and balloon, and the link.
127
- const onPeerEvent = useCallback((event) => {
128
- const next = flowAfter(event);
129
- clearTimeout(flowTimer.current);
130
- setFlow(next.flow);
131
- if (next.holdMs) {
132
- flowTimer.current = setTimeout(() => setFlow('still'), next.holdMs);
133
- }
134
- // The calls to the peer's connections: a call's end, when it came
135
- // quickly, is shown a moment longer.
136
- const after = callsAfter(callsNow.current, event, peerApp.id, peerConnections, Date.now());
137
- callsNow.current = after.calls;
138
- setCalls(after.calls);
139
- if (after.holdMs) {
140
- clearTimeout(callTimer.current);
141
- callTimer.current = setTimeout(() => {
142
- callsNow.current = pruneCalls(callsNow.current, Date.now());
143
- setCalls(callsNow.current);
144
- }, after.holdMs);
231
+ const flowTimers = useRef(new Map());
232
+ // What a member does, as it does it: its own state and balloon, and its
233
+ // link. A peer's when the entry asks it; the entry's own when the entry
234
+ // is asked over A2A.
235
+ const onMemberEvent = useCallback((id, event) => {
236
+ const connections = id === entry.id
237
+ ? NO_CONNECTIONS
238
+ : (peerOf(id)?.connections ?? NO_CONNECTIONS);
239
+ if (id !== entry.id) {
240
+ const next = flowAfter(event);
241
+ clearTimeout(flowTimers.current.get(id));
242
+ setFlows(prev => ({ ...prev, [id]: next.flow }));
243
+ if (next.holdMs) {
244
+ flowTimers.current.set(id, setTimeout(() => setFlows(prev => ({ ...prev, [id]: 'still' })), next.holdMs));
245
+ }
246
+ // The calls to its connections: a call's end, when it came
247
+ // quickly, is shown a moment longer. Another peer's calls are its own.
248
+ const others = callsNow.current.filter(call => call.member !== id);
249
+ const after = callsAfter(callsNow.current.filter(call => call.member === id), event, id, connections, Date.now());
250
+ callsNow.current = [...others, ...after.calls];
251
+ setCalls(callsNow.current);
252
+ if (after.holdMs) {
253
+ clearTimeout(callTimer.current);
254
+ callTimer.current = setTimeout(() => {
255
+ callsNow.current = pruneCalls(callsNow.current, Date.now());
256
+ setCalls(callsNow.current);
257
+ }, after.holdMs);
258
+ }
145
259
  }
146
260
  if (event.phase === 'asked') {
147
- tell('user', event.request);
148
- setPeerPersona(prev => ({
261
+ tell(id, 'user', event.request);
262
+ setPersona(id, prev => ({
149
263
  ...prev,
150
264
  state: 'greeting',
151
- saying: 'On it. Let me read the books.',
265
+ saying: askedLine(connections),
152
266
  full: undefined,
153
267
  insist: true,
154
268
  tool: undefined,
@@ -156,9 +270,9 @@ export function useA2ATeam(options) {
156
270
  }
157
271
  else if (event.phase === 'working') {
158
272
  if (event.tool) {
159
- toolCalled(event.tool);
273
+ toolCalled(id, event.tool);
160
274
  }
161
- setPeerPersona(prev => ({
275
+ setPersona(id, prev => ({
162
276
  ...prev,
163
277
  state: 'working',
164
278
  saying: event.note ? balloonLine(event.note) : prev.saying,
@@ -167,19 +281,21 @@ export function useA2ATeam(options) {
167
281
  // The tool it calls, in plain words, from the step it told; a
168
282
  // step without a tool (its own words) puts the tool line away.
169
283
  tool: event.tool
170
- ? toolLineOfStep(event.tool, toolOwnName(event.tool.name, peerConnections))
284
+ ? toolLineOfStep(event.tool, toolOwnName(event.tool.name, connections))
171
285
  : undefined,
172
286
  }));
173
287
  }
174
288
  else if (event.phase === 'answered') {
175
- tell('assistant', event.answer);
289
+ tell(id, 'assistant', event.answer);
176
290
  setReport(event.answer);
291
+ setReports(prev => ({ ...prev, [id]: event.answer }));
177
292
  setArtifacts(event.artifacts);
293
+ setGivens(prev => ({ ...prev, [id]: event.artifacts }));
178
294
  const given = notebookAmong(event.artifacts);
179
295
  if (given) {
180
296
  setNotebook(given);
181
297
  }
182
- setPeerPersona(prev => ({
298
+ setPersona(id, prev => ({
183
299
  ...prev,
184
300
  state: 'speaking',
185
301
  saying: balloonLine(event.answer),
@@ -188,13 +304,13 @@ export function useA2ATeam(options) {
188
304
  tool: undefined,
189
305
  }));
190
306
  // The entry was given a notebook: in its balloon, read-only.
191
- if (given) {
307
+ if (given && id !== entry.id) {
192
308
  setEntryPersona(prev => ({ ...prev, notebook: given }));
193
309
  }
194
310
  }
195
311
  else {
196
- tell('assistant', `Could not answer: ${event.error}`);
197
- setPeerPersona(prev => ({
312
+ tell(id, 'assistant', `Could not answer: ${event.error}`);
313
+ setPersona(id, prev => ({
198
314
  ...prev,
199
315
  state: 'idle',
200
316
  saying: balloonLine(event.error),
@@ -203,27 +319,81 @@ export function useA2ATeam(options) {
203
319
  tool: undefined,
204
320
  }));
205
321
  }
206
- }, [peerApp.id, peerConnections, tell, toolCalled]);
322
+ }, [entry.id, peerOf, setPersona, tell, toolCalled]);
323
+ // The peers' ids and whether each is reached: the agent is made again
324
+ // when one connects, not when a peer's state changes.
207
325
  const agent = useMemo(() => {
208
- if (!peer) {
326
+ if (entryPeer || !peerList.length || peerList.some(one => !one.peer)) {
209
327
  return null;
210
328
  }
329
+ const tools = {};
330
+ for (const one of peerList) {
331
+ tools[one.askTool] = a2aPeerTool({
332
+ peer: one.peer,
333
+ onEvent: event => onMemberEvent(one.id, event),
334
+ accept,
335
+ });
336
+ }
211
337
  return new ToolLoopAgent({
212
338
  id: entry.id,
213
339
  model: createBrowserModel({
214
340
  ...inference,
215
341
  model: entry.model || undefined,
216
342
  }),
217
- instructions: entry.instructions,
218
- tools: {
219
- [askTool]: a2aPeerTool({ peer, onEvent: onPeerEvent, accept }),
220
- },
343
+ instructions: teamInstructions(entry, peerList),
344
+ tools,
221
345
  stopWhen: stepCountIs(maxSteps),
222
346
  });
223
- }, [peer, inference, onPeerEvent, entry, askTool, maxSteps, accept]);
347
+ }, [entryPeer, peerList, inference, onMemberEvent, entry, maxSteps, accept]);
348
+ /** Every peer at rest again, its greeting gone. */
349
+ const peersAtRest = useCallback(() => {
350
+ setPersonas(Object.fromEntries(peersNow.current.map(one => [one.id, AT_REST])));
351
+ }, []);
352
+ // The entry on a runtime: asked over A2A from the page, as the person.
353
+ const sendOverA2A = useCallback(async (asked, to) => {
354
+ setBusy(true);
355
+ setReport(null);
356
+ setArtifacts([]);
357
+ setTurns(prev => [...prev, { role: 'user', text: asked }]);
358
+ abort.current = new AbortController();
359
+ try {
360
+ const { answer } = await askA2APeer(to, asked, {
361
+ signal: abort.current.signal,
362
+ onEvent: event => onMemberEvent(entry.id, event),
363
+ accept,
364
+ });
365
+ setTurns(prev => [...prev, { role: 'assistant', text: answer }]);
366
+ setEntryPersona(prev => ({ ...prev, state: 'idle' }));
367
+ }
368
+ catch (error) {
369
+ const message = error instanceof Error ? error.message : String(error);
370
+ setTurns(prev => [
371
+ ...prev,
372
+ { role: 'assistant', text: `Could not answer: ${message}` },
373
+ ]);
374
+ setEntryPersona(prev => ({
375
+ ...prev,
376
+ state: 'idle',
377
+ saying: balloonLine(message),
378
+ full: message,
379
+ insist: true,
380
+ tool: undefined,
381
+ }));
382
+ }
383
+ finally {
384
+ abort.current = null;
385
+ setBusy(false);
386
+ }
387
+ }, [entry.id, onMemberEvent, accept]);
224
388
  const send = useCallback(async (text) => {
225
389
  const asked = text.trim();
226
- if (!asked || !agent || busy) {
390
+ if (!asked || busy) {
391
+ return;
392
+ }
393
+ if (entryPeer) {
394
+ return sendOverA2A(asked, entryPeer);
395
+ }
396
+ if (!agent) {
227
397
  return;
228
398
  }
229
399
  setBusy(true);
@@ -236,12 +406,12 @@ export function useA2ATeam(options) {
236
406
  saying: 'Let me see…',
237
407
  insist: true,
238
408
  });
239
- setPeerPersona(AT_REST);
409
+ peersAtRest();
240
410
  history.current = [...history.current, { role: 'user', content: asked }];
241
411
  abort.current = new AbortController();
242
412
  let said = '';
243
413
  // The turn, as a span: the entry's model, from the question to the
244
- // answer, and its tokens; its calls to the peer under it.
414
+ // answer, and its tokens; its calls to the peers under it.
245
415
  const turnSpan = inspector
246
416
  ? startAgentTurn(inspector, {
247
417
  agent: entry.name,
@@ -250,19 +420,26 @@ export function useA2ATeam(options) {
250
420
  })
251
421
  : undefined;
252
422
  const toolSpans = new Map();
423
+ // The peer a tool asks, by the tool's name.
424
+ const askedPeer = (toolName) => peersNow.current.find(one => one.askTool === toolName);
253
425
  try {
254
426
  const result = await agent.stream({
255
427
  messages: history.current,
256
428
  abortSignal: abort.current.signal,
257
429
  });
258
430
  for await (const part of result.fullStream) {
259
- if (part.type === 'tool-call' && part.toolName === askTool) {
431
+ const to = part.type === 'tool-call' ||
432
+ part.type === 'tool-result' ||
433
+ part.type === 'tool-error'
434
+ ? askedPeer(part.toolName)
435
+ : undefined;
436
+ if (part.type === 'tool-call' && to) {
260
437
  const request = String(part.input?.request ?? '');
261
438
  // Its tool runs here, in the page, and asks the peer over A2A.
262
439
  if (inspector && turnSpan) {
263
440
  toolSpans.set(part.toolCallId, startToolCall(inspector, {
264
441
  agent: entry.name,
265
- name: askTool,
442
+ name: to.askTool,
266
443
  id: part.toolCallId,
267
444
  args: part.input,
268
445
  tool: { kind: 'frontend' },
@@ -272,20 +449,20 @@ export function useA2ATeam(options) {
272
449
  setEntryPersona(prev => ({
273
450
  ...prev,
274
451
  state: 'waiting',
275
- saying: balloonLine(`${peerApp.name}, could you: ${request}`),
276
- full: `${peerApp.name}, could you: ${request}`,
452
+ saying: balloonLine(`${to.app.name}, could you: ${request}`),
453
+ full: `${to.app.name}, could you: ${request}`,
277
454
  insist: true,
278
- // Its one tool, in its own words: it asks its peer.
455
+ // Its tool, in its own words: it asks its peer.
279
456
  tool: {
280
457
  id: part.toolCallId,
281
- tool: askTool,
282
- name: askTool,
458
+ tool: to.askTool,
459
+ name: to.askTool,
283
460
  phase: 'running',
284
- words: `Asking ${peerApp.name}…`,
461
+ words: `Asking ${to.app.name}…`,
285
462
  },
286
463
  }));
287
464
  }
288
- else if (part.type === 'tool-result' && part.toolName === askTool) {
465
+ else if (part.type === 'tool-result' && to) {
289
466
  const output = part.output;
290
467
  const toolSpan = toolSpans.get(part.toolCallId);
291
468
  if (toolSpan) {
@@ -302,9 +479,9 @@ export function useA2ATeam(options) {
302
479
  full: undefined,
303
480
  tool: undefined,
304
481
  }));
305
- // Accounting has handed its answer over: back at rest, its last
482
+ // The peer has handed its answer over: back at rest, its last
306
483
  // words still in its balloon.
307
- setPeerPersona(prev => ({ ...prev, state: 'idle' }));
484
+ setPersona(to.id, prev => ({ ...prev, state: 'idle' }));
308
485
  }
309
486
  else if (part.type === 'text-delta') {
310
487
  said += part.text;
@@ -317,7 +494,7 @@ export function useA2ATeam(options) {
317
494
  tool: undefined,
318
495
  }));
319
496
  }
320
- else if (part.type === 'tool-error' && part.toolName === askTool) {
497
+ else if (part.type === 'tool-error' && to) {
321
498
  const toolSpan = toolSpans.get(part.toolCallId);
322
499
  if (toolSpan) {
323
500
  toolSpans.delete(part.toolCallId);
@@ -373,48 +550,83 @@ export function useA2ATeam(options) {
373
550
  insist: true,
374
551
  tool: undefined,
375
552
  }));
376
- setFlow('still');
553
+ setFlows({});
377
554
  callsNow.current = [];
378
555
  setCalls([]);
379
556
  }
380
557
  finally {
381
558
  abort.current = null;
382
559
  setBusy(false);
383
- // However the turn ended, neither member is still at work.
384
- setPeerPersona(prev => prev.state === 'idle' && !prev.tool
385
- ? prev
386
- : { ...prev, state: 'idle', tool: undefined });
560
+ // However the turn ended, no member is still at work.
561
+ setPersonas(prev => Object.fromEntries(Object.entries(prev).map(([id, one]) => [
562
+ id,
563
+ one.state === 'idle' && !one.tool
564
+ ? one
565
+ : { ...one, state: 'idle', tool: undefined },
566
+ ])));
387
567
  }
388
- }, [agent, busy, askTool, peerApp.name, entry, inspector]);
568
+ }, [
569
+ agent,
570
+ busy,
571
+ entry,
572
+ inspector,
573
+ entryPeer,
574
+ sendOverA2A,
575
+ setPersona,
576
+ peersAtRest,
577
+ ]);
389
578
  const stop = useCallback(() => abort.current?.abort(), []);
390
579
  useEffect(() => () => {
391
580
  abort.current?.abort();
392
- clearTimeout(flowTimer.current);
581
+ flowTimers.current.forEach(timer => clearTimeout(timer));
393
582
  clearTimeout(callTimer.current);
394
583
  }, []);
395
584
  const setEntryAway = useCallback((away) => setEntryPersona(prev => ({ ...prev, away })), []);
396
- const setPeerAway = useCallback((away) => setPeerPersona(prev => ({ ...prev, away })), []);
585
+ const setAwayOf = useCallback((id, away) => setPersona(id, prev => ({ ...prev, away })), [setPersona]);
586
+ const setPeerAway = useCallback((away) => {
587
+ const first = peersNow.current[0];
588
+ if (first) {
589
+ setAwayOf(first.id, away);
590
+ }
591
+ }, [setAwayOf]);
397
592
  const entryHistory = useMemo(() => turns.map((turn, index) => ({
398
593
  id: `turn-${index}`,
399
594
  role: turn.role,
400
595
  content: turn.text,
401
596
  createdAt: new Date(0),
402
597
  })), [turns]);
598
+ const NONE = useMemo(() => [], []);
599
+ const teamPeers = useMemo(() => peerList.map(one => ({
600
+ id: one.id,
601
+ app: one.app,
602
+ askTool: one.askTool,
603
+ connections: one.connections,
604
+ connected: one.peer !== null,
605
+ persona: personaOf(one.id),
606
+ history: histories[one.id] ?? NONE,
607
+ report: reports[one.id] ?? null,
608
+ artifacts: givens[one.id] ?? [],
609
+ flow: flows[one.id] ?? 'still',
610
+ setAway: (away) => setAwayOf(one.id, away),
611
+ })), [peerList, personaOf, histories, reports, givens, flows, setAwayOf, NONE]);
612
+ const first = teamPeers[0];
403
613
  return {
404
614
  entryHistory,
405
- peerHistory,
615
+ peerHistory: first?.history ?? NONE,
406
616
  entryPersona,
407
- peerPersona,
617
+ peerPersona: first?.persona ?? AT_REST,
408
618
  setEntryAway,
409
619
  setPeerAway,
410
620
  turns,
621
+ peers: teamPeers,
622
+ flows,
411
623
  report,
412
624
  artifacts,
413
625
  notebook,
414
- flow,
626
+ flow: first?.flow ?? 'still',
415
627
  calls,
416
628
  busy,
417
- ready: agent !== null,
629
+ ready: entryPeer ? true : agent !== null,
418
630
  send,
419
631
  stop,
420
632
  };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The scenes of the catalogue (agentspecs `scenes`, LOOP A-15), in tabs:
3
+ * each scene's face and name, its `assumes` line under them, its graph and
4
+ * its transcript behind *Graph · Transcript* (`SceneView`), its members
5
+ * standing where its stage directions put them (`positions`), the entry's
6
+ * balloon open first (`opens_first`), and the script's cues as the entry's
7
+ * suggestions. A scene is tried here before it is deployed anywhere.
8
+ *
9
+ * Played against the local servers (`npm run examples`): the entry in the
10
+ * browser asks its model through the examples' inference — a signed-in
11
+ * person's token or a visitor's trial key — and the members on a runtime
12
+ * are served over A2A by `examples/sales-accounting-a2a/serve_scenes.py`,
13
+ * Accounting on :8767 as before and each other member on a runtime of its
14
+ * own from :8768 (`sceneRuntimePorts`), or wherever `VITE_A2A_<MEMBER>_URL`
15
+ * says. A scene whose entry runs on a runtime itself (Month-end close, Crop
16
+ * monitoring) is asked over A2A from the page, as the person
17
+ * (`useA2ATeam`'s `entryPeer`). A member that is not reached is drawn all
18
+ * the same, and the box says what its application needs (`setup`) and how
19
+ * to serve it, rather than failing.
20
+ *
21
+ * Right-click a member for its menu: *Inspect the agent…* opens its Agent
22
+ * Inspector over the page, on the one tracer every member records into.
23
+ */
24
+ import type { JSX } from 'react';
25
+ export declare function ScenesExample(): JSX.Element;
26
+ export default ScenesExample;