@datalayer/agent-runtimes 1.3.67 → 1.3.69
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/assistant/AssistantStage.d.ts +7 -1
- package/lib/chat/assistant/AssistantStage.js +2 -2
- package/lib/components/teams/A2ATeamGraph.d.ts +22 -3
- package/lib/components/teams/A2ATeamGraph.js +168 -13
- package/lib/components/teams/a2aTeamFlow.d.ts +60 -0
- package/lib/components/teams/a2aTeamFlow.js +76 -0
- package/lib/components/teams/index.d.ts +1 -0
- package/lib/components/teams/index.js +1 -0
- package/lib/components/teams/teamConnections.d.ts +8 -0
- package/lib/components/teams/teamConnections.js +49 -0
- package/lib/components/teams/useA2ATeam.d.ts +5 -1
- package/lib/components/teams/useA2ATeam.js +31 -4
- package/lib/examples/AgentA2ATeamExample.d.ts +4 -2
- package/lib/examples/AgentA2ATeamExample.js +6 -2
- package/lib/runtimes/browser/a2aPeer.d.ts +28 -0
- package/lib/runtimes/browser/a2aPeer.js +27 -9
- package/package.json +1 -1
|
@@ -100,6 +100,12 @@ export interface AssistantStageProps {
|
|
|
100
100
|
* the talking loop. Still, for a reader who asks for reduced motion.
|
|
101
101
|
*/
|
|
102
102
|
mouthLevel?: () => number;
|
|
103
|
+
/**
|
|
104
|
+
* Set in a layout — a team's graph — rather than floating over the page:
|
|
105
|
+
* it never steps aside (T-27), since what is around it is its own label
|
|
106
|
+
* and its neighbours, not a page somebody is working on.
|
|
107
|
+
*/
|
|
108
|
+
stayPut?: boolean;
|
|
103
109
|
}
|
|
104
110
|
/** The openings of the mouth, by level: shut, a little, half, wide. */
|
|
105
111
|
export declare function mouthOpening(level: number): 0 | 1 | 2 | 3;
|
|
@@ -108,5 +114,5 @@ export declare function mouthOpening(level: number): 0 | 1 | 2 | 3;
|
|
|
108
114
|
* `data-assistant-mouth`, each frame, without drawing the character again.
|
|
109
115
|
*/
|
|
110
116
|
export declare function useMouth(stageRef: RefObject<HTMLElement | null>, mouthLevel: (() => number) | undefined, speaking: boolean): void;
|
|
111
|
-
export declare function AssistantStage({ character, state, size, place, stageRef, onDragStart, open, onToggle, balloon, insist, onDismiss, ownRef, decide, mouthLevel, }: AssistantStageProps): JSX.Element;
|
|
117
|
+
export declare function AssistantStage({ character, state, size, place, stageRef, onDragStart, open, onToggle, balloon, insist, onDismiss, ownRef, decide, mouthLevel, stayPut, }: AssistantStageProps): JSX.Element;
|
|
112
118
|
export default AssistantStage;
|
|
@@ -209,7 +209,7 @@ export function useMouth(stageRef, mouthLevel, speaking) {
|
|
|
209
209
|
};
|
|
210
210
|
}, [stageRef, mouthLevel, speaking]);
|
|
211
211
|
}
|
|
212
|
-
export function AssistantStage({ character, state, size = 88, place, stageRef, onDragStart, open, onToggle, balloon, insist = false, onDismiss, ownRef, decide, mouthLevel, }) {
|
|
212
|
+
export function AssistantStage({ character, state, size = 88, place, stageRef, onDragStart, open, onToggle, balloon, insist = false, onDismiss, ownRef, decide, mouthLevel, stayPut = false, }) {
|
|
213
213
|
// A shipped one by id, a drawing contributed by a plugin (T-24), or a
|
|
214
214
|
// character read from a file (T-26).
|
|
215
215
|
const shipped = typeof character === 'string'
|
|
@@ -227,7 +227,7 @@ export function AssistantStage({ character, state, size = 88, place, stageRef, o
|
|
|
227
227
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
228
228
|
// Where the press began: a press that moves is a drag, not a click.
|
|
229
229
|
const pressedAt = useRef(null);
|
|
230
|
-
const aside = useKeepClear(stageRef, ownRef, menuOpen);
|
|
230
|
+
const aside = useKeepClear(stageRef, ownRef, menuOpen || stayPut);
|
|
231
231
|
useMouth(stageRef, mouthLevel, state === 'speaking');
|
|
232
232
|
// A decision being asked, or its answer, keeps the balloon up.
|
|
233
233
|
const [deciding, setDeciding] = useState(false);
|
|
@@ -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
|
|
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,37 @@ 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')
|
|
75
|
+
: undefined, insist: persona.insist, onDismiss: away => member.onAway?.(away !== 'none'),
|
|
76
|
+
// A member of the graph: what is clicked around it is the graph.
|
|
77
|
+
stayPut: true })) }), _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] }));
|
|
78
|
+
});
|
|
79
|
+
/** A connection of a member's: the server's mark, what it reaches, and how. */
|
|
80
|
+
const ConnectionNode = memo(function ConnectionNode({ id, data, }) {
|
|
81
|
+
const { member, connection: connectionId, size } = data;
|
|
82
|
+
const connection = useContext(Members)[member]?.connections?.find(known => known.id === connectionId);
|
|
83
|
+
const tool = useContext(Busy)[id];
|
|
84
|
+
if (!connection) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return (_jsxs(Box, { className: "nodrag nopan nowheel", sx: {
|
|
88
|
+
width: CONNECTION_WIDTH,
|
|
89
|
+
display: 'flex',
|
|
90
|
+
flexDirection: 'column',
|
|
91
|
+
alignItems: 'center',
|
|
92
|
+
gap: '2px',
|
|
93
|
+
cursor: 'default',
|
|
94
|
+
pointerEvents: 'all',
|
|
95
|
+
}, 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: {
|
|
96
|
+
width: size,
|
|
97
|
+
height: size,
|
|
98
|
+
display: 'flex',
|
|
99
|
+
alignItems: 'center',
|
|
100
|
+
justifyContent: 'center',
|
|
101
|
+
borderRadius: 2,
|
|
102
|
+
border: '1px solid',
|
|
103
|
+
borderColor: tool ? 'accent.emphasis' : 'border.default',
|
|
104
|
+
bg: 'canvas.default',
|
|
105
|
+
}, 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
106
|
});
|
|
66
107
|
/** The words on the edge for a flow. */
|
|
67
108
|
export function flowWords(flow, entry, peer) {
|
|
@@ -94,15 +135,42 @@ const LinkEdge = memo(function LinkEdge({ id, sourceX, sourceY, targetX, targetY
|
|
|
94
135
|
textAlign: 'center',
|
|
95
136
|
}, children: _jsx(Label, { variant: data?.connected ? 'accent' : 'secondary', children: data?.label }) }) })] }));
|
|
96
137
|
});
|
|
97
|
-
|
|
98
|
-
const
|
|
138
|
+
/** A member's link to a connection: still, or flowing toward it while a call runs. */
|
|
139
|
+
export const CallEdge = memo(function CallEdge({ id, sourceX, sourceY, targetX, targetY, data, }) {
|
|
140
|
+
const calling = Boolean(data?.calling);
|
|
141
|
+
const path = `M ${sourceX},${sourceY} L ${targetX},${targetY}`;
|
|
142
|
+
const arrow = `M ${targetX},${targetY} L ${targetX - ARROW / 1.6},${targetY - ARROW} L ${targetX + ARROW / 1.6},${targetY - ARROW} Z`;
|
|
143
|
+
return (_jsxs(_Fragment, { children: [_jsx(BaseEdge, { id: id, path: path, style: {
|
|
144
|
+
stroke: 'var(--borderColor-default, #d0d7de)',
|
|
145
|
+
strokeWidth: 2,
|
|
146
|
+
opacity: calling ? 0.3 : 0.8,
|
|
147
|
+
} }), 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: {
|
|
148
|
+
position: 'absolute',
|
|
149
|
+
// Beside the edge, toward the middle of the graph.
|
|
150
|
+
transform: `translate(calc(-100% - 10px), -50%) translate(${(sourceX + targetX) / 2}px, ${(sourceY + targetY) / 2}px)`,
|
|
151
|
+
pointerEvents: 'none',
|
|
152
|
+
whiteSpace: 'nowrap',
|
|
153
|
+
}, children: _jsx(Label, { variant: "accent", children: data?.calling }) }) }))] }));
|
|
154
|
+
});
|
|
155
|
+
const NODE_TYPES = { member: MemberNode, connection: ConnectionNode };
|
|
156
|
+
const EDGE_TYPES = { a2a: LinkEdge, mcp: CallEdge };
|
|
157
|
+
/** Where a member's connections sit: under it, side by side, centred. */
|
|
158
|
+
function connectionsX(memberX, count) {
|
|
159
|
+
const row = count * CONNECTION_WIDTH + (count - 1) * CONNECTIONS_APART;
|
|
160
|
+
const left = memberX + (NODE_WIDTH - row) / 2;
|
|
161
|
+
return Array.from({ length: count }, (_, at) => left + at * (CONNECTION_WIDTH + CONNECTIONS_APART));
|
|
162
|
+
}
|
|
163
|
+
/** What a member's call says, on its edge and to a screen reader. */
|
|
164
|
+
export function callWords(member, connection, tool) {
|
|
165
|
+
return `${member} calls ${connection.label} · ${toolWords(tool, connection)}`;
|
|
166
|
+
}
|
|
99
167
|
/** The viewport that shows the graph's width whole in a box `width` wide, never larger than drawn. */
|
|
100
168
|
export function teamViewport(width) {
|
|
101
169
|
const zoom = Math.min(1, width / WIDTH);
|
|
102
170
|
return { zoom, x: (width - WIDTH * zoom) / 2, y: 0 };
|
|
103
171
|
}
|
|
104
172
|
/** 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, }) {
|
|
173
|
+
export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size = 96, calls = [], }) {
|
|
106
174
|
const box = useRef(null);
|
|
107
175
|
const [width, setWidth] = useState(0);
|
|
108
176
|
useEffect(() => {
|
|
@@ -122,7 +190,21 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
|
|
|
122
190
|
const viewport = useMemo(() => teamViewport(width || WIDTH), [width]);
|
|
123
191
|
// The members' height: the character, its name and where it runs.
|
|
124
192
|
const memberHeight = size + 56;
|
|
125
|
-
const
|
|
193
|
+
const connectionSize = Math.round(size / 2);
|
|
194
|
+
// Its mark, its label and how it is reached (about 45px under the mark),
|
|
195
|
+
// and room under it before the graph's edge.
|
|
196
|
+
const connectionHeight = connectionSize + 58;
|
|
197
|
+
// The connections' ids, member by member: the nodes change only when they do.
|
|
198
|
+
const placed = [entry, peer].map(member => ({
|
|
199
|
+
member: member.id,
|
|
200
|
+
connections: (member.connections ?? []).map(connection => connection.id),
|
|
201
|
+
}));
|
|
202
|
+
const placedKey = JSON.stringify(placed);
|
|
203
|
+
const hasConnections = placed.some(({ connections }) => connections.length);
|
|
204
|
+
const height = Math.ceil((BALLOON_ROOM +
|
|
205
|
+
memberHeight +
|
|
206
|
+
(hasConnections ? CONNECTION_GAP + connectionHeight : 0)) *
|
|
207
|
+
viewport.zoom);
|
|
126
208
|
const nodes = useMemo(() => [
|
|
127
209
|
{
|
|
128
210
|
id: entry.id,
|
|
@@ -147,9 +229,50 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
|
|
|
147
229
|
zIndex: 10,
|
|
148
230
|
data: { id: peer.id, side: 'right', size },
|
|
149
231
|
},
|
|
150
|
-
|
|
232
|
+
...JSON.parse(placedKey).flatMap(({ member, connections }, side) => {
|
|
233
|
+
const xs = connectionsX(side === 0 ? 0 : NODE_WIDTH + GAP, connections.length);
|
|
234
|
+
return connections.map((connection, at) => ({
|
|
235
|
+
id: connectionNodeId(member, connection),
|
|
236
|
+
type: 'connection',
|
|
237
|
+
position: {
|
|
238
|
+
x: xs[at],
|
|
239
|
+
y: BALLOON_ROOM + memberHeight + CONNECTION_GAP,
|
|
240
|
+
},
|
|
241
|
+
width: CONNECTION_WIDTH,
|
|
242
|
+
height: connectionHeight,
|
|
243
|
+
draggable: false,
|
|
244
|
+
selectable: false,
|
|
245
|
+
zIndex: 5,
|
|
246
|
+
data: { member, connection, size: connectionSize },
|
|
247
|
+
}));
|
|
248
|
+
}),
|
|
249
|
+
], [
|
|
250
|
+
entry.id,
|
|
251
|
+
peer.id,
|
|
252
|
+
size,
|
|
253
|
+
memberHeight,
|
|
254
|
+
placedKey,
|
|
255
|
+
connectionSize,
|
|
256
|
+
connectionHeight,
|
|
257
|
+
]);
|
|
151
258
|
const members = useMemo(() => ({ [entry.id]: entry, [peer.id]: peer }), [entry, peer]);
|
|
152
259
|
const words = flowWords(flow, entry.name, peer.name);
|
|
260
|
+
// The tool each connection is answering now, in words, by its node's id.
|
|
261
|
+
const busy = useMemo(() => {
|
|
262
|
+
const byNode = {};
|
|
263
|
+
const said = [];
|
|
264
|
+
for (const call of calls) {
|
|
265
|
+
const member = call.member === entry.id ? entry : peer;
|
|
266
|
+
const connection = member.connections?.find(known => known.id === call.connection);
|
|
267
|
+
if (!connection || member.id !== call.member) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const node = connectionNodeId(member.id, connection.id);
|
|
271
|
+
byNode[node] ??= toolWords(call.tool, connection);
|
|
272
|
+
said.push(callWords(member.name, connection, call.tool));
|
|
273
|
+
}
|
|
274
|
+
return { byNode, said: [...new Set(said)] };
|
|
275
|
+
}, [calls, entry, peer]);
|
|
153
276
|
const edges = useMemo(() => [
|
|
154
277
|
{
|
|
155
278
|
id: `${entry.id}->${peer.id}`,
|
|
@@ -164,8 +287,23 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
|
|
|
164
287
|
label: words ? `${label} · ${words}` : label,
|
|
165
288
|
},
|
|
166
289
|
},
|
|
167
|
-
|
|
168
|
-
|
|
290
|
+
...JSON.parse(placedKey).flatMap(({ member, connections }) => connections.map(connection => {
|
|
291
|
+
const node = connectionNodeId(member, connection);
|
|
292
|
+
const calling = busy.byNode[node] ?? '';
|
|
293
|
+
return {
|
|
294
|
+
id: `${member}->${node}`,
|
|
295
|
+
source: member,
|
|
296
|
+
sourceHandle: 'connections',
|
|
297
|
+
target: node,
|
|
298
|
+
type: 'mcp',
|
|
299
|
+
selectable: false,
|
|
300
|
+
focusable: false,
|
|
301
|
+
data: { calling },
|
|
302
|
+
};
|
|
303
|
+
})),
|
|
304
|
+
], [entry.id, peer.id, flow, connected, label, words, placedKey, busy]);
|
|
305
|
+
const heard = [words, ...busy.said].filter(Boolean).join('. ');
|
|
306
|
+
return (_jsxs(Box, { ref: box, "data-a2a-team-graph": "", "data-a2a-flow": flow, "data-a2a-calls": calls.length, sx: {
|
|
169
307
|
width: '100%',
|
|
170
308
|
height,
|
|
171
309
|
position: 'relative',
|
|
@@ -179,17 +317,32 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
|
|
|
179
317
|
'& .a2a-team-flow-answering': {
|
|
180
318
|
animation: 'a2aTeamFlow 0.6s linear infinite reverse',
|
|
181
319
|
},
|
|
320
|
+
'@keyframes a2aTeamCall': {
|
|
321
|
+
from: { strokeDashoffset: 14 },
|
|
322
|
+
to: { strokeDashoffset: 0 },
|
|
323
|
+
},
|
|
324
|
+
'@keyframes a2aTeamPulse': {
|
|
325
|
+
'0%, 100%': { boxShadow: '0 0 0 0 transparent' },
|
|
326
|
+
'50%': {
|
|
327
|
+
boxShadow: '0 0 0 4px var(--bgColor-accent-muted, #ddf4ff)',
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
'& .a2a-team-call': {
|
|
331
|
+
animation: 'a2aTeamCall 0.5s linear infinite',
|
|
332
|
+
},
|
|
333
|
+
'& .a2a-team-connection-busy': {
|
|
334
|
+
animation: 'a2aTeamPulse 1s ease-in-out infinite',
|
|
335
|
+
},
|
|
182
336
|
// The arrow and the words say which way; the movement is extra.
|
|
183
337
|
'@media (prefers-reduced-motion: reduce)': {
|
|
184
|
-
'& .a2a-team-flow': {
|
|
338
|
+
'& .a2a-team-flow, & .a2a-team-connection-busy': {
|
|
339
|
+
animation: 'none',
|
|
340
|
+
},
|
|
185
341
|
},
|
|
186
342
|
'& .react-flow, & .react-flow__renderer, & .react-flow__viewport': {
|
|
187
343
|
overflow: 'visible',
|
|
188
344
|
},
|
|
189
345
|
'& .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
346
|
}, children: [_jsx(Box, { as: "span", "aria-live": "polite", sx: {
|
|
194
347
|
position: 'absolute',
|
|
195
348
|
width: 1,
|
|
@@ -197,6 +350,8 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
|
|
|
197
350
|
overflow: 'hidden',
|
|
198
351
|
clip: 'rect(0 0 0 0)',
|
|
199
352
|
whiteSpace: 'nowrap',
|
|
200
|
-
}, children:
|
|
353
|
+
}, 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' },
|
|
354
|
+
// The library is MIT: its credit is not required on the page.
|
|
355
|
+
proOptions: { hideAttribution: true }, "aria-label": `${entry.name} and ${peer.name}, over A2A` }) }) })] }));
|
|
201
356
|
}
|
|
202
357
|
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
|
+
}
|
|
@@ -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;
|
|
@@ -165,6 +183,9 @@ export function useA2ATeam(options) {
|
|
|
165
183
|
state: 'thinking',
|
|
166
184
|
saying: 'Thanks!',
|
|
167
185
|
}));
|
|
186
|
+
// Accounting has handed its answer over: back at rest, its last
|
|
187
|
+
// words still in its balloon.
|
|
188
|
+
setPeerPersona(prev => ({ ...prev, state: 'idle' }));
|
|
168
189
|
}
|
|
169
190
|
else if (part.type === 'text-delta') {
|
|
170
191
|
said += part.text;
|
|
@@ -203,16 +224,21 @@ export function useA2ATeam(options) {
|
|
|
203
224
|
insist: true,
|
|
204
225
|
}));
|
|
205
226
|
setFlow('still');
|
|
227
|
+
callsNow.current = [];
|
|
228
|
+
setCalls([]);
|
|
206
229
|
}
|
|
207
230
|
finally {
|
|
208
231
|
abort.current = null;
|
|
209
232
|
setBusy(false);
|
|
233
|
+
// However the turn ended, neither member is still at work.
|
|
234
|
+
setPeerPersona(prev => prev.state === 'idle' ? prev : { ...prev, state: 'idle' });
|
|
210
235
|
}
|
|
211
236
|
}, [agent, busy, askTool, peerApp.name]);
|
|
212
237
|
const stop = useCallback(() => abort.current?.abort(), []);
|
|
213
238
|
useEffect(() => () => {
|
|
214
239
|
abort.current?.abort();
|
|
215
240
|
clearTimeout(flowTimer.current);
|
|
241
|
+
clearTimeout(callTimer.current);
|
|
216
242
|
}, []);
|
|
217
243
|
const setEntryAway = useCallback((away) => setEntryPersona(prev => ({ ...prev, away })), []);
|
|
218
244
|
const setPeerAway = useCallback((away) => setPeerPersona(prev => ({ ...prev, away })), []);
|
|
@@ -225,6 +251,7 @@ export function useA2ATeam(options) {
|
|
|
225
251
|
exchange,
|
|
226
252
|
report,
|
|
227
253
|
flow,
|
|
254
|
+
calls,
|
|
228
255
|
busy,
|
|
229
256
|
ready: agent !== null,
|
|
230
257
|
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.
|
|
15
|
-
*
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
-
/**
|
|
94
|
-
|
|
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
|
|
104
|
-
if (name) {
|
|
105
|
-
|
|
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
|
|
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,
|
|
179
|
+
onEvent?.({ phase: 'working', taskId, ...stepOf(status.message) });
|
|
162
180
|
}
|
|
163
181
|
else if (status) {
|
|
164
182
|
ended = status.state;
|