@datalayer/agent-runtimes 1.3.74 → 1.3.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/lib/chat/ChatFloating.d.ts +17 -1
  2. package/lib/chat/ChatFloating.js +52 -5
  3. package/lib/chat/assistant/AssistantContextMenu.d.ts +67 -0
  4. package/lib/chat/assistant/AssistantContextMenu.js +68 -0
  5. package/lib/chat/assistant/AssistantStage.d.ts +95 -1
  6. package/lib/chat/assistant/AssistantStage.js +227 -7
  7. package/lib/chat/assistant/BalloonParts.d.ts +15 -1
  8. package/lib/chat/assistant/BalloonParts.js +26 -9
  9. package/lib/chat/assistant/BalloonVisual.d.ts +10 -2
  10. package/lib/chat/assistant/BalloonVisual.js +3 -3
  11. package/lib/chat/assistant/SpeechBalloon.d.ts +47 -1
  12. package/lib/chat/assistant/SpeechBalloon.js +60 -8
  13. package/lib/chat/base/ChatBase.js +51 -1
  14. package/lib/chat/indicators/TypingDots.d.ts +13 -0
  15. package/lib/chat/indicators/TypingDots.js +21 -0
  16. package/lib/chat/messages/ChatMessageList.js +1 -15
  17. package/lib/components/inspector/AgentInspector.d.ts +59 -0
  18. package/lib/components/inspector/AgentInspector.js +220 -0
  19. package/lib/components/inspector/AgentInspectorDialog.d.ts +19 -0
  20. package/lib/components/inspector/AgentInspectorDialog.js +9 -0
  21. package/lib/components/inspector/JsonTree.d.ts +24 -0
  22. package/lib/components/inspector/JsonTree.js +89 -0
  23. package/lib/components/inspector/a2aInspect.d.ts +56 -0
  24. package/lib/components/inspector/a2aInspect.js +478 -0
  25. package/lib/components/inspector/agentInspector.d.ts +179 -0
  26. package/lib/components/inspector/agentInspector.js +267 -0
  27. package/lib/components/inspector/chatInspect.d.ts +38 -0
  28. package/lib/components/inspector/chatInspect.js +65 -0
  29. package/lib/components/inspector/classify.d.ts +19 -0
  30. package/lib/components/inspector/classify.js +42 -0
  31. package/lib/components/inspector/index.d.ts +14 -0
  32. package/lib/components/inspector/index.js +18 -0
  33. package/lib/components/teams/A2ATeamGraph.d.ts +42 -1
  34. package/lib/components/teams/A2ATeamGraph.js +24 -7
  35. package/lib/components/teams/NotebookPreviewView.js +9 -1
  36. package/lib/components/teams/TeamNotebook.d.ts +3 -1
  37. package/lib/components/teams/TeamNotebook.js +3 -3
  38. package/lib/components/teams/jupyterThemeVariables.d.ts +9 -0
  39. package/lib/components/teams/jupyterThemeVariables.js +441 -0
  40. package/lib/components/teams/teamConnections.d.ts +9 -1
  41. package/lib/components/teams/teamConnections.js +20 -0
  42. package/lib/components/teams/useA2ATeam.d.ts +17 -4
  43. package/lib/components/teams/useA2ATeam.js +87 -23
  44. package/lib/examples/AgentA2ATeamExample.d.ts +13 -0
  45. package/lib/examples/AgentA2ATeamExample.js +61 -13
  46. package/lib/examples/AssistantExample.js +62 -2
  47. package/lib/loop/apps/AppRenderer.js +1 -1
  48. package/lib/loop/core/index.d.ts +17 -0
  49. package/lib/loop/core/index.js +1 -0
  50. package/lib/loop/plugins/assistant/index.d.ts +1 -1
  51. package/lib/loop/plugins/assistant/index.js +8 -3
  52. package/lib/runtimes/browser/a2aPeer.d.ts +4 -1
  53. package/lib/types/chat.d.ts +7 -0
  54. package/package.json +1 -1
  55. package/scripts/codegen/generate_jupyter_theme_variables.mjs +57 -0
@@ -11,8 +11,8 @@
11
11
  * with whatever `inference` holds — a signed-in person's token, or a
12
12
  * visitor's trial key. Its one tool asks the peer (Accounting) with
13
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
14
+ * state its character acts and what its balloon says — the peer's last
15
+ * answer as the report, and the way
16
16
  * the link carries a message as the flow ({@link flowAfter}). What the peer
17
17
  * gives besides words — the formats the page accepts (`accept`), such as a
18
18
  * Jupyter notebook — is kept as its artifacts, and its latest notebook kept
@@ -20,6 +20,11 @@
20
20
  * peer calls on its connections (its MCP servers), told over A2A as it calls
21
21
  * them, are kept as the calls running now ({@link callsAfter}).
22
22
  *
23
+ * Given an Agent Inspector's sink (`inspector`), the entry's turns (its
24
+ * model, its tokens), its call to the peer, and what the peer's events were
25
+ * made of are recorded there; the A2A traffic itself is recorded by the
26
+ * peer's fetch (`connectA2APeer`'s `inspect`).
27
+ *
23
28
  * What `A2ATeamGraph` draws, and what a page's composer sends.
24
29
  *
25
30
  * @module components/teams/useA2ATeam
@@ -41,7 +46,7 @@ export const NOTEBOOK_AND_WORDS = [
41
46
  NOTEBOOK_MEDIA_TYPE,
42
47
  'text/markdown',
43
48
  ];
