@datalayer/agent-runtimes 1.3.66 → 1.3.68

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.
@@ -9,6 +9,13 @@
9
9
  * who asks for no motion sees the same thing without the movement: the arrow
10
10
  * at the end the message goes to, and the word for it on the edge.
11
11
  *
12
+ * A member's connections — the MCP servers it reaches, Odoo for Accounting —
13
+ * hang under it, each a node half a member's size with the server's mark
14
+ * (`SpecMark`), linked to it by an edge of its own. That edge is still until
15
+ * the member calls one of the connection's tools: then it flows toward the
16
+ * connection for as long as the call runs ({@link A2ATeamCall}), with the
17
+ * tool's name on it; under reduced motion, the arrow and the words alone.
18
+ *
12
19
  * The graph is a picture, not an editor: nothing is dragged, panned or
13
20
  * zoomed, and the wheel scrolls the page. Its viewport is worked out from its
14
21
  * box, so it fits a phone as it fits a desk.
@@ -16,9 +23,9 @@
16
23
  * @module components/teams/A2ATeamGraph
17
24
  */
18
25
  import type { JSX } from 'react';
19
- import { type Viewport } from '@xyflow/react';
26
+ import { type Edge, type EdgeProps, type Viewport } from '@xyflow/react';
20
27
  import '@xyflow/react/dist/base.css';
21
- import type { A2ATeamFlow } from './a2aTeamFlow';
28
+ import { type A2ATeamCall, type A2ATeamConnection, type A2ATeamFlow } from './a2aTeamFlow';
22
29
  import type { A2ATeamPersona } from './useA2ATeam';
23
30
  /** One member of the team, as the graph draws it. */
24
31
  export type A2ATeamGraphMember = {
@@ -34,6 +41,8 @@ export type A2ATeamGraphMember = {
34
41
  onToggle?: () => void;
35
42
  /** It was sent away, or called back. */
36
43
  onAway?: (away: boolean) => void;
44
+ /** The MCP servers it reaches, drawn under it (`teamConnectionsOf`). */
45
+ connections?: A2ATeamConnection[];
37
46
  };
38
47
  export type A2ATeamGraphProps = {
39
48
  /** The member that asks: drawn on the left. */
@@ -48,11 +57,21 @@ export type A2ATeamGraphProps = {
48
57
  label?: string;
49
58
  /** The character's size, in pixels at full scale. */
50
59
  size?: number;
60
+ /** The tool calls running now, to the members' connections (`useA2ATeam`). */
61
+ calls?: A2ATeamCall[];
51
62
  };
52
63
  /** The words on the edge for a flow. */
53
64
  export declare function flowWords(flow: A2ATeamFlow, entry: string, peer: string): string;
65
+ type CallData = {
66
+ /** The tool being called, in words (`list invoices`); empty while none is. */
67
+ calling: string;
68
+ };
69
+ /** A member's link to a connection: still, or flowing toward it while a call runs. */
70
+ export declare const CallEdge: import("react").NamedExoticComponent<EdgeProps<Edge<CallData>>>;
71
+ /** What a member's call says, on its edge and to a screen reader. */
72
+ export declare function callWords(member: string, connection: A2ATeamConnection, tool: string): string;
54
73
  /** The viewport that shows the graph's width whole in a box `width` wide, never larger than drawn. */
55
74
  export declare function teamViewport(width: number): Viewport;
56
75
  /** Draw a team of two over A2A, the link moving with what travels on it. */
57
- export declare function A2ATeamGraph({ entry, peer, flow, connected, label, size, }: A2ATeamGraphProps): JSX.Element;
76
+ export declare function A2ATeamGraph({ entry, peer, flow, connected, label, size, calls, }: A2ATeamGraphProps): JSX.Element;
58
77
  export default A2ATeamGraph;
@@ -5,11 +5,18 @@ import '@xyflow/react/dist/base.css';
5
5
  import { Button, Label, Text } from '@primer/react';
6
6
  import { Box } from '@datalayer/primer-addons';
7
7
  import { AssistantStage } from '../../chat/assistant/AssistantStage';
8
+ import { SpecMark } from '../../chat/marks/SpecMark';
9
+ import { toolWords, } from './a2aTeamFlow';
8
10
  /** The graph's own coordinates: the members, and room above them for their balloons. */
9
11
  const NODE_WIDTH = 200;
10
12
  const BALLOON_ROOM = 120;
11
13
  const GAP = 220;
12
14
  const WIDTH = NODE_WIDTH * 2 + GAP;
15
+ /** A connection's node: half a member's. */
16
+ const CONNECTION_WIDTH = NODE_WIDTH / 2;
17
+ /** Between a member and its connections. */
18
+ const CONNECTION_GAP = 44;
19
+ const CONNECTIONS_APART = 16;
13
20
  /** Where the edge leaves and reaches a member: the middle of its character. */
14
21
  const HANDLE = (size) => ({
15
22
  opacity: 0,
@@ -28,6 +35,10 @@ const HANDLE = (size) => ({
28
35
  * moved. The nodes stay the same objects; what they show is read from here.
29
36
  */
30
37
  const Members = createContext({});
38
+ /** The connections being called now, by node id: read by their nodes, as the members are. */
39
+ const Busy = createContext({});
40
+ /** A connection's node id: under its member. */
41
+ const connectionNodeId = (member, connection) => `${member}/${connection}`;
31
42
  const MemberNode = memo(function MemberNode({ data, }) {
32
43
  const { id, side, size } = data;
33
44
  const member = useContext(Members)[id];
@@ -61,7 +72,35 @@ const MemberNode = memo(function MemberNode({ data, }) {
61
72
  ? { position: 'absolute', left: 'auto' }
62
73
  : { position: 'absolute', right: 'auto' }, stageRef: stageRef, onDragStart: () => undefined, open: false, onToggle: member.onToggle ?? (() => undefined), balloon: persona.saying
63
74
  ? { text: persona.saying, more: persona.saying.endsWith('…') }
64
- : undefined, insist: persona.insist, onDismiss: away => member.onAway?.(away !== 'none') })) }), _jsxs(Text, { sx: { fontWeight: 600, textAlign: 'center' }, children: [member.emoji ? `${member.emoji} ` : '', member.name] }), _jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', textAlign: 'center' }, "data-member-where": "", children: member.where }), _jsx(Handle, { type: "source", position: Position.Right, style: HANDLE(size), isConnectable: false })] }));
75
+ : undefined, insist: persona.insist, onDismiss: away => member.onAway?.(away !== 'none') })) }), _jsxs(Text, { sx: { fontWeight: 600, textAlign: 'center' }, children: [member.emoji ? `${member.emoji} ` : '', member.name] }), _jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', textAlign: 'center' }, "data-member-where": "", children: member.where }), _jsx(Handle, { type: "source", position: Position.Right, style: HANDLE(size), isConnectable: false }), member.connections?.length ? (_jsx(Handle, { id: "connections", type: "source", position: Position.Bottom, style: { ...HANDLE(size), top: 'auto', bottom: 0 }, isConnectable: false })) : null] }));
76
+ });
77
+ /** A connection of a member's: the server's mark, what it reaches, and how. */
78
+ const ConnectionNode = memo(function ConnectionNode({ id, data, }) {
79
+ const { member, connection: connectionId, size } = data;
80
+ const connection = useContext(Members)[member]?.connections?.find(known => known.id === connectionId);
81
+ const tool = useContext(Busy)[id];
82
+ if (!connection) {
83
+ return null;
84
+ }
85
+ return (_jsxs(Box, { className: "nodrag nopan nowheel", sx: {
86
+ width: CONNECTION_WIDTH,
87
+ display: 'flex',
88
+ flexDirection: 'column',
89
+ alignItems: 'center',
90
+ gap: '2px',
91
+ cursor: 'default',
92
+ pointerEvents: 'all',
93
+ }, title: connection.name, "data-team-connection": connection.id, "data-connection-busy": tool ? 'true' : 'false', children: [_jsx(Handle, { type: "target", position: Position.Top, style: { ...HANDLE(0), top: 0 }, isConnectable: false }), _jsx(Box, { className: tool ? 'a2a-team-connection-busy' : undefined, sx: {
94
+ width: size,
95
+ height: size,
96
+ display: 'flex',
97
+ alignItems: 'center',
98
+ justifyContent: 'center',
99
+ borderRadius: 2,
100
+ border: '1px solid',
101
+ borderColor: tool ? 'accent.emphasis' : 'border.default',
102
+ bg: 'canvas.default',
103
+ }, children: _jsx(SpecMark, { icon: connection.icon, emoji: connection.emoji, size: Math.round(size * 0.66) }) }), _jsx(Text, { sx: { fontWeight: 600, fontSize: 1, textAlign: 'center' }, children: connection.label }), connection.via && (_jsx(Text, { sx: { fontSize: '10px', color: 'fg.muted', textAlign: 'center' }, "data-connection-via": "", children: connection.via }))] }));
65
104
  });
