@datalayer/agent-runtimes 1.3.38 → 1.3.40
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/base/ChatBase.js +7 -3
- package/lib/components/a2ui/visibleWhen.d.ts +7 -0
- package/lib/components/a2ui/visibleWhen.js +13 -0
- package/lib/loop/apps/AppRenderer.d.ts +79 -2
- package/lib/loop/apps/AppRenderer.js +87 -36
- package/lib/loop/core/index.d.ts +5 -1
- package/lib/loop/embed/AppEmbed.js +4 -2
- package/lib/loop/embed/LoopEmbed.js +1 -0
- package/lib/loop/plugins/a2ui-surface/InlineSurface.d.ts +6 -3
- package/lib/loop/plugins/a2ui-surface/InlineSurface.js +5 -2
- package/lib/loop/plugins/app-page/AppPage.d.ts +14 -1
- package/lib/loop/plugins/app-page/AppPage.js +60 -5
- package/lib/loop/plugins/app-page/appPageModel.d.ts +45 -12
- package/lib/loop/plugins/app-page/appPageModel.js +78 -66
- package/lib/loop/plugins/app-page/index.d.ts +36 -0
- package/lib/loop/plugins/app-page/index.js +32 -1
- package/lib/loop/plugins/chat/ChatView.js +33 -6
- package/lib/loop/presets.d.ts +11 -0
- package/lib/loop/presets.js +16 -1
- package/lib/protocols/AGUIAdapter.d.ts +2 -0
- package/lib/protocols/AGUIAdapter.js +1 -1
- package/lib/types/chat.d.ts +2 -1
- package/lib/types/protocol.d.ts +2 -0
- package/package.json +1 -1
|
@@ -2812,7 +2812,10 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
|
|
|
2812
2812
|
// ========================================================================
|
|
2813
2813
|
// handleSend
|
|
2814
2814
|
// ========================================================================
|
|
2815
|
-
const handleSend = useCallback(async (messageOverride
|
|
2815
|
+
const handleSend = useCallback(async (messageOverride,
|
|
2816
|
+
// AG-UI's: what goes with the run besides the conversation — an
|
|
2817
|
+
// application's page says there what it did (LOOP R-04).
|
|
2818
|
+
forwardedProps) => {
|
|
2816
2819
|
const messageContent = (messageOverride ?? input).trim();
|
|
2817
2820
|
if (!messageContent || isLoading)
|
|
2818
2821
|
return;
|
|
@@ -2915,6 +2918,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
|
|
|
2915
2918
|
builtinTools: enabledMcpToolNames,
|
|
2916
2919
|
skills: enabledSkillIds,
|
|
2917
2920
|
identities: connectedIdentitiesRef.current,
|
|
2921
|
+
...(forwardedProps ? { forwardedProps } : {}),
|
|
2918
2922
|
});
|
|
2919
2923
|
}
|
|
2920
2924
|
}
|
|
@@ -3099,8 +3103,8 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
|
|
|
3099
3103
|
return undefined;
|
|
3100
3104
|
}
|
|
3101
3105
|
onSendReady({
|
|
3102
|
-
send: (message) => {
|
|
3103
|
-
void handleSend(message);
|
|
3106
|
+
send: (message, forwardedProps) => {
|
|
3107
|
+
void handleSend(message, forwardedProps);
|
|
3104
3108
|
},
|
|
3105
3109
|
stop: handleStop,
|
|
3106
3110
|
// The same reset the header's + performs, for a host whose controls
|
|
@@ -17,3 +17,10 @@ export declare function withVisibleWhen(component: ReactComponentImplementation)
|
|
|
17
17
|
* it — with `visible_when` on every component.
|
|
18
18
|
*/
|
|
19
19
|
export declare const datalayerCatalog: Catalog<ReactComponentImplementation, import("@a2ui/web_core/v0_9").FunctionImplementation>;
|
|
20
|
+
/**
|
|
21
|
+
* Datalayer's catalog narrowed to the components named — what the enabled
|
|
22
|
+
* plugins contribute as blocks (LOOP R-01b, `loop.canvas.block`), so that an
|
|
23
|
+
* application's page draws what its Canvas can place and nothing else. Under
|
|
24
|
+
* the same id; a name the catalog does not draw is an error.
|
|
25
|
+
*/
|
|
26
|
+
export declare function catalogOfBlocks(names: readonly string[]): Catalog<ReactComponentImplementation>;
|
|
@@ -55,3 +55,16 @@ export function withVisibleWhen(component) {
|
|
|
55
55
|
* it — with `visible_when` on every component.
|
|
56
56
|
*/
|
|
57
57
|
export const datalayerCatalog = new Catalog(basicCatalog.id, basicCatalog.protocolVersion, [...basicCatalog.components.values(), ...OWN_COMPONENTS].map(withVisibleWhen), [...basicCatalog.functions.values()], basicCatalog.themeSchema, basicCatalog.instructions);
|
|
58
|
+
/**
|
|
59
|
+
* Datalayer's catalog narrowed to the components named — what the enabled
|
|
60
|
+
* plugins contribute as blocks (LOOP R-01b, `loop.canvas.block`), so that an
|
|
61
|
+
* application's page draws what its Canvas can place and nothing else. Under
|
|
62
|
+
* the same id; a name the catalog does not draw is an error.
|
|
63
|
+
*/
|
|
64
|
+
export function catalogOfBlocks(names) {
|
|
65
|
+
const unknown = names.filter(name => !datalayerCatalog.components.has(name));
|
|
66
|
+
if (unknown.length > 0) {
|
|
67
|
+
throw new Error(`No renderer draws ${unknown.join(', ')}: a block is contributed by a plugin whose component the catalog draws.`);
|
|
68
|
+
}
|
|
69
|
+
return new Catalog(datalayerCatalog.id, datalayerCatalog.protocolVersion, names.map(name => datalayerCatalog.components.get(name)), [...datalayerCatalog.functions.values()], datalayerCatalog.themeSchema, datalayerCatalog.instructions);
|
|
70
|
+
}
|
|
@@ -1,6 +1,45 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* An application, rendered: the LOOP workspace configured from an Appspec
|
|
3
|
+
* (LOOP R-01).
|
|
4
|
+
*
|
|
5
|
+
* One renderer for every place an application appears — the Studio's
|
|
6
|
+
* Preview, its hosted page, an embed, the home page — so that what a builder
|
|
7
|
+
* sees while building is what its users get. It is not a second shell: it is
|
|
8
|
+
* `LoopEmbed`, given
|
|
9
|
+
*
|
|
10
|
+
* - an **application plugin** (`defineAppPlugin`): the agent that answers,
|
|
11
|
+
* created with the application in its payload — so the runtime reaches only
|
|
12
|
+
* what the application connects to and enforces its rules before every tool
|
|
13
|
+
* call — and the application's starters as the openers of the empty chat;
|
|
14
|
+
* - its **layout**, as `interface.layout` says (`appLayoutOptions`, LOOP
|
|
15
|
+
* T-07), on the workspace's own `page-layout` plugin: `chat` is the
|
|
16
|
+
* conversation alone, with the A2UI surfaces its answers draw; `page` is
|
|
17
|
+
* the page on a sheet with the composer over it and the conversation in a
|
|
18
|
+
* panel the composer's display modes open; `split` is the conversation and
|
|
19
|
+
* the page side by side, a hairline between them to drag;
|
|
20
|
+
* - its **page**, when it has one (`hasAppPage`: a chat, a widget or a worker
|
|
21
|
+
* with a `page` or `split` layout): its A2UI surface drawn by the
|
|
22
|
+
* `app-page` plugin, in place of the notebook and the document, fed from
|
|
23
|
+
* the conversation and answered through it (`APP_KIND_PATHS`), with the
|
|
24
|
+
* Canvas's block plugins (`CANVAS_BLOCK_PLUGINS`), whose blocks are what
|
|
25
|
+
* the page draws (R-01b);
|
|
26
|
+
* - a **decision**: a workspace without a conversation whose one view is the
|
|
27
|
+
* page its host draws (`page`, R-02) — the Studio's run page, at its
|
|
28
|
+
* address and embedded;
|
|
29
|
+
* - its **frame**, when the host asks (`frame`): the `window-frame` plugin's
|
|
30
|
+
* window, its title the application's face and name.
|
|
31
|
+
*
|
|
32
|
+
* Which plugins a kind needs, and how its workspace is laid out, is one
|
|
33
|
+
* function: `appPreset`, the preset per kind, as `loopPlugins` is for the
|
|
34
|
+
* examples.
|
|
35
|
+
*
|
|
36
|
+
* @module loop/apps/AppRenderer
|
|
37
|
+
*/
|
|
38
|
+
import { type ComponentType } from 'react';
|
|
39
|
+
import type { PluginRef, ReactorPlugin } from '@datalayer/reactor';
|
|
2
40
|
import type { AppSpec } from '../../types/agentspecs';
|
|
3
41
|
import type { ThemeOverrides } from '../../types/chat';
|
|
42
|
+
import { type AppHostPageProps } from '../plugins/app-page';
|
|
4
43
|
import { type LoopEmbedProps } from '../embed/LoopEmbed';
|
|
5
44
|
import type { LoopPresetOptions } from '../presets';
|
|
6
45
|
import type { PresenceState } from '../../chat/presence/presenceStatus';
|
|
@@ -44,6 +83,19 @@ export type AppRendererProps = Omit<LoopEmbedProps, 'agentId'> & {
|
|
|
44
83
|
* the application's face and name. A host's own `frameTitle` wins.
|
|
45
84
|
*/
|
|
46
85
|
frame?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* The page a host draws for a kind the workspace has no page for: a
|
|
88
|
+
* decision's (R-02). The workspace's one view. A component defined once,
|
|
89
|
+
* outside the host's render: a new one would be a new plugin, and restart
|
|
90
|
+
* the workspace.
|
|
91
|
+
*/
|
|
92
|
+
page?: ComponentType<AppHostPageProps>;
|
|
93
|
+
/**
|
|
94
|
+
* The UI plugins its organization has turned off (`plugins_off`, catalogue
|
|
95
|
+
* ids such as 'a2ui'): their blocks are off its page as they are off its
|
|
96
|
+
* Canvas's palette. Compared by content.
|
|
97
|
+
*/
|
|
98
|
+
pluginsOff?: readonly string[];
|
|
47
99
|
};
|
|
48
100
|
/** What `interface.layout` sets on the workspace. */
|
|
49
101
|
export type AppLayoutOptions = Pick<LoopPresetOptions, 'editors' | 'showViewSelector' | 'defaultEditor' | 'pageLayout' | 'pageLayoutArrangement' | 'pageLayoutPrompt' | 'pageLayoutPromptAnchor' | 'pageLayoutTurnPanelFooter'>;
|
|
@@ -62,6 +114,31 @@ export type AppLayoutOptions = Pick<LoopPresetOptions, 'editors' | 'showViewSele
|
|
|
62
114
|
* it; without one (a decision, whose page is the Studio's), the editors stay.
|
|
63
115
|
*/
|
|
64
116
|
export declare function appLayoutOptions(app: Pick<AppSpec, 'kind' | 'interface' | 'agent'>): AppLayoutOptions;
|
|
117
|
+
/** What a kind of application is drawn with: its plugins, and its workspace. */
|
|
118
|
+
export type AppPreset = {
|
|
119
|
+
plugins: PluginRef[];
|
|
120
|
+
workspace: AppLayoutOptions & Pick<LoopPresetOptions, 'conversation'>;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* The plugins an application's kind needs, and how its workspace is laid
|
|
124
|
+
* out (LOOP R-01): the preset per kind, as `loopPlugins` is for the examples.
|
|
125
|
+
*
|
|
126
|
+
* - a **chat**, a **widget**, a **worker**: its agent (`defineAppPlugin`);
|
|
127
|
+
* its page (`app-page`) when it has one, with the Canvas's block plugins
|
|
128
|
+
* but those of the UI plugins its organization turned off (`pluginsOff`),
|
|
129
|
+
* whose contributions are the blocks it may draw (R-01b); a thumb and a
|
|
130
|
+
* comment on each answer when its record keeps feedback (V-18); laid out
|
|
131
|
+
* as `interface.layout` says;
|
|
132
|
+
* - a **decision**: the page its host draws (`page`), as the one view of a
|
|
133
|
+
* workspace without a conversation (R-02).
|
|
134
|
+
*
|
|
135
|
+
* An application that cannot be drawn is refused with a sentence: a
|
|
136
|
+
* decision without its host's page, an application run by a team.
|
|
137
|
+
*/
|
|
138
|
+
export declare function appPreset(app: AppSpec, options?: {
|
|
139
|
+
page?: ComponentType<AppHostPageProps>;
|
|
140
|
+
pluginsOff?: readonly string[];
|
|
141
|
+
}): AppPreset;
|
|
65
142
|
/**
|
|
66
143
|
* What an application's agent is created with on a Datalayer runtime: under
|
|
67
144
|
* the application's id, over AG-UI, with the application's own document —
|
|
@@ -87,5 +164,5 @@ export declare const DATALAYER_BOOTSTRAP_AGENTSPEC = "example-simple";
|
|
|
87
164
|
* what the page around it set does not reach in.
|
|
88
165
|
*/
|
|
89
166
|
export declare function appThemeOverrides(app: AppSpec): ThemeOverrides | undefined;
|
|
90
|
-
export declare function AppRenderer({ app, plugins, instance, onPresence, frame, ...embed }: AppRendererProps): React.JSX.Element;
|
|
167
|
+
export declare function AppRenderer({ app, plugins, instance, onPresence, frame, page, pluginsOff, ...embed }: AppRendererProps): React.JSX.Element;
|
|
91
168
|
export default AppRenderer;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
/*
|
|
3
3
|
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
4
4
|
* Distributed under the terms of the Modified BSD License.
|
|
@@ -25,18 +25,26 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
25
25
|
* - its **page**, when it has one (`hasAppPage`: a chat, a widget or a worker
|
|
26
26
|
* with a `page` or `split` layout): its A2UI surface drawn by the
|
|
27
27
|
* `app-page` plugin, in place of the notebook and the document, fed from
|
|
28
|
-
* the conversation and answered through it (`APP_KIND_PATHS`)
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* the conversation and answered through it (`APP_KIND_PATHS`), with the
|
|
29
|
+
* Canvas's block plugins (`CANVAS_BLOCK_PLUGINS`), whose blocks are what
|
|
30
|
+
* the page draws (R-01b);
|
|
31
|
+
* - a **decision**: a workspace without a conversation whose one view is the
|
|
32
|
+
* page its host draws (`page`, R-02) — the Studio's run page, at its
|
|
33
|
+
* address and embedded;
|
|
31
34
|
* - its **frame**, when the host asks (`frame`): the `window-frame` plugin's
|
|
32
35
|
* window, its title the application's face and name.
|
|
33
36
|
*
|
|
37
|
+
* Which plugins a kind needs, and how its workspace is laid out, is one
|
|
38
|
+
* function: `appPreset`, the preset per kind, as `loopPlugins` is for the
|
|
39
|
+
* examples.
|
|
40
|
+
*
|
|
34
41
|
* @module loop/apps/AppRenderer
|
|
35
42
|
*/
|
|
36
43
|
import { useMemo } from 'react';
|
|
37
44
|
import { loopAccentStyles } from '@datalayer/primer-addons';
|
|
38
45
|
import { defineAgentCapacityPlugin } from '../plugins/agent-capacity';
|
|
39
|
-
import { APP_PAGE_SURFACE, defineAppPagePlugin, hasAppPage, } from '../plugins/app-page';
|
|
46
|
+
import { APP_PAGE_SURFACE, defineAppHostPagePlugin, defineAppPagePlugin, hasAppPage, } from '../plugins/app-page';
|
|
47
|
+
import { CANVAS_BLOCK_PLUGINS, canvasBlocksPluginName, } from '../plugins/canvas-blocks';
|
|
40
48
|
import { LoopEmbed } from '../embed/LoopEmbed';
|
|
41
49
|
import { dumpAppspec } from './appspec';
|
|
42
50
|
import { defineAppFeedbackPlugin } from './AppFeedback';
|
|
@@ -118,6 +126,51 @@ export function appLayoutOptions(app) {
|
|
|
118
126
|
pageLayoutTurnPanelFooter: 'actions',
|
|
119
127
|
};
|
|
120
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* The plugins an application's kind needs, and how its workspace is laid
|
|
131
|
+
* out (LOOP R-01): the preset per kind, as `loopPlugins` is for the examples.
|
|
132
|
+
*
|
|
133
|
+
* - a **chat**, a **widget**, a **worker**: its agent (`defineAppPlugin`);
|
|
134
|
+
* its page (`app-page`) when it has one, with the Canvas's block plugins
|
|
135
|
+
* but those of the UI plugins its organization turned off (`pluginsOff`),
|
|
136
|
+
* whose contributions are the blocks it may draw (R-01b); a thumb and a
|
|
137
|
+
* comment on each answer when its record keeps feedback (V-18); laid out
|
|
138
|
+
* as `interface.layout` says;
|
|
139
|
+
* - a **decision**: the page its host draws (`page`), as the one view of a
|
|
140
|
+
* workspace without a conversation (R-02).
|
|
141
|
+
*
|
|
142
|
+
* An application that cannot be drawn is refused with a sentence: a
|
|
143
|
+
* decision without its host's page, an application run by a team.
|
|
144
|
+
*/
|
|
145
|
+
export function appPreset(app, options = {}) {
|
|
146
|
+
if (app.kind === 'decision') {
|
|
147
|
+
if (!options.page) {
|
|
148
|
+
throw new Error(`“${app.name}” is a decision, whose page is drawn by where it runs: none was given here.`);
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
plugins: [defineAppHostPagePlugin(app, options.page)],
|
|
152
|
+
workspace: {
|
|
153
|
+
conversation: false,
|
|
154
|
+
editors: false,
|
|
155
|
+
showViewSelector: false,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const withPage = hasAppPage(app);
|
|
160
|
+
// The blocks its Canvas offers: those of the UI plugins not turned off.
|
|
161
|
+
const off = new Set((options.pluginsOff ?? []).map(canvasBlocksPluginName));
|
|
162
|
+
const blocks = CANVAS_BLOCK_PLUGINS.filter(plugin => !off.has(plugin.name));
|
|
163
|
+
return {
|
|
164
|
+
plugins: [
|
|
165
|
+
defineAppPlugin(app),
|
|
166
|
+
...(withPage ? [defineAppPagePlugin(app), ...blocks] : []),
|
|
167
|
+
// A thumb and a comment on each answer, kept in its record (LOOP
|
|
168
|
+
// V-18): only for an application whose record keeps feedback.
|
|
169
|
+
...(keepsFeedback(app) ? [defineAppFeedbackPlugin(app)] : []),
|
|
170
|
+
],
|
|
171
|
+
workspace: appLayoutOptions(app),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
121
174
|
/**
|
|
122
175
|
* What an application's agent is created with on a Datalayer runtime: under
|
|
123
176
|
* the application's id, over AG-UI, with the application's own document —
|
|
@@ -174,52 +227,50 @@ export function appThemeOverrides(app) {
|
|
|
174
227
|
}
|
|
175
228
|
: undefined;
|
|
176
229
|
}
|
|
177
|
-
export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, frame = false, ...embed }) {
|
|
230
|
+
export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, frame = false, page, pluginsOff, ...embed }) {
|
|
178
231
|
/*
|
|
179
|
-
* The
|
|
180
|
-
*
|
|
181
|
-
*
|
|
232
|
+
* The kind's plugins, made once per application. `LoopEmbed` rebuilds its
|
|
233
|
+
* whole reactor when its plugins change, so a new plugin on every render
|
|
234
|
+
* of the host would restart the running workspace. The key is the
|
|
182
235
|
* application as its file holds it: a change to it is a new application.
|
|
183
236
|
*/
|
|
184
237
|
const source = JSON.stringify(dumpAppspec(app));
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const keeps = keepsFeedback(app);
|
|
197
|
-
const feedbackPlugin = useMemo(() => (keeps ? defineAppFeedbackPlugin(app) : null),
|
|
238
|
+
const off = [...(pluginsOff ?? [])].sort().join(',');
|
|
239
|
+
const preset = useMemo(() => {
|
|
240
|
+
try {
|
|
241
|
+
return appPreset(app, { page, pluginsOff });
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
return {
|
|
245
|
+
problem: error instanceof Error ? error.message : String(error),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
},
|
|
198
249
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
199
|
-
[source,
|
|
200
|
-
const allPlugins = useMemo(() =>
|
|
201
|
-
? [
|
|
202
|
-
appPlugin,
|
|
203
|
-
...(pagePlugin ? [pagePlugin] : []),
|
|
204
|
-
...(feedbackPlugin ? [feedbackPlugin] : []),
|
|
205
|
-
...plugins,
|
|
206
|
-
]
|
|
207
|
-
: plugins, [appPlugin, pagePlugin, feedbackPlugin, plugins]);
|
|
250
|
+
[source, page, off]);
|
|
251
|
+
const allPlugins = useMemo(() => ('problem' in preset ? plugins : [...preset.plugins, ...plugins]), [preset, plugins]);
|
|
208
252
|
/*
|
|
209
253
|
* On Datalayer, the application runs on a runtime: allocated with a plain
|
|
210
254
|
* agentspec, its agent created there with the application's spec — so that
|
|
211
255
|
* the runtime registers it and decides every tool call by its rules (LOOP
|
|
212
256
|
* R-03, R-05), as `loop apps run --cloud` does.
|
|
213
257
|
*/
|
|
214
|
-
const
|
|
258
|
+
const conversation = !('problem' in preset) && preset.workspace.conversation !== false;
|
|
259
|
+
const datalayerCreatePayload = useMemo(() => (conversation ? appDatalayerCreatePayload(app, instance) : undefined),
|
|
215
260
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
216
|
-
[
|
|
261
|
+
[
|
|
262
|
+
source,
|
|
263
|
+
conversation,
|
|
264
|
+
instance?.appUid,
|
|
265
|
+
instance?.deploymentUid,
|
|
266
|
+
instance?.version,
|
|
267
|
+
]);
|
|
217
268
|
const accent = app.interface?.accent;
|
|
218
269
|
const themeOverrides = useMemo(() => appThemeOverrides(app),
|
|
219
270
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
220
271
|
[accent]);
|
|
221
|
-
if (
|
|
222
|
-
return (
|
|
272
|
+
if ('problem' in preset) {
|
|
273
|
+
return (_jsx("div", { role: "status", style: { padding: 16 }, children: preset.problem }));
|
|
223
274
|
}
|
|
224
275
|
return (_jsx(LoopEmbed
|
|
225
276
|
// The application's own agent: created under its id, so that its
|
|
@@ -227,7 +278,7 @@ export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, f
|
|
|
227
278
|
, {
|
|
228
279
|
// The application's own agent: created under its id, so that its
|
|
229
280
|
// spec is applied to an agent of its own, never to the one it extends.
|
|
230
|
-
agentId: app.id, ...
|
|
281
|
+
agentId: app.id, ...preset.workspace, ...(frame
|
|
231
282
|
? {
|
|
232
283
|
frameTitle: [app.emoji, app.name].filter(Boolean).join(' '),
|
|
233
284
|
}
|
package/lib/loop/core/index.d.ts
CHANGED
|
@@ -227,8 +227,12 @@ export type ViewControls = {
|
|
|
227
227
|
* rendered surface, whose values the agent should answer. The chat had
|
|
228
228
|
* this control from the start and kept it to itself; a host with a
|
|
229
229
|
* message had no way in.
|
|
230
|
+
*
|
|
231
|
+
* `forwardedProps` go with the run as AG-UI's: an application's page says
|
|
232
|
+
* there what it did besides the message (`loop`, LOOP R-04). Answers why
|
|
233
|
+
* the message cannot go, when it cannot.
|
|
230
234
|
*/
|
|
231
|
-
send?: (message: string) => void;
|
|
235
|
+
send?: (message: string, forwardedProps?: Record<string, unknown>) => string | void;
|
|
232
236
|
};
|
|
233
237
|
/** Props every view receives. */
|
|
234
238
|
export type LoopViewProps = {
|
|
@@ -80,7 +80,8 @@ function useServerAgent(app, serverUrl) {
|
|
|
80
80
|
setState({
|
|
81
81
|
protocol: {
|
|
82
82
|
type: 'ag-ui',
|
|
83
|
-
|
|
83
|
+
// Its session API: each thread a session (LOOP R-04).
|
|
84
|
+
endpoint: `${base}/api/v1/apps/agents/${encodeURIComponent(app.id)}/ag-ui/`,
|
|
84
85
|
agentId: app.id,
|
|
85
86
|
},
|
|
86
87
|
});
|
|
@@ -137,7 +138,8 @@ function useDatalayerAgent(app, instance) {
|
|
|
137
138
|
return {
|
|
138
139
|
protocol: {
|
|
139
140
|
type: 'ag-ui',
|
|
140
|
-
|
|
141
|
+
// Its session API: each thread a session (LOOP R-04).
|
|
142
|
+
endpoint: `${runtime.agentBaseUrl}/api/v1/apps/agents/${encodeURIComponent(app.id)}/ag-ui/`,
|
|
141
143
|
agentId: app.id,
|
|
142
144
|
authToken: token,
|
|
143
145
|
},
|
|
@@ -13,13 +13,14 @@
|
|
|
13
13
|
* an action is handed the surface it came from, so that what a block wrote
|
|
14
14
|
* into its data model can be read when a button is pressed. It draws from
|
|
15
15
|
* Datalayer's catalog: the basic one, any block shown only while its
|
|
16
|
-
* `visible_when` holds
|
|
16
|
+
* `visible_when` holds — or, given one, a catalog narrowed to the blocks
|
|
17
|
+
* the workspace's plugins contribute (`catalogOfBlocks`).
|
|
17
18
|
*
|
|
18
19
|
* @module loop/plugins/a2ui-surface/InlineSurface
|
|
19
20
|
*/
|
|
20
21
|
import type { JSX } from 'react';
|
|
21
22
|
import type { ReactComponentImplementation } from '@a2ui/react/v0_9';
|
|
22
|
-
import { type A2uiClientAction, type A2uiMessage, type SurfaceModel } from '@a2ui/web_core/v0_9';
|
|
23
|
+
import { Catalog, type A2uiClientAction, type A2uiMessage, type SurfaceModel } from '@a2ui/web_core/v0_9';
|
|
23
24
|
/** A surface as the renderer holds it: its data model is `dataModel`. */
|
|
24
25
|
export type InlineSurfaceModel = SurfaceModel<ReactComponentImplementation>;
|
|
25
26
|
type Surface = InlineSurfaceModel;
|
|
@@ -27,8 +28,10 @@ type Surface = InlineSurfaceModel;
|
|
|
27
28
|
export declare const SURFACE_CATALOG_ID: string;
|
|
28
29
|
/** Values to publish into a surface, by path. */
|
|
29
30
|
export type InlineSurfaceData = Record<string, unknown>;
|
|
30
|
-
export declare function InlineSurface({ messages, onAction, validationError, data, }: {
|
|
31
|
+
export declare function InlineSurface({ messages, onAction, validationError, data, catalog, }: {
|
|
31
32
|
messages: A2uiMessage[];
|
|
33
|
+
/** What it draws with, read once: Datalayer's catalog unless given. */
|
|
34
|
+
catalog?: Catalog<ReactComponentImplementation>;
|
|
32
35
|
/** Told of an action, with the surface it came from. */
|
|
33
36
|
onAction: (action: A2uiClientAction, surface?: Surface) => void;
|
|
34
37
|
validationError?: string | null;
|
|
@@ -11,12 +11,15 @@ const INHERIT_THEME = {
|
|
|
11
11
|
['--a2ui-color-primary']: 'var(--fgColor-accent)',
|
|
12
12
|
['--a2ui-color-outline']: 'var(--borderColor-default)',
|
|
13
13
|
};
|
|
14
|
-
export function InlineSurface({ messages, onAction, validationError, data, }) {
|
|
14
|
+
export function InlineSurface({ messages, onAction, validationError, data, catalog = datalayerCatalog, }) {
|
|
15
15
|
// Reached through a ref: the processor is built once, and the handler it
|
|
16
16
|
// was built with must not go stale when the host's does not.
|
|
17
17
|
const onActionRef = useRef(onAction);
|
|
18
18
|
onActionRef.current = onAction;
|
|
19
|
-
const processor = useMemo(() => new MessageProcessor([
|
|
19
|
+
const processor = useMemo(() => new MessageProcessor([catalog], action => onActionRef.current(action, processorRef.current?.model.getSurface(action.surfaceId))),
|
|
20
|
+
// Once: a host that changes what it draws with mounts a new surface.
|
|
21
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
22
|
+
[]);
|
|
20
23
|
const processorRef = useRef(undefined);
|
|
21
24
|
processorRef.current = processor;
|
|
22
25
|
const [surfaces, setSurfaces] = useState([]);
|
|
@@ -2,13 +2,26 @@
|
|
|
2
2
|
* An application's page, drawn beside its conversation: its A2UI surface on
|
|
3
3
|
* the workspace's own renderer (`InlineSurface`), fed by the chat's current
|
|
4
4
|
* turn and its conversation, its buttons — and a Chat block's message, a File
|
|
5
|
-
* upload's files — answered through the chat's controls
|
|
5
|
+
* upload's files — answered through the chat's controls, what the page did
|
|
6
|
+
* going with the chat's run to the application's session (LOOP R-04).
|
|
7
|
+
*
|
|
8
|
+
* It draws the blocks the workspace's plugins contribute to the Canvas's
|
|
9
|
+
* palette (`loop.canvas.block`, R-01b) and nothing else, so that what the
|
|
10
|
+
* Canvas can place and what the page can draw are one list: a page that
|
|
11
|
+
* uses a block no enabled plugin contributes says so, in place of the page.
|
|
6
12
|
*
|
|
7
13
|
* @module loop/plugins/app-page/AppPage
|
|
8
14
|
*/
|
|
9
15
|
import type { JSX } from 'react';
|
|
10
16
|
import type { AppSpec } from '../../../types/agentspecs';
|
|
11
17
|
import { type LoopWorkspaceContext } from '../../core';
|
|
18
|
+
/** The components a page's messages place, each once. */
|
|
19
|
+
export declare function componentsOnPage(messages: readonly unknown[]): string[];
|
|
20
|
+
/**
|
|
21
|
+
* Why a page is not drawn with the blocks contributed, or null: the blocks it
|
|
22
|
+
* places that no enabled plugin contributes.
|
|
23
|
+
*/
|
|
24
|
+
export declare function blocksMissing(placed: readonly string[], contributed: readonly string[]): string | null;
|
|
12
25
|
export type AppPageProps = {
|
|
13
26
|
app: AppSpec;
|
|
14
27
|
workspace: LoopWorkspaceContext;
|
|
@@ -3,17 +3,60 @@ import { useCallback, useMemo, useRef, useState } from 'react';
|
|
|
3
3
|
import { Box } from '@datalayer/primer-addons';
|
|
4
4
|
import { signal } from '@datalayer/reactor';
|
|
5
5
|
import { useContributions, useSignalValue } from '@datalayer/reactor/react';
|
|
6
|
-
import {
|
|
6
|
+
import { catalogOfBlocks } from '../../../components/a2ui';
|
|
7
|
+
import { LoopCanvasBlock, LoopChatTurn, } from '../../core';
|
|
7
8
|
import { InlineSurface, SURFACE_CATALOG_ID, } from '../a2ui-surface/InlineSurface';
|
|
8
9
|
import { appPageAction, appPageData, appPageMessages } from './appPageModel';
|
|
9
10
|
/** No chat in the workspace: the page reads a turn that never starts. */
|
|
10
11
|
const NO_TURN = signal({ id: 0, status: 'idle' });
|
|
11
12
|
const NO_CONVERSATION = signal([]);
|
|
13
|
+
/** The components a page's messages place, each once. */
|
|
14
|
+
export function componentsOnPage(messages) {
|
|
15
|
+
const names = new Set();
|
|
16
|
+
for (const message of messages) {
|
|
17
|
+
const update = message.updateComponents;
|
|
18
|
+
for (const node of update?.components ?? []) {
|
|
19
|
+
names.add(String(node.component));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return [...names];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Why a page is not drawn with the blocks contributed, or null: the blocks it
|
|
26
|
+
* places that no enabled plugin contributes.
|
|
27
|
+
*/
|
|
28
|
+
export function blocksMissing(placed, contributed) {
|
|
29
|
+
const have = new Set(contributed);
|
|
30
|
+
const missing = placed.filter(name => !have.has(name));
|
|
31
|
+
if (missing.length === 0) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return `This page places ${missing.join(', ')}, which no enabled plugin contributes: ${missing.length === 1 ? 'it is' : 'they are'} drawn once the plugin that contributes ${missing.length === 1 ? 'it' : 'them'} is on.`;
|
|
35
|
+
}
|
|
12
36
|
export function AppPage({ app, workspace }) {
|
|
13
37
|
const entries = useContributions(LoopChatTurn);
|
|
14
38
|
const turn = useSignalValue(entries[0]?.value.turn ?? NO_TURN);
|
|
15
39
|
const conversation = useSignalValue(entries[0]?.value.conversation ?? NO_CONVERSATION);
|
|
16
40
|
const messages = useMemo(() => appPageMessages(app, SURFACE_CATALOG_ID), [app]);
|
|
41
|
+
// The blocks the enabled plugins contribute: what this page may draw.
|
|
42
|
+
const blocks = useContributions(LoopCanvasBlock);
|
|
43
|
+
const contributed = [...new Set(blocks.map(entry => entry.value.id))].sort();
|
|
44
|
+
const drawnWith = contributed.join(',');
|
|
45
|
+
const drawing = useMemo(() => {
|
|
46
|
+
const missing = blocksMissing(componentsOnPage(messages), contributed);
|
|
47
|
+
if (missing) {
|
|
48
|
+
return { problem: missing };
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return { catalog: catalogOfBlocks(contributed) };
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return {
|
|
55
|
+
problem: error instanceof Error ? error.message : String(error),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
59
|
+
}, [messages, drawnWith]);
|
|
17
60
|
const data = useMemo(() => appPageData(app, turn, conversation), [app, turn, conversation]);
|
|
18
61
|
const [refusal, setRefusal] = useState(null);
|
|
19
62
|
// The workspace changes as the chat reports itself; the handler reads the latest.
|
|
@@ -36,10 +79,20 @@ export function AppPage({ app, workspace }) {
|
|
|
36
79
|
controls.newChat?.();
|
|
37
80
|
}
|
|
38
81
|
else {
|
|
39
|
-
// The chat's own send,
|
|
40
|
-
//
|
|
82
|
+
// The chat's own send, with what the page did besides — the block's
|
|
83
|
+
// action, its files, the settings — for the application's session
|
|
84
|
+
// (R-04); the prompt channel when the chat has not reported itself
|
|
85
|
+
// yet, which carries words only.
|
|
41
86
|
if (controls.send) {
|
|
42
|
-
controls.send(outcome.send);
|
|
87
|
+
const refused = controls.send(outcome.send, { loop: outcome.loop });
|
|
88
|
+
if (refused) {
|
|
89
|
+
setRefusal(refused);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else if (outcome.loop.files) {
|
|
94
|
+
setRefusal('The conversation is not ready for a file yet: try again.');
|
|
95
|
+
return;
|
|
43
96
|
}
|
|
44
97
|
else {
|
|
45
98
|
workspaceRef.current.prompts.submit(outcome.send);
|
|
@@ -50,6 +103,8 @@ export function AppPage({ app, workspace }) {
|
|
|
50
103
|
}
|
|
51
104
|
}
|
|
52
105
|
}, [app]);
|
|
53
|
-
return (_jsx(Box, { "data-testid": "app-page", sx: { height: '100%', minHeight: 0, overflow: 'auto', p: 3 }, children: _jsx(
|
|
106
|
+
return (_jsx(Box, { "data-testid": "app-page", sx: { height: '100%', minHeight: 0, overflow: 'auto', p: 3 }, children: 'problem' in drawing ? (_jsx(Box, { role: "status", sx: { color: 'fg.muted', fontSize: 1 }, children: drawing.problem })) : (_jsx(InlineSurface
|
|
107
|
+
// A new list of blocks is a new surface, drawn with it.
|
|
108
|
+
, { messages: messages, data: data, onAction: onAction, validationError: refusal, catalog: drawing.catalog }, drawnWith)) }));
|
|
54
109
|
}
|
|
55
110
|
export default AppPage;
|
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
* data model at a path the application takes, and what a button does reaches
|
|
11
11
|
* the application through the chat's own channels: a message sent, the
|
|
12
12
|
* answer stopped, the conversation started over. Nothing here is a server
|
|
13
|
-
* API
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* API: what a button did besides its message — the block's action, the files
|
|
14
|
+
* given, the settings — goes with the chat's own run to the application's
|
|
15
|
+
* session (LOOP R-04, `forwardedProps.loop`), which puts a file where the
|
|
16
|
+
* application reads it; a setting also reaches a written application in the
|
|
17
|
+
* words of its message.
|
|
16
18
|
*
|
|
17
19
|
* A decision is not here: its page is drawn by the Studio's run page, with
|
|
18
20
|
* its own paths (`INTERFACE_PATHS`), until it moves onto the workspace (R-02).
|
|
@@ -108,21 +110,50 @@ export declare function appPageInitialData(app: Pick<AppSpec, 'kind' | 'name' |
|
|
|
108
110
|
*/
|
|
109
111
|
export declare function inputsInWords(app: Pick<AppSpec, 'interface'>, inputs: unknown): string;
|
|
110
112
|
/**
|
|
111
|
-
* The
|
|
112
|
-
*
|
|
113
|
-
*
|
|
113
|
+
* The largest file a session takes: what the session API refuses above
|
|
114
|
+
* (`agent_runtimes.loop.apps.sessions.MAX_FILE_BYTES`), said here before
|
|
115
|
+
* the file is sent.
|
|
114
116
|
*/
|
|
115
|
-
export declare const
|
|
117
|
+
export declare const MAX_FILE_BYTES: number;
|
|
118
|
+
/** A file as File upload gives it, and as the session API takes it. */
|
|
119
|
+
export type GivenFile = {
|
|
120
|
+
name: string;
|
|
121
|
+
type: string;
|
|
122
|
+
size: number;
|
|
123
|
+
data_url: string;
|
|
124
|
+
};
|
|
116
125
|
/**
|
|
117
|
-
* The files given on the page
|
|
118
|
-
*
|
|
119
|
-
*
|
|
126
|
+
* The files given on the page, as the session API takes them — or why they
|
|
127
|
+
* cannot go: what was given is not files as File upload gives them, or one
|
|
128
|
+
* is larger than a session takes. What becomes of each — put in the
|
|
129
|
+
* application's sandbox, or its text in the message — is the session's to
|
|
130
|
+
* decide (LOOP R-04).
|
|
120
131
|
*/
|
|
121
|
-
export declare function
|
|
122
|
-
|
|
132
|
+
export declare function givenFiles(files: unknown): {
|
|
133
|
+
files: GivenFile[];
|
|
123
134
|
} | {
|
|
124
135
|
refused: string;
|
|
125
136
|
};
|
|
137
|
+
/**
|
|
138
|
+
* The application's settings as the page holds them, as the session API
|
|
139
|
+
* takes them: a choice as the option chosen, a number as a number, a toggle
|
|
140
|
+
* as on or off — each setting the page has a value for.
|
|
141
|
+
*/
|
|
142
|
+
export declare function settingsOf(app: Pick<AppSpec, 'interface'>, inputs: unknown): Record<string, unknown>;
|
|
143
|
+
/**
|
|
144
|
+
* What the page did besides its message, for the session API: the action of
|
|
145
|
+
* the block pressed, the files given with it, the settings it holds. Sent
|
|
146
|
+
* with the chat's own run (AG-UI's `forwardedProps.loop`), so that what it
|
|
147
|
+
* answers streams in the conversation (LOOP R-04).
|
|
148
|
+
*/
|
|
149
|
+
export type AppPageLoop = {
|
|
150
|
+
action: {
|
|
151
|
+
name: string;
|
|
152
|
+
payload: Record<string, unknown>;
|
|
153
|
+
};
|
|
154
|
+
files?: GivenFile[];
|
|
155
|
+
settings?: Record<string, unknown>;
|
|
156
|
+
};
|
|
126
157
|
/** What a button asked, as the surface hands it over. */
|
|
127
158
|
export type AppPageEvent = {
|
|
128
159
|
name: string;
|
|
@@ -132,6 +163,8 @@ export type AppPageEvent = {
|
|
|
132
163
|
export type AppPageOutcome = {
|
|
133
164
|
/** A message for the application, sent as the person's next turn. */
|
|
134
165
|
send: string;
|
|
166
|
+
/** What the page did besides, for the session it is sent in. */
|
|
167
|
+
loop: AppPageLoop;
|
|
135
168
|
/** The inputs in words as sent, to tell a change next time. */
|
|
136
169
|
inputs: string;
|
|
137
170
|
/** Paths to empty once it is sent. */
|
|
@@ -36,7 +36,7 @@ const MESSAGES = {
|
|
|
36
36
|
};
|
|
37
37
|
const FILES = {
|
|
38
38
|
path: '/files',
|
|
39
|
-
meaning: 'The files given on the page, each {name, type, size, data_url}:
|
|
39
|
+
meaning: 'The files given on the page, each {name, type, size, data_url}: they go to the application’s session with the next run, put in its sandbox when it has a shell, a text file in the message when it has none.',
|
|
40
40
|
words: 'The files given',
|
|
41
41
|
list: true,
|
|
42
42
|
};
|
|
@@ -96,7 +96,7 @@ export const APP_KIND_PATHS = {
|
|
|
96
96
|
actions: [
|
|
97
97
|
{
|
|
98
98
|
name: 'run',
|
|
99
|
-
meaning: 'Run it on its inputs: every value written under /inputs is sent, and each
|
|
99
|
+
meaning: 'Run it on its inputs: every value written under /inputs is sent, and each file at /files, with the context’s message when there is one.',
|
|
100
100
|
words: 'Run it on its inputs',
|
|
101
101
|
context: ['message'],
|
|
102
102
|
asks: { message: 'What it says before the inputs' },
|
|
@@ -301,81 +301,85 @@ export function inputsInWords(app, inputs) {
|
|
|
301
301
|
return lines.filter(Boolean).join('\n');
|
|
302
302
|
}
|
|
303
303
|
/**
|
|
304
|
-
* The
|
|
305
|
-
*
|
|
306
|
-
*
|
|
304
|
+
* The largest file a session takes: what the session API refuses above
|
|
305
|
+
* (`agent_runtimes.loop.apps.sessions.MAX_FILE_BYTES`), said here before
|
|
306
|
+
* the file is sent.
|
|
307
307
|
*/
|
|
308
|
-
export const
|
|
309
|
-
/** The kinds of file read as text, besides `text/*`. */
|
|
310
|
-
const TEXT_TYPES = new Set([
|
|
311
|
-
'application/json',
|
|
312
|
-
'application/xml',
|
|
313
|
-
'application/yaml',
|
|
314
|
-
'application/x-yaml',
|
|
315
|
-
'application/csv',
|
|
316
|
-
]);
|
|
317
|
-
/** The extensions read as text whatever type the browser gives. */
|
|
318
|
-
const TEXT_EXTENSIONS = /\.(csv|tsv|txt|md|json|jsonl|xml|ya?ml|log)$/i;
|
|
319
|
-
/** The words of a data URL, decoded as UTF-8. */
|
|
320
|
-
function dataUrlText(dataUrl) {
|
|
321
|
-
const comma = dataUrl.indexOf(',');
|
|
322
|
-
if (!dataUrl.startsWith('data:') || comma < 0) {
|
|
323
|
-
throw new Error('it is not a data URL');
|
|
324
|
-
}
|
|
325
|
-
const body = dataUrl.slice(comma + 1);
|
|
326
|
-
if (!dataUrl.slice(0, comma).endsWith(';base64')) {
|
|
327
|
-
return decodeURIComponent(body);
|
|
328
|
-
}
|
|
329
|
-
const bytes = Uint8Array.from(atob(body), char => char.charCodeAt(0));
|
|
330
|
-
return new TextDecoder('utf-8').decode(bytes);
|
|
331
|
-
}
|
|
308
|
+
export const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
332
309
|
/**
|
|
333
|
-
* The files given on the page
|
|
334
|
-
*
|
|
335
|
-
*
|
|
310
|
+
* The files given on the page, as the session API takes them — or why they
|
|
311
|
+
* cannot go: what was given is not files as File upload gives them, or one
|
|
312
|
+
* is larger than a session takes. What becomes of each — put in the
|
|
313
|
+
* application's sandbox, or its text in the message — is the session's to
|
|
314
|
+
* decide (LOOP R-04).
|
|
336
315
|
*/
|
|
337
|
-
export function
|
|
316
|
+
export function givenFiles(files) {
|
|
338
317
|
if (files === undefined || files === null) {
|
|
339
|
-
return {
|
|
318
|
+
return { files: [] };
|
|
340
319
|
}
|
|
341
320
|
if (!Array.isArray(files)) {
|
|
342
321
|
return { refused: 'What was given as files is not a list of files.' };
|
|
343
322
|
}
|
|
344
|
-
const
|
|
323
|
+
const given = [];
|
|
345
324
|
for (const [index, file] of files.entries()) {
|
|
346
|
-
const
|
|
347
|
-
if (typeof
|
|
325
|
+
const item = (file ?? {});
|
|
326
|
+
if (typeof item.name !== 'string' || typeof item.data_url !== 'string') {
|
|
348
327
|
return {
|
|
349
328
|
refused: `File ${index + 1} is not a file as File upload gives it ({name, type, size, data_url}).`,
|
|
350
329
|
};
|
|
351
330
|
}
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
if (!type.startsWith('text/') &&
|
|
355
|
-
!TEXT_TYPES.has(type) &&
|
|
356
|
-
!TEXT_EXTENSIONS.test(name)) {
|
|
331
|
+
const size = typeof item.size === 'number' ? item.size : 0;
|
|
332
|
+
if (size > MAX_FILE_BYTES) {
|
|
357
333
|
return {
|
|
358
|
-
refused: `${name} is
|
|
334
|
+
refused: `${item.name} is ${size.toLocaleString('en')} bytes: a session takes files of at most ${MAX_FILE_BYTES.toLocaleString('en')}.`,
|
|
359
335
|
};
|
|
360
336
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
337
|
+
given.push({
|
|
338
|
+
name: item.name,
|
|
339
|
+
type: typeof item.type === 'string' ? item.type : '',
|
|
340
|
+
size,
|
|
341
|
+
data_url: item.data_url,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return { files: given };
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* The application's settings as the page holds them, as the session API
|
|
348
|
+
* takes them: a choice as the option chosen, a number as a number, a toggle
|
|
349
|
+
* as on or off — each setting the page has a value for.
|
|
350
|
+
*/
|
|
351
|
+
export function settingsOf(app, inputs) {
|
|
352
|
+
const values = inputs && typeof inputs === 'object'
|
|
353
|
+
? inputs
|
|
354
|
+
: {};
|
|
355
|
+
const settings = {};
|
|
356
|
+
for (const setting of app.interface.settings) {
|
|
357
|
+
const value = values[setting.id];
|
|
358
|
+
if (value === undefined || value === null) {
|
|
359
|
+
continue;
|
|
369
360
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
361
|
+
switch (setting.type) {
|
|
362
|
+
case 'select': {
|
|
363
|
+
const chosen = Array.isArray(value) ? value[0] : value;
|
|
364
|
+
if (chosen !== undefined && chosen !== '') {
|
|
365
|
+
settings[setting.id] = String(chosen);
|
|
366
|
+
}
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
case 'toggle':
|
|
370
|
+
settings[setting.id] = Boolean(value);
|
|
371
|
+
break;
|
|
372
|
+
case 'slider':
|
|
373
|
+
case 'number':
|
|
374
|
+
if (value !== '' && !Number.isNaN(Number(value))) {
|
|
375
|
+
settings[setting.id] = Number(value);
|
|
376
|
+
}
|
|
377
|
+
break;
|
|
378
|
+
default:
|
|
379
|
+
settings[setting.id] = String(value);
|
|
374
380
|
}
|
|
375
|
-
const fence = text.includes('```') ? '~~~~' : '```';
|
|
376
|
-
parts.push(`The file ${name} (${type || 'text'}):\n${fence}\n${text.replace(/\n$/, '')}\n${fence}`);
|
|
377
381
|
}
|
|
378
|
-
return
|
|
382
|
+
return settings;
|
|
379
383
|
}
|
|
380
384
|
/**
|
|
381
385
|
* What a button pressed on the page does.
|
|
@@ -397,6 +401,13 @@ export function appPageAction(app, event, read, lastInputs = '') {
|
|
|
397
401
|
const given = event.context?.message;
|
|
398
402
|
const message = typeof given === 'string' ? given.trim() : '';
|
|
399
403
|
const inputs = paths.inputs ? inputsInWords(app, read('/inputs')) : '';
|
|
404
|
+
const settings = paths.inputs ? settingsOf(app, read('/inputs')) : undefined;
|
|
405
|
+
const payload = Object.fromEntries(Object.entries(event.context ?? {}).filter(([key]) => key !== 'message' && key !== 'files'));
|
|
406
|
+
const loop = (files) => ({
|
|
407
|
+
action: { name: event.name, payload },
|
|
408
|
+
...(files && files.length > 0 ? { files } : {}),
|
|
409
|
+
...(settings ? { settings } : {}),
|
|
410
|
+
});
|
|
400
411
|
switch (event.name) {
|
|
401
412
|
case 'stop':
|
|
402
413
|
return { stop: true };
|
|
@@ -404,21 +415,21 @@ export function appPageAction(app, event, read, lastInputs = '') {
|
|
|
404
415
|
return { newChat: true };
|
|
405
416
|
case 'run':
|
|
406
417
|
case 'upload': {
|
|
407
|
-
const
|
|
408
|
-
const files =
|
|
409
|
-
?
|
|
418
|
+
const context = event.context?.files;
|
|
419
|
+
const files = givenFiles(event.name === 'upload' && context !== undefined
|
|
420
|
+
? context
|
|
410
421
|
: read(FILES.path));
|
|
411
422
|
if ('refused' in files) {
|
|
412
423
|
return files;
|
|
413
424
|
}
|
|
414
|
-
if (event.name === 'upload' &&
|
|
425
|
+
if (event.name === 'upload' && files.files.length === 0) {
|
|
415
426
|
return { refused: 'No file was given: choose one first.' };
|
|
416
427
|
}
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
428
|
+
const names = files.files.map(file => file.name).join(', ');
|
|
429
|
+
const text = [message, inputs].filter(Boolean).join('\n') ||
|
|
430
|
+
(names ? `Run on ${names}.` : '');
|
|
420
431
|
return text
|
|
421
|
-
? { send: text, inputs, clear: [] }
|
|
432
|
+
? { send: text, inputs, clear: [], loop: loop(files.files) }
|
|
422
433
|
: { refused: `${app.name} has no inputs to run on: set one first.` };
|
|
423
434
|
}
|
|
424
435
|
default: {
|
|
@@ -432,6 +443,7 @@ export function appPageAction(app, event, read, lastInputs = '') {
|
|
|
432
443
|
send: changed ? `${written}\n\n${inputs}` : written,
|
|
433
444
|
inputs,
|
|
434
445
|
clear: message ? [] : [DRAFT.path],
|
|
446
|
+
loop: loop(),
|
|
435
447
|
};
|
|
436
448
|
}
|
|
437
449
|
}
|
|
@@ -1,8 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@datalayer/loop-plugin-app-page` — an application's page, as a plugin
|
|
3
|
+
* (LOOP R-01, R-01b).
|
|
4
|
+
*
|
|
5
|
+
* A chat, a widget or a worker runs as its conversation; its page — its
|
|
6
|
+
* A2UI surface, or the default page of its kind when none is composed — is
|
|
7
|
+
* contributed as the editor beside the chat (`LoopEditorView`), the place a
|
|
8
|
+
* notebook or a document takes in other workspaces. `AppRenderer` mounts it
|
|
9
|
+
* for an application that has one (`hasAppPage`) and opens the chat on it.
|
|
10
|
+
*
|
|
11
|
+
* What the page shows and takes, by kind, is `APP_KIND_PATHS`.
|
|
12
|
+
*
|
|
13
|
+
* A page the workspace does not draw itself — a decision's, whose page is
|
|
14
|
+
* its host's (the Studio's run page, its hosted address, its embed) — is
|
|
15
|
+
* contributed by the host through `defineAppHostPagePlugin`, as the
|
|
16
|
+
* workspace's one view (`LoopViewType`), in a workspace without a
|
|
17
|
+
* conversation (LOOP R-02).
|
|
18
|
+
*
|
|
19
|
+
* @module loop/plugins/app-page
|
|
20
|
+
*/
|
|
21
|
+
import type { ComponentType } from 'react';
|
|
1
22
|
import { type ReactorPlugin } from '@datalayer/reactor';
|
|
2
23
|
import type { AppSpec } from '../../../types/agentspecs';
|
|
24
|
+
import { type LoopWorkspaceContext } from '../../core';
|
|
3
25
|
export declare const APP_PAGE_PLUGIN_NAME = "@datalayer/loop-plugin-app-page";
|
|
4
26
|
/** The plugin that draws an application's page beside its conversation. */
|
|
5
27
|
export declare function defineAppPagePlugin(app: AppSpec): ReactorPlugin<Record<string, never>, unknown, unknown>;
|
|
28
|
+
/** The view an application's page is, when its host draws it. */
|
|
29
|
+
export declare const APP_HOST_PAGE_VIEW = "app-page";
|
|
30
|
+
/** What a host's page is given: the application, and the workspace it is the view of. */
|
|
31
|
+
export type AppHostPageProps = {
|
|
32
|
+
app: AppSpec;
|
|
33
|
+
workspace: LoopWorkspaceContext;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The plugin that makes a host's page the workspace's view (LOOP R-02): the
|
|
37
|
+
* page a host draws for an application whose kind the workspace has no page
|
|
38
|
+
* for — a decision's. The workspace opens on it; it is ordered before every
|
|
39
|
+
* other view.
|
|
40
|
+
*/
|
|
41
|
+
export declare function defineAppHostPagePlugin(app: AppSpec, Page: ComponentType<AppHostPageProps>): ReactorPlugin<Record<string, never>, unknown, unknown>;
|
|
6
42
|
export { AppPage, type AppPageProps } from './AppPage';
|
|
7
43
|
export * from './appPageModel';
|
|
8
44
|
export default defineAppPagePlugin;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { BrowserIcon } from '@primer/octicons-react';
|
|
3
3
|
import { contribution, definePlugin, } from '@datalayer/reactor';
|
|
4
|
-
import { LoopEditorView } from '../../core';
|
|
4
|
+
import { LoopEditorView, LoopViewType, } from '../../core';
|
|
5
5
|
import { AppPage } from './AppPage';
|
|
6
6
|
import { APP_PAGE_SURFACE } from './appPageModel';
|
|
7
7
|
export const APP_PAGE_PLUGIN_NAME = '@datalayer/loop-plugin-app-page';
|
|
@@ -29,6 +29,37 @@ export function defineAppPagePlugin(app) {
|
|
|
29
29
|
});
|
|
30
30
|
return plugin;
|
|
31
31
|
}
|
|
32
|
+
/** The view an application's page is, when its host draws it. */
|
|
33
|
+
export const APP_HOST_PAGE_VIEW = 'app-page';
|
|
34
|
+
/**
|
|
35
|
+
* The plugin that makes a host's page the workspace's view (LOOP R-02): the
|
|
36
|
+
* page a host draws for an application whose kind the workspace has no page
|
|
37
|
+
* for — a decision's. The workspace opens on it; it is ordered before every
|
|
38
|
+
* other view.
|
|
39
|
+
*/
|
|
40
|
+
export function defineAppHostPagePlugin(app, Page) {
|
|
41
|
+
// One component per plugin: the view host keeps it, and the application with it.
|
|
42
|
+
function View({ workspace, }) {
|
|
43
|
+
return _jsx(Page, { app: app, workspace: workspace });
|
|
44
|
+
}
|
|
45
|
+
const plugin = definePlugin({
|
|
46
|
+
name: `${APP_PAGE_PLUGIN_NAME}-${app.id}`,
|
|
47
|
+
displayName: `${app.name}: its page`,
|
|
48
|
+
description: `The page of ${app.name}, drawn by its host.`,
|
|
49
|
+
octicon: 'browser',
|
|
50
|
+
emoji: app.emoji || undefined,
|
|
51
|
+
contributes: [
|
|
52
|
+
contribution(LoopViewType, {
|
|
53
|
+
viewType: APP_HOST_PAGE_VIEW,
|
|
54
|
+
title: app.name,
|
|
55
|
+
icon: BrowserIcon,
|
|
56
|
+
order: -10,
|
|
57
|
+
load: async () => ({ default: View }),
|
|
58
|
+
}, { id: APP_HOST_PAGE_VIEW, order: -10 }),
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
return plugin;
|
|
62
|
+
}
|
|
32
63
|
export { AppPage } from './AppPage';
|
|
33
64
|
export * from './appPageModel';
|
|
34
65
|
export default defineAppPagePlugin;
|
|
@@ -326,21 +326,32 @@ export default function ChatView({ workspace }) {
|
|
|
326
326
|
feed.assistant(reply);
|
|
327
327
|
}, []);
|
|
328
328
|
const heldForAdapter = useRef([]);
|
|
329
|
+
/** Whether the agent is turned in this page: read by `sendNow`, set below. */
|
|
330
|
+
const inPageRef = useRef(false);
|
|
329
331
|
/*
|
|
330
332
|
* Every message that goes to the agent goes through here: the composer's
|
|
331
333
|
* (through the workspace's dispatch and the prompt channel), a host's, and
|
|
332
334
|
* one sent from the application's page (`viewControls.send`) — so each
|
|
333
335
|
* begins its turn, and a page reading the turn sees it asked and answered.
|
|
336
|
+
*
|
|
337
|
+
* What the page did besides the message goes with it as AG-UI's
|
|
338
|
+
* `forwardedProps` (`loop`, LOOP R-04): the application's session takes a
|
|
339
|
+
* file there. An agent turned in this page has no session, and no file is
|
|
340
|
+
* handed to it: said, rather than dropped.
|
|
334
341
|
*/
|
|
335
|
-
const sendNow = useCallback((message) => {
|
|
342
|
+
const sendNow = useCallback((message, forwardedProps) => {
|
|
343
|
+
const loop = forwardedProps?.loop;
|
|
344
|
+
if (inPageRef.current && loop?.files && loop.files.length > 0) {
|
|
345
|
+
return 'A file is given to an application running on a runtime: run it on Datalayer or on your machine to give it one.';
|
|
346
|
+
}
|
|
336
347
|
// A new turn: whatever the panel showed is gone, this message is it.
|
|
337
348
|
turnFeedRef.current?.begin(message, controlsRef.current?.thread());
|
|
338
349
|
const send = controlsRef.current?.send;
|
|
339
350
|
if (!send) {
|
|
340
|
-
heldForAdapter.current.push(message);
|
|
351
|
+
heldForAdapter.current.push([message, forwardedProps]);
|
|
341
352
|
return;
|
|
342
353
|
}
|
|
343
|
-
send(message);
|
|
354
|
+
send(message, forwardedProps);
|
|
344
355
|
}, []);
|
|
345
356
|
// One writer, so neither fact can erase the other.
|
|
346
357
|
useEffect(() => {
|
|
@@ -374,8 +385,8 @@ export default function ChatView({ workspace }) {
|
|
|
374
385
|
return;
|
|
375
386
|
}
|
|
376
387
|
const held = heldForAdapter.current.splice(0, heldForAdapter.current.length);
|
|
377
|
-
for (const message of held) {
|
|
378
|
-
controlsRef.current?.send(message);
|
|
388
|
+
for (const [message, forwardedProps] of held) {
|
|
389
|
+
controlsRef.current?.send(message, forwardedProps);
|
|
379
390
|
}
|
|
380
391
|
}, [sendReady]);
|
|
381
392
|
/*
|
|
@@ -629,6 +640,7 @@ export default function ChatView({ workspace }) {
|
|
|
629
640
|
* for — so the location wins, which is the same rule the examples follow.
|
|
630
641
|
*/
|
|
631
642
|
const inPage = targetRunsAgentInPage(workspace.sandbox.target ?? 'local');
|
|
643
|
+
inPageRef.current = inPage;
|
|
632
644
|
/*
|
|
633
645
|
* Who is being addressed, when this workspace runs a team.
|
|
634
646
|
*
|
|
@@ -1088,6 +1100,12 @@ export default function ChatView({ workspace }) {
|
|
|
1088
1100
|
// a new identity would resend the last suggestion.
|
|
1089
1101
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1090
1102
|
}, [suggestion]);
|
|
1103
|
+
/*
|
|
1104
|
+
* An application's session checks who is calling (LOOP R-32, R-04): its
|
|
1105
|
+
* chat says so with the person's token. The embed hands its own.
|
|
1106
|
+
*/
|
|
1107
|
+
const runsApp = Boolean(blueprintTurn?.createPayload?.app_spec);
|
|
1108
|
+
const iamToken = useIAMStore(state => state.token);
|
|
1091
1109
|
const protocol = useMemo(() => inPage
|
|
1092
1110
|
? browserProtocolConfig({
|
|
1093
1111
|
agentId: member?.specId ?? agentId,
|
|
@@ -1116,7 +1134,14 @@ export default function ChatView({ workspace }) {
|
|
|
1116
1134
|
})
|
|
1117
1135
|
: {
|
|
1118
1136
|
type: 'ag-ui',
|
|
1119
|
-
|
|
1137
|
+
// An application's agent is spoken to through its session API
|
|
1138
|
+
// (LOOP R-04): each thread a session, recorded under its uid,
|
|
1139
|
+
// run in the name the application runs in, and taking what the
|
|
1140
|
+
// page did besides the message — the same AG-UI events back.
|
|
1141
|
+
endpoint: runsApp
|
|
1142
|
+
? `${agentServerUrl}/api/v1/apps/agents/${encodeURIComponent(agentId)}/ag-ui/`
|
|
1143
|
+
: `${agentServerUrl}/api/v1/ag-ui/${agentId}/`,
|
|
1144
|
+
...(runsApp && iamToken ? { authToken: iamToken } : {}),
|
|
1120
1145
|
agentId,
|
|
1121
1146
|
// `/api/v1/configure`, not `/api/v1/configure/config`: the hooks
|
|
1122
1147
|
// strip one trailing `config`/`configure` segment to find the API
|
|
@@ -1134,6 +1159,8 @@ export default function ChatView({ workspace }) {
|
|
|
1134
1159
|
blueprintTurn,
|
|
1135
1160
|
activeModel,
|
|
1136
1161
|
agentServerUrl,
|
|
1162
|
+
runsApp,
|
|
1163
|
+
iamToken,
|
|
1137
1164
|
]);
|
|
1138
1165
|
/*
|
|
1139
1166
|
* The model catalogue, and the builtin tools and skills the agent reports.
|
package/lib/loop/presets.d.ts
CHANGED
|
@@ -173,6 +173,17 @@ export type LoopPresetOptions = {
|
|
|
173
173
|
* cells and documents.
|
|
174
174
|
*/
|
|
175
175
|
editors?: boolean;
|
|
176
|
+
/**
|
|
177
|
+
* Whether the workspace holds a conversation at all.
|
|
178
|
+
*
|
|
179
|
+
* True by default. A host whose one view is a page of its own — an
|
|
180
|
+
* application that answers in its page and has no agent to talk to, a
|
|
181
|
+
* decision (LOOP R-02) — passes `false`: the chat and its composer, the
|
|
182
|
+
* agents, the models, the editors and the page layout all stay out, so
|
|
183
|
+
* that nothing is launched or fetched for a conversation nobody has, and
|
|
184
|
+
* the workspace opens on the view a host's plugin contributes.
|
|
185
|
+
*/
|
|
186
|
+
conversation?: boolean;
|
|
176
187
|
/**
|
|
177
188
|
* The editor choice in the workspace header rather than above the chat.
|
|
178
189
|
*
|
package/lib/loop/presets.js
CHANGED
|
@@ -52,8 +52,23 @@ import { LoopPageLayoutPlugin } from './plugins/page-layout';
|
|
|
52
52
|
* be installed or signed into, and the switches a demonstration wants are off.
|
|
53
53
|
*/
|
|
54
54
|
export function loopPlugins(options = {}) {
|
|
55
|
-
const { serverUrl, target = 'browser', defaultEditor: askedEditor, showViewSelector = true, hideChatHeader = false, chatHeaderButtons = false, promptPlacement, autoFocusPrompt = true, suggestionLabels = true, fullScreenTopOffset = 0, showAgentVariants = false, agentSummary = true, teamId, teamPicker = true, pageLayout = false, pageLayoutArrangement = 'page', pageLayoutTurnPanel = 'below', pageLayoutPrompt = 'docked', pageLayoutPromptAnchor = 'top', pageLayoutSize, pageLayoutTurnPanelFooter = 'full', localAgent, localAgentSpec, datalayerAgentSpecId, datalayerCreatePayload, targetFixed, themeVariant, themeOverrides, presence, showTokenUsage, floatingPrompt = false, editorSelector = false, editors = true, graph = false, commandPalette = false, pluginsPanel = false, windowFrame = false, firstPromptHook, } = options;
|
|
55
|
+
const { serverUrl, target = 'browser', defaultEditor: askedEditor, showViewSelector = true, hideChatHeader = false, chatHeaderButtons = false, promptPlacement, autoFocusPrompt = true, suggestionLabels = true, fullScreenTopOffset = 0, showAgentVariants = false, agentSummary = true, teamId, teamPicker = true, pageLayout = false, pageLayoutArrangement = 'page', pageLayoutTurnPanel = 'below', pageLayoutPrompt = 'docked', pageLayoutPromptAnchor = 'top', pageLayoutSize, pageLayoutTurnPanelFooter = 'full', localAgent, localAgentSpec, datalayerAgentSpecId, datalayerCreatePayload, targetFixed, themeVariant, themeOverrides, presence, showTokenUsage, floatingPrompt = false, editorSelector = false, editors = true, conversation = true, graph = false, commandPalette = false, pluginsPanel = false, windowFrame = false, firstPromptHook, } = options;
|
|
56
56
|
const defaultEditor = askedEditor ?? (editors ? 'notebook' : 'none');
|
|
57
|
+
// A workspace without a conversation: the theme, the shell's points, and
|
|
58
|
+
// the host's chrome; its view is a host's plugin's.
|
|
59
|
+
if (!conversation) {
|
|
60
|
+
return [
|
|
61
|
+
ThemePlugin,
|
|
62
|
+
configurePlugin(ShellPlugin, {
|
|
63
|
+
defaultEditor: 'none',
|
|
64
|
+
showSelector: false,
|
|
65
|
+
}),
|
|
66
|
+
...(graph ? [GraphViewPlugin] : []),
|
|
67
|
+
...(commandPalette ? [LoopCommandsPlugin] : []),
|
|
68
|
+
...(pluginsPanel ? [PluginsPanelPlugin] : []),
|
|
69
|
+
...(windowFrame ? [WindowFramePlugin] : []),
|
|
70
|
+
];
|
|
71
|
+
}
|
|
57
72
|
return [
|
|
58
73
|
// The chat owns the editor beside it, so which one opens is its
|
|
59
74
|
// configuration rather than the workspace's.
|
|
@@ -83,6 +83,8 @@ export declare class AGUIAdapter extends BaseProtocolAdapter {
|
|
|
83
83
|
provider: string;
|
|
84
84
|
accessToken: string;
|
|
85
85
|
}>;
|
|
86
|
+
/** What goes with the run besides the conversation (AG-UI's). */
|
|
87
|
+
forwardedProps?: Record<string, unknown>;
|
|
86
88
|
}): Promise<void>;
|
|
87
89
|
/**
|
|
88
90
|
* Send tool result back through AG-UI and continue the conversation.
|
|
@@ -191,7 +191,7 @@ export class AGUIAdapter extends BaseProtocolAdapter {
|
|
|
191
191
|
skills: options.skills,
|
|
192
192
|
}),
|
|
193
193
|
context: [],
|
|
194
|
-
forwardedProps: null,
|
|
194
|
+
forwardedProps: options?.forwardedProps ?? null,
|
|
195
195
|
// Include model for per-request model override
|
|
196
196
|
...(options?.model && { model: options.model }),
|
|
197
197
|
// Include identities for tool execution with OAuth tokens
|
package/lib/types/chat.d.ts
CHANGED
|
@@ -654,7 +654,8 @@ export interface ChatBaseProps {
|
|
|
654
654
|
* Called again with `null` when the chat can no longer send.
|
|
655
655
|
*/
|
|
656
656
|
onSendReady?: (controls: {
|
|
657
|
-
|
|
657
|
+
/** Send a message; `forwardedProps` go with its run (AG-UI's). */
|
|
658
|
+
send: (message: string, forwardedProps?: Record<string, unknown>) => void;
|
|
658
659
|
stop: () => void;
|
|
659
660
|
/** Start the conversation over — what the header's + does. */
|
|
660
661
|
newChat: () => void;
|
package/lib/types/protocol.d.ts
CHANGED
|
@@ -142,6 +142,8 @@ export interface ProtocolAdapter {
|
|
|
142
142
|
provider: string;
|
|
143
143
|
accessToken: string;
|
|
144
144
|
}>;
|
|
145
|
+
/** What goes with the run besides the conversation (AG-UI's `forwardedProps`). */
|
|
146
|
+
forwardedProps?: Record<string, unknown>;
|
|
145
147
|
}): Promise<void>;
|
|
146
148
|
/**
|
|
147
149
|
* Send tool execution result back
|