@datalayer/agent-runtimes 1.3.69 → 1.3.71

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.
@@ -71,7 +71,15 @@ export function EphemeralNotebook({ notebookId, runtimeName, runtimeOverride, se
71
71
  // deliberately NO fallback to "first running" or `runtimes[0]`: the ephemeral
72
72
  // notebook must bind to exactly the runtime assigned to this agent, or to
73
73
  // none at all (straight path).
74
- const { runtimes, refetchRuntimes } = useAgentsRuntimes();
74
+ //
75
+ // Asked only when there is a pod to look up: a host that hands its own
76
+ // manager (a browser sandbox) or an endpoint has nothing to find there,
77
+ // and a page that never needs the runtimes list does not poll it.
78
+ const { runtimes, refetchRuntimes } = useAgentsRuntimes(undefined, {
79
+ enabled: Boolean(String(runtimeName || '').trim()) &&
80
+ !externalServiceManager &&
81
+ !String(runtimeOverride?.baseUrl || '').trim(),
82
+ });
75
83
  const resolvedRuntime = useMemo(() => {
76
84
  const preferredRuntime = String(runtimeName || '').trim();
77
85
  if (!preferredRuntime) {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A notebook a member of the team gave, open in the page to run.
3
+ *
4
+ * What a peer gives besides words arrives as an artifact by media type
5
+ * (`a2aPeer`'s {@link A2APeerArtifact}); a Jupyter notebook
6
+ * (`application/x-ipynb+json`) is shown here, as the loop's notebook view
7
+ * shows one: jupyter-react's notebook on the browser sandbox, a Pyodide
8
+ * kernel in the page, where the reader runs cells, edits them and adds more.
9
+ * It runs in the reader's browser and costs nothing elsewhere.
10
+ *
11
+ * This module is light: the notebook, JupyterLab and Pyodide are in
12
+ * `TeamNotebookView`, imported only once a notebook is drawn, so that a page
13
+ * that shows a team — the landing's home page — loads none of them until a
14
+ * notebook arrives.
15
+ *
16
+ * @module components/teams/TeamNotebook
17
+ */
18
+ import type { JSX } from 'react';
19
+ import type { A2APeerArtifact } from '../../runtimes/browser/a2aPeer';
20
+ export type TeamNotebookProps = {
21
+ /** The notebook, as the peer gave it: its `data` is the nbformat document. */
22
+ notebook: A2APeerArtifact;
23
+ /** What the section is called: `Accounting's notebook`. */
24
+ title: string;
25
+ /** How tall the notebook is, in pixels. */
26
+ height?: number;
27
+ };
28
+ /** The file a notebook is saved as: the peer's name for it, else its name. */
29
+ export declare function notebookFileName(notebook: A2APeerArtifact): string;
30
+ /** A notebook a member of the team gave, under its title, to run in the page. */
31
+ export declare function TeamNotebook({ notebook, title, height, }: TeamNotebookProps): JSX.Element;
32
+ export default TeamNotebook;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Suspense, lazy } from 'react';
3
+ import { Heading, Text } from '@primer/react';
4
+ import { Box } from '@datalayer/primer-addons';
5
+ /** The view itself: jupyter-react, JupyterLab and Pyodide, loaded when a notebook is drawn. */
6
+ const TeamNotebookView = lazy(() => import('./TeamNotebookView'));
7
+ /** The file a notebook is saved as: the peer's name for it, else its name. */
8
+ export function notebookFileName(notebook) {
9
+ if (notebook.filename) {
10
+ return notebook.filename;
11
+ }
12
+ const stem = notebook.name
13
+ .replace(/[^A-Za-z0-9]+/g, '-')
14
+ .replace(/^-+|-+$/g, '')
15
+ .toLowerCase() || 'notebook';
16
+ return `${stem}.ipynb`;
17
+ }
18
+ /** A notebook a member of the team gave, under its title, to run in the page. */
19
+ export function TeamNotebook({ notebook, title, height = 560, }) {
20
+ return (_jsxs(Box, { as: "section", "aria-label": title, "data-team-notebook": "", sx: { display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }, children: [_jsx(Heading, { as: "h3", sx: { fontSize: 2, m: 0 }, children: title }), _jsx(Suspense, { fallback: _jsx(Text, { as: "p", sx: { color: 'fg.muted', fontSize: 1, m: 0 }, children: "Opening the notebook\u2026" }), children: _jsx(TeamNotebookView, { notebook: notebook, fileName: notebookFileName(notebook), height: height }) })] }));
21
+ }
22
+ export default TeamNotebook;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The notebook a member of the team gave, on the browser sandbox.
3
+ *
4
+ * What the loop's notebook view does (`loop/plugins/notebook/NotebookView`),
5
+ * for a notebook that arrived over A2A: the browser sandbox
6
+ * (`createBrowserSandboxService`, a JupyterLite Pyodide kernel in the page)
7
+ * is started, and `EphemeralNotebook` is bound to its manager and its
8
+ * kernel. Nothing runs anywhere but in the reader's browser.
9
+ *
10
+ * Loaded by `TeamNotebook` when a notebook is drawn, never before.
11
+ *
12
+ * @module components/teams/TeamNotebookView
13
+ */
14
+ import type { JSX } from 'react';
15
+ import type { A2APeerArtifact } from '../../runtimes/browser/a2aPeer';
16
+ export type TeamNotebookViewProps = {
17
+ notebook: A2APeerArtifact;
18
+ /** The file it is saved as. */
19
+ fileName: string;
20
+ height: number;
21
+ };
22
+ export declare function TeamNotebookView({ notebook, fileName, height, }: TeamNotebookViewProps): JSX.Element;
23
+ export default TeamNotebookView;
@@ -0,0 +1,90 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useState } from 'react';
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4
+ import { DownloadIcon } from '@primer/octicons-react';
5
+ import { Button, Flash, Text } from '@primer/react';
6
+ import { Box } from '@datalayer/primer-addons';
7
+ import { useSignalValue } from '@datalayer/reactor/react';
8
+ import { notebookStore } from '@datalayer/jupyter-react';
9
+ import { EphemeralNotebook } from '../../chat/notebook/EphemeralNotebook';
10
+ import { createBrowserSandboxService } from '../../loop/plugins/agents/browserService';
11
+ /** Each notebook drawn has its own id in jupyter-react's store. */
12
+ let drawn = 0;
13
+ /** Save a notebook as an `.ipynb` file, as Jupyter writes one. */
14
+ function download(content, fileName) {
15
+ const text = `${JSON.stringify(content, null, 1)}\n`;
16
+ const url = URL.createObjectURL(new Blob([text], { type: 'application/x-ipynb+json' }));
17
+ const link = document.createElement('a');
18
+ link.href = url;
19
+ link.download = fileName;
20
+ link.click();
21
+ URL.revokeObjectURL(url);
22
+ }
23
+ export function TeamNotebookView({ notebook, fileName, height, }) {
24
+ // One sandbox for the view: a notebook that replaces another runs on the
25
+ // same kernel, already started.
26
+ const service = useMemo(() => createBrowserSandboxService(), []);
27
+ const snapshot = useSignalValue(service.snapshot);
28
+ const [failed, setFailed] = useState(null);
29
+ // The notebook's store needs a query client; this view asks it nothing.
30
+ const queries = useMemo(() => new QueryClient(), []);
31
+ useEffect(() => {
32
+ const disconnect = service.connect();
33
+ return disconnect;
34
+ }, [service]);
35
+ useEffect(() => {
36
+ if (snapshot.state === 'error') {
37
+ setFailed('Python could not start in this browser, so the notebook cannot run here. Download it to open it in Jupyter.');
38
+ }
39
+ }, [snapshot.state]);
40
+ // A new id for each notebook given: the notebook component keeps the
41
+ // document it is first handed.
42
+ const notebookId = useMemo(() => {
43
+ drawn += 1;
44
+ return `team-notebook-${drawn}`;
45
+ }, [notebook]);
46
+ const content = notebook.data;
47
+ // Handed once: the component edits it in place.
48
+ const opening = useMemo(() => JSON.parse(JSON.stringify(content)), [content]);
49
+ const save = () => {
50
+ // What the reader has made of it, when it is open; as it came otherwise.
51
+ const model = notebookStore
52
+ .getState()
53
+ .selectNotebook(notebookId)
54
+ ?.model?.toJSON();
55
+ download(model ?? content, fileName);
56
+ };
57
+ const manager = service.getServiceManager() ??
58
+ undefined;
59
+ const running = snapshot.state === 'running' && manager !== undefined;
60
+ return (_jsx(QueryClientProvider, { client: queries, children: _jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsxs(Box, { sx: {
61
+ display: 'flex',
62
+ alignItems: 'center',
63
+ gap: 2,
64
+ flexWrap: 'wrap',
65
+ }, children: [_jsx(Text, { sx: { color: 'fg.muted', fontSize: 1, flex: '1 1 auto' }, children: running
66
+ ? 'It runs in your browser, with Python (Pyodide): run its cells, change them, add your own.'
67
+ : 'Starting Python in your browser…' }), _jsx(Button, { size: "small", leadingVisual: DownloadIcon, onClick: save, "data-team-notebook-download": "", children: "Download .ipynb" })] }), failed && _jsx(Flash, { variant: "warning", children: failed }), _jsx(Box, { "data-team-notebook-sheet": "", sx: {
68
+ position: 'relative',
69
+ height,
70
+ minHeight: 0,
71
+ border: '1px solid',
72
+ borderColor: 'border.default',
73
+ borderRadius: 2,
74
+ overflow: 'hidden',
75
+ // As the loop's notebook view pins them: Lumino sizes these
76
+ // itself, and a resize it never hears of leaves them at zero.
77
+ '& .jp-NotebookPanel': {
78
+ height: '100% !important',
79
+ minHeight: 0,
80
+ },
81
+ '& .dla-Box-Notebook': {
82
+ display: 'flex',
83
+ flexDirection: 'column',
84
+ minHeight: 0,
85
+ },
86
+ }, children: running && (_jsx(EphemeralNotebook, { notebookId: notebookId, nbformat: opening, inheritTheme: true, serviceManager: manager,
87
+ // Join the sandbox's kernel rather than starting a rival one.
88
+ kernelId: snapshot.kernelId }, notebookId)) })] }) }));
89
+ }
90
+ export default TeamNotebookView;
@@ -10,3 +10,4 @@ export * from './a2aTeamFlow';
10
10
  export * from './teamConnections';
11
11
  export * from './useA2ATeam';
12
12
  export * from './A2ATeamGraph';
13
+ export * from './TeamNotebook';
@@ -14,3 +14,4 @@ export * from './a2aTeamFlow';
14
14
  export * from './teamConnections';
15
15
  export * from './useA2ATeam';
16
16
  export * from './A2ATeamGraph';
17
+ export * from './TeamNotebook';
@@ -1,6 +1,6 @@
1
1
  import type { AssistantState } from '../../chat/assistant/state';
2
2
  import { type BrowserModelOptions } from '../../runtimes/browser/model';
3
- import { type A2APeer } from '../../runtimes/browser/a2aPeer';
3
+ import { type A2APeer, type A2APeerArtifact } from '../../runtimes/browser/a2aPeer';
4
4
  import type { AppSpec } from '../../types/agentspecs';
5
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. */
@@ -18,6 +18,12 @@ export type A2ATeamTurn = {
18
18
  role: 'user' | 'assistant';
19
19
  text: string;
20
20
  };
21
+ /** What a page that shows notebooks accepts: a notebook, and words in Markdown. */
22
+ export declare const NOTEBOOK_AND_WORDS: readonly string[];
23
+ /** What an exchange line calls what the peer gave: its words, and each of its artifacts. */
24
+ export declare function answeredLine(artifacts: A2APeerArtifact[]): string;
25
+ /** The latest notebook among what a peer gave, or none. */
26
+ export declare function notebookAmong(artifacts: A2APeerArtifact[]): A2APeerArtifact | null;
21
27
  /** A line short enough for a balloon. */
22
28
  export declare function balloonLine(text: string, length?: number): string;
23
29
  export type UseA2ATeamOptions = {
@@ -35,6 +41,11 @@ export type UseA2ATeamOptions = {
35
41
  maxSteps?: number;
36
42
  /** The peer's connections (`teamConnectionsOf(peerApp)`): the calls to them are kept. */
37
43
  peerConnections?: A2ATeamConnection[];
44
+ /**
45
+ * The media types the page accepts from the peer (`acceptedOutputModes`):
46
+ * words, and the formats it can show, such as a notebook. Unsaid, words alone.
47
+ */
48
+ accept?: readonly string[];
38
49
  };
39
50
  export type A2ATeam = {
40
51
  entryPersona: A2ATeamPersona;
@@ -46,6 +57,10 @@ export type A2ATeam = {
46
57
  exchange: string[];
47
58
  /** The peer's last answer, as it gave it. */
48
59
  report: string | null;
60
+ /** What the peer gave besides words with its last answer, by media type. */
61
+ artifacts: A2APeerArtifact[];
62
+ /** The latest notebook the peer gave, kept until another one comes. */
63
+ notebook: A2APeerArtifact | null;
49
64
  /** Which way the link carries a message now. */
50
65
  flow: A2ATeamFlow;
51
66
  /** The tool calls the peer's connections are answering now. */
@@ -13,7 +13,10 @@
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}). The tools the
16
+ * the link carries a message as the flow ({@link flowAfter}). What the peer
17
+ * gives besides words — the formats the page accepts (`accept`), such as a
18
+ * Jupyter notebook — is kept as its artifacts, and its latest notebook kept
19
+ * until another one comes ({@link A2ATeam.notebook}). The tools the
17
20
  * peer calls on its connections (its MCP servers), told over A2A as it calls
18
21
  * them, are kept as the calls running now ({@link callsAfter}).
19
22
  *
@@ -24,7 +27,7 @@
24
27
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
25
28
  import { stepCountIs, ToolLoopAgent } from 'ai';
26
29
  import { createBrowserModel, } from '../../runtimes/browser/model';
27
- import { a2aPeerTool, } from '../../runtimes/browser/a2aPeer';
30
+ import { a2aPeerTool, NOTEBOOK_MEDIA_TYPE, } from '../../runtimes/browser/a2aPeer';
28
31
  import { callsAfter, flowAfter, pruneCalls, } from './a2aTeamFlow';
29
32
  const NO_CONNECTIONS = [];
30
33
  export const AT_REST = {
@@ -32,6 +35,24 @@ export const AT_REST = {
32
35
  insist: false,
33
36
  away: false,
34
37
  };
38
+ /** What a page that shows notebooks accepts: a notebook, and words in Markdown. */
39
+ export const NOTEBOOK_AND_WORDS = [
40
+ NOTEBOOK_MEDIA_TYPE,
41
+ 'text/markdown',
42
+ ];
43
+ /** What an exchange line calls what the peer gave: its words, and each of its artifacts. */
44
+ export function answeredLine(artifacts) {
45
+ const besides = artifacts.map(artifact => artifact.mediaType === NOTEBOOK_MEDIA_TYPE
46
+ ? `a notebook, ${artifact.name}`
47
+ : artifact.name);
48
+ return besides.length ? `the report and ${besides.join(', ')}` : 'the report';
49
+ }
50
+ /** The latest notebook among what a peer gave, or none. */
51
+ export function notebookAmong(artifacts) {
52
+ return ([...artifacts]
53
+ .reverse()
54
+ .find(artifact => artifact.mediaType === NOTEBOOK_MEDIA_TYPE) ?? null);
55
+ }
35
56
  /** A line short enough for a balloon. */
36
57
  export function balloonLine(text, length = 120) {
37
58
  const flat = text.replace(/\s+/g, ' ').trim();
@@ -40,6 +61,10 @@ export function balloonLine(text, length = 120) {
40
61
  /** Run a team of two over A2A in the page. */
41
62
  export function useA2ATeam(options) {
42
63
  const { entry, peerApp, peer, inference, maxSteps = 6, peerConnections = NO_CONNECTIONS, } = options;
64
+ // By what it names, so that a page passing a new array of the same media
65
+ // types does not make the agent again.
66
+ const acceptKey = options.accept?.join('\n');
67
+ const accept = useMemo(() => (acceptKey ? acceptKey.split('\n') : undefined), [acceptKey]);
43
68
  const askTool = options.askTool ?? `ask_${peerApp.id.replace(/-/g, '_')}`;
44
69
  const [entryPersona, setEntryPersona] = useState(() => ({
45
70
  ...AT_REST,
@@ -50,6 +75,8 @@ export function useA2ATeam(options) {
50
75
  const [peerPersona, setPeerPersona] = useState(AT_REST);
51
76
  const [turns, setTurns] = useState([]);
52
77
  const [report, setReport] = useState(null);
78
+ const [artifacts, setArtifacts] = useState([]);
79
+ const [notebook, setNotebook] = useState(null);
53
80
  const [exchange, setExchange] = useState([]);
54
81
  const [flow, setFlow] = useState('still');
55
82
  const [calls, setCalls] = useState([]);
@@ -105,9 +132,14 @@ export function useA2ATeam(options) {
105
132
  else if (event.phase === 'answered') {
106
133
  setExchange(prev => [
107
134
  ...prev,
108
- `${peerApp.name} → ${entry.name}: the report`,
135
+ `${peerApp.name} → ${entry.name}: ${answeredLine(event.artifacts)}`,
109
136
  ]);
110
137
  setReport(event.answer);
138
+ setArtifacts(event.artifacts);
139
+ const given = notebookAmong(event.artifacts);
140
+ if (given) {
141
+ setNotebook(given);
142
+ }
111
143
  setPeerPersona(prev => ({
112
144
  ...prev,
113
145
  state: 'speaking',
@@ -139,10 +171,12 @@ export function useA2ATeam(options) {
139
171
  model: entry.model || undefined,
140
172
  }),
141
173
  instructions: entry.instructions,
142
- tools: { [askTool]: a2aPeerTool({ peer, onEvent: onPeerEvent }) },
174
+ tools: {
175
+ [askTool]: a2aPeerTool({ peer, onEvent: onPeerEvent, accept }),
176
+ },
143
177
  stopWhen: stepCountIs(maxSteps),
144
178
  });
145
- }, [peer, inference, onPeerEvent, entry, askTool, maxSteps]);
179
+ }, [peer, inference, onPeerEvent, entry, askTool, maxSteps, accept]);
146
180
  const send = useCallback(async (text) => {
147
181
  const asked = text.trim();
148
182
  if (!asked || !agent || busy) {
@@ -150,6 +184,7 @@ export function useA2ATeam(options) {
150
184
  }
151
185
  setBusy(true);
152
186
  setReport(null);
187
+ setArtifacts([]);
153
188
  setExchange([]);
154
189
  setTurns(prev => [...prev, { role: 'user', text: asked }]);
155
190
  setEntryPersona({
@@ -250,6 +285,8 @@ export function useA2ATeam(options) {
250
285
  turns,
251
286
  exchange,
252
287
  report,
288
+ artifacts,
289
+ notebook,
253
290
  flow,
254
291
  calls,
255
292
  busy,
@@ -17,6 +17,12 @@
17
17
  * the tool's name on it. Then the report appears. The page's own state is `useA2ATeam`'s, which the landing's home
18
18
  * page runs too.
19
19
  *
20
+ * Sales accepts a Jupyter notebook from Accounting besides its words
21
+ * (`acceptedOutputModes`, `NOTEBOOK_AND_WORDS`). When Accounting gives one,
22
+ * it opens under the conversation in place of the report, on the browser
23
+ * sandbox — a Pyodide kernel in this page — to run, change and download
24
+ * (`TeamNotebook`, loaded only then).
25
+ *
20
26
  * Where Accounting is: `VITE_A2A_ACCOUNTING_URL`, else the examples' local
21
27
  * server (`…:8765/api/v1/a2a/agents/accounting`), which answers this machine
22
28
  * without a key. A cloud runtime needs a key granted to its route:
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx, 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,7 +10,7 @@ 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, teamConnectionsOf, useA2ATeam, } from '../components/teams';
13
+ import { A2ATeamGraph, NOTEBOOK_AND_WORDS, TeamNotebook, 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;
@@ -70,6 +70,8 @@ function AgentA2ATeam() {
70
70
  inference,
71
71
  askTool: 'ask_accounting',
72
72
  peerConnections: ACCOUNTING_CONNECTIONS,
73
+ // Its words, and a notebook to run here when it gives one.
74
+ accept: NOTEBOOK_AND_WORDS,
73
75
  });
74
76
  const send = (text) => {
75
77
  setDraft('');
@@ -120,13 +122,13 @@ function AgentA2ATeam() {
120
122
  mb: 2,
121
123
  borderRadius: 2,
122
124
  bg: turn.role === 'user' ? 'accent.subtle' : 'canvas.subtle',
123
- }, 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)))) }), _jsx(Heading, { as: "h3", sx: { fontSize: 2, mt: 3, mb: 2 }, children: "The report" }), _jsx(Box, { sx: {
124
- p: 3,
125
- border: '1px solid',
126
- borderColor: 'border.default',
127
- borderRadius: 2,
128
- minHeight: 120,
129
- }, "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." })) })] })] })] }));
125
+ }, 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: {
126
+ p: 3,
127
+ border: '1px solid',
128
+ borderColor: 'border.default',
129
+ borderRadius: 2,
130
+ minHeight: 120,
131
+ }, "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." })) })] }))] })] }), team.notebook && (_jsx(Box, { sx: { mt: 3 }, children: _jsx(TeamNotebook, { notebook: team.notebook, title: `${ACCOUNTING.name}\u2019s notebook` }) }))] }));
130
132
  }