66
105
  /** The words on the edge for a flow. */
67
106
  export function flowWords(flow, entry, peer) {
@@ -94,15 +133,42 @@ const LinkEdge = memo(function LinkEdge({ id, sourceX, sourceY, targetX, targetY
94
133
  textAlign: 'center',
95
134
  }, children: _jsx(Label, { variant: data?.connected ? 'accent' : 'secondary', children: data?.label }) }) })] }));
96
135
  });
97
- const NODE_TYPES = { member: MemberNode };
98
- const EDGE_TYPES = { a2a: LinkEdge };
136
+ /** A member's link to a connection: still, or flowing toward it while a call runs. */
137
+ export const CallEdge = memo(function CallEdge({ id, sourceX, sourceY, targetX, targetY, data, }) {
138
+ const calling = Boolean(data?.calling);
139
+ const path = `M ${sourceX},${sourceY} L ${targetX},${targetY}`;
140
+ const arrow = `M ${targetX},${targetY} L ${targetX - ARROW / 1.6},${targetY - ARROW} L ${targetX + ARROW / 1.6},${targetY - ARROW} Z`;
141
+ return (_jsxs(_Fragment, { children: [_jsx(BaseEdge, { id: id, path: path, style: {
142
+ stroke: 'var(--borderColor-default, #d0d7de)',
143
+ strokeWidth: 2,
144
+ opacity: calling ? 0.3 : 0.8,
145
+ } }), calling && (_jsxs("g", { "data-mcp-edge-call": data?.calling, children: [_jsx("path", { d: path, fill: "none", className: "a2a-team-flow a2a-team-call", stroke: "var(--fgColor-accent, #0969da)", strokeWidth: 3, strokeLinecap: "round", strokeDasharray: "6 8" }), _jsx("path", { d: arrow, fill: "var(--fgColor-accent, #0969da)" })] })), calling && (_jsx(EdgeLabelRenderer, { children: _jsx(Box, { className: "nodrag nopan", sx: {
146
+ position: 'absolute',
147
+ // Beside the edge, toward the middle of the graph.
148
+ transform: `translate(calc(-100% - 10px), -50%) translate(${(sourceX + targetX) / 2}px, ${(sourceY + targetY) / 2}px)`,
149
+ pointerEvents: 'none',
150
+ whiteSpace: 'nowrap',
151
+ }, children: _jsx(Label, { variant: "accent", children: data?.calling }) }) }))] }));
152
+ });
153
+ const NODE_TYPES = { member: MemberNode, connection: ConnectionNode };
154
+ const EDGE_TYPES = { a2a: LinkEdge, mcp: CallEdge };
155
+ /** Where a member's connections sit: under it, side by side, centred. */
156
+ function connectionsX(memberX, count) {
157
+ const row = count * CONNECTION_WIDTH + (count - 1) * CONNECTIONS_APART;
158
+ const left = memberX + (NODE_WIDTH - row) / 2;
159
+ return Array.from({ length: count }, (_, at) => left + at * (CONNECTION_WIDTH + CONNECTIONS_APART));
160
+ }
161
+ /** What a member's call says, on its edge and to a screen reader. */
162
+ export function callWords(member, connection, tool) {
163
+ return `${member} calls ${connection.label} · ${toolWords(tool, connection)}`;
164
+ }
99
165
  /** The viewport that shows the graph's width whole in a box `width` wide, never larger than drawn. */