44
- /** What an exchange line calls what the peer gave: its words, and each of its artifacts. */
49
+ /** What the peer gave, in words: its words, and each of its artifacts. */
45
50
  export function answeredLine(artifacts) {
46
51
  const besides = artifacts.map(artifact => artifact.mediaType === NOTEBOOK_MEDIA_TYPE
47
52
  ? `a notebook, ${artifact.name}`
@@ -61,7 +66,7 @@ export function balloonLine(text, length = 120) {
61
66
  }
62
67
  /** Run a team of two over A2A in the page. */
63
68
  export function useA2ATeam(options) {
64
- const { entry, peerApp, peer, inference, maxSteps = 6, peerConnections = NO_CONNECTIONS, } = options;
69
+ const { entry, peerApp, peer, inference, maxSteps = 6, peerConnections = NO_CONNECTIONS, inspector = null, } = options;
65
70
  // By what it names, so that a page passing a new array of the same media
66
71
  // types does not make the agent again.
67
72
  const acceptKey = options.accept?.join('\n');
@@ -71,24 +76,34 @@ export function useA2ATeam(options) {
71
76
  ...AT_REST,
72
77
  state: 'greeting',
73
78
  saying: balloonLine(entry.interface.welcome ?? ''),
79
+ full: entry.interface.welcome ?? '',
74
80
  insist: Boolean(entry.interface.welcome),
75
81
  }));
76
82
  const [peerPersona, setPeerPersona] = useState(AT_REST);
77
83
  const [turns, setTurns] = useState([]);
84
+ const [peerHistory, setPeerHistory] = useState([]);
85
+ const told = useRef(0);
86
+ const tell = useCallback((role, text) => {
87
+ told.current += 1;
88
+ const id = `peer-${told.current}`;
89
+ setPeerHistory(prev => [...prev, { id, role, text }]);
90
+ }, []);
78
91
  const [report, setReport] = useState(null);
79
92
  const [artifacts, setArtifacts] = useState([]);
80
93
  const [notebook, setNotebook] = useState(null);
81
- const [exchange, setExchange] = useState([]);
82
94
  const [flow, setFlow] = useState('still');
83
95
  const [calls, setCalls] = useState([]);
84
96
  const callsNow = useRef([]);
85
97
  const callTimer = useRef(undefined);
86
98
  const [busy, setBusy] = useState(false);
87
99
  const history = useRef([]);
100
+ const turnCount = useRef(0);
88
101
  const abort = useRef(null);
89
102
  const flowTimer = useRef(undefined);
90
103
  // What the peer does, as it does it: its own state and balloon, and the link.
91
104
  const onPeerEvent = useCallback((event) => {
105
+ // Linked to the A2A entry it came from (the capture's linker).
106
+ inspector?.note(event);
92
107
  const next = flowAfter(event);
93
108
  clearTimeout(flowTimer.current);
94
109
  setFlow(next.flow);
@@ -108,26 +123,27 @@ export function useA2ATeam(options) {
108
123
  }, after.holdMs);
109
124
  }
110
125
  if (event.phase === 'asked') {
111
- setExchange(prev => [
112
- ...prev,
113
- `${entry.name} → ${peerApp.name}: ${event.request}`,
114
- ]);
126
+ tell('user', event.request);
115
127
  setPeerPersona(prev => ({
116
128
  ...prev,
117
129
  state: 'greeting',
118
130
  saying: 'On it. Let me read the books.',
131
+ full: undefined,
119
132
  insist: true,
120
133
  tool: undefined,
121
134
  }));
122
135
  }
123
136
  else if (event.phase === 'working') {
124
- if (event.note) {
125
- setExchange(prev => [...prev, `${peerApp.name}: ${event.note}`]);
137
+ if (event.tool?.ended) {
138
+ tell('assistant', event.tool.error
139
+ ? `${event.tool.name} failed: ${event.tool.error}`
140
+ : `Used ${toolOwnName(event.tool.name, peerConnections)}`);
126
141
  }
127
142
  setPeerPersona(prev => ({
128
143
  ...prev,
129
144
  state: 'working',
130
145
  saying: event.note ? balloonLine(event.note) : prev.saying,
146
+ full: event.note ?? prev.full,
131
147
  insist: Boolean(event.note) || Boolean(event.tool) || prev.insist,
132
148
  // The tool it calls, in plain words, from the step it told; a
133
149
  // step without a tool (its own words) puts the tool line away.
@@ -137,10 +153,7 @@ export function useA2ATeam(options) {
137
153
  }));
138
154
  }
139
155
  else if (event.phase === 'answered') {
140
- setExchange(prev => [
141
- ...prev,
142
- `${peerApp.name} → ${entry.name}: ${answeredLine(event.artifacts)}`,
143
- ]);
156
+ tell('assistant', event.answer);
144
157
  setReport(event.answer);
145
158
  setArtifacts(event.artifacts);
146
159
  const given = notebookAmong(event.artifacts);
@@ -151,6 +164,7 @@ export function useA2ATeam(options) {
151
164
  ...prev,
152
165
  state: 'speaking',
153
166
  saying: balloonLine(event.answer),
167
+ full: event.answer,
154
168
  insist: true,
155
169
  tool: undefined,
156
170
  }));
@@ -160,19 +174,17 @@ export function useA2ATeam(options) {
160
174
  }
161
175
  }
162
176
  else {
163
- setExchange(prev => [
164
- ...prev,
165
- `${peerApp.name} could not answer: ${event.error}`,
166
- ]);
177
+ tell('assistant', `Could not answer: ${event.error}`);
167
178
  setPeerPersona(prev => ({
168
179
  ...prev,
169
180
  state: 'idle',
170
181
  saying: balloonLine(event.error),
182
+ full: event.error,
171
183
  insist: true,
172
184
  tool: undefined,
173
185
  }));
174
186
  }
175
- }, [entry.name, peerApp.id, peerApp.name, peerConnections]);
187
+ }, [peerApp.id, peerConnections, inspector, tell]);
176
188
  const agent = useMemo(() => {
177
189
  if (!peer) {
178
190
  return null;
@@ -198,7 +210,6 @@ export function useA2ATeam(options) {
198
210
  setBusy(true);
199
211
  setReport(null);
200
212
  setArtifacts([]);
201
- setExchange([]);
202
213
  setTurns(prev => [...prev, { role: 'user', text: asked }]);
203
214
  setEntryPersona({
204
215
  ...AT_REST,
@@ -210,6 +221,19 @@ export function useA2ATeam(options) {
210
221
  history.current = [...history.current, { role: 'user', content: asked }];
211
222
  abort.current = new AbortController();
212
223
  let said = '';
224
+ // The turn, in the inspector: the entry's model, from the question to
225
+ // the answer, and its tokens.
226
+ turnCount.current += 1;
227
+ const turnKey = `turn:${entry.id}:${turnCount.current}`;
228
+ inspector?.start({
229
+ key: turnKey,
230
+ actor: entry.name,
231
+ source: 'model',
232
+ kind: 'turn',
233
+ name: entry.model || undefined,
234
+ summary: balloonLine(asked, 160),
235
+ payload: { prompt: asked, model: entry.model || undefined },
236
+ });
213
237
  try {
214
238
  const result = await agent.stream({
215
239
  messages: history.current,
@@ -218,10 +242,21 @@ export function useA2ATeam(options) {
218
242
  for await (const part of result.fullStream) {
219
243
  if (part.type === 'tool-call' && part.toolName === askTool) {
220
244
  const request = String(part.input?.request ?? '');
245
+ // Its tool runs here, in the page, and asks the peer over A2A.
246
+ inspector?.start({
247
+ key: `tool:${part.toolCallId}`,
248
+ actor: entry.name,
249
+ source: 'frontend-tool',
250
+ kind: 'tool-call',
251
+ name: askTool,
252
+ summary: balloonLine(request, 160),
253
+ payload: part.input,
254
+ });
221
255
  setEntryPersona(prev => ({
222
256
  ...prev,
223
257
  state: 'waiting',
224
258
  saying: balloonLine(`${peerApp.name}, could you: ${request}`),
259
+ full: `${peerApp.name}, could you: ${request}`,
225
260
  insist: true,
226
261
  // Its one tool, in its own words: it asks its peer.
227
262
  tool: {
@@ -234,10 +269,16 @@ export function useA2ATeam(options) {
234
269
  }));
235
270
  }
236
271
  else if (part.type === 'tool-result' && part.toolName === askTool) {
272
+ const output = part.output;
273
+ inspector?.end(`tool:${part.toolCallId}`, {
274
+ result: part.output,
275
+ ...(output?.error ? { error: String(output.error) } : {}),
276
+ });
237
277
  setEntryPersona(prev => ({
238
278
  ...prev,
239
279
  state: 'thinking',
240
280
  saying: 'Thanks!',
281
+ full: undefined,
241
282
  tool: undefined,
242
283
  }));
243
284
  // Accounting has handed its answer over: back at rest, its last
@@ -250,11 +291,18 @@ export function useA2ATeam(options) {
250
291
  ...prev,
251
292
  state: 'speaking',
252
293
  saying: balloonLine(said),
294
+ full: said,
253
295
  insist: true,
254
296
  tool: undefined,
255
297
  }));
256
298
  }
257
299
  else if (part.type === 'tool-error' && part.toolName === askTool) {
300
+ inspector?.end(`tool:${part.toolCallId}`, {
301
+ status: 'failed',
302
+ error: part.error instanceof Error
303
+ ? part.error.message
304
+ : String(part.error),
305
+ });
258
306
  setEntryPersona(prev => ({
259
307
  ...prev,
260
308
  state: 'thinking',
@@ -271,15 +319,24 @@ export function useA2ATeam(options) {
271
319
  ...history.current,
272
320
  { role: 'assistant', content: said },
273
321
  ];
322
+ if (inspector) {
323
+ const usage = await Promise.resolve(result.totalUsage).catch(() => undefined);
324
+ inspector.end(turnKey, {
325
+ result: { text: said, ...(usage ? { usage } : {}) },
326
+ summary: `${balloonLine(asked, 60)} → ${balloonLine(said, 80)}${usage?.totalTokens ? ` · ${usage.totalTokens} tokens` : ''}`,
327
+ });
328
+ }
274
329
  setTurns(prev => [...prev, { role: 'assistant', text: said }]);
275
330
  setEntryPersona(prev => ({
276
331
  ...prev,
277
332
  state: 'idle',
278
333
  saying: balloonLine(said),
334
+ full: said,
279
335
  }));
280
336
  }
281
337
  catch (error) {
282
338
  const message = error instanceof Error ? error.message : String(error);
339
+ inspector?.end(turnKey, { status: 'failed', error: message });
283
340
  setTurns(prev => [
284
341
  ...prev,
285
342
  { role: 'assistant', text: `Something went wrong: ${message}` },
@@ -288,6 +345,7 @@ export function useA2ATeam(options) {
288
345
  ...prev,
289
346
  state: 'idle',
290
347
  saying: balloonLine(`Something went wrong: ${message}`),
348
+ full: `Something went wrong: ${message}`,
291
349
  insist: true,
292
350
  tool: undefined,
293
351
  }));
@@ -303,7 +361,7 @@ export function useA2ATeam(options) {
303
361
  ? prev
304
362
  : { ...prev, state: 'idle', tool: undefined });
305
363
  }
306
- }, [agent, busy, askTool, peerApp.name]);
364
+ }, [agent, busy, askTool, peerApp.name, entry, inspector]);
307
365
  const stop = useCallback(() => abort.current?.abort(), []);
308
366
  useEffect(() => () => {
309
367
  abort.current?.abort();
@@ -312,13 +370,19 @@ export function useA2ATeam(options) {
312
370
  }, []);
313
371
  const setEntryAway = useCallback((away) => setEntryPersona(prev => ({ ...prev, away })), []);
314
372
  const setPeerAway = useCallback((away) => setPeerPersona(prev => ({ ...prev, away })), []);
373
+ const entryHistory = useMemo(() => turns.map((turn, index) => ({
374
+ id: `turn-${index}`,
375
+ role: turn.role,
376
+ text: turn.text,
377
+ })), [turns]);
315
378
  return {
379
+ entryHistory,
380
+ peerHistory,
316
381
  entryPersona,
317
382
  peerPersona,
318
383
  setEntryAway,
319
384
  setPeerAway,
320
385
  turns,
321
- exchange,
322
386
  report,
323
387
  artifacts,
324
388
  notebook,
@@ -19,6 +19,19 @@
19
19
  * list_invoices…" while Accounting calls Odoo. Then the report appears. The
20
20
  * page's own state is `useA2ATeam`'s, which the landing's home page runs too.
21
21
  *
22
+ * Under the graph, each member has its own Agent Inspector: Sales' — its
23
+ * model turns, its `ask_accounting` call, the A2A messages it sends and
24
+ * receives — and Accounting's — what it received over A2A, its Odoo MCP
25
+ * calls, `write_notebook`, its status updates and artifacts, as the A2A
26
+ * stream tells them. Both read one record (`useAgentInspector`), fed by the
27
+ * team (`useA2ATeam`'s `inspector`) and by the fetch the A2A client sends
28
+ * with (`inspectA2AFetch`); an A2A message shows in both, sent in one and
29
+ * received in the other. Right-click a member for its menu: *Inspect the
30
+ * agent…* opens its inspector over the page.
31
+ *
32
+ * Sales' suggestions — its Appspec's starters — are chips in its balloon
33
+ * while it waits for a question: one clicked is asked, as if typed.
34
+ *
22
35
  * Sales accepts a Jupyter notebook from Accounting besides its words
23
36
  * (`acceptedOutputModes`, `NOTEBOOK_AND_WORDS`). When Accounting gives one,
24
37
  * Sales' balloon shows it read-only (`NotebookPreview`: no kernel), and it
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { Button, Flash, Heading, Text, Textarea } from '@primer/react';
4
4
  import { Box } from '@datalayer/primer-addons';
@@ -10,8 +10,9 @@ 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, NOTEBOOK_AND_WORDS, teamConnectionsOf, useA2ATeam, } from '../components/teams';
13
+ import { A2ATeamGraph, NOTEBOOK_AND_WORDS, teamConnectionsOf, teamToolClassifier, useA2ATeam, } from '../components/teams';
14
14
  import { ACCOUNTING_APP_0_0_1, SALES_APP_0_0_1 } from '../specs/apps';
15
+ import { AgentInspector, classifyToolCall, inspectA2AFetch, useAgentInspector, } from '../components/inspector';
15
16
  import { SALES_AND_ACCOUNTING_TEAM_SPEC_0_0_1 } from '../specs/teams/teams';
16
17
  const TEAM = SALES_AND_ACCOUNTING_TEAM_SPEC_0_0_1;
17
18
  const SALES = SALES_APP_0_0_1;
@@ -45,6 +46,8 @@ function AgentA2ATeam() {
45
46
  }, [accountingKey]);
46
47
  const [accountingKeyEnded, setAccountingKeyEnded] = useState(() => accountingKeyClock !== undefined &&
47
48
  accountingKeyClock.expiresAt <= Date.now());
49
+ // One record of what both members do, each inspector showing its own.
50
+ const { sink: inspector } = useAgentInspector();
48
51
  const [peer, setPeer] = useState(null);
49
52
  const [peerError, setPeerError] = useState(null);
50
53
  const [draft, setDraft] = useState('');
@@ -55,14 +58,24 @@ function AgentA2ATeam() {
55
58
  let current = true;
56
59
  setPeer(null);
57
60
  setPeerError(null);
58
- connectA2APeer({ url: accountingUrl, key: accountingKey })
61
+ connectA2APeer({
62
+ url: accountingUrl,
63
+ key: accountingKey,
64
+ // The A2A traffic, recorded: Sales sends, Accounting answers.
65
+ fetch: inspectA2AFetch(globalThis.fetch.bind(globalThis), {
66
+ sink: inspector,
67
+ asker: SALES.name,
68
+ peer: ACCOUNTING.name,
69
+ classifyTool: teamToolClassifier(ACCOUNTING_CONNECTIONS, classifyToolCall),
70
+ }),
71
+ })
59
72
  .then(found => current && setPeer(found))
60
73
  .catch(error => current &&
61
74
  setPeerError(error instanceof Error ? error.message : String(error)));
62
75
  return () => {
63
76
  current = false;
64
77
  };
65
- }, [accountingUrl, accountingKey]);
78
+ }, [accountingUrl, accountingKey, inspector]);
66
79
  // Sales runs here and asks Accounting over A2A: the conversation, the
67
80
  // exchange, the report and the way the link carries a message.
68
81
  const team = useA2ATeam({
@@ -74,7 +87,13 @@ function AgentA2ATeam() {
74
87
  peerConnections: ACCOUNTING_CONNECTIONS,
75
88
  // Its words, and a notebook to run here when it gives one.
76
89
  accept: NOTEBOOK_AND_WORDS,
90
+ inspector,
77
91
  });
92
+ // Sales' starters, in its balloon while it waits for a question.
93
+ const suggestions = useMemo(() => (SALES.interface.starters ?? []).map(starter => ({
94
+ label: starter.label,
95
+ prompt: starter.message,
96
+ })), []);
78
97
  const send = (text) => {
79
98
  setDraft('');
80
99
  void team.send(text);
@@ -82,7 +101,7 @@ function AgentA2ATeam() {
82
101
  const accountingWhere = accountingKey
83
102
  ? `on a runtime, ${new URL(accountingUrl).host}, with a temporary key`
84
103
  : `on a runtime, ${new URL(accountingUrl).host}`;
85
- return (_jsxs(Box, { sx: { p: 4, maxWidth: 1080, mx: 'auto' }, children: [_jsxs(Heading, { as: "h2", sx: { fontSize: 4, mb: 1 }, children: [TEAM.emoji, " ", TEAM.name] }), _jsx(Text, { as: "p", sx: { color: 'fg.muted', mt: 0 }, children: TEAM.description }), ((anonymous.status === 'active' && anonymous.expiresAt) ||
104
+ return (_jsxs(Box, { sx: { p: 4, maxWidth: 1080, mx: 'auto' }, children: [_jsx(Heading, { as: "h2", sx: { fontSize: 4, mb: 1 }, children: "Agents A2A Team" }), _jsxs(Text, { as: "p", sx: { color: 'fg.muted', mt: 0 }, children: [TEAM.emoji, " ", _jsx("strong", { children: TEAM.name }), ": ", TEAM.description] }), ((anonymous.status === 'active' && anonymous.expiresAt) ||
86
105
  (accountingKeyClock && !accountingKeyEnded)) && (_jsxs(Box, { sx: { display: 'flex', gap: 4, flexWrap: 'wrap', mb: 3 }, children: [anonymous.status === 'active' && anonymous.expiresAt && (_jsx(AnonymousKeyTimer, { expiresAt: anonymous.expiresAt, grantedMs: anonymous.grantedMs, label: `${SALES.name}'s trial key`, onExpire: () => useAnonymousSessionStore.getState().expire() })), accountingKeyClock && !accountingKeyEnded && (_jsx(AnonymousKeyTimer, { expiresAt: accountingKeyClock.expiresAt, grantedMs: accountingKeyClock.grantedMs, label: `${ACCOUNTING.name}'s temporary key`, onExpire: () => setAccountingKeyEnded(true) }))] })), anonymous.status === 'expired' && (_jsx(Box, { sx: { mb: 3 }, children: _jsx(AnonymousKeyExpired, { agentName: SALES.name,
87
106
  // Sales runs in this page: what ended is its model's key, and
88
107
  // signing in gives it the person's own.
@@ -104,9 +123,21 @@ function AgentA2ATeam() {
104
123
  : 'on a runtime',
105
124
  persona: team.entryPersona,
106
125
  onToggle: () => composer.current?.focus(),
126
+ conversationLabel: `Ask ${SALES.name}`,
107
127
  onAway: team.setEntryAway,
128
+ history: team.entryHistory,
108
129
  expandTarget: notebookArea,
109
130
  notebookTitle: `${ACCOUNTING.name}\u2019s notebook`,
131
+ suggestions: team.ready && !team.busy ? suggestions : undefined,
132
+ onSuggestion: suggestion => send(suggestion.prompt),
133
+ inspector,
134
+ onStop: team.busy ? team.stop : undefined,
135
+ about: {
136
+ name: SALES.name,
137
+ spec: `${SALES.id}:${SALES.version}`,
138
+ model: SALES.model || undefined,
139
+ where: 'in your browser',
140
+ },
110
141
  }, peer: {
111
142
  id: ACCOUNTING.id,
112
143
  name: ACCOUNTING.name,
@@ -115,8 +146,25 @@ function AgentA2ATeam() {
115
146
  where: accountingWhere,
116
147
  persona: team.peerPersona,
117
148
  onAway: team.setPeerAway,
149
+ history: team.peerHistory,
118
150
  connections: ACCOUNTING_CONNECTIONS,
119
- }, flow: team.flow, calls: team.calls, connected: peer !== null, label: peer ? `A2A · ${peer.skill.name}` : 'A2A · not connected' }) }), _jsx(Box, { ref: notebookArea, sx: { mb: team.notebook ? 3 : 0 }, "data-team-notebook-placement": "under-graph" }), _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 => {
151
+ inspector,
152
+ about: {
153
+ name: ACCOUNTING.name,
154
+ spec: `${ACCOUNTING.id}:${ACCOUNTING.version}`,
155
+ model: ACCOUNTING.model || undefined,
156
+ where: accountingWhere,
157
+ },
158
+ }, flow: team.flow, calls: team.calls,
159
+ // Room for the balloons: suggestions, *more* and a notebook.
160
+ balloonRoom: 320, connected: peer !== null, label: peer ? `A2A · ${peer.skill.name}` : 'A2A · not connected' }) }), _jsx(Box, { ref: notebookArea, sx: { mb: team.notebook ? 3 : 0 }, "data-team-notebook-placement": "under-graph" }), _jsx(Box, { sx: {
161
+ display: 'grid',
162
+ gridTemplateColumns: ['1fr', '1fr', '1fr 1fr'],
163
+ gap: 3,
164
+ mb: 3,
165
+ }, "data-team-inspectors": "", children: [SALES, ACCOUNTING].map(member => (_jsxs(Box, { sx: { minWidth: 0 }, "data-team-inspector": member.id, children: [_jsxs(Heading, { as: "h3", sx: { fontSize: 2, mb: 2 }, children: [member.emoji, " ", member.name, "\u2019s Agent Inspector"] }), _jsx(AgentInspector, { sink: inspector, agent: member.name, maxHeight: 360, exportName: `agent-inspector-${member.id}`, emptyText: member === SALES
166
+ ? 'Its model turns, its call to Accounting and the A2A messages it sends and receives show here.'
167
+ : 'What it receives over A2A, its Odoo MCP calls and what it sends back show here.' })] }, member.id))) }), _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] }), _jsxs(Text, { as: "p", sx: { color: 'fg.muted', fontSize: 1, mt: 0 }, children: ["Or choose one of ", SALES.name, "\u2019s suggestions in its balloon."] }), _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 => {
120
168
  if (event.key === 'Enter' && !event.shiftKey) {
121
169
  event.preventDefault();
122
170
  send(draft);
@@ -126,13 +174,13 @@ function AgentA2ATeam() {
126
174
  mb: 2,
127
175
  borderRadius: 2,
128
176
  bg: turn.role === 'user' ? 'accent.subtle' : 'canvas.subtle',
129
- }, children: [_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', display: 'block' }, children: turn.role === 'user' ? 'You' : SALES.name }), _jsx(Streamdown, { children: turn.text })] }, index))) })] }), _jsxs(Box, { sx: { flex: '1 1 360px', minWidth: 0 }, children: [_jsx(Heading, { as: "h3", sx: { fontSize: 2, mb: 2 }, children: "The exchange" }), _jsx(Box, { as: "ol", sx: { pl: 3, color: 'fg.muted', fontSize: 1 }, "data-team-exchange": "", children: team.exchange.length === 0 ? (_jsx(Text, { as: "li", children: "Nothing asked yet." })) : (team.exchange.map((step, index) => (_jsx(Text, { as: "li", children: step }, index)))) }), !team.notebook && (_jsxs(_Fragment, { children: [_jsx(Heading, { as: "h3", sx: { fontSize: 2, mt: 3, mb: 2 }, children: "The report" }), _jsx(Box, { sx: {
130
- p: 3,
131
- border: '1px solid',
132
- borderColor: 'border.default',
133
- borderRadius: 2,
134
- minHeight: 120,
135
- }, "data-team-report": "", children: team.report ? (_jsx(Streamdown, { children: team.report })) : (_jsx(Text, { sx: { color: 'fg.muted' }, children: "The report Accounting returns appears here, as it returned it, or as a notebook to run when it gives one." })) })] }))] })] })] }));
177
+ }, children: [_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', display: 'block' }, children: turn.role === 'user' ? 'You' : SALES.name }), _jsx(Streamdown, { children: turn.text })] }, index))) })] }), _jsx(Box, { sx: { flex: '1 1 360px', minWidth: 0 }, children: !team.notebook && (_jsxs(_Fragment, { children: [_jsx(Heading, { as: "h3", sx: { fontSize: 2, mb: 2 }, children: "The report" }), _jsx(Box, { sx: {
178
+ p: 3,
179
+ border: '1px solid',
180
+ borderColor: 'border.default',
181
+ borderRadius: 2,
182
+ minHeight: 120,
183
+ }, "data-team-report": "", children: team.report ? (_jsx(Streamdown, { children: team.report })) : (_jsx(Text, { sx: { color: 'fg.muted' }, children: "The report Accounting returns appears here, as it returned it, or as a notebook to run when it gives one." })) })] })) })] })] }));
136
184
  }
