@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.
- package/lib/chat/notebook/EphemeralNotebook.js +9 -1
- package/lib/components/teams/TeamNotebook.d.ts +32 -0
- package/lib/components/teams/TeamNotebook.js +22 -0
- package/lib/components/teams/TeamNotebookView.d.ts +23 -0
- package/lib/components/teams/TeamNotebookView.js +90 -0
- package/lib/components/teams/index.d.ts +1 -0
- package/lib/components/teams/index.js +1 -0
- package/lib/components/teams/useA2ATeam.d.ts +16 -1
- package/lib/components/teams/useA2ATeam.js +42 -5
- package/lib/examples/AgentA2ATeamExample.d.ts +6 -0
- package/lib/examples/AgentA2ATeamExample.js +11 -9
- package/lib/loop/apps/appspec.js +4 -0
- package/lib/runtimes/__tests__/fixtures/accounting-agent-card.json +3 -3
- package/lib/runtimes/browser/a2aPeer.d.ts +66 -9
- package/lib/runtimes/browser/a2aPeer.js +151 -37
- package/lib/specs/apps.js +15 -0
- package/lib/specs/appspecSchema.js +10 -0
- package/lib/types/agentspecs.d.ts +6 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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;
|
|
@@ -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}).
|
|
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}:
|
|
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: {
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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, {}) }));
|
package/lib/loop/apps/appspec.js
CHANGED
|
@@ -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/
|
|
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": ["
|
|
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
|
|
101
|
-
* or the final status message's when there is
|
|
102
|
-
*
|
|
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
|
|
114
|
-
export declare function
|
|
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
|
-
|
|
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
|
|
140
|
-
* or the final status message's when there is
|
|
141
|
-
*
|
|
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?.({
|
|
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
|
|
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:
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 = [...
|
|
207
|
-
|
|
208
|
-
|
|
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,
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
220
|
-
}
|
|
221
|
-
/** The tool's description: the peer's skill,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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;
|