100
166
  export function teamViewport(width) {
101
167
  const zoom = Math.min(1, width / WIDTH);
102
168
  return { zoom, x: (width - WIDTH * zoom) / 2, y: 0 };
103
169
  }
104
170
  /** Draw a team of two over A2A, the link moving with what travels on it. */
105
- export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size = 96, }) {
171
+ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size = 96, calls = [], }) {
106
172
  const box = useRef(null);
107
173
  const [width, setWidth] = useState(0);
108
174
  useEffect(() => {
@@ -122,7 +188,21 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
122
188
  const viewport = useMemo(() => teamViewport(width || WIDTH), [width]);
123
189
  // The members' height: the character, its name and where it runs.
124
190
  const memberHeight = size + 56;
125
- const height = Math.ceil((BALLOON_ROOM + memberHeight) * viewport.zoom);
191
+ const connectionSize = Math.round(size / 2);
192
+ // Its mark, its label and how it is reached (about 45px under the mark),
193
+ // and room under it before the graph's edge.
194
+ const connectionHeight = connectionSize + 58;
195
+ // The connections' ids, member by member: the nodes change only when they do.
196
+ const placed = [entry, peer].map(member => ({
197
+ member: member.id,
198
+ connections: (member.connections ?? []).map(connection => connection.id),
199
+ }));
200
+ const placedKey = JSON.stringify(placed);
201
+ const hasConnections = placed.some(({ connections }) => connections.length);
202
+ const height = Math.ceil((BALLOON_ROOM +
203
+ memberHeight +
204
+ (hasConnections ? CONNECTION_GAP + connectionHeight : 0)) *
205
+ viewport.zoom);
126
206
  const nodes = useMemo(() => [
127
207
  {
128
208
  id: entry.id,
@@ -147,9 +227,50 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
147
227
  zIndex: 10,
148
228
  data: { id: peer.id, side: 'right', size },
149
229
  },
150
- ], [entry.id, peer.id, size, memberHeight]);
230
+ ...JSON.parse(placedKey).flatMap(({ member, connections }, side) => {
231
+ const xs = connectionsX(side === 0 ? 0 : NODE_WIDTH + GAP, connections.length);
232
+ return connections.map((connection, at) => ({
233
+ id: connectionNodeId(member, connection),
234
+ type: 'connection',
235
+ position: {
236
+ x: xs[at],
237
+ y: BALLOON_ROOM + memberHeight + CONNECTION_GAP,
238
+ },
239
+ width: CONNECTION_WIDTH,
240
+ height: connectionHeight,
241
+ draggable: false,
242
+ selectable: false,
243
+ zIndex: 5,
244
+ data: { member, connection, size: connectionSize },
245
+ }));
246
+ }),
247
+ ], [
248
+ entry.id,
249
+ peer.id,
250
+ size,
251
+ memberHeight,
252
+ placedKey,
253
+ connectionSize,
254
+ connectionHeight,
255
+ ]);
151
256
  const members = useMemo(() => ({ [entry.id]: entry, [peer.id]: peer }), [entry, peer]);
152
257
  const words = flowWords(flow, entry.name, peer.name);
258
+ // The tool each connection is answering now, in words, by its node's id.
259
+ const busy = useMemo(() => {
260
+ const byNode = {};
261
+ const said = [];
262
+ for (const call of calls) {
263
+ const member = call.member === entry.id ? entry : peer;
264
+ const connection = member.connections?.find(known => known.id === call.connection);
265
+ if (!connection || member.id !== call.member) {
266
+ continue;
267
+ }
268
+ const node = connectionNodeId(member.id, connection.id);
269
+ byNode[node] ??= toolWords(call.tool, connection);
270
+ said.push(callWords(member.name, connection, call.tool));
271
+ }
272
+ return { byNode, said: [...new Set(said)] };
273
+ }, [calls, entry, peer]);
153
274
  const edges = useMemo(() => [
154
275
  {
155
276
  id: `${entry.id}->${peer.id}`,
@@ -164,8 +285,23 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
164
285
  label: words ? `${label} · ${words}` : label,
165
286
  },
166
287
  },
167
- ], [entry.id, peer.id, flow, connected, label, words]);
168
- return (_jsxs(Box, { ref: box, "data-a2a-team-graph": "", "data-a2a-flow": flow, sx: {
288
+ ...JSON.parse(placedKey).flatMap(({ member, connections }) => connections.map(connection => {
289
+ const node = connectionNodeId(member, connection);
290
+ const calling = busy.byNode[node] ?? '';
291
+ return {
292
+ id: `${member}->${node}`,
293
+ source: member,
294
+ sourceHandle: 'connections',
295
+ target: node,
296
+ type: 'mcp',
297
+ selectable: false,
298
+ focusable: false,
299
+ data: { calling },
300
+ };
301
+ })),
302
+ ], [entry.id, peer.id, flow, connected, label, words, placedKey, busy]);
303
+ const heard = [words, ...busy.said].filter(Boolean).join('. ');
304
+ return (_jsxs(Box, { ref: box, "data-a2a-team-graph": "", "data-a2a-flow": flow, "data-a2a-calls": calls.length, sx: {
169
305
  width: '100%',
170
306
  height,
171
307
  position: 'relative',
@@ -179,17 +315,32 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
179
315
  '& .a2a-team-flow-answering': {
180
316
  animation: 'a2aTeamFlow 0.6s linear infinite reverse',
181
317
  },
318
+ '@keyframes a2aTeamCall': {
319
+ from: { strokeDashoffset: 14 },
320
+ to: { strokeDashoffset: 0 },
321
+ },
322
+ '@keyframes a2aTeamPulse': {
323
+ '0%, 100%': { boxShadow: '0 0 0 0 transparent' },
324
+ '50%': {
325
+ boxShadow: '0 0 0 4px var(--bgColor-accent-muted, #ddf4ff)',
326
+ },
327
+ },
328
+ '& .a2a-team-call': {
329
+ animation: 'a2aTeamCall 0.5s linear infinite',
330
+ },
331
+ '& .a2a-team-connection-busy': {
332
+ animation: 'a2aTeamPulse 1s ease-in-out infinite',
333
+ },
182
334
  // The arrow and the words say which way; the movement is extra.
183
335
  '@media (prefers-reduced-motion: reduce)': {
184
- '& .a2a-team-flow': { animation: 'none' },
336
+ '& .a2a-team-flow, & .a2a-team-connection-busy': {
337
+ animation: 'none',
338
+ },
185
339
  },
186
340
  '& .react-flow, & .react-flow__renderer, & .react-flow__viewport': {
187
341
  overflow: 'visible',
188
342
  },
189
343
  '& .react-flow__node': { cursor: 'default' },
190
- // The library's credit stays, quietly (as the landing's network does).
191
- '& .react-flow__attribution': { bg: 'transparent', color: 'fg.muted' },
192
- '& .react-flow__attribution a': { color: 'fg.muted' },
193
344
  }, children: [_jsx(Box, { as: "span", "aria-live": "polite", sx: {
194
345
  position: 'absolute',
195
346
  width: 1,
@@ -197,6 +348,8 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
197
348
  overflow: 'hidden',
198
349
  clip: 'rect(0 0 0 0)',
199
350
  whiteSpace: 'nowrap',
200
- }, children: words }), _jsx(Members.Provider, { value: members, children: _jsx(ReactFlow, { nodes: nodes, edges: edges, nodeTypes: NODE_TYPES, edgeTypes: EDGE_TYPES, viewport: viewport, nodesDraggable: false, nodesConnectable: false, nodesFocusable: false, edgesFocusable: false, elementsSelectable: false, deleteKeyCode: null, selectionKeyCode: null, multiSelectionKeyCode: null, panOnDrag: false, panOnScroll: false, zoomOnScroll: false, zoomOnPinch: false, zoomOnDoubleClick: false, preventScrolling: false, style: { overflow: 'visible', background: 'transparent' }, "aria-label": `${entry.name} and ${peer.name}, over A2A` }) })] }));
351
+ }, children: heard }), _jsx(Members.Provider, { value: members, children: _jsx(Busy.Provider, { value: busy.byNode, children: _jsx(ReactFlow, { nodes: nodes, edges: edges, nodeTypes: NODE_TYPES, edgeTypes: EDGE_TYPES, viewport: viewport, nodesDraggable: false, nodesConnectable: false, nodesFocusable: false, edgesFocusable: false, elementsSelectable: false, deleteKeyCode: null, selectionKeyCode: null, multiSelectionKeyCode: null, panOnDrag: false, panOnScroll: false, zoomOnScroll: false, zoomOnPinch: false, zoomOnDoubleClick: false, preventScrolling: false, style: { overflow: 'visible', background: 'transparent' },
352
+ // The library is MIT: its credit is not required on the page.
353
+ proOptions: { hideAttribution: true }, "aria-label": `${entry.name} and ${peer.name}, over A2A` }) }) })] }));
201
354
  }