137
185
  export function AgentA2ATeamExample() {
138
186
  return (_jsx(ThemedProvider, { children: _jsx(AgentA2ATeam, {}) }));
@@ -15,6 +15,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
15
15
  * and so are the characters clippy.js publishes, fetched when picked.
16
16
  */
17
17
  import { useEffect, useRef, useState } from 'react';
18
+ import { useAgentInspector } from '../components/inspector/agentInspector';
18
19
  import { buildReactorFromPlugins } from '@datalayer/reactor';
19
20
  import { Button, Heading, Text, ToggleSwitch } from '@primer/react';
20
21
  import { Box } from '@datalayer/primer-addons';
@@ -34,6 +35,35 @@ const SERVER = ((typeof window === 'undefined'
34
35
  ? null
35
36
  : new URLSearchParams(window.location.search).get('agentRuntimesUrl')) ||
36
37
  'http://127.0.0.1:8765').replace(/\/+$/, '');
38
+ /** The agent the assistant talks to on that server. */
39
+ const AGENT_ID = 'assistant';
40
+ /**
41
+ * Make sure the server has the assistant's agent: `npm run examples` starts
42
+ * the server with none, so the page creates it from the
43
+ * `example-agentic-chat` spec the first time, over AG-UI, the spec's own
44
+ * protocol.
45
+ */
46
+ async function ensureAssistantAgent() {
47
+ const found = await fetch(`${SERVER}/api/v1/agents/${AGENT_ID}`);
48
+ if (found.ok) {
49
+ return;
50
+ }
51
+ if (found.status !== 404) {
52
+ throw new Error(`The server could not say whether the ${AGENT_ID} agent exists (${found.status}).`);
53
+ }
54
+ const created = await fetch(`${SERVER}/api/v1/agents`, {
55
+ method: 'POST',
56
+ headers: { 'content-type': 'application/json' },
57
+ body: JSON.stringify({
58
+ name: AGENT_ID,
59
+ agent_spec_id: 'example-agentic-chat',
60
+ transport: 'ag-ui',
61
+ }),
62
+ });
63
+ if (!created.ok && created.status !== 409) {
64
+ throw new Error(`The server could not create the ${AGENT_ID} agent (${created.status}).`);
65
+ }
66
+ }
37
67
  /** The plugins this page enables: Datalayer's characters and the owl's. */
38
68
  const reactor = buildReactorFromPlugins([
39
69
  AssistantCharactersPlugin,
@@ -73,8 +103,21 @@ const AssistantExample = () => {
73
103
  ? assistantCharacterNamed(reactor, character)
74
104
  : character;
75
105
  const [loadError, setLoadError] = useState();
106
+ // The agent on the server, made if it is not there yet.
107
+ const [agentReady, setAgentReady] = useState(false);
108
+ const [agentError, setAgentError] = useState();
109
+ useEffect(() => {
110
+ let cancelled = false;
111
+ ensureAssistantAgent().then(() => !cancelled && setAgentReady(true), error => !cancelled &&
112
+ setAgentError(`${error instanceof Error ? error.message : String(error)} The assistant talks to an agent-runtimes server at ${SERVER}.`));
113
+ return () => {
114
+ cancelled = true;
115
+ };
116
+ }, []);
76
117
  // How the balloon shows the conversation (T-23): history, or current.
77
118
  const [balloon, setBalloon] = useState('history');
119
+ // What the agent does, for the menu's *Inspect the agent…*.
120
+ const { sink: inspector } = useAgentInspector();
78
121
  // Pixel, the test sprite, read once through the clippy.js reader.
79
122
  const [pixel, setPixel] = useState();
80
123
  useEffect(() => {
@@ -156,8 +199,25 @@ const AssistantExample = () => {
156
199
  }
157
200
  } }), loadError && (_jsx(Text, { as: "p", role: "alert", sx: { color: 'danger.fg', mt: 2 }, children: loadError })), typeof character !== 'string' &&
158
201
  character !== pixel &&
159
- !isClippyJs && (_jsxs(Text, { as: "p", sx: { mt: 2 }, children: ["Playing ", character.name, "."] }))] })] }), _jsx(ChatFloating, { defaultViewMode: "assistant", assistantCharacter: drawn, balloonDisplay: balloon, protocol: "vercel-ai", endpoint: `${SERVER}/api/v1/vercel-ai/assistant`, title: "Assistant", description: "Hello! Ask me anything about this page.", position: "bottom-right", useStore: false,
202
+ !isClippyJs && (_jsxs(Text, { as: "p", sx: { mt: 2 }, children: ["Playing ", character.name, "."] }))] })] }), agentError && (_jsx(Box, { sx: { maxWidth: 720, mx: 'auto', mt: 3 }, children: _jsx(Text, { as: "p", role: "alert", sx: { color: 'danger.fg' }, children: agentError }) })), agentReady && (_jsx(ChatFloating, { defaultViewMode: "assistant", assistantCharacter: drawn, balloonDisplay: balloon, protocol: "ag-ui", endpoint: `${SERVER}/api/v1/ag-ui/${AGENT_ID}/`, title: "Assistant", description: "Hello! Ask me anything about this page.", position: "bottom-right", useStore: false,
160
203
  // Ask a decision beside the composer: Jev, through this server.
161
- decisions: { serverUrl: SERVER } }, typeof character === 'string' ? character : character.sprite)] }) }));
204
+ decisions: { serverUrl: SERVER },
205
+ // Right-click the character: its menu.
206
+ inspector: inspector, onBalloonDisplayChange: setBalloon, onChangeCharacter: () => document
207
+ .querySelector('[data-assistant-character]')
208
+ ?.focus(), about: {
209
+ name: 'Assistant',
210
+ spec: AGENT_ID,
211
+ where: `on a runtime, ${new URL(SERVER).host}`,
212
+ }, suggestions: [
213
+ {
214
+ title: 'What is on this page?',
215
+ message: 'What is on this page?',
216
+ },
217
+ {
218
+ title: 'Tell me a joke',
219
+ message: 'Tell me a short joke about accountants.',
220
+ },
221
+ ] }, typeof character === 'string' ? character : character.sprite))] }) }));
162
222
  };