131
133
  export function AgentA2ATeamExample() {
132
134
  return (_jsx(ThemedProvider, { children: _jsx(AgentA2ATeam, {}) }));
@@ -105,6 +105,7 @@ export function emptyAppspec(kind = 'chat') {
105
105
  starters: [],
106
106
  settings: [],
107
107
  components: [],
108
+ outputs: [],
108
109
  },
109
110
  tests: {
110
111
  readyAt: DEFAULT_READY_AT,
@@ -246,6 +247,7 @@ function parseInterface(data, kind) {
246
247
  settings: records(data.settings).map(parseSetting),
247
248
  components: texts(data.components),
248
249
  voice: parseVoice(data.voice),
250
+ outputs: texts(data.outputs),
249
251
  };
250
252
  if (isData(data.surface)) {
251
253
  parsed.surface = parseSurface(data.surface);
@@ -524,6 +526,8 @@ function dumpInterface(spec, kind) {
524
526
  .text('language', spec.voice.language)
525
527
  .value('where', spec.voice.where, DEFAULT_VOICE.where).data);
526
528
  }
529
+ // The formats its answers come in, words first, when it says any.
530
+ writer.list('outputs', spec.outputs ?? []);
527
531
  return writer.data;
528
532
  }
529
533
  function dumpDecision(decision) {
@@ -16,7 +16,7 @@
16
16
  "description": "Answers requests for financial reports, such as open invoices, aged balances, a trial balance or a customer's ledger, from the Odoo books, which it only reads.",
17
17
  "tags": ["example", "accounting", "finance", "odoo", "a2a", "team"],
18
18
  "inputModes": ["text/plain"],
19
- "outputModes": ["text/plain"],
19
+ "outputModes": ["text/markdown", "application/x-ipynb+json"],
20
20
  "examples": [
21
21
  "List the customer invoices that are still open, with the total due.",
22
22
  "Give the aged receivables as of today, by customer.",
@@ -24,8 +24,8 @@
24
24
  ]
25
25
  }
26
26
  ],