202
355
  export default A2ATeamGraph;
@@ -31,3 +31,63 @@ export declare function flowEnds(flow: A2ATeamFlow, entry: string, peer: string)
31
31
  from: string;
32
32
  to: string;
33
33
  } | undefined;
34
+ /**
35
+ * A member's connection, as the graph draws it: an MCP server it reaches,
36
+ * with its mark, and the tools that are its (`teamConnectionsOf` reads them
37
+ * from the member's Appspec and the server's spec).
38
+ */
39
+ export type A2ATeamConnection = {
40
+ /** The MCP server's id: `odoo-accounting`. */
41
+ id: string;
42
+ /** The server's name: `Odoo Accounting`. */
43
+ name: string;
44
+ /** What the node says: the system it reaches, `Odoo`. */
45
+ label: string;
46
+ /** Its mark, `<package>:<name>`, as `SpecMark` draws it. */
47
+ icon?: string;
48
+ emoji?: string;
49
+ /** How it is reached, in small words under the label: `via MCP`. */
50
+ via?: string;
51
+ /** Its tools, by name: `odoo_accounting_list_invoices`. */
52
+ tools: string[];
53
+ /** What its tools' names begin with when its spec lists none: `odoo_accounting_`. */
54
+ prefix?: string;
55
+ };
56
+ /** A tool call a member's connection is answering now. */
57
+ export type A2ATeamCall = {
58
+ /** The member that calls. */
59
+ member: string;
60
+ /** The connection called. */
61
+ connection: string;
62
+ /** The tool, as the runtime names it. */
63
+ tool: string;
64
+ /** The call's id, when the runtime gives one. */
65
+ id?: string;
66
+ /** When it started, in milliseconds. */
67
+ started: number;
68
+ /** Ended, but shown until it has been shown {@link CALL_SHOWN_MS}. */
69
+ ended?: boolean;
70
+ };
71
+ /** The least time a call is shown, in milliseconds: a quick one still reads. */
72
+ export declare const CALL_SHOWN_MS = 900;
73
+ /**
74
+ * The connection a tool is of: one whose tools name it (a toolset's prefix
75
+ * before the name is allowed, as a host may add one), else one whose
76
+ * prefix it begins with.
77
+ */
78
+ export declare function connectionOfTool(tool: string, connections: A2ATeamConnection[]): A2ATeamConnection | undefined;
79
+ /** A tool's name in a few words, without its connection's prefix: `list invoices`. */
80
+ export declare function toolWords(tool: string, connection?: A2ATeamConnection): string;
81
+ /**
82
+ * The calls running after an event of the peer's (`member`): a call it
83
+ * starts to one of its connections is added; its end marks it ended, and
84
+ * drops it once it has been shown long enough (else `holdMs` says when to
85
+ * look again, with {@link pruneCalls}). A new request, an answer or a
86
+ * failure ends them all.
87
+ */
88
+ export declare function callsAfter(calls: A2ATeamCall[], event: A2APeerEvent, member: string, connections: A2ATeamConnection[], now: number): {
89
+ calls: A2ATeamCall[];
90
+ holdMs?: number;
91
+ };
92
+ /** The calls without those ended and shown long enough. */
93
+ export declare function pruneCalls(calls: A2ATeamCall[], now: number): A2ATeamCall[];
@@ -30,3 +30,79 @@ export function flowEnds(flow, entry, peer) {
30
30
  }
31
31
  return undefined;
32
32
  }