163
223
  export default AssistantExample;
@@ -209,7 +209,7 @@ export function appDatalayerCreatePayload(app, instance) {
209
209
  transport: 'ag-ui',
210
210
  agent_spec_id: agentIdOf(app),
211
211
  app_spec: dumpAppspec(app),
212
- // Code Mode calls every tool through `execute_code`, which an
212
+ // Codemode calls every tool through `execute_code`, which an
213
213
  // application without a shell is refused: its tools are called
214
214
  // one by one instead, each decided by its rules.
215
215
  enable_codemode: Boolean(app.permissions?.computer?.shell),
@@ -531,6 +531,23 @@ export type AssistantCharacterContribution = {
531
531
  character: AssistantCharacter | AssistantCharacterData;
532
532
  };
533
533
  export declare const LoopAssistantCharacter: import("@datalayer/reactor").ContributionPoint<AssistantCharacterContribution>;
534
+ /**
535
+ * An entry of the floating assistant's own menu (right-click, Shift+F10, a
536
+ * long press): a plugin's, beside what the assistant offers itself. Its
537
+ * `group` is the heading it is listed under; `checked` makes it one of a
538
+ * choice.
539
+ */
540
+ export type AssistantMenuContribution = {
541
+ /** Stable id, for the registry and the graph. */
542
+ id: string;
543
+ label: string;
544
+ description?: string;
545
+ onSelect: () => void;
546
+ checked?: boolean;
547
+ disabled?: boolean;
548
+ group?: string;
549
+ };
550
+ export declare const LoopAssistantMenu: import("@datalayer/reactor").ContributionPoint<AssistantMenuContribution>;
534
551
  export { LoopCanvasBlock, type CanvasBlockContribution } from './canvasBlocks';
535
552
  /**
536
553
  * The per-example chat extras a host feeds the loop's conversation live.
@@ -142,6 +142,7 @@ export const LoopChatHeader = defineContributionPoint('loop.chat.header');
142
142
  export const LoopAgentBlueprint = defineContributionPoint('loop.agent.blueprint');
143
143
  export const LoopChatSuggestion = defineContributionPoint('loop.chat.suggestion');
144
144
  export const LoopAssistantCharacter = defineContributionPoint('loop.assistant.character');
145
+ export const LoopAssistantMenu = defineContributionPoint('loop.assistant.menu');
145
146
  // A block the Canvas may place (LOOP C-12), in a module of its own so that
146
147
  // the palette is read without the rest of the contracts.
147
148
  export { LoopCanvasBlock } from './canvasBlocks';
@@ -68,6 +68,6 @@ export declare function assistantStateOfTurn(turn: ChatTurnSnapshot, presence: P
68
68
  arriving?: boolean;
69
69
  leaving?: boolean;
70
70
  }): AssistantState;
71
- export declare function LoopAssistant({ app, person, appId, decisions, balloon: display, }: LoopAssistantConfig): JSX.Element | null;
71
+ export declare function LoopAssistant({ app, person, appId, decisions, balloon: displayGiven, }: LoopAssistantConfig): JSX.Element | null;
72
72
  export declare const LoopAssistantPlugin: import("@datalayer/reactor").ReactorPlugin<LoopAssistantConfig, unknown, unknown>;
73
73
  export default LoopAssistantPlugin;
@@ -13,7 +13,7 @@ import { ASSISTANT_WORDS, assistantStateOf, keepAway, keptAway, latestSaying, }
13
13
  import { useApproveToolRequest, useRejectToolRequest, useToolApprovalsQuery, } from '../../../hooks/useToolApprovals';
14
14
  import { approvalsOfApp, ruleOfApproval } from '../app-rules/AppRulesCard';
15
15
  import { useViewportDrag } from '../../../chat/useViewportDrag';
16
- import { LoopAssistantCharacter, LoopChatLayout, LoopChatTurn, LoopSlots, } from '../../core';
16
+ import { LoopAssistantCharacter, LoopAssistantMenu, LoopChatLayout, LoopChatTurn, LoopSlots, } from '../../core';
17
17
  import { assistantCharacterFor } from '../assistant-characters';
18
18
  export const LOOP_ASSISTANT_PLUGIN_NAME = '@datalayer/loop-plugin-assistant';
19
19
  /** The character's size, in pixels, as the chat's own assistant. */
@@ -69,9 +69,14 @@ function focusComposer() {
69
69
  const composer = document.querySelector('[data-chat-composer] textarea, [data-chat-composer] [contenteditable="true"]');
70
70
  composer?.focus();
71
71
  }
72
- export function LoopAssistant({ app, person, appId, decisions, balloon: display = 'history', }) {
72
+ export function LoopAssistant({ app, person, appId, decisions, balloon: displayGiven = 'history', }) {
73
+ // The balloon's display, as its menu chose it; the Appspec's until then.
74
+ const [chosenDisplay, setChosenDisplay] = useState();
75
+ const display = chosenDisplay ?? displayGiven;
73
76
  const contributed = useContributions(LoopAssistantCharacter).map(entry => entry.value);
74
77
  const chosen = assistantCharacterFor(contributed, { app, person });
78
+ // What the enabled plugins add to its menu.
79
+ const menuContributions = useContributions(LoopAssistantMenu).map(entry => entry.value);
75
80
  const turnEntries = useContributions(LoopChatTurn);
76
81
  const turn = useSignalValue(turnEntries[0]?.value.turn ?? NO_TURN);
77
82
  const presence = useSignalValue(turnEntries[0]?.value.presence ?? NO_PRESENCE);
@@ -199,7 +204,7 @@ export function LoopAssistant({ app, person, appId, decisions, balloon: display
199
204
  };
200
205
  return (_jsxs(_Fragment, { children: [appId && signedIn ? (_jsx(WaitingApprovals, { appId: appId, onWaiting: setApproval })) : null, _jsx(AssistantStage, { character: chosen.character, state: state, size: SIZE, place: drag.position
201
206
  ? { left: `${drag.position.left}px`, top: `${drag.position.top}px` }
202
- : { right: '24px', bottom: '24px' }, stageRef: stageRef, onDragStart: drag.onHandlePointerDown, open: open, onToggle: onToggle, balloon: balloon, balloonDisplay: display, insist: insist, onDismiss: onDismiss, decide: decide })] }));
207
+ : { right: '24px', bottom: '24px' }, stageRef: stageRef, onDragStart: drag.onHandlePointerDown, open: open, onToggle: onToggle, balloon: balloon, balloonDisplay: display, insist: insist, onDismiss: onDismiss, decide: decide, onResetPosition: drag.position ? drag.reset : undefined, onBalloonDisplayChange: setChosenDisplay, about: appId ? { name: appId, spec: appId } : undefined, contextMenu: menuContributions })] }));
203
208
  }
204
209
  export const LoopAssistantPlugin = definePlugin({
205
210
  name: LOOP_ASSISTANT_PLUGIN_NAME,