@datalayer/agent-runtimes 1.3.63 → 1.3.65

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.
Files changed (60) hide show
  1. package/lib/chat/ChatFloating.d.ts +9 -1
  2. package/lib/chat/ChatFloating.js +69 -18
  3. package/lib/chat/assistant/AssistantStage.d.ts +14 -1
  4. package/lib/chat/assistant/AssistantStage.js +48 -1
  5. package/lib/chat/assistant/state.d.ts +7 -1
  6. package/lib/chat/assistant/state.js +3 -2
  7. package/lib/chat/base/ChatBase.js +32 -4
  8. package/lib/chat/messages/ChatMessageList.d.ts +0 -6
  9. package/lib/chat/messages/ChatMessageList.js +8 -2
  10. package/lib/components/teams/A2ATeamGraph.d.ts +58 -0
  11. package/lib/components/teams/A2ATeamGraph.js +190 -0
  12. package/lib/components/teams/a2aTeamFlow.d.ts +33 -0
  13. package/lib/components/teams/a2aTeamFlow.js +32 -0
  14. package/lib/components/teams/index.d.ts +11 -0
  15. package/lib/components/teams/index.js +15 -0
  16. package/lib/components/teams/useA2ATeam.d.ts +57 -0
  17. package/lib/components/teams/useA2ATeam.js +233 -0
  18. package/lib/examples/AgentA2ATeamExample.d.ts +6 -3
  19. package/lib/examples/AgentA2ATeamExample.js +42 -208
  20. package/lib/examples/VoiceChatExample.d.ts +20 -0
  21. package/lib/examples/VoiceChatExample.js +64 -0
  22. package/lib/examples/example-selector.js +1 -0
  23. package/lib/examples/main.js +7 -1
  24. package/lib/loop/apps/appspec.d.ts +3 -1
  25. package/lib/loop/apps/appspec.js +31 -0
  26. package/lib/protocols/VercelAIAdapter.js +5 -0
  27. package/lib/specs/apps.js +112 -0
  28. package/lib/specs/appspecSchema.js +65 -0
  29. package/lib/specs/index.d.ts +1 -0
  30. package/lib/specs/index.js +1 -0
  31. package/lib/specs/voices.d.ts +72 -0
  32. package/lib/specs/voices.js +344 -0
  33. package/lib/types/agentspecs.d.ts +16 -0
  34. package/lib/types/chat.d.ts +11 -0
  35. package/lib/voice/VoiceInput.d.ts +35 -0
  36. package/lib/voice/VoiceInput.js +233 -0
  37. package/lib/voice/capture.d.ts +30 -0
  38. package/lib/voice/capture.js +113 -0
  39. package/lib/voice/consent.d.ts +6 -0
  40. package/lib/voice/consent.js +34 -0
  41. package/lib/voice/hearing.d.ts +37 -0
  42. package/lib/voice/hearing.js +168 -0
  43. package/lib/voice/index.d.ts +47 -0
  44. package/lib/voice/index.js +13 -0
  45. package/lib/voice/pinned.d.ts +28 -0
  46. package/lib/voice/pinned.js +75 -0
  47. package/lib/voice/sentences.d.ts +26 -0
  48. package/lib/voice/sentences.js +71 -0
  49. package/lib/voice/speaker.d.ts +66 -0
  50. package/lib/voice/speaker.js +168 -0
  51. package/lib/voice/types.d.ts +92 -0
  52. package/lib/voice/types.js +5 -0
  53. package/lib/voice/useSpokenAnswers.d.ts +12 -0
  54. package/lib/voice/useSpokenAnswers.js +90 -0
  55. package/package.json +7 -3
  56. package/scripts/codegen/generate_voices.py +279 -0
  57. package/scripts/voice/measure.py +244 -0
  58. package/scripts/voice/pin_store.py +159 -0
  59. package/scripts/voice/serve_store.py +70 -0
  60. package/scripts/voice/transcribe.mjs +58 -0