33
+ /** The least time a call is shown, in milliseconds: a quick one still reads. */
34
+ export const CALL_SHOWN_MS = 900;
35
+ /**
36
+ * The connection a tool is of: one whose tools name it (a toolset's prefix
37
+ * before the name is allowed, as a host may add one), else one whose
38
+ * prefix it begins with.
39
+ */
40
+ export function connectionOfTool(tool, connections) {
41
+ return (connections.find(connection => connection.tools.some(name => tool === name ||
42
+ tool.endsWith(`_${name}`) ||
43
+ tool.endsWith(`.${name}`))) ??
44
+ connections.find(connection => connection.prefix && tool.includes(connection.prefix)));
45
+ }
46
+ /** A tool's name in a few words, without its connection's prefix: `list invoices`. */
47
+ export function toolWords(tool, connection) {
48
+ let name = tool;
49
+ const prefix = connection?.prefix;
50
+ if (prefix && name.includes(prefix)) {
51
+ name = name.slice(name.indexOf(prefix) + prefix.length);
52
+ }
53
+ return name.replace(/[_-]+/g, ' ').trim() || tool;
54
+ }
55
+ /**
56
+ * The calls running after an event of the peer's (`member`): a call it
57
+ * starts to one of its connections is added; its end marks it ended, and
58
+ * drops it once it has been shown long enough (else `holdMs` says when to
59
+ * look again, with {@link pruneCalls}). A new request, an answer or a
60
+ * failure ends them all.
61
+ */
62
+ export function callsAfter(calls, event, member, connections, now) {
63
+ if (event.phase !== 'working') {
64
+ return { calls: [] };
65
+ }
66
+ const step = event.tool;
67
+ if (!step) {
68
+ return { calls };
69
+ }
70
+ const connection = connectionOfTool(step.name, connections);
71
+ if (!connection) {
72
+ return { calls };
73
+ }
74
+ if (!step.ended) {
75
+ return {
76
+ calls: [
77
+ ...calls,
78
+ {
79
+ member,
80
+ connection: connection.id,
81
+ tool: step.name,
82
+ ...(step.id ? { id: step.id } : {}),
83
+ started: now,
84
+ },
85
+ ],
86
+ };
87
+ }
88
+ const index = calls.findIndex(call => !call.ended &&
89
+ call.connection === connection.id &&
90
+ (step.id && call.id ? call.id === step.id : call.tool === step.name));
91
+ if (index < 0) {
92
+ return { calls };
93
+ }
94
+ const call = calls[index];
95
+ const left = call.started + CALL_SHOWN_MS - now;
96
+ if (left <= 0) {
97
+ return { calls: calls.filter((_, at) => at !== index) };
98
+ }
99
+ return {
100
+ calls: calls.map((c, at) => (at === index ? { ...c, ended: true } : c)),
101
+ holdMs: left,
102
+ };
103
+ }
104
+ /** The calls without those ended and shown long enough. */
105
+ export function pruneCalls(calls, now) {
106
+ const kept = calls.filter(call => !call.ended || call.started + CALL_SHOWN_MS > now);
107
+ return kept.length === calls.length ? calls : kept;
108
+ }
@@ -7,5 +7,6 @@
7
7
  * @module components/teams
8
8
  */
9
9
  export * from './a2aTeamFlow';
10
+ export * from './teamConnections';
10
11
  export * from './useA2ATeam';
11
12
  export * from './A2ATeamGraph';
@@ -11,5 +11,6 @@
11
11
  * @module components/teams
12
12
  */
13
13
  export * from './a2aTeamFlow';
14
+ export * from './teamConnections';
14
15
  export * from './useA2ATeam';
15
16
  export * from './A2ATeamGraph';