27
- "defaultInputModes": ["application/json"],
28
- "defaultOutputModes": ["application/json"],
27
+ "defaultInputModes": ["text/plain"],
28
+ "defaultOutputModes": ["text/markdown", "application/x-ipynb+json"],
29
29
  "capabilities": {
30
30
  "streaming": true,
31
31
  "pushNotifications": false,
@@ -14,6 +14,13 @@
14
14
  * peer's answer in words. Each step is told to {@link A2APeerEvent} as it
15
15
  * happens, so a page can show the exchange: asked, working, answered.
16
16
  *
17
+ * **Output formats.** A peer's card says the media types it answers in (its
18
+ * skill's `outputModes`). The asker says which it accepts with each request
19
+ * (`acceptedOutputModes`, {@link AskA2APeerOptions.accept}); what the peer
20
+ * gives besides words — a Jupyter notebook, `application/x-ipynb+json` —
21
+ * arrives as artifacts, one part of a media type each, and is handed over on
22
+ * `answered` as {@link A2APeerArtifact}s, whatever their format.
23
+ *
17
24
  * The key goes as a bearer token on every request, the card's too. The
18
25
  * runtime hands it to the peer's run, which then reaches the peer's
19
26
  * connections with it and with nothing broader.
@@ -59,10 +66,34 @@ export type A2APeerToolStep = {
59
66
  /** Why it failed, when it did. */
60
67
  error?: string;
61
68
  };
69
+ /** The media types an answer in words comes in: the answer itself, never an artifact of its own. */
70
+ export declare const TEXT_MEDIA_TYPES: readonly string[];
71
+ /** A Jupyter notebook, as A2A carries it. */
72
+ export declare const NOTEBOOK_MEDIA_TYPE = "application/x-ipynb+json";
73
+ /**
74
+ * What a peer gave besides words: one part of an artifact, by its media type.
75
+ *
76
+ * `data` is the part's structured data (a notebook is its nbformat document),
77
+ * a JSON file's bytes read as JSON, other bytes as they came, or a URL.
78
+ */
79
+ export type A2APeerArtifact = {
80
+ mediaType: string;
81
+ /** The artifact's name: what it is, in a few words. */
82
+ name: string;
83
+ /** The file it would be saved as, when the peer named one. */
84
+ filename?: string;
85
+ data: unknown;
86
+ };
87
+ /** A peer's answer: its words, and what it gave besides them. */
88
+ export type A2APeerAnswer = {
89
+ answer: string;
90
+ artifacts: A2APeerArtifact[];
91
+ };
62
92
  /** What happens to one request, as it happens. */
63
93
  export type A2APeerEvent = {
64
94
  phase: 'asked';
65
95
  request: string;
96
+ accept?: string[];
66
97
  } | {
67
98
  phase: 'working';
68
99
  taskId: string;
@@ -73,12 +104,22 @@ export type A2APeerEvent = {
73
104
  phase: 'answered';
74
105
  taskId: string;
75
106
  answer: string;
107
+ /** What it gave besides words, in the order it gave them. */
108
+ artifacts: A2APeerArtifact[];
76
109
  } | {
77
110
  phase: 'failed';
78
111
  error: string;
79
112
  };
80
113
  /** The text of a message's or an artifact's parts. */
81
114
  export declare function textOf(parts: Part[] | undefined): string;
115
+ /**
116
+ * What an artifact gives besides words: each part that is not text, by its
117
+ * media type. A part without one is not told apart, and is left out.
118
+ */
119
+ export declare function artifactsOf(artifact: {
120
+ name?: string;
121
+ parts?: Part[];
122
+ } | undefined): A2APeerArtifact[];
82
123
  /** A user's message of one text part, as the SDK's 1.x types write it. */
83
124
  export declare function textMessage(text: string): Message;
84
125
  /**
@@ -94,24 +135,40 @@ export declare function stepOf(message: Message | undefined): {
94
135
  note?: string;
95
136
  tool?: A2APeerToolStep;
96
137
  };
138
+ export type AskA2APeerOptions = {
139
+ signal?: AbortSignal;
140
+ onEvent?: (event: A2APeerEvent) => void;
141
+ /**
142
+ * The media types the asker accepts (`acceptedOutputModes`): words, and
143
+ * any format it can show, such as a notebook. Unsaid, nothing is named and
144
+ * the peer answers in words alone.
145
+ */
146
+ accept?: readonly string[];
147
+ };
97
148
  /**
98
149
  * Ask a peer once, streaming the task, and return its answer.
99
150
  *
100
- * The answer is the text of the task's artifact, assembled from its chunks,
101
- * or the final status message's when there is no artifact. A task that ends
102
- * other than completed is thrown, with what the peer said.
151
+ * The answer is the text of the task's artifacts, assembled from their
152
+ * chunks, or the final status message's when there is none; what it gave
153
+ * besides words comes with it, by media type. A task that ends other than
154
+ * completed is thrown, with what the peer said.
103
155
  */
104
- export declare function askA2APeer(peer: A2APeer, request: string, options?: {
105
- signal?: AbortSignal;
106
- onEvent?: (event: A2APeerEvent) => void;
107
- }): Promise<string>;
156
+ export declare function askA2APeer(peer: A2APeer, request: string, options?: AskA2APeerOptions): Promise<A2APeerAnswer>;
108
157
  export type A2APeerToolOptions = {
109
158
  peer: A2APeer;
110
159
  /** Told each step of each request, as it happens. */
111
160
  onEvent?: (event: A2APeerEvent) => void;
161
+ /**
162
+ * The media types the asker accepts, words and the formats its page can
163
+ * show: what each request accepts unless the model narrows it with
164
+ * `formats`. Unsaid, words alone.
165
+ */
166
+ accept?: readonly string[];
112
167
  };
113
- /** The tool's description: the peer's skill, and what it may be asked. */
114
- export declare function peerToolDescription(peer: A2APeer): string;
168
+ /** The formats a peer gives besides words that the asker accepts too. */
169
+ export declare function offeredFormats(peer: A2APeer, accept: readonly string[] | undefined): string[];
170
+ /** The tool's description: the peer's skill, what it may be asked, and what it gives besides words. */
171
+ export declare function peerToolDescription(peer: A2APeer, accept?: readonly string[]): string;
115
172
  /**
116
173
  * The tool an agent asks its peer with: a request in, the peer's answer out.
117
174
  *
@@ -18,6 +18,13 @@
18
18
  * peer's answer in words. Each step is told to {@link A2APeerEvent} as it
19
19
  * happens, so a page can show the exchange: asked, working, answered.
20
20
  *
21
+ * **Output formats.** A peer's card says the media types it answers in (its
22
+ * skill's `outputModes`). The asker says which it accepts with each request
23
+ * (`acceptedOutputModes`, {@link AskA2APeerOptions.accept}); what the peer
24
+ * gives besides words — a Jupyter notebook, `application/x-ipynb+json` —
25
+ * arrives as artifacts, one part of a media type each, and is handed over on
26
+ * `answered` as {@link A2APeerArtifact}s, whatever their format.
27
+ *
21
28
  * The key goes as a bearer token on every request, the card's too. The
22
29
  * runtime hands it to the peer's run, which then reaches the peer's
23
30
  * connections with it and with nothing broader.
@@ -64,12 +71,52 @@ export async function connectA2APeer(options) {
64
71
  }
65
72
  return { client, card, skill };
66
73
  }
74
+ /** The media types an answer in words comes in: the answer itself, never an artifact of its own. */
75
+ export const TEXT_MEDIA_TYPES = [
76
+ 'text/plain',
77
+ 'text/markdown',
78
+ ];
79
+ /** A Jupyter notebook, as A2A carries it. */
80
+ export const NOTEBOOK_MEDIA_TYPE = 'application/x-ipynb+json';
81
+ /** What a format is, in words, for a model reading a tool's description. */
82
+ const FORMAT_NAMES = {
83
+ [NOTEBOOK_MEDIA_TYPE]: 'a Jupyter notebook',
84
+ };
67
85
  /** The text of a message's or an artifact's parts. */
68
86
  export function textOf(parts) {
69
87
  return (parts ?? [])
70
88
  .map(part => (part.content?.$case === 'text' ? part.content.value : ''))
71
89
  .join('');
72
90
  }
91
+ /** Whether a media type is JSON: `application/json`, or `+json` like a notebook. */
92
+ function isJson(mediaType) {
93
+ return mediaType === 'application/json' || mediaType.endsWith('+json');
94
+ }
95
+ /**
96
+ * What an artifact gives besides words: each part that is not text, by its
97
+ * media type. A part without one is not told apart, and is left out.
98
+ */
99
+ export function artifactsOf(artifact) {
100
+ const found = [];
101
+ for (const part of artifact?.parts ?? []) {
102
+ const content = part.content;
103
+ const mediaType = part.mediaType;
104
+ if (!content || content.$case === 'text' || !mediaType) {
105
+ continue;
106
+ }
107
+ let data = content.value;
108
+ if (content.$case === 'raw' && isJson(mediaType)) {
109
+ data = JSON.parse(new TextDecoder().decode(content.value));
110
+ }
111
+ found.push({
112
+ mediaType,
113
+ name: artifact?.name || part.filename || mediaType,
114
+ ...(part.filename ? { filename: part.filename } : {}),
115
+ data,
116
+ });
117
+ }
118
+ return found;
119
+ }
73
120
  /** A user's message of one text part, as the SDK's 1.x types write it. */
74
121
  export function textMessage(text) {
75
122
  return {
@@ -111,7 +158,9 @@ export function stepOf(message) {
111
158
  if (told?.name) {
112
159
  const ended = !data.tool_call;
113
160
  return {
114
- note: ended ? `Read ${told.name}` : `Calling ${told.name}`,
161
+ // A tool that composes an output says what it does in words
162
+ // (`Writing a notebook…`); any other is named.
163
+ note: told.note || (ended ? `Read ${told.name}` : `Calling ${told.name}`),
115
164
  tool: {
116
165
  ...(told.id ? { id: told.id } : {}),
117
166
  name: told.name,
@@ -136,15 +185,20 @@ const ENDED_BADLY = {
136
185
  /**
137
186
  * Ask a peer once, streaming the task, and return its answer.
138
187
  *
139
- * The answer is the text of the task's artifact, assembled from its chunks,
140
- * or the final status message's when there is no artifact. A task that ends
141
- * other than completed is thrown, with what the peer said.
188
+ * The answer is the text of the task's artifacts, assembled from their
189
+ * chunks, or the final status message's when there is none; what it gave
190
+ * besides words comes with it, by media type. A task that ends other than
191
+ * completed is thrown, with what the peer said.
142
192
  */
143
193
  export async function askA2APeer(peer, request, options = {}) {
144
- const { signal, onEvent } = options;
145
- onEvent?.({ phase: 'asked', request });
194
+ const { signal, onEvent, accept } = options;
195
+ onEvent?.({
196
+ phase: 'asked',
197
+ request,
198
+ ...(accept?.length ? { accept: [...accept] } : {}),
199
+ });
146
200
  try {
147
- return await streamTask(peer, request, signal, onEvent);
201
+ return await streamTask(peer, request, signal, onEvent, accept);
148
202
  }
149
203
  catch (reason) {
150
204
  const error = reason instanceof Error ? reason.message : String(reason);
@@ -152,15 +206,23 @@ export async function askA2APeer(peer, request, options = {}) {
152
206
  throw reason;
153
207
  }
154
208
  }
155
- async function streamTask(peer, request, signal, onEvent) {
156
- const artifacts = new Map();
209
+ async function streamTask(peer, request, signal, onEvent, accept) {
210
+ const texts = new Map();
211
+ // What it gave besides words, by artifact: the last chunk says it whole.
212
+ const given = new Map();
157
213
  let taskId = '';
158
214
  let final = '';
159
215
  let ended;
160
216
  const stream = peer.client.sendMessageStream({
161
217
  tenant: '',
162
218
  message: textMessage(request),
163
- configuration: undefined,
219
+ configuration: accept?.length
220
+ ? {
221
+ acceptedOutputModes: [...accept],
222
+ taskPushNotificationConfig: undefined,
223
+ returnImmediately: false,
224
+ }
225
+ : undefined,
164
226
  metadata: undefined,
165
227
  }, { signal });
166
228
  for await (const event of stream) {
@@ -187,11 +249,17 @@ async function streamTask(peer, request, signal, onEvent) {
187
249
  const artifact = payload.value.artifact;
188
250
  if (artifact) {
189
251
  const text = textOf(artifact.parts);
190
- const before = artifacts.get(artifact.artifactId) ?? '';
191
- // Chunks are appended; the last chunk carries the whole answer again.
192
- artifacts.set(artifact.artifactId, payload.value.append && !payload.value.lastChunk
193
- ? before + text
194
- : text);
252
+ const besides = artifactsOf(artifact);
253
+ if (besides.length) {
254
+ given.set(artifact.artifactId, besides);
255
+ }
256
+ if (text || !besides.length) {
257
+ const before = texts.get(artifact.artifactId) ?? '';
258
+ // Chunks are appended; the last chunk carries the whole answer again.
259
+ texts.set(artifact.artifactId, payload.value.append && !payload.value.lastChunk
260
+ ? before + text
261
+ : text);
262
+ }
195
263
  }
196
264
  }
197
265
  else if (payload.$case === 'message') {
@@ -203,28 +271,53 @@ async function streamTask(peer, request, signal, onEvent) {
203
271
  const how = (ended !== undefined && ENDED_BADLY[ended]) || 'ended without an answer';
204
272
  throw new Error(`${peer.card.name} ${how}${final ? `: ${final}` : '.'}`);
205
273
  }
206
- const answer = [...artifacts.values()].join('\n') || final;
207
- onEvent?.({ phase: 'answered', taskId, answer });
208
- return answer;
274
+ const answer = [...texts.values()].filter(text => text).join('\n') || final;
275
+ const artifacts = [...given.values()].flat();
276
+ onEvent?.({ phase: 'answered', taskId, answer, artifacts });
277
+ return { answer, artifacts };
278
+ }
279
+ /** The formats a peer gives besides words that the asker accepts too. */
280
+ export function offeredFormats(peer, accept) {
281
+ const outputs = peer.skill.outputModes.length
282
+ ? peer.skill.outputModes
283
+ : peer.card.defaultOutputModes;
284
+ return outputs.filter(mode => !TEXT_MEDIA_TYPES.includes(mode) && (accept ?? []).includes(mode));
209
285
  }
210
- /** The input of a peer's tool: one request, in words, as its skill takes it. */
211
- const REQUEST_SCHEMA = {
212
- type: 'object',
213
- properties: {
214
- request: {
215
- type: 'string',
216
- description: 'What to ask, in one request that can be acted on without the rest of this conversation.',
286
+ /** The input of a peer's tool: one request, in words, and the formats wanted besides. */
287
+ function requestSchema(formats) {
288
+ return {
289
+ type: 'object',
290
+ properties: {
291
+ request: {
292
+ type: 'string',
293
+ description: 'What to ask, in one request that can be acted on without the rest of this conversation.',
294
+ },
295
+ ...(formats.length
296
+ ? {
297
+ formats: {
298
+ type: 'array',
299
+ items: { type: 'string', enum: formats },
300
+ description: `What to ask for besides its answer in words, by media type: ${formats
301
+ .map(format => `${format} (${FORMAT_NAMES[format] ?? format})`)
302
+ .join(', ')}. Empty for words alone; left out, it may give any of them.`,
303
+ },
304
+ }
305
+ : {}),
217
306
  },
218
- },
219
- required: ['request'],
220
- };
221
- /** The tool's description: the peer's skill, and what it may be asked. */
222
- export function peerToolDescription(peer) {
307
+ required: ['request'],
308
+ };
309
+ }
310
+ /** The tool's description: the peer's skill, what it may be asked, and what it gives besides words. */
311
+ export function peerToolDescription(peer, accept) {
223
312
  const { card, skill } = peer;
224
313
  const examples = skill.examples.length
225
314
  ? ` For example: ${skill.examples.map(example => `"${example}"`).join('; ')}.`
226
315
  : '';
227
- return `Ask ${card.name}, over A2A: ${skill.description}${examples} It answers in words; report its answer as it gave it.`;
316
+ const formats = offeredFormats(peer, accept);
317
+ const besides = formats.length
318
+ ? ` It can also give ${formats.map(format => FORMAT_NAMES[format] ?? format).join(' or ')}, shown to the person as it arrives: ask for it with \`formats\` when the person wants one, and do not repeat its content.`
319
+ : '';
320
+ return `Ask ${card.name}, over A2A: ${skill.description}${examples} It answers in words; report its answer as it gave it.${besides}`;
228
321
  }
229
322
  /**
230
323
  * The tool an agent asks its peer with: a request in, the peer's answer out.
@@ -233,21 +326,42 @@ export function peerToolDescription(peer) {
233
326
  * person what the peer said instead of the whole run failing.
234
327
  */
235
328
  export function a2aPeerTool(options) {
236
- const { peer, onEvent } = options;
329
+ const { peer, onEvent, accept } = options;
330
+ const formats = offeredFormats(peer, accept);
237
331
  return tool({
238
- description: peerToolDescription(peer),
239
- inputSchema: jsonSchema(REQUEST_SCHEMA),
332
+ description: peerToolDescription(peer, accept),
333
+ inputSchema: jsonSchema(requestSchema(formats)),
240
334
  execute: async (input, execution) => {
241
- const { request } = (input ?? {});
335
+ const { request, formats: wanted } = (input ?? {});
242
336
  if (!request?.trim()) {
243
337
  return { error: `A request to ${peer.card.name} says what it asks.` };
244
338
  }
339
+ // Words always; besides them, what the model asked for, or else
340
+ // whatever the asker accepts.
341
+ const accepted = Array.isArray(wanted)
342
+ ? [
343
+ ...(accept ?? []).filter(mode => TEXT_MEDIA_TYPES.includes(mode)),
344
+ ...wanted.filter(mode => formats.includes(mode)),
345
+ ]
346
+ : accept;
245
347
  try {
246
- const answer = await askA2APeer(peer, request, {
348
+ const { answer, artifacts } = await askA2APeer(peer, request, {
247
349
  signal: execution?.abortSignal,
248
350
  onEvent,
351
+ accept: accepted,
249
352
  });
250
- return { answer };
353
+ // What it gave besides words is shown to the person; the model is
354
+ // told it came, not its content.
355
+ return artifacts.length
356
+ ? {
357
+ answer,
358
+ attached: artifacts.map(artifact => ({
359
+ mediaType: artifact.mediaType,
360
+ name: artifact.name,
361
+ shown: 'to the person, as it arrived',
362
+ })),
363
+ }
364
+ : { answer };
251
365
  }
252
366
  catch (reason) {
253
367
  return {
package/lib/specs/apps.js CHANGED
@@ -75,6 +75,7 @@ export const ACCOUNTING_APP_0_0_1 = {
75
75
  language: '',
76
76
  where: 'auto',
77
77
  },
78
+ outputs: ['text/markdown', 'application/x-ipynb+json'],
78
79
  },
79
80
  tests: {
80
81
  readyAt: 0.8,
@@ -212,6 +213,7 @@ export const CUSTOMER_INTERVIEW_APP_0_0_1 = {
212
213
  language: '',
213
214
  where: 'auto',
214
215
  },
216
+ outputs: [],
215
217
  },
216
218
  tests: {
217
219
  readyAt: 0.8,
@@ -327,6 +329,7 @@ export const DATA_QUALITY_APP_0_0_1 = {
327
329
  language: '',
328
330
  where: 'auto',
329
331
  },
332
+ outputs: [],
330
333
  },
331
334
  tests: {
332
335
  readyAt: 0.8,
@@ -482,6 +485,7 @@ export const DECIDE_APP_0_0_1 = {
482
485
  language: '',
483
486
  where: 'auto',
484
487
  },
488
+ outputs: [],
485
489
  },
486
490
  tests: {
487
491
  readyAt: 0.8,
@@ -633,6 +637,7 @@ export const INBOX_TRIAGE_APP_0_0_1 = {
633
637
  language: '',
634
638
  where: 'auto',
635
639
  },
640
+ outputs: [],
636
641
  },
637
642
  tests: {
638
643
  readyAt: 0.9,
@@ -770,6 +775,7 @@ export const MODEL_CHOICE_APP_0_0_1 = {
770
775
  language: '',
771
776
  where: 'auto',
772
777
  },
778
+ outputs: [],
773
779
  },
774
780
  tests: {
775
781
  readyAt: 0.8,
@@ -1102,6 +1108,7 @@ export const PIPELINE_REPORT_APP_0_0_1 = {
1102
1108
  language: '',
1103
1109
  where: 'auto',
1104
1110
  },
1111
+ outputs: [],
1105
1112
  },
1106
1113
  tests: {
1107
1114
  readyAt: 0.9,
@@ -1396,6 +1403,7 @@ export const QUOTE_CALCULATOR_APP_0_0_1 = {
1396
1403
  language: '',
1397
1404
  where: 'auto',
1398
1405
  },
1406
+ outputs: [],
1399
1407
  },
1400
1408
  tests: {
1401
1409
  readyAt: 1.0,
@@ -1631,6 +1639,7 @@ export const REPORT_FROM_A_FILE_APP_0_0_1 = {
1631
1639
  language: '',
1632
1640
  where: 'auto',
1633
1641
  },
1642
+ outputs: [],
1634
1643
  },
1635
1644
  tests: {
1636
1645
  readyAt: 0.8,
@@ -1752,6 +1761,7 @@ export const SALES_APP_0_0_1 = {
1752
1761
  language: '',
1753
1762
  where: 'auto',
1754
1763
  },
1764
+ outputs: [],
1755
1765
  },
1756
1766
  tests: {
1757
1767
  readyAt: 0.8,
@@ -1866,6 +1876,7 @@ export const SHIP_OR_FIX_APP_0_0_1 = {
1866
1876
  language: '',
1867
1877
  where: 'auto',
1868
1878
  },
1879
+ outputs: [],
1869
1880
  },
1870
1881
  tests: {
1871
1882
  readyAt: 0.8,
@@ -2041,6 +2052,7 @@ export const SUPPLIER_COMPARISON_APP_0_0_1 = {
2041
2052
  language: '',
2042
2053
  where: 'auto',
2043
2054
  },
2055
+ outputs: [],
2044
2056
  },
2045
2057
  tests: {
2046
2058
  readyAt: 0.8,
@@ -2334,6 +2346,7 @@ export const SUPPORT_DESK_APP_0_0_1 = {
2334
2346
  language: '',
2335
2347
  where: 'auto',
2336
2348
  },
2349
+ outputs: [],
2337
2350
  },
2338
2351
  tests: {
2339
2352
  readyAt: 0.8,
@@ -2468,6 +2481,7 @@ export const WEB_RESEARCH_APP_0_0_1 = {
2468
2481
  language: '',
2469
2482
  where: 'auto',
2470
2483
  },
2484
+ outputs: [],
2471
2485
  },
2472
2486
  tests: {
2473
2487
  readyAt: 0.8,
@@ -2603,6 +2617,7 @@ export const APP_SOURCES = {
2603
2617
  },
2604
2618
  ],
2605
2619
  assistant: 'wizard',
2620
+ outputs: ['text/markdown', 'application/x-ipynb+json'],
2606
2621
  },
2607
2622
  tests: {
2608
2623
  cases: [
@@ -330,6 +330,16 @@ export const APPSPEC_SCHEMA = {
330
330
  $ref: '#/$defs/AppVoice',
331
331
  description: 'Its voice: whether it listens and speaks, with which voice, in which language (off unless said)',
332
332
  },
333
+ outputs: {
334
+ description: "The formats its answers come in, by media type, words first: `text/markdown`, then `application/x-ipynb+json` for a Jupyter notebook. Over A2A, its agent card's output modes; a caller asks for some of them (`acceptedOutputModes`). Plain text alone when unsaid",
335
+ items: {
336
+ pattern: '^[a-z0-9][a-z0-9.+-]*/[a-z0-9][a-z0-9.+-]*$',
337
+ type: 'string',
338
+ },
339
+ title: 'Outputs',
340
+ type: 'array',
341
+ uniqueItems: true,
342
+ },
333
343
  },
334
344
  title: 'AppInterface',
335
345
  type: 'object',
@@ -481,6 +481,12 @@ export interface AppInterfaceSpec {
481
481
  assistant?: AppAssistantCharacter;
482
482
  /** Its voice: off unless said (VO-41); absent from a spec made before voice. */
483
483
  voice?: AppVoiceSpec;
484
+ /**
485
+ * The formats its answers come in, by media type, words first:
486
+ * `text/markdown`, then `application/x-ipynb+json` for a Jupyter notebook.
487
+ * Over A2A, its agent card's output modes. Empty: plain text alone.
488
+ */
489
+ outputs: string[];
484
490
  }
485
491
  export interface AppTestCaseSpec {
486
492
  ask: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalayer/agent-runtimes",
3
- "version": "1.3.69",
3
+ "version": "1.3.71",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  ".",