@@ -0,0 +1,190 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { memo, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { BaseEdge, EdgeLabelRenderer, Handle, Position, ReactFlow, } from '@xyflow/react';
4
+ import '@xyflow/react/dist/base.css';
5
+ import { Button, Label, Text } from '@primer/react';
6
+ import { Box } from '@datalayer/primer-addons';
7
+ import { AssistantStage } from '../../chat/assistant/AssistantStage';
8
+ /** The graph's own coordinates: the members, and room above them for their balloons. */
9
+ const NODE_WIDTH = 200;
10
+ const BALLOON_ROOM = 120;
11
+ const GAP = 220;
12
+ const WIDTH = NODE_WIDTH * 2 + GAP;
13
+ /** Where the edge leaves and reaches a member: the middle of its character. */
14
+ const HANDLE = (size) => ({
15
+ opacity: 0,
16
+ pointerEvents: 'none',
17
+ top: size / 2,
18
+ width: 1,
19
+ height: 1,
20
+ minWidth: 0,
21
+ minHeight: 0,
22
+ border: 0,
23
+ });
24
+ const MemberNode = memo(function MemberNode({ data, }) {
25
+ const { member, side, size } = data;
26
+ const stageRef = useRef(null);
27
+ const { persona } = member;
28
+ return (_jsxs(Box
29
+ // React Flow lets the pointer through a node that is neither dragged
30
+ // nor selected (`pointer-events: none` on its wrapper): the character,
31
+ // its balloon and its menu take it back, and are left to themselves —
32
+ // no drag, no pan, no wheel.
33
+ , {
34
+ // React Flow lets the pointer through a node that is neither dragged
35
+ // nor selected (`pointer-events: none` on its wrapper): the character,
36
+ // its balloon and its menu take it back, and are left to themselves —
37
+ // no drag, no pan, no wheel.
38
+ className: "nodrag nopan nowheel", sx: {
39
+ width: NODE_WIDTH,
40
+ display: 'flex',
41
+ flexDirection: 'column',
42
+ alignItems: 'center',
43
+ gap: 1,
44
+ cursor: 'default',
45
+ pointerEvents: 'all',
46
+ }, "data-team-member": member.id, "data-member-state": persona.state, children: [_jsx(Handle, { type: "target", position: Position.Left, style: HANDLE(size), isConnectable: false }), _jsx(Box, { sx: { width: size, height: size, position: 'relative' }, children: persona.away ? (_jsxs(Button, { size: "small", sx: { position: 'absolute', top: size / 2 - 16, left: -24 }, onClick: () => member.onAway?.(false), children: ["Call ", member.name, " back"] })) : (_jsx(AssistantStage, { character: member.character, state: persona.state, size: size,
47
+ // In the node, not over the page; the balloon opens toward the
48
+ // middle of the graph: rightward from the left member.
49
+ place: side === 'left'
50
+ ? { position: 'absolute', left: 'auto' }
51
+ : { position: 'absolute', right: 'auto' }, stageRef: stageRef, onDragStart: () => undefined, open: false, onToggle: member.onToggle ?? (() => undefined), balloon: persona.saying
52
+ ? { text: persona.saying, more: persona.saying.endsWith('…') }
53
+ : 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 })] }));
54
+ });
55
+ /** The words on the edge for a flow. */
56
+ export function flowWords(flow, entry, peer) {
57
+ return flow === 'asking'
58
+ ? `${entry} asks ${peer}`
59
+ : flow === 'answering'
60
+ ? `${peer} answers ${entry}`
61
+ : '';
62
+ }
63
+ const ARROW = 9;
64
+ const LinkEdge = memo(function LinkEdge({ id, sourceX, sourceY, targetX, targetY, data, }) {
65
+ const flow = data?.flow ?? 'still';
66
+ const path = `M ${sourceX},${sourceY} L ${targetX},${targetY}`;
67
+ // The arrow at the end the message goes to.
68
+ const towardPeer = flow === 'asking';
69
+ const tipX = towardPeer ? targetX : sourceX;
70
+ const back = towardPeer ? -ARROW : ARROW;
71
+ const arrow = `M ${tipX},${targetY} L ${tipX + back},${targetY - ARROW / 1.6} L ${tipX + back},${targetY + ARROW / 1.6} Z`;
72
+ return (_jsxs(_Fragment, { children: [_jsx(BaseEdge, { id: id, path: path, style: {
73
+ stroke: data?.connected
74
+ ? 'var(--borderColor-accent-emphasis, #0969da)'
75
+ : 'var(--borderColor-muted, #d0d7de)',
76
+ strokeWidth: 2,
77
+ strokeDasharray: data?.connected ? undefined : '6 6',
78
+ opacity: flow === 'still' ? 0.6 : 0.25,
79
+ } }), flow !== 'still' && (_jsxs("g", { "data-a2a-edge-flow": flow, children: [_jsx("path", { d: path, fill: "none", className: `a2a-team-flow a2a-team-flow-${flow}`, stroke: "var(--fgColor-accent, #0969da)", strokeWidth: 3, strokeLinecap: "round", strokeDasharray: "10 14" }), _jsx("path", { d: arrow, fill: "var(--fgColor-accent, #0969da)" })] })), _jsx(EdgeLabelRenderer, { children: _jsx(Box, { className: "nodrag nopan", sx: {
80
+ position: 'absolute',
81
+ transform: `translate(-50%, 12px) translate(${(sourceX + targetX) / 2}px, ${(sourceY + targetY) / 2}px)`,
82
+ pointerEvents: 'none',
83
+ textAlign: 'center',
84
+ }, children: _jsx(Label, { variant: data?.connected ? 'accent' : 'secondary', children: data?.label }) }) })] }));
85
+ });
86
+ const NODE_TYPES = { member: MemberNode };
87
+ const EDGE_TYPES = { a2a: LinkEdge };
88
+ /** The viewport that shows the graph's width whole in a box `width` wide, never larger than drawn. */
89
+ export function teamViewport(width) {
90
+ const zoom = Math.min(1, width / WIDTH);
91
+ return { zoom, x: (width - WIDTH * zoom) / 2, y: 0 };
92
+ }
93
+ /** Draw a team of two over A2A, the link moving with what travels on it. */
94
+ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size = 96, }) {
95
+ const box = useRef(null);
96
+ const [width, setWidth] = useState(0);
97
+ useEffect(() => {
98
+ const element = box.current;
99
+ if (!element) {
100
+ return undefined;
101
+ }
102
+ const measure = () => setWidth(element.clientWidth);
103
+ measure();
104
+ if (typeof ResizeObserver === 'undefined') {
105
+ return undefined;
106
+ }
107
+ const observer = new ResizeObserver(measure);
108
+ observer.observe(element);
109
+ return () => observer.disconnect();
110
+ }, []);
111
+ const viewport = useMemo(() => teamViewport(width || WIDTH), [width]);
112
+ // The members' height: the character, its name and where it runs.
113
+ const memberHeight = size + 56;
114
+ const height = Math.ceil((BALLOON_ROOM + memberHeight) * viewport.zoom);
115
+ const nodes = useMemo(() => [
116
+ {
117
+ id: entry.id,
118
+ type: 'member',
119
+ position: { x: 0, y: BALLOON_ROOM },
120
+ width: NODE_WIDTH,
121
+ height: memberHeight,
122
+ draggable: false,
123
+ selectable: false,
124
+ // Above the edge and its label: a balloon overflows its node.
125
+ zIndex: 10,
126
+ data: { member: entry, side: 'left', size },
127
+ },
128
+ {
129
+ id: peer.id,
130
+ type: 'member',
131
+ position: { x: NODE_WIDTH + GAP, y: BALLOON_ROOM },
132
+ width: NODE_WIDTH,
133
+ height: memberHeight,
134
+ draggable: false,
135
+ selectable: false,
136
+ zIndex: 10,
137
+ data: { member: peer, side: 'right', size },
138
+ },
139
+ ], [entry, peer, size, memberHeight]);
140
+ const words = flowWords(flow, entry.name, peer.name);
141
+ const edges = useMemo(() => [
142
+ {
143
+ id: `${entry.id}->${peer.id}`,
144
+ source: entry.id,
145
+ target: peer.id,
146
+ type: 'a2a',
147
+ selectable: false,
148
+ focusable: false,
149
+ data: {
150
+ flow,
151
+ connected,
152
+ label: words ? `${label} · ${words}` : label,
153
+ },
154
+ },
155
+ ], [entry.id, peer.id, flow, connected, label, words]);
156
+ return (_jsxs(Box, { ref: box, "data-a2a-team-graph": "", "data-a2a-flow": flow, sx: {
157
+ width: '100%',
158
+ height,
159
+ position: 'relative',
160
+ '@keyframes a2aTeamFlow': {
161
+ from: { strokeDashoffset: 24 },
162
+ to: { strokeDashoffset: 0 },
163
+ },
164
+ '& .a2a-team-flow-asking': {
165
+ animation: 'a2aTeamFlow 0.6s linear infinite',
166
+ },
167
+ '& .a2a-team-flow-answering': {
168
+ animation: 'a2aTeamFlow 0.6s linear infinite reverse',
169
+ },
170
+ // The arrow and the words say which way; the movement is extra.
171
+ '@media (prefers-reduced-motion: reduce)': {
172
+ '& .a2a-team-flow': { animation: 'none' },
173
+ },
174
+ '& .react-flow, & .react-flow__renderer, & .react-flow__viewport': {
175
+ overflow: 'visible',
176
+ },
177
+ '& .react-flow__node': { cursor: 'default' },
178
+ // The library's credit stays, quietly (as the landing's network does).
179
+ '& .react-flow__attribution': { bg: 'transparent', color: 'fg.muted' },
180
+ '& .react-flow__attribution a': { color: 'fg.muted' },
181
+ }, children: [_jsx(Box, { as: "span", "aria-live": "polite", sx: {
182
+ position: 'absolute',
183
+ width: 1,
184
+ height: 1,
185
+ overflow: 'hidden',
186
+ clip: 'rect(0 0 0 0)',
187
+ whiteSpace: 'nowrap',
188
+ }, children: words }), _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` })] }));
189
+ }
190
+ export default A2ATeamGraph;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Which way a team's A2A link carries a message, read from what happens to
3
+ * a request (`A2APeerEvent`).
4
+ *
5
+ * The member that is asked is the peer; the one that asks is the entry (the
6
+ * team's `entry`, who talks to the person). While a request is out — asked,
7
+ * then worked on — the link carries it from the entry to the peer:
8
+ * `asking`. When the peer answers, the answer comes back the other way for a
9
+ * moment: `answering`. Otherwise, and when a request fails, it is `still`.
10
+ *
11
+ * Pure, so that a test reads it and a page drives it the same way.
12
+ *
13
+ * @module components/teams/a2aTeamFlow
14
+ */
15
+ import type { A2APeerEvent } from '../../runtimes/browser/a2aPeer';
16
+ /** Which way the link carries a message, if it carries one. */
17
+ export type A2ATeamFlow = 'still' | 'asking' | 'answering';
18
+ /** How long an answer is shown travelling back, in milliseconds. */
19
+ export declare const ANSWER_SHOWN_MS = 1800;
20
+ /**
21
+ * The flow after an event: where the link goes now, and for how long it
22
+ * stays that way before it is `still` again (`holdMs`, only for an answer:
23
+ * the request's flow lasts as long as the request).
24
+ */
25
+ export declare function flowAfter(event: A2APeerEvent): {
26
+ flow: A2ATeamFlow;
27
+ holdMs?: number;
28
+ };
29
+ /** The member a message leaves, and the one it reaches, for a flow. */
30
+ export declare function flowEnds(flow: A2ATeamFlow, entry: string, peer: string): {
31
+ from: string;
32
+ to: string;
33
+ } | undefined;
@@ -0,0 +1,32 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /** How long an answer is shown travelling back, in milliseconds. */
6
+ export const ANSWER_SHOWN_MS = 1800;
7
+ /**
8
+ * The flow after an event: where the link goes now, and for how long it
9
+ * stays that way before it is `still` again (`holdMs`, only for an answer:
10
+ * the request's flow lasts as long as the request).
11
+ */
12
+ export function flowAfter(event) {
13
+ switch (event.phase) {
14
+ case 'asked':
15
+ case 'working':
16
+ return { flow: 'asking' };
17
+ case 'answered':
18
+ return { flow: 'answering', holdMs: ANSWER_SHOWN_MS };
19
+ default:
20
+ return { flow: 'still' };
21
+ }
22
+ }
23
+ /** The member a message leaves, and the one it reaches, for a flow. */
24
+ export function flowEnds(flow, entry, peer) {
25
+ if (flow === 'asking') {
26
+ return { from: entry, to: peer };
27
+ }
28
+ if (flow === 'answering') {
29
+ return { from: peer, to: entry };
30
+ }
31
+ return undefined;
32
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * A team of applications over A2A, drawn and run in a page.
3
+ *
4
+ * Not re-exported by `components/index.ts`: it brings React Flow, which a
5
+ * page that shows no team should not load. Import it from here.
6
+ *
7
+ * @module components/teams
8
+ */
9
+ export * from './a2aTeamFlow';
10
+ export * from './useA2ATeam';
11
+ export * from './A2ATeamGraph';
@@ -0,0 +1,15 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * A team of applications over A2A, drawn and run in a page.
7
+ *
8
+ * Not re-exported by `components/index.ts`: it brings React Flow, which a
9
+ * page that shows no team should not load. Import it from here.
10
+ *
11
+ * @module components/teams
12
+ */
13
+ export * from './a2aTeamFlow';
14
+ export * from './useA2ATeam';
15
+ export * from './A2ATeamGraph';
@@ -0,0 +1,57 @@
1
+ import type { AssistantState } from '../../chat/assistant/state';
2
+ import { type BrowserModelOptions } from '../../runtimes/browser/model';
3
+ import { type A2APeer } from '../../runtimes/browser/a2aPeer';
4
+ import type { AppSpec } from '../../types/agentspecs';
5
+ import { type A2ATeamFlow } from './a2aTeamFlow';
6
+ /** What one member's character does: its state, its balloon, and whether it was sent away. */
7
+ export type A2ATeamPersona = {
8
+ state: AssistantState;
9
+ /** What its balloon says. */
10
+ saying?: string;
11
+ /** Whether the balloon shows without the pointer over it. */
12
+ insist: boolean;
13
+ away: boolean;
14
+ };
15
+ export declare const AT_REST: A2ATeamPersona;
16
+ /** One turn of the conversation with the entry. */
17
+ export type A2ATeamTurn = {
18
+ role: 'user' | 'assistant';
19
+ text: string;
20
+ };
21
+ /** A line short enough for a balloon. */
22
+ export declare function balloonLine(text: string, length?: number): string;
23
+ export type UseA2ATeamOptions = {
24
+ /** The member that talks to the person and asks: it runs in this page. */
25
+ entry: AppSpec;
26
+ /** The member it asks over A2A. */
27
+ peerApp: AppSpec;
28
+ /** The peer, once its card is read; `null` until then. */
29
+ peer: A2APeer | null;
30
+ /** Where the entry's model is asked, and with whose token. */
31
+ inference: Omit<BrowserModelOptions, 'model'>;
32
+ /** The name of the entry's tool, as its instructions call it: `ask_<peer id>`. */
33
+ askTool?: string;
34
+ /** The most steps of one turn. */
35
+ maxSteps?: number;
36
+ };
37
+ export type A2ATeam = {
38
+ entryPersona: A2ATeamPersona;
39
+ peerPersona: A2ATeamPersona;
40
+ setEntryAway: (away: boolean) => void;
41
+ setPeerAway: (away: boolean) => void;
42
+ turns: A2ATeamTurn[];
43
+ /** The exchange between the two, a line a step. */
44
+ exchange: string[];
45
+ /** The peer's last answer, as it gave it. */
46
+ report: string | null;
47
+ /** Which way the link carries a message now. */
48
+ flow: A2ATeamFlow;
49
+ /** Whether a turn is under way. */
50
+ busy: boolean;
51
+ /** Whether the entry can be asked: its peer is connected. */
52
+ ready: boolean;
53
+ send: (text: string) => Promise<void>;
54
+ stop: () => void;
55
+ };
56
+ /** Run a team of two over A2A in the page. */
57
+ export declare function useA2ATeam(options: UseA2ATeamOptions): A2ATeam;
@@ -0,0 +1,233 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
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.
8
+ *
9
+ * The entry (Sales) talks to the person: its instructions and its model run
10
+ * here (the Vercel AI SDK's loop), its model reached through ai-inference
11
+ * 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 — and the exchange
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}).
17
+ *
18
+ * What `A2ATeamGraph` draws, and what a page's composer sends.
19
+ *
20
+ * @module components/teams/useA2ATeam
21
+ */
22
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
23
+ import { stepCountIs, ToolLoopAgent } from 'ai';
24
+ import { createBrowserModel, } from '../../runtimes/browser/model';
25
+ import { a2aPeerTool, } from '../../runtimes/browser/a2aPeer';
26
+ import { flowAfter } from './a2aTeamFlow';
27
+ export const AT_REST = {
28
+ state: 'idle',
29
+ insist: false,
30
+ away: false,
31
+ };
32
+ /** A line short enough for a balloon. */
33
+ export function balloonLine(text, length = 120) {
34
+ const flat = text.replace(/\s+/g, ' ').trim();
35
+ return flat.length > length ? `${flat.slice(0, length - 1)}…` : flat;
36
+ }
37
+ /** Run a team of two over A2A in the page. */
38
+ export function useA2ATeam(options) {
39
+ const { entry, peerApp, peer, inference, maxSteps = 6 } = options;
40
+ const askTool = options.askTool ?? `ask_${peerApp.id.replace(/-/g, '_')}`;
41
+ const [entryPersona, setEntryPersona] = useState(() => ({
42
+ ...AT_REST,
43
+ state: 'greeting',
44
+ saying: balloonLine(entry.interface.welcome ?? ''),
45
+ insist: Boolean(entry.interface.welcome),
46
+ }));
47
+ const [peerPersona, setPeerPersona] = useState(AT_REST);
48
+ const [turns, setTurns] = useState([]);
49
+ const [report, setReport] = useState(null);
50
+ const [exchange, setExchange] = useState([]);
51
+ const [flow, setFlow] = useState('still');
52
+ const [busy, setBusy] = useState(false);
53
+ const history = useRef([]);
54
+ const abort = useRef(null);
55
+ const flowTimer = useRef(undefined);
56
+ // What the peer does, as it does it: its own state and balloon, and the link.
57
+ const onPeerEvent = useCallback((event) => {
58
+ const next = flowAfter(event);
59
+ clearTimeout(flowTimer.current);
60
+ setFlow(next.flow);
61
+ if (next.holdMs) {
62
+ flowTimer.current = setTimeout(() => setFlow('still'), next.holdMs);
63
+ }
64
+ if (event.phase === 'asked') {
65
+ setExchange(prev => [
66
+ ...prev,
67
+ `${entry.name} → ${peerApp.name}: ${event.request}`,
68
+ ]);
69
+ setPeerPersona(prev => ({
70
+ ...prev,
71
+ state: 'greeting',
72
+ saying: 'On it. Let me read the books.',
73
+ insist: true,
74
+ }));
75
+ }
76
+ else if (event.phase === 'working') {
77
+ if (event.note) {
78
+ setExchange(prev => [...prev, `${peerApp.name}: ${event.note}`]);
79
+ }
80
+ setPeerPersona(prev => ({
81
+ ...prev,
82
+ state: 'working',
83
+ saying: event.note ? balloonLine(event.note) : prev.saying,
84
+ insist: Boolean(event.note) || prev.insist,
85
+ }));
86
+ }
87
+ else if (event.phase === 'answered') {
88
+ setExchange(prev => [
89
+ ...prev,
90
+ `${peerApp.name} → ${entry.name}: the report`,
91
+ ]);
92
+ setReport(event.answer);
93
+ setPeerPersona(prev => ({
94
+ ...prev,
95
+ state: 'speaking',
96
+ saying: balloonLine(event.answer),
97
+ insist: true,
98
+ }));
99
+ }
100
+ else {
101
+ setExchange(prev => [
102
+ ...prev,
103
+ `${peerApp.name} could not answer: ${event.error}`,
104
+ ]);
105
+ setPeerPersona(prev => ({
106
+ ...prev,
107
+ state: 'idle',
108
+ saying: balloonLine(event.error),
109
+ insist: true,
110
+ }));
111
+ }
112
+ }, [entry.name, peerApp.name]);
113
+ const agent = useMemo(() => {
114
+ if (!peer) {
115
+ return null;
116
+ }
117
+ return new ToolLoopAgent({
118
+ id: entry.id,
119
+ model: createBrowserModel({
120
+ ...inference,
121
+ model: entry.model || undefined,
122
+ }),
123
+ instructions: entry.instructions,
124
+ tools: { [askTool]: a2aPeerTool({ peer, onEvent: onPeerEvent }) },
125
+ stopWhen: stepCountIs(maxSteps),
126
+ });
127
+ }, [peer, inference, onPeerEvent, entry, askTool, maxSteps]);
128
+ const send = useCallback(async (text) => {
129
+ const asked = text.trim();
130
+ if (!asked || !agent || busy) {
131
+ return;
132
+ }
133
+ setBusy(true);
134
+ setReport(null);
135
+ setExchange([]);
136
+ setTurns(prev => [...prev, { role: 'user', text: asked }]);
137
+ setEntryPersona({
138
+ ...AT_REST,
139
+ state: 'thinking',
140
+ saying: 'Let me see…',
141
+ insist: true,
142
+ });
143
+ setPeerPersona(AT_REST);
144
+ history.current = [...history.current, { role: 'user', content: asked }];
145
+ abort.current = new AbortController();
146
+ let said = '';
147
+ try {
148
+ const result = await agent.stream({
149
+ messages: history.current,
150
+ abortSignal: abort.current.signal,
151
+ });
152
+ for await (const part of result.fullStream) {
153
+ if (part.type === 'tool-call' && part.toolName === askTool) {
154
+ const request = String(part.input?.request ?? '');
155
+ setEntryPersona(prev => ({
156
+ ...prev,
157
+ state: 'waiting',
158
+ saying: balloonLine(`${peerApp.name}, could you: ${request}`),
159
+ insist: true,
160
+ }));
161
+ }
162
+ else if (part.type === 'tool-result' && part.toolName === askTool) {
163
+ setEntryPersona(prev => ({
164
+ ...prev,
165
+ state: 'thinking',
166
+ saying: 'Thanks!',
167
+ }));
168
+ }
169
+ else if (part.type === 'text-delta') {
170
+ said += part.text;
171
+ setEntryPersona(prev => ({
172
+ ...prev,
173
+ state: 'speaking',
174
+ saying: balloonLine(said),
175
+ insist: true,
176
+ }));
177
+ }
178
+ else if (part.type === 'error') {
179
+ throw part.error;
180
+ }
181
+ }
182
+ history.current = [
183
+ ...history.current,
184
+ { role: 'assistant', content: said },
185
+ ];
186
+ setTurns(prev => [...prev, { role: 'assistant', text: said }]);
187
+ setEntryPersona(prev => ({
188
+ ...prev,
189
+ state: 'idle',
190
+ saying: balloonLine(said),
191
+ }));
192
+ }
193
+ catch (error) {
194
+ const message = error instanceof Error ? error.message : String(error);
195
+ setTurns(prev => [
196
+ ...prev,
197
+ { role: 'assistant', text: `Something went wrong: ${message}` },
198
+ ]);
199
+ setEntryPersona(prev => ({
200
+ ...prev,
201
+ state: 'idle',
202
+ saying: balloonLine(`Something went wrong: ${message}`),
203
+ insist: true,
204
+ }));
205
+ setFlow('still');
206
+ }
207
+ finally {
208
+ abort.current = null;
209
+ setBusy(false);
210
+ }
211
+ }, [agent, busy, askTool, peerApp.name]);
212
+ const stop = useCallback(() => abort.current?.abort(), []);
213
+ useEffect(() => () => {
214
+ abort.current?.abort();
215
+ clearTimeout(flowTimer.current);
216
+ }, []);
217
+ const setEntryAway = useCallback((away) => setEntryPersona(prev => ({ ...prev, away })), []);
218
+ const setPeerAway = useCallback((away) => setPeerPersona(prev => ({ ...prev, away })), []);
219
+ return {
220
+ entryPersona,
221
+ peerPersona,
222
+ setEntryAway,
223
+ setPeerAway,
224
+ turns,
225
+ exchange,
226
+ report,
227
+ flow,
228
+ busy,
229
+ ready: agent !== null,
230
+ send,
231
+ stop,
232
+ };
233
+ }
@@ -8,9 +8,12 @@
8
8
  * `odoo-accounting` toolset, and only reads them.
9
9
  *
10
10
  * Both are on screen as Office Assistant characters, Sales the paper clip and
11
- * Accounting the wizard, as their Appspecs say. The exchange is shown as it
12
- * happens: Sales asks, Accounting works and then answers, each in its own
13
- * state and balloon. Then the report appears.
11
+ * Accounting the wizard, as their Appspecs say, in a graph (`A2ATeamGraph`)
12
+ * whose one edge is the A2A link. The exchange is shown as it happens: Sales
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
16
+ * page runs too.
14
17
  *
15
18
  * Where Accounting is: `VITE_A2A_ACCOUNTING_URL`, else the examples' local
16
19
  * server (`…:8765/api/v1/a2a/agents/accounting`), which answers this machine