@@ -0,0 +1,8 @@
1
+ import type { AppSpec } from '../../types/agentspecs';
2
+ import type { A2ATeamConnection } from './a2aTeamFlow';
3
+ /**
4
+ * The connections of an application, each with its server's name and mark
5
+ * and its tools. A connection to a server the catalogue does not have is
6
+ * left out: there is nothing to draw it with.
7
+ */
8
+ export declare function teamConnectionsOf(app: Pick<AppSpec, 'connections'>): A2ATeamConnection[];
@@ -0,0 +1,49 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * A member's connections, as the team graph draws them, read from its
7
+ * Appspec: each connection names an MCP server, whose spec gives its name,
8
+ * its mark (`icon`, as `SpecMark` draws it) and, from the server's actions,
9
+ * the names of its tools.
10
+ *
11
+ * @module components/teams/teamConnections
12
+ */
13
+ import { MCP_SERVER_LIBRARY } from '../../specs/mcpServers';
14
+ import { SERVER_ACTIONS } from '../../specs/actions';
15
+ /** A server reference without its version: `odoo-accounting:0.0.1` is `odoo-accounting`. */
16
+ const idOf = (ref) => {
17
+ const at = ref.lastIndexOf(':');
18
+ return at > 0 && ref.slice(at + 1).includes('.') ? ref.slice(0, at) : ref;
19
+ };
20
+ /**
21
+ * The connections of an application, each with its server's name and mark
22
+ * and its tools. A connection to a server the catalogue does not have is
23
+ * left out: there is nothing to draw it with.
24
+ */
25
+ export function teamConnectionsOf(app) {
26
+ const connections = [];
27
+ for (const connection of app.connections ?? []) {
28
+ const id = idOf(connection.server);
29
+ const server = MCP_SERVER_LIBRARY[id];
30
+ if (!server || connections.some(known => known.id === id)) {
31
+ continue;
32
+ }
33
+ // A pattern (`odoo_accounting_*`) is not a name a call has.
34
+ const tools = Object.keys(SERVER_ACTIONS[id]?.tools ?? {}).filter(name => !/[*?[]/.test(name));
35
+ connections.push({
36
+ id,
37
+ name: server.name,
38
+ // The system it reaches: the first word of its name ("Odoo Accounting").
39
+ label: server.name.split(/\s+/)[0] || server.name,
40
+ ...(server.icon ? { icon: server.icon } : {}),
41
+ ...(server.emoji ? { emoji: server.emoji } : {}),
42
+ via: 'via MCP',
43
+ tools,
44
+ // The gateway names a toolset's tools after it: `odoo_accounting_…`.
45
+ prefix: `${id.replace(/-/g, '_')}_`,
46
+ });
47
+ }
48
+ return connections;
49
+ }
@@ -2,7 +2,7 @@ import type { AssistantState } from '../../chat/assistant/state';
2
2
  import { type BrowserModelOptions } from '../../runtimes/browser/model';
3
3
  import { type A2APeer } from '../../runtimes/browser/a2aPeer';
4
4
  import type { AppSpec } from '../../types/agentspecs';
5
- import { type A2ATeamFlow } from './a2aTeamFlow';
5
+ import { type A2ATeamCall, type A2ATeamConnection, type A2ATeamFlow } from './a2aTeamFlow';
6
6
  /** What one member's character does: its state, its balloon, and whether it was sent away. */
7
7
  export type A2ATeamPersona = {
8
8
  state: AssistantState;
@@ -33,6 +33,8 @@ export type UseA2ATeamOptions = {
33
33
  askTool?: string;
34
34
  /** The most steps of one turn. */
35
35
  maxSteps?: number;
36
+ /** The peer's connections (`teamConnectionsOf(peerApp)`): the calls to them are kept. */
37
+ peerConnections?: A2ATeamConnection[];
36
38
  };
37
39
  export type A2ATeam = {
38
40
  entryPersona: A2ATeamPersona;
@@ -46,6 +48,8 @@ export type A2ATeam = {
46
48
  report: string | null;
47
49
  /** Which way the link carries a message now. */
48
50
  flow: A2ATeamFlow;
51
+ /** The tool calls the peer's connections are answering now. */
52
+ calls: A2ATeamCall[];
49
53
  /** Whether a turn is under way. */
50
54
  busy: boolean;
51
55
  /** Whether the entry can be asked: its peer is connected. */
@@ -13,7 +13,9 @@
13
13
  * {@link a2aPeerTool}. What each of them does is kept as a persona — the
14
14
  * state its character acts and what its balloon says — and the exchange
15
15
  * between them as a log, the peer's last answer as the report, and the way
16
- * the link carries a message as the flow ({@link flowAfter}).
16
+ * the link carries a message as the flow ({@link flowAfter}). The tools the
17
+ * peer calls on its connections (its MCP servers), told over A2A as it calls
18
+ * them, are kept as the calls running now ({@link callsAfter}).
17
19
  *
18
20
  * What `A2ATeamGraph` draws, and what a page's composer sends.
19
21
  *
@@ -23,7 +25,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
23
25
  import { stepCountIs, ToolLoopAgent } from 'ai';
24
26
  import { createBrowserModel, } from '../../runtimes/browser/model';
25
27
  import { a2aPeerTool, } from '../../runtimes/browser/a2aPeer';
26
- import { flowAfter } from './a2aTeamFlow';
28
+ import { callsAfter, flowAfter, pruneCalls, } from './a2aTeamFlow';
29
+ const NO_CONNECTIONS = [];
27
30
  export const AT_REST = {
28
31
  state: 'idle',
29
32
  insist: false,
@@ -36,7 +39,7 @@ export function balloonLine(text, length = 120) {
36
39
  }
37
40
  /** Run a team of two over A2A in the page. */
38
41
  export function useA2ATeam(options) {
39
- const { entry, peerApp, peer, inference, maxSteps = 6 } = options;
42
+ const { entry, peerApp, peer, inference, maxSteps = 6, peerConnections = NO_CONNECTIONS, } = options;
40
43
  const askTool = options.askTool ?? `ask_${peerApp.id.replace(/-/g, '_')}`;
41
44
  const [entryPersona, setEntryPersona] = useState(() => ({
42
45
  ...AT_REST,
@@ -49,6 +52,9 @@ export function useA2ATeam(options) {
49
52
  const [report, setReport] = useState(null);
50
53
  const [exchange, setExchange] = useState([]);
51
54
  const [flow, setFlow] = useState('still');
55
+ const [calls, setCalls] = useState([]);
56
+ const callsNow = useRef([]);
57
+ const callTimer = useRef(undefined);
52
58
  const [busy, setBusy] = useState(false);
53
59
  const history = useRef([]);
54
60
  const abort = useRef(null);
@@ -61,6 +67,18 @@ export function useA2ATeam(options) {
61
67
  if (next.holdMs) {
62
68
  flowTimer.current = setTimeout(() => setFlow('still'), next.holdMs);
63
69
  }
70
+ // The calls to the peer's connections: a call's end, when it came
71
+ // quickly, is shown a moment longer.
72
+ const after = callsAfter(callsNow.current, event, peerApp.id, peerConnections, Date.now());
73
+ callsNow.current = after.calls;
74
+ setCalls(after.calls);
75
+ if (after.holdMs) {
76
+ clearTimeout(callTimer.current);
77
+ callTimer.current = setTimeout(() => {
78
+ callsNow.current = pruneCalls(callsNow.current, Date.now());
79
+ setCalls(callsNow.current);
80
+ }, after.holdMs);
81
+ }
64
82
  if (event.phase === 'asked') {
65
83
  setExchange(prev => [
66
84
  ...prev,
@@ -109,7 +127,7 @@ export function useA2ATeam(options) {
109
127
  insist: true,
110
128
  }));
111
129
  }
112
- }, [entry.name, peerApp.name]);
130
+ }, [entry.name, peerApp.id, peerApp.name, peerConnections]);
113
131
  const agent = useMemo(() => {
114
132
  if (!peer) {
115
133
  return null;
@@ -203,6 +221,8 @@ export function useA2ATeam(options) {
203
221
  insist: true,
204
222
  }));
205
223
  setFlow('still');
224
+ callsNow.current = [];
225
+ setCalls([]);
206
226
  }
207
227
  finally {
208
228
  abort.current = null;
@@ -213,6 +233,7 @@ export function useA2ATeam(options) {
213
233
  useEffect(() => () => {
214
234
  abort.current?.abort();
215
235
  clearTimeout(flowTimer.current);
236
+ clearTimeout(callTimer.current);
216
237
  }, []);
217
238
  const setEntryAway = useCallback((away) => setEntryPersona(prev => ({ ...prev, away })), []);
218
239
  const setPeerAway = useCallback((away) => setPeerPersona(prev => ({ ...prev, away })), []);
@@ -225,6 +246,7 @@ export function useA2ATeam(options) {
225
246
  exchange,
226
247
  report,
227
248
  flow,
249
+ calls,
228
250
  busy,
229
251
  ready: agent !== null,
230
252
  send,
@@ -11,8 +11,10 @@
11
11
  * Accounting the wizard, as their Appspecs say, in a graph (`A2ATeamGraph`)
12
12
  * whose one edge is the A2A link. The exchange is shown as it happens: Sales
13
13
  * asks — the edge flows toward Accounting — Accounting works and then answers
14
- * — the edge flows back — each in its own state and balloon. Then the report
15
- * appears. The page's own state is `useA2ATeam`'s, which the landing's home
14
+ * — the edge flows back — each in its own state and balloon. Under
15
+ * Accounting hangs Odoo, its connection (the odoo-accounting MCP server, from
16
+ * its Appspec): the edge to it flows while Accounting calls one of its tools,
17
+ * the tool's name on it. Then the report appears. The page's own state is `useA2ATeam`'s, which the landing's home
16
18
  * page runs too.
17
19
  *
18
20
  * Where Accounting is: `VITE_A2A_ACCOUNTING_URL`, else the examples' local
@@ -10,12 +10,14 @@ import { useExampleAgentRuntimesUrl } from './utils/useExampleAgentRuntimesUrl';
10
10
  import { useBrowserInference } from '../hooks/useBrowserInference';
11
11
  import { readJwtClaims, tokenLifetimeMs, useAnonymousSessionStore, } from '../runtimes/browser/anonymousToken';
12
12
  import { connectA2APeer } from '../runtimes/browser/a2aPeer';
13
- import { A2ATeamGraph, useA2ATeam } from '../components/teams';
13
+ import { A2ATeamGraph, teamConnectionsOf, useA2ATeam, } from '../components/teams';
14
14
  import { ACCOUNTING_APP_0_0_1, SALES_APP_0_0_1 } from '../specs/apps';
15
15
  import { SALES_AND_ACCOUNTING_TEAM_SPEC_0_0_1 } from '../specs/teams/teams';
16
16
  const TEAM = SALES_AND_ACCOUNTING_TEAM_SPEC_0_0_1;
17
17
  const SALES = SALES_APP_0_0_1;
18
18
  const ACCOUNTING = ACCOUNTING_APP_0_0_1;
19
+ /** What Accounting reaches, from its Appspec: Odoo, through the odoo-accounting MCP server. */
20
+ const ACCOUNTING_CONNECTIONS = teamConnectionsOf(ACCOUNTING);
19
21
  /** The members of the team, as the team spec says them. */
20
22
  function members() {
21
23
  const sales = TEAM.agents.find(member => member.id === TEAM.entry);
@@ -67,6 +69,7 @@ function AgentA2ATeam() {
67
69
  peer,
68
70
  inference,
69
71
  askTool: 'ask_accounting',
72
+ peerConnections: ACCOUNTING_CONNECTIONS,
70
73
  });
71
74
  const send = (text) => {
72
75
  setDraft('');
@@ -106,7 +109,8 @@ function AgentA2ATeam() {
106
109
  where: accountingWhere,
107
110
  persona: team.peerPersona,
108
111
  onAway: team.setPeerAway,
109
- }, flow: team.flow, connected: peer !== null, label: peer ? `A2A · ${peer.skill.name}` : 'A2A · not connected' }) }), _jsxs(Box, { sx: { display: 'flex', gap: 3, flexWrap: 'wrap' }, children: [_jsxs(Box, { sx: { flex: '1 1 420px', minWidth: 0 }, children: [_jsxs(Heading, { as: "h3", sx: { fontSize: 2, mb: 2 }, children: ["Ask ", SALES.name] }), _jsx(Box, { sx: { display: 'flex', gap: 2, flexWrap: 'wrap', mb: 2 }, children: (SALES.interface.starters ?? []).map(starter => (_jsx(Button, { size: "small", disabled: !team.ready || team.busy, onClick: () => send(starter.message), children: starter.label }, starter.label))) }), _jsx(Textarea, { ref: composer, block: true, rows: 3, value: draft, placeholder: "Ask for a financial report\u2026", onChange: event => setDraft(event.target.value), onKeyDown: event => {
112
+ connections: ACCOUNTING_CONNECTIONS,
113
+ }, flow: team.flow, calls: team.calls, connected: peer !== null, label: peer ? `A2A · ${peer.skill.name}` : 'A2A · not connected' }) }), _jsxs(Box, { sx: { display: 'flex', gap: 3, flexWrap: 'wrap' }, children: [_jsxs(Box, { sx: { flex: '1 1 420px', minWidth: 0 }, children: [_jsxs(Heading, { as: "h3", sx: { fontSize: 2, mb: 2 }, children: ["Ask ", SALES.name] }), _jsx(Box, { sx: { display: 'flex', gap: 2, flexWrap: 'wrap', mb: 2 }, children: (SALES.interface.starters ?? []).map(starter => (_jsx(Button, { size: "small", disabled: !team.ready || team.busy, onClick: () => send(starter.message), children: starter.label }, starter.label))) }), _jsx(Textarea, { ref: composer, block: true, rows: 3, value: draft, placeholder: "Ask for a financial report\u2026", onChange: event => setDraft(event.target.value), onKeyDown: event => {
110
114
  if (event.key === 'Enter' && !event.shiftKey) {
111
115
  event.preventDefault();
112
116
  send(draft);
@@ -13,7 +13,8 @@
13
13
  * agent (http://127.0.0.1:8765);
14
14
  * - `?voiceModelsUrl=` — the browser's models, as pinned
15
15
  * (`scripts/voice/serve_store.py`, http://127.0.0.1:8770);
16
- * - `?speechUrl=` — ai-agents' speech service (http://127.0.0.1:4401).
16
+ * - `?speechUrl=` — Datalayer's speech service, datalayer-speech on r1
17
+ * (https://r1.datalayer.run), asked with the signed-in person's token.
17
18
  */
18
19
  import React from 'react';
19
20
  declare const VoiceChatExample: React.FC;
@@ -18,7 +18,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
18
  * agent (http://127.0.0.1:8765);
19
19
  * - `?voiceModelsUrl=` — the browser's models, as pinned
20
20
  * (`scripts/voice/serve_store.py`, http://127.0.0.1:8770);
21
- * - `?speechUrl=` — ai-agents' speech service (http://127.0.0.1:4401).
21
+ * - `?speechUrl=` — Datalayer's speech service, datalayer-speech on r1
22
+ * (https://r1.datalayer.run), asked with the signed-in person's token.
22
23
  */
23
24
  import { useMemo, useState } from 'react';
24
25
  import { Heading, Link, SegmentedControl, Text } from '@primer/react';
@@ -34,7 +35,7 @@ function fromAddress(name, otherwise) {
34
35
  }
35
36
  const SERVER = fromAddress('agentRuntimesUrl', 'http://127.0.0.1:8765');
36
37
  const MODELS = fromAddress('voiceModelsUrl', 'http://127.0.0.1:8770');
37
- const SPEECH = fromAddress('speechUrl', 'http://127.0.0.1:4401');
38
+ const SPEECH = fromAddress('speechUrl', 'https://r1.datalayer.run');
38
39
  /** The packages that hear, loaded the first time the microphone is used. */
39
40
  const ENGINES = {
40
41
  transformers: () => import('@huggingface/transformers'),
@@ -46,6 +46,19 @@ export type ConnectA2APeerOptions = {
46
46
  * application has one, named for it.
47
47
  */
48
48
  export declare function connectA2APeer(options: ConnectA2APeerOptions): Promise<A2APeer>;
49
+ /**
50
+ * A tool the peer calls while it works, as its runtime tells it: the call,
51
+ * then its end (`ended`), the two sharing the call's id.
52
+ */
53
+ export type A2APeerToolStep = {
54
+ /** The call's id, when the runtime gives one: its end has the same. */
55
+ id?: string;
56
+ name: string;
57
+ /** Whether this is the call's end rather than its start. */
58
+ ended: boolean;
59
+ /** Why it failed, when it did. */
60
+ error?: string;
61
+ };
49
62
  /** What happens to one request, as it happens. */
50
63
  export type A2APeerEvent = {
51
64
  phase: 'asked';
@@ -54,6 +67,8 @@ export type A2APeerEvent = {
54
67
  phase: 'working';
55
68
  taskId: string;
56
69
  note?: string;
70
+ /** The tool it calls or has finished with, when the step is one. */
71
+ tool?: A2APeerToolStep;
57
72
  } | {
58
73
  phase: 'answered';
59
74
  taskId: string;
@@ -66,6 +81,19 @@ export type A2APeerEvent = {
66
81
  export declare function textOf(parts: Part[] | undefined): string;
67
82
  /** A user's message of one text part, as the SDK's 1.x types write it. */
68
83
  export declare function textMessage(text: string): Message;
84
+ /**
85
+ * What a working status says while the peer works: in a few words, and the
86
+ * tool step when it is one.
87
+ *
88
+ * The runtime's A2A worker publishes each tool call of its agent, and each
89
+ * call's end, as a `working` status whose message has one data part:
90
+ * `{tool_call: {id, name, arguments}}`, then
91
+ * `{tool_result: {id, name, result, error}}`.
92
+ */
93
+ export declare function stepOf(message: Message | undefined): {
94
+ note?: string;
95
+ tool?: A2APeerToolStep;
96
+ };
69
97
  /**
70
98
  * Ask a peer once, streaming the task, and return its answer.
71
99
  *
@@ -90,23 +90,41 @@ export function textMessage(text) {
90
90
  referenceTaskIds: [],
91
91
  };
92
92
  }
93
- /** What a tool call or a tool result says while the peer works, in a few words. */
94
- function noteOf(message) {
93
+ /**
94
+ * What a working status says while the peer works: in a few words, and the
95
+ * tool step when it is one.
96
+ *
97
+ * The runtime's A2A worker publishes each tool call of its agent, and each
98
+ * call's end, as a `working` status whose message has one data part:
99
+ * `{tool_call: {id, name, arguments}}`, then
100
+ * `{tool_result: {id, name, result, error}}`.
101
+ */
102
+ export function stepOf(message) {
95
103
  for (const part of message?.parts ?? []) {
96
104
  const content = part.content;
97
105
  if (content?.$case === 'text' && content.value.trim()) {
98
- return content.value;
106
+ return { note: content.value };
99
107
  }
100
108
  if (content?.$case === 'data' && content.value) {
101
- // What the runtime's A2A worker publishes while its agent works.
102
109
  const data = content.value;
103
- const name = (data.tool_call ?? data.tool_result)?.name;
104
- if (name) {
105
- return data.tool_call ? `Calling ${name}` : `Read ${name}`;
110
+ const told = data.tool_call ?? data.tool_result;
111
+ if (told?.name) {
112
+ const ended = !data.tool_call;
113
+ return {
114
+ note: ended ? `Read ${told.name}` : `Calling ${told.name}`,
115
+ tool: {
116
+ ...(told.id ? { id: told.id } : {}),
117
+ name: told.name,
118
+ ended,
119
+ ...(data.tool_result?.error
120
+ ? { error: String(data.tool_result.error) }
121
+ : {}),
122
+ },
123
+ };
106
124
  }
107
125
  }
108
126
  }
109
- return undefined;
127
+ return {};
110
128
  }
111
129
  const ENDED_BADLY = {
112
130
  [TaskState.TASK_STATE_FAILED]: 'failed',
@@ -158,7 +176,7 @@ async function streamTask(peer, request, signal, onEvent) {
158
176
  const status = payload.value.status;
159
177
  taskId = payload.value.taskId || taskId;
160
178
  if (status?.state === TaskState.TASK_STATE_WORKING) {
161
- onEvent?.({ phase: 'working', taskId, note: noteOf(status.message) });
179
+ onEvent?.({ phase: 'working', taskId, ...stepOf(status.message) });
162
180
  }
163
181
  else if (status) {
164
182
  ended = status.state;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalayer/agent-runtimes",
3
- "version": "1.3.66",
3
+ "version": "1.3.68",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  ".",