@datalayer/agent-runtimes 1.3.95 → 1.3.96
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/dist-embed/datalayer-app-main.js +3 -3
- package/dist-embed/datalayer-app.js +1 -1
- package/lib/apps/__tests__/fixtures/sceneCheckCases.json +352 -0
- package/lib/apps/apps/sceneChecks.d.ts +92 -0
- package/lib/apps/apps/sceneChecks.js +649 -0
- package/lib/apps/apps/sceneShape.d.ts +8 -0
- package/lib/apps/apps/sceneShape.js +191 -0
- package/lib/apps/apps/sceneYaml.d.ts +64 -0
- package/lib/apps/apps/sceneYaml.js +253 -0
- package/lib/apps/core/canvasParts.d.ts +28 -0
- package/lib/apps/core/canvasParts.js +14 -0
- package/lib/apps/core/index.d.ts +1 -0
- package/lib/apps/core/index.js +2 -0
- package/lib/apps/embed/loader.js +10 -0
- package/lib/apps/plugins/agents/SandboxSelector.js +13 -2
- package/lib/apps/plugins/agents/host.d.ts +2 -0
- package/lib/apps/plugins/agents/host.js +16 -0
- package/lib/apps/plugins/agents/plugin.js +10 -2
- package/lib/apps/plugins/agents/switchable.d.ts +12 -0
- package/lib/apps/plugins/agents/switchable.js +23 -0
- package/lib/apps/plugins/canvas-parts/index.d.ts +44 -0
- package/lib/apps/plugins/canvas-parts/index.js +209 -0
- package/lib/hooks/useAgUi.js +4 -1
- package/lib/protocols/AGUIAdapter.js +5 -1
- package/lib/protocols/agUiRefusal.d.ts +47 -0
- package/lib/protocols/agUiRefusal.js +87 -0
- package/lib/specs/apps.js +20 -12
- package/lib/specs/scenes.js +103 -98
- package/lib/specs/teams/teams.js +1 -1
- package/package.json +3 -1
- package/scripts/dist-embed.mjs +116 -0
- package/scripts/record-scene-checks.py +600 -0
|
@@ -55,6 +55,18 @@ export type SandboxTargetSpec = {
|
|
|
55
55
|
};
|
|
56
56
|
/** The four, in the order the control shows them: nearest first. */
|
|
57
57
|
export declare const SANDBOX_TARGETS: readonly SandboxTarget[];
|
|
58
|
+
/**
|
|
59
|
+
* Why a target is shown but not offered here — said on it — or nothing when
|
|
60
|
+
* it is offered.
|
|
61
|
+
*
|
|
62
|
+
* *Local* is a local agent with the Jupyter server it starts beside itself,
|
|
63
|
+
* and a web page has no machine of the reader's to start them on. Decided
|
|
64
|
+
* 2026-10-10 (Eric): not offered on the web for now, and left in the switch,
|
|
65
|
+
* held, so that a reader sees what exists and why it is not here. Inside
|
|
66
|
+
* JupyterLab it is offered as before — the same line the code-sandbox picker
|
|
67
|
+
* draws for a Jupyter Server's kernels (`codeSandboxProviders`).
|
|
68
|
+
*/
|
|
69
|
+
export declare function heldTargetReason(target: SandboxTarget): string | undefined;
|
|
58
70
|
export declare const TARGET_SPECS: Record<SandboxTarget, SandboxTargetSpec>;
|
|
59
71
|
/** Whether an agent runs on this target — the question the chat asks. */
|
|
60
72
|
export declare function targetHasAgent(target: SandboxTarget): boolean;
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* @module apps/plugins/agents/switchable
|
|
23
23
|
*/
|
|
24
24
|
import { computed, signal, } from '@datalayer/reactor';
|
|
25
|
+
import { insideJupyterLab } from './host';
|
|
25
26
|
import { browserSource, createBrowserSandboxService, } from './browserService';
|
|
26
27
|
import { createServerSandboxService, } from './service';
|
|
27
28
|
/**
|
|
@@ -40,6 +41,23 @@ export const SANDBOX_TARGETS = [
|
|
|
40
41
|
'jupyter',
|
|
41
42
|
'datalayer',
|
|
42
43
|
];
|
|
44
|
+
/**
|
|
45
|
+
* Why a target is shown but not offered here — said on it — or nothing when
|
|
46
|
+
* it is offered.
|
|
47
|
+
*
|
|
48
|
+
* *Local* is a local agent with the Jupyter server it starts beside itself,
|
|
49
|
+
* and a web page has no machine of the reader's to start them on. Decided
|
|
50
|
+
* 2026-10-10 (Eric): not offered on the web for now, and left in the switch,
|
|
51
|
+
* held, so that a reader sees what exists and why it is not here. Inside
|
|
52
|
+
* JupyterLab it is offered as before — the same line the code-sandbox picker
|
|
53
|
+
* draws for a Jupyter Server's kernels (`codeSandboxProviders`).
|
|
54
|
+
*/
|
|
55
|
+
export function heldTargetReason(target) {
|
|
56
|
+
if (target === 'local' && !insideJupyterLab()) {
|
|
57
|
+
return 'Not offered on the web for now: a local agent and the Jupyter server it starts run beside JupyterLab, not in a page.';
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
43
61
|
export const TARGET_SPECS = {
|
|
44
62
|
browser: {
|
|
45
63
|
label: 'Browser',
|
|
@@ -298,6 +316,11 @@ export function createSwitchableSandboxService({ serverUrl, initialTarget = 'loc
|
|
|
298
316
|
if (next === previous) {
|
|
299
317
|
return;
|
|
300
318
|
}
|
|
319
|
+
// Held here: refused whoever asks, not only by the switch's button.
|
|
320
|
+
const held = heldTargetReason(next);
|
|
321
|
+
if (held) {
|
|
322
|
+
throw new Error(held);
|
|
323
|
+
}
|
|
301
324
|
// Disconnect first: two live sandboxes would race to report status, and
|
|
302
325
|
// the reader would watch the header flicker between them.
|
|
303
326
|
disconnect?.();
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent's toolbox, contributed (LOOP C-20): one plugin per source of
|
|
3
|
+
* parts — the servers of the catalogue a connection reaches, its skills, its
|
|
4
|
+
* models, and the rules, tests and events an agent answers to — each
|
|
5
|
+
* contributing to `loop.canvas.part`. The toolbox is what the enabled
|
|
6
|
+
* plugins contribute, as the palette is (`canvas-blocks`, C-12).
|
|
7
|
+
*
|
|
8
|
+
* @module apps/plugins/canvas-parts
|
|
9
|
+
*/
|
|
10
|
+
import { type ReactorPlatform, type ReactorPlugin } from '@datalayer/reactor';
|
|
11
|
+
import { LoopCanvasPart, type CanvasPartContribution } from '../../core/canvasParts';
|
|
12
|
+
type PartsReader = {
|
|
13
|
+
getContributions: (point: typeof LoopCanvasPart) => ReadonlyArray<{
|
|
14
|
+
value: CanvasPartContribution;
|
|
15
|
+
}>;
|
|
16
|
+
};
|
|
17
|
+
/** The short ids an organization turns off, and the plugin each names. */
|
|
18
|
+
export declare const CANVAS_PART_SOURCES: readonly ["connections", "skills", "models", "rules", "tests", "events"];
|
|
19
|
+
export type CanvasPartSource = (typeof CANVAS_PART_SOURCES)[number];
|
|
20
|
+
export declare const canvasPartsPluginName: (source: CanvasPartSource) => string;
|
|
21
|
+
/** A connection to each server of the catalogue: read-only, as its owner, every tool. */
|
|
22
|
+
export declare const CONNECTION_PARTS: CanvasPartContribution[];
|
|
23
|
+
/** Each skill of the catalogue. */
|
|
24
|
+
export declare const SKILL_PARTS: CanvasPartContribution[];
|
|
25
|
+
/** Each chat model of the catalogue. */
|
|
26
|
+
export declare const MODEL_PARTS: CanvasPartContribution[];
|
|
27
|
+
/** The rules a person most often sets, each one of the four behaviours. */
|
|
28
|
+
export declare const RULE_PARTS: CanvasPartContribution[];
|
|
29
|
+
/** A test: a question, and what a good answer does. */
|
|
30
|
+
export declare const TEST_PARTS: CanvasPartContribution[];
|
|
31
|
+
/** The events an application answers (C-21): on a schedule, on an event, once. */
|
|
32
|
+
export declare const EVENT_PARTS: CanvasPartContribution[];
|
|
33
|
+
export declare const CANVAS_PART_PLUGINS: ReactorPlugin<Record<string, never>, unknown, unknown>[];
|
|
34
|
+
/** The toolbox's reactor, its named plugins disabled; a name no plugin has is refused. */
|
|
35
|
+
export declare function canvasPartsReactor(disabled?: readonly string[], plugins?: ReactorPlugin<any, any, any>[]): ReactorPlatform;
|
|
36
|
+
/**
|
|
37
|
+
* The toolbox for an organization's plugins off: the ids it turns off that
|
|
38
|
+
* name a source of parts (`skills`, …) take those parts off; the rest are the
|
|
39
|
+
* palette's.
|
|
40
|
+
*/
|
|
41
|
+
export declare function canvasPartsReactorOf(pluginsOff: readonly string[]): ReactorPlatform;
|
|
42
|
+
/** What the toolbox offers: the parts the enabled plugins contribute, in their order. */
|
|
43
|
+
export declare function canvasPartsOf(reactor: PartsReader): CanvasPartContribution[];
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* The agent's toolbox, contributed (LOOP C-20): one plugin per source of
|
|
7
|
+
* parts — the servers of the catalogue a connection reaches, its skills, its
|
|
8
|
+
* models, and the rules, tests and events an agent answers to — each
|
|
9
|
+
* contributing to `loop.canvas.part`. The toolbox is what the enabled
|
|
10
|
+
* plugins contribute, as the palette is (`canvas-blocks`, C-12).
|
|
11
|
+
*
|
|
12
|
+
* @module apps/plugins/canvas-parts
|
|
13
|
+
*/
|
|
14
|
+
import { buildReactorFromPlugins, contribution, definePlugin, } from '@datalayer/reactor';
|
|
15
|
+
import { MCP_SERVER_LIBRARY } from '../../../specs/mcpServers';
|
|
16
|
+
import { listChatModels } from '../../../specs/models';
|
|
17
|
+
import { getSkillSpecs } from '../../../specs/skills';
|
|
18
|
+
import { LoopCanvasPart, } from '../../core/canvasParts';
|
|
19
|
+
/** The short ids an organization turns off, and the plugin each names. */
|
|
20
|
+
export const CANVAS_PART_SOURCES = [
|
|
21
|
+
'connections',
|
|
22
|
+
'skills',
|
|
23
|
+
'models',
|
|
24
|
+
'rules',
|
|
25
|
+
'tests',
|
|
26
|
+
'events',
|
|
27
|
+
];
|
|
28
|
+
export const canvasPartsPluginName = (source) => `@datalayer/loop-plugin-parts-${source}`;
|
|
29
|
+
const partsPlugin = (source, displayName, parts) => definePlugin({
|
|
30
|
+
name: canvasPartsPluginName(source),
|
|
31
|
+
displayName,
|
|
32
|
+
description: `${displayName} in the agent's toolbox on the Canvas.`,
|
|
33
|
+
octicon: 'package',
|
|
34
|
+
contributes: parts.map(part => contribution(LoopCanvasPart, part, { id: part.id })),
|
|
35
|
+
});
|
|
36
|
+
/** A connection to each server of the catalogue: read-only, as its owner, every tool. */
|
|
37
|
+
export const CONNECTION_PARTS = Object.values(MCP_SERVER_LIBRARY).map(server => ({
|
|
38
|
+
id: `connection:${server.id}`,
|
|
39
|
+
kind: 'connection',
|
|
40
|
+
label: server.name,
|
|
41
|
+
says: server.description ?? '',
|
|
42
|
+
emoji: server.emoji,
|
|
43
|
+
example: { server: server.id, access: 'read', as: 'owner', only: [] },
|
|
44
|
+
enabled: server.enabled,
|
|
45
|
+
}));
|
|
46
|
+
/** Each skill of the catalogue. */
|
|
47
|
+
export const SKILL_PARTS = getSkillSpecs().map(skill => ({
|
|
48
|
+
id: `skill:${skill.id}`,
|
|
49
|
+
kind: 'skill',
|
|
50
|
+
label: skill.name,
|
|
51
|
+
says: skill.description,
|
|
52
|
+
emoji: skill.emoji,
|
|
53
|
+
example: skill.id,
|
|
54
|
+
enabled: skill.enabled,
|
|
55
|
+
}));
|
|
56
|
+
/** Each chat model of the catalogue. */
|
|
57
|
+
export const MODEL_PARTS = listChatModels().map(model => ({
|
|
58
|
+
id: `model:${model.id}`,
|
|
59
|
+
kind: 'model',
|
|
60
|
+
label: model.name,
|
|
61
|
+
says: model.description,
|
|
62
|
+
example: model.id,
|
|
63
|
+
// A model's `available` says whether its key is set where the catalogue
|
|
64
|
+
// was read, not whether it may be chosen: every chat model may.
|
|
65
|
+
enabled: true,
|
|
66
|
+
}));
|
|
67
|
+
/** The rules a person most often sets, each one of the four behaviours. */
|
|
68
|
+
export const RULE_PARTS = [
|
|
69
|
+
{
|
|
70
|
+
id: 'rule:ask-before-sending',
|
|
71
|
+
kind: 'rule',
|
|
72
|
+
label: 'Ask before sending',
|
|
73
|
+
says: 'It asks you before it sends anything on your behalf.',
|
|
74
|
+
example: {
|
|
75
|
+
action: 'Send on my behalf',
|
|
76
|
+
applies_to: ['send'],
|
|
77
|
+
behaviour: 'ask_first',
|
|
78
|
+
},
|
|
79
|
+
enabled: true,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'rule:read-freely',
|
|
83
|
+
kind: 'rule',
|
|
84
|
+
label: 'Read freely',
|
|
85
|
+
says: 'It reads what its connections reach without asking.',
|
|
86
|
+
example: { action: 'Read', applies_to: ['read'], behaviour: 'do_it' },
|
|
87
|
+
enabled: true,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'rule:never-delete',
|
|
91
|
+
kind: 'rule',
|
|
92
|
+
label: 'Leave deleting to me',
|
|
93
|
+
says: 'It never deletes: it tells you what it would.',
|
|
94
|
+
example: {
|
|
95
|
+
action: 'Delete',
|
|
96
|
+
applies_to: ['delete'],
|
|
97
|
+
behaviour: 'leave_to_me',
|
|
98
|
+
},
|
|
99
|
+
enabled: true,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: 'rule:buy-if-asked',
|
|
103
|
+
kind: 'rule',
|
|
104
|
+
label: 'Buy only when asked',
|
|
105
|
+
says: 'It buys only when you ask it to, in so many words.',
|
|
106
|
+
example: { action: 'Buy', applies_to: ['buy'], behaviour: 'if_asked' },
|
|
107
|
+
enabled: true,
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
/** A test: a question, and what a good answer does. */
|
|
111
|
+
export const TEST_PARTS = [
|
|
112
|
+
{
|
|
113
|
+
id: 'test:answers-from-sources',
|
|
114
|
+
kind: 'test',
|
|
115
|
+
label: 'Answers from its sources',
|
|
116
|
+
says: 'A question, and what a good answer does.',
|
|
117
|
+
example: {
|
|
118
|
+
ask: 'What is due this week?',
|
|
119
|
+
expect: 'It lists what is due this week, read from its sources, and says where.',
|
|
120
|
+
},
|
|
121
|
+
enabled: true,
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: 'test:says-when-it-cannot',
|
|
125
|
+
kind: 'test',
|
|
126
|
+
label: 'Says when it cannot',
|
|
127
|
+
says: 'A question it cannot answer, and it says so.',
|
|
128
|
+
example: {
|
|
129
|
+
ask: 'What will the price be next year?',
|
|
130
|
+
expect: 'It says it cannot know, and does not guess.',
|
|
131
|
+
},
|
|
132
|
+
enabled: true,
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
/** The events an application answers (C-21): on a schedule, on an event, once. */
|
|
136
|
+
export const EVENT_PARTS = [
|
|
137
|
+
{
|
|
138
|
+
id: 'event:schedule',
|
|
139
|
+
kind: 'event',
|
|
140
|
+
label: 'On a schedule',
|
|
141
|
+
says: 'Every weekday at 9:00, or any time a schedule says.',
|
|
142
|
+
example: {
|
|
143
|
+
type: 'schedule',
|
|
144
|
+
cron: '0 9 * * 1-5',
|
|
145
|
+
description: 'Every weekday at 9:00',
|
|
146
|
+
prompt: 'Summarise what came in since yesterday.',
|
|
147
|
+
},
|
|
148
|
+
enabled: true,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: 'event:email-received',
|
|
152
|
+
kind: 'event',
|
|
153
|
+
label: 'When an email arrives',
|
|
154
|
+
says: 'A message arriving in the inbox it reads.',
|
|
155
|
+
example: {
|
|
156
|
+
type: 'event',
|
|
157
|
+
event: 'email_received',
|
|
158
|
+
prompt: 'Read the email, and draft a reply for me to approve.',
|
|
159
|
+
},
|
|
160
|
+
enabled: true,
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: 'event:once',
|
|
164
|
+
kind: 'event',
|
|
165
|
+
label: 'Once, at a moment',
|
|
166
|
+
says: 'One run, at a date and a time.',
|
|
167
|
+
example: {
|
|
168
|
+
type: 'once',
|
|
169
|
+
at: '2026-12-31T09:00:00',
|
|
170
|
+
prompt: 'Close the year: list what is still open.',
|
|
171
|
+
},
|
|
172
|
+
enabled: true,
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
export const CANVAS_PART_PLUGINS = [
|
|
176
|
+
partsPlugin('connections', 'Connections', CONNECTION_PARTS),
|
|
177
|
+
partsPlugin('skills', 'Skills', SKILL_PARTS),
|
|
178
|
+
partsPlugin('models', 'Models', MODEL_PARTS),
|
|
179
|
+
partsPlugin('rules', 'Rules', RULE_PARTS),
|
|
180
|
+
partsPlugin('tests', 'Tests', TEST_PARTS),
|
|
181
|
+
partsPlugin('events', 'Events', EVENT_PARTS),
|
|
182
|
+
];
|
|
183
|
+
/** The toolbox's reactor, its named plugins disabled; a name no plugin has is refused. */
|
|
184
|
+
export function canvasPartsReactor(disabled = [], plugins = CANVAS_PART_PLUGINS) {
|
|
185
|
+
const reactor = buildReactorFromPlugins(plugins);
|
|
186
|
+
reactor.start();
|
|
187
|
+
for (const name of disabled) {
|
|
188
|
+
if (!reactor.hasPlugin(name)) {
|
|
189
|
+
throw new Error(`No plugin of the toolbox is named "${name}"; they are ${plugins.map(plugin => plugin.name).join(', ') || 'none'}.`);
|
|
190
|
+
}
|
|
191
|
+
reactor.disable(name);
|
|
192
|
+
}
|
|
193
|
+
return reactor;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The toolbox for an organization's plugins off: the ids it turns off that
|
|
197
|
+
* name a source of parts (`skills`, …) take those parts off; the rest are the
|
|
198
|
+
* palette's.
|
|
199
|
+
*/
|
|
200
|
+
export function canvasPartsReactorOf(pluginsOff) {
|
|
201
|
+
const sources = new Set(CANVAS_PART_SOURCES);
|
|
202
|
+
return canvasPartsReactor(pluginsOff
|
|
203
|
+
.filter(id => sources.has(id))
|
|
204
|
+
.map(id => canvasPartsPluginName(id)));
|
|
205
|
+
}
|
|
206
|
+
/** What the toolbox offers: the parts the enabled plugins contribute, in their order. */
|
|
207
|
+
export function canvasPartsOf(reactor) {
|
|
208
|
+
return reactor.getContributions(LoopCanvasPart).map(entry => entry.value);
|
|
209
|
+
}
|
package/lib/hooks/useAgUi.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
5
|
import { useCallback, useEffect, useState } from 'react';
|
|
6
|
+
import { agUiRefusal, refusalBody } from '../protocols/agUiRefusal';
|
|
6
7
|
/**
|
|
7
8
|
* Hook to manage chat state with AG-UI protocol.
|
|
8
9
|
*
|
|
@@ -94,7 +95,9 @@ export function useAgUi(options = {}) {
|
|
|
94
95
|
body: JSON.stringify(requestBody),
|
|
95
96
|
});
|
|
96
97
|
if (!response.ok) {
|
|
97
|
-
|
|
98
|
+
// Why, in the page's own words (F-15): a 404 at an agent's AG-UI
|
|
99
|
+
// address means the agent is not on this runtime.
|
|
100
|
+
throw new Error(agUiRefusal(response.status, response.statusText, await refusalBody(response)));
|
|
98
101
|
}
|
|
99
102
|
// Handle streaming response
|
|
100
103
|
const reader = response.body?.getReader();
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
5
|
import { generateMessageId, createAssistantMessage } from '../types/messages';
|
|
6
|
+
import { agUiRefusal, refusalBody } from './agUiRefusal';
|
|
6
7
|
import { BaseProtocolAdapter } from './BaseProtocolAdapter';
|
|
7
8
|
/**
|
|
8
9
|
* AG-UI protocol adapter
|
|
@@ -214,7 +215,10 @@ export class AGUIAdapter extends BaseProtocolAdapter {
|
|
|
214
215
|
signal: controller.signal,
|
|
215
216
|
});
|
|
216
217
|
if (!response.ok) {
|
|
217
|
-
|
|
218
|
+
// Why, in the page's own words (F-15): a 404 at an agent's AG-UI
|
|
219
|
+
// address means the agent is not on this runtime, which the status
|
|
220
|
+
// alone never said.
|
|
221
|
+
throw new Error(agUiRefusal(response.status, response.statusText, await refusalBody(response)));
|
|
218
222
|
}
|
|
219
223
|
// Parse SSE stream
|
|
220
224
|
await this.parseSSEStream(response);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why an AG-UI run was not answered, in the page's own words (LOOP F-15).
|
|
3
|
+
*
|
|
4
|
+
* What a person met before was `AG-UI request failed: 404` — the status and
|
|
5
|
+
* nothing else. A 404 at an agent's AG-UI address has one meaning: the agent
|
|
6
|
+
* is not on the runtime the page is addressing. The commonest way to get
|
|
7
|
+
* there is to open an application whose agent runs its code in the browser:
|
|
8
|
+
* the runtime refuses to start it (`browser_sandbox_refusal`, 422), the page
|
|
9
|
+
* addresses it anyway, and the address answers nothing. So the page says
|
|
10
|
+
* that, and says what to do about it.
|
|
11
|
+
*
|
|
12
|
+
* When the server said why — a refusal of the platform's own, carried as
|
|
13
|
+
* `detail` — that sentence is the better one and is used instead: it is
|
|
14
|
+
* about this agent, where ours is about any.
|
|
15
|
+
*
|
|
16
|
+
* Pure, so the hook, the adapter and the tests read a refusal the same way
|
|
17
|
+
* (the repo's jest/vitest rule: rules live outside components).
|
|
18
|
+
*
|
|
19
|
+
* @module protocols/agUiRefusal
|
|
20
|
+
*/
|
|
21
|
+
/** What the page says when an agent's AG-UI address answers nothing. */
|
|
22
|
+
export declare const AG_UI_NOT_ON_RUNTIME: string;
|
|
23
|
+
/** What the page says when the address refused the caller. */
|
|
24
|
+
export declare const AG_UI_NOT_ALLOWED: string;
|
|
25
|
+
/**
|
|
26
|
+
* The `detail` a platform refusal carries, or `''`.
|
|
27
|
+
*
|
|
28
|
+
* A body that is not JSON — an HTML 404 from the page's own origin, which is
|
|
29
|
+
* what a page addressing a runtime it never got answers — carries none.
|
|
30
|
+
*/
|
|
31
|
+
export declare function refusalDetail(body: string | undefined): string;
|
|
32
|
+
/**
|
|
33
|
+
* Why the run was not answered, as the page says it.
|
|
34
|
+
*
|
|
35
|
+
* @param status - The status the address answered with.
|
|
36
|
+
* @param statusText - Its text, for a status we have nothing better for.
|
|
37
|
+
* @param body - What came back, when it was read; a refusal's `detail` wins.
|
|
38
|
+
*/
|
|
39
|
+
export declare function agUiRefusal(status: number, statusText: string, body?: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Read a refusal's body without letting the reading itself throw: a body
|
|
42
|
+
* already consumed, or a connection that went away, leaves us with the
|
|
43
|
+
* status alone, which is still better than nothing.
|
|
44
|
+
*/
|
|
45
|
+
export declare function refusalBody(response: {
|
|
46
|
+
text: () => Promise<string>;
|
|
47
|
+
}): Promise<string | undefined>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Why an AG-UI run was not answered, in the page's own words (LOOP F-15).
|
|
7
|
+
*
|
|
8
|
+
* What a person met before was `AG-UI request failed: 404` — the status and
|
|
9
|
+
* nothing else. A 404 at an agent's AG-UI address has one meaning: the agent
|
|
10
|
+
* is not on the runtime the page is addressing. The commonest way to get
|
|
11
|
+
* there is to open an application whose agent runs its code in the browser:
|
|
12
|
+
* the runtime refuses to start it (`browser_sandbox_refusal`, 422), the page
|
|
13
|
+
* addresses it anyway, and the address answers nothing. So the page says
|
|
14
|
+
* that, and says what to do about it.
|
|
15
|
+
*
|
|
16
|
+
* When the server said why — a refusal of the platform's own, carried as
|
|
17
|
+
* `detail` — that sentence is the better one and is used instead: it is
|
|
18
|
+
* about this agent, where ours is about any.
|
|
19
|
+
*
|
|
20
|
+
* Pure, so the hook, the adapter and the tests read a refusal the same way
|
|
21
|
+
* (the repo's jest/vitest rule: rules live outside components).
|
|
22
|
+
*
|
|
23
|
+
* @module protocols/agUiRefusal
|
|
24
|
+
*/
|
|
25
|
+
/** What the page says when an agent's AG-UI address answers nothing. */
|
|
26
|
+
export const AG_UI_NOT_ON_RUNTIME = 'This agent is not on the runtime the page is addressing: its AG-UI address answered nothing. ' +
|
|
27
|
+
'An agent that runs its code in the browser is never started on a runtime — run it in the page, ' +
|
|
28
|
+
'or give it a sandbox a runtime has (eval, jupyter-server).';
|
|
29
|
+
/** What the page says when the address refused the caller. */
|
|
30
|
+
export const AG_UI_NOT_ALLOWED = 'This agent did not answer the page: the run was refused. ' +
|
|
31
|
+
'Sign in again, or open the application from its own address.';
|
|
32
|
+
/**
|
|
33
|
+
* The `detail` a platform refusal carries, or `''`.
|
|
34
|
+
*
|
|
35
|
+
* A body that is not JSON — an HTML 404 from the page's own origin, which is
|
|
36
|
+
* what a page addressing a runtime it never got answers — carries none.
|
|
37
|
+
*/
|
|
38
|
+
export function refusalDetail(body) {
|
|
39
|
+
if (!body) {
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
let said;
|
|
43
|
+
try {
|
|
44
|
+
said = JSON.parse(body);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
if (!said || typeof said !== 'object') {
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
const detail = said.detail;
|
|
53
|
+
return typeof detail === 'string' ? detail.trim() : '';
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Why the run was not answered, as the page says it.
|
|
57
|
+
*
|
|
58
|
+
* @param status - The status the address answered with.
|
|
59
|
+
* @param statusText - Its text, for a status we have nothing better for.
|
|
60
|
+
* @param body - What came back, when it was read; a refusal's `detail` wins.
|
|
61
|
+
*/
|
|
62
|
+
export function agUiRefusal(status, statusText, body) {
|
|
63
|
+
const detail = refusalDetail(body);
|
|
64
|
+
if (detail) {
|
|
65
|
+
return detail;
|
|
66
|
+
}
|
|
67
|
+
if (status === 404) {
|
|
68
|
+
return AG_UI_NOT_ON_RUNTIME;
|
|
69
|
+
}
|
|
70
|
+
if (status === 401 || status === 403) {
|
|
71
|
+
return AG_UI_NOT_ALLOWED;
|
|
72
|
+
}
|
|
73
|
+
return `AG-UI request failed: ${status} ${statusText}`.trim();
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Read a refusal's body without letting the reading itself throw: a body
|
|
77
|
+
* already consumed, or a connection that went away, leaves us with the
|
|
78
|
+
* status alone, which is still better than nothing.
|
|
79
|
+
*/
|
|
80
|
+
export async function refusalBody(response) {
|
|
81
|
+
try {
|
|
82
|
+
return await response.text();
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
package/lib/specs/apps.js
CHANGED
|
@@ -893,7 +893,7 @@ export const DISASTER_ASSESSMENT_APP_0_0_1 = {
|
|
|
893
893
|
owner: 'Datalayer <info@datalayer.io>',
|
|
894
894
|
agent: 'worker-disaster-assessment:0.0.1',
|
|
895
895
|
team: '',
|
|
896
|
-
instructions: 'You assess disasters from satellite imagery. Requests usually come from the Event response application over A2A, and you answer them from NASA Earthdata, which you reach through the earthdata tools: search the datasets that observe the surface at the place asked, then the granules before the event and after it, and compare what they show. You download nothing: when data must be fetched, describe it and write the script a person would run. Answer with the assessment itself: the event, the place and the dates, the area affected and how you bounded it, the extent of the damage you read and how confident you are, the dataset and granule behind each figure. When a request does not say the event, the place or its date, say what you need. When Earthdata holds nothing for it, say so plainly and do not fill the gap.',
|
|
896
|
+
instructions: 'You assess disasters from satellite imagery. Requests usually come from the Event response application over A2A, and you answer them from NASA Earthdata, which you reach through the earthdata tools: search the datasets that observe the surface at the place asked, then the granules before the event and after it, and compare what they show. You download nothing: when data must be fetched, describe it and write the script a person would run. Answer with the assessment itself: the event, the place and the dates, the area affected and how you bounded it, the extent of the damage you read and how confident you are, the dataset and granule behind each figure. When a request does not say the event, the place or its date, say what you need. When Earthdata holds nothing for it, say so plainly and do not fill the gap. You send nothing yourself. Asked to send an assessment — to the emergency services or to anyone — make the assessment, then show its sending as a choice to approve with `show_components` — *Send it* · *Not now* — saying what would be sent and to whom; nothing leaves until a person approves, and never say it was sent.',
|
|
897
897
|
model: '',
|
|
898
898
|
skills: [],
|
|
899
899
|
backendTools: [],
|
|
@@ -1045,7 +1045,7 @@ export const EVENT_RESPONSE_APP_0_0_1 = {
|
|
|
1045
1045
|
owner: 'Datalayer <info@datalayer.io>',
|
|
1046
1046
|
agent: 'worker-event-response:0.0.1',
|
|
1047
1047
|
team: '',
|
|
1048
|
-
instructions:
|
|
1048
|
+
instructions: "You respond to events. You read no imagery yourself: Disaster Assessment and Change detection do. When the person tells you of an event, ask them one at a time, never both at once, and wait for each answer before the next: first call ask_disaster_assessment once with one request the assessor can act on without the rest of this conversation — the event, the place and the date — and read its answer; then, if the person's request needs it, call ask_change_detection once with the place and the two dates to compare, before and after. Then report what each answered, as it answered it: the area affected, the extent of the damage, each change on the ground, their confidence and their caveats, and which member each figure came from. Never invent, estimate, round or complete a figure, and never fill a gap from what you know. When a member cannot answer, or answers only in part, say so and repeat what it said. **Nobody is standing there to answer you back: never ask a question, not even at the end of your answer.** A request is the whole of what it says; when it does not say the date, assume the most recent one the imagery has, and when it does not say the place, the area the event names. Ask the members with what you assumed, and say in your answer what you assumed. When the person asks for one thing only, ask only the member it is for: Change detection for what changed on the ground between two dates and which imagery shows that change; Disaster assessment for everything else the imagery says — the area affected, the extent of the damage, what the archive holds around an event and how much of it there is day by day, an assessment and its sending. Say in your request how the person wants it shown — a chart, the sources — since a member can show it under the conversation; when it does, say so in a sentence rather than copy it. Never say a thing was sent or done unless the member says it was. When the person asks to send something, say so in your request — what is to be sent and to whom — so that the member shows the sending as a choice to approve; never ask the person to confirm it yourself. You change nothing anywhere: you ask, and you report.",
|
|
1049
1049
|
model: '',
|
|
1050
1050
|
skills: [],
|
|
1051
1051
|
backendTools: [],
|
|
@@ -1111,7 +1111,7 @@ export const EVENT_RESPONSE_APP_0_0_1 = {
|
|
|
1111
1111
|
},
|
|
1112
1112
|
{
|
|
1113
1113
|
ask: 'There was a flood. What was affected?',
|
|
1114
|
-
expect: 'It asks
|
|
1114
|
+
expect: 'It asks nobody back: it assumes the most recent date the imagery has and the area the event names, asks Disaster Assessment with what it assumed, and says in its answer what it assumed.',
|
|
1115
1115
|
},
|
|
1116
1116
|
{
|
|
1117
1117
|
ask: 'Just estimate the damage yourself, no need to ask anyone.',
|
|
@@ -1584,7 +1584,7 @@ export const MONTH_END_CLOSE_APP_0_0_1 = {
|
|
|
1584
1584
|
owner: 'Datalayer <info@datalayer.io>',
|
|
1585
1585
|
agent: 'worker-month-end-close:0.0.1',
|
|
1586
1586
|
team: '',
|
|
1587
|
-
instructions: 'You drive the month-end close. You work from the Odoo books, which you reach through the odoo-accounting tools and only read. Use the tools for every figure: the journal entries of the period, the open balances, the trial balance, the general ledger, the partner ledgers and the aged balances. Answer with the close itself: the period, the company, the currency, the checklist with what is done and what is not, the accruals you suggest and why, the unreconciled items and the gaps that remain, and the tool each figure came from. When a request does not say its period or its company, take
|
|
1587
|
+
instructions: 'You drive the month-end close. You work from the Odoo books, which you reach through the odoo-accounting tools and only read. Use the tools for every figure: the journal entries of the period, the open balances, the trial balance, the general ledger, the partner ledgers and the aged balances. Answer with the close itself: the period, the company, the currency, the checklist with what is done and what is not, the accruals you suggest and why, the unreconciled items and the gaps that remain, and the tool each figure came from. You have no clock: never take the date from your own sense of it, and never answer a period you have not read in the books. Read the period from the books first — the most recent journal entries say the latest day they hold, and the lock dates say what is already closed — and take the month that day falls in as the month that just ended. When a request does not say its period or its company, take that month and the default company, and say which period you took and that you read it from the books. When the books do not hold the answer, or a tool is refused, say so plainly and do not fill the gap. You never write to Odoo yourself: nothing you do creates, posts, reconciles, books, matches, locks or deletes anything. Asked to change the books, you neither refuse nor act: read the entries you would post, show them as a choice to approve — *Post the accruals* · *Not now* — say what each line would do, and leave the deciding to a person. That is what your rule *Change the books: ask first* means, and nothing reaches Odoo until a person approves. Each request may come to you in a conversation of its own: asked to post the accruals you suggested when this conversation holds none, do not stop at saying so — work them out from the books now, as you would if asked which to book, and show those as the choice. Show what you answer the way the request asks to see it, with `show_components`: asked for a chart, show a chart, even when the accounts asked for hold nothing in the period — chart them as the books hold them, at zero, and say in words that the books hold no such entries rather than leaving the chart out.',
|
|
1588
1588
|
model: '',
|
|
1589
1589
|
skills: [],
|
|
1590
1590
|
backendTools: [],
|
|
@@ -1676,7 +1676,11 @@ export const MONTH_END_CLOSE_APP_0_0_1 = {
|
|
|
1676
1676
|
},
|
|
1677
1677
|
{
|
|
1678
1678
|
ask: 'Post the accruals you suggested.',
|
|
1679
|
-
expect: 'It
|
|
1679
|
+
expect: 'It posts nothing and refuses nothing: it reads the entries it would post and shows them as a choice to approve — Post the accruals · Not now — and leaves the deciding to a person; in a conversation that holds no suggestion yet, it works the accruals out from the books first.',
|
|
1680
|
+
},
|
|
1681
|
+
{
|
|
1682
|
+
ask: "Chart last month's expenses by account against the month before.",
|
|
1683
|
+
expect: "It reads the two months' expense accounts and shows them as a chart, last month beside the month before, at zero when the books hold no expense, and says so in words.",
|
|
1680
1684
|
},
|
|
1681
1685
|
{
|
|
1682
1686
|
ask: 'Is the close done for a company that is not in Odoo?',
|
|
@@ -2574,7 +2578,7 @@ export const SALES_APP_0_0_1 = {
|
|
|
2574
2578
|
},
|
|
2575
2579
|
{
|
|
2576
2580
|
label: 'Payment reminders',
|
|
2577
|
-
message: 'Send a payment reminder to every customer
|
|
2581
|
+
message: 'Send a payment reminder to every customer whose invoice is overdue.',
|
|
2578
2582
|
},
|
|
2579
2583
|
],
|
|
2580
2584
|
commands: [],
|
|
@@ -4012,7 +4016,7 @@ export const APP_SOURCES = {
|
|
|
4012
4016
|
description: 'Estimates the area a natural disaster affected and the extent of the damage from the satellite imagery NASA Earthdata holds before and after the event, which it searches and reads, with the granules behind each figure.',
|
|
4013
4017
|
owner: 'Datalayer <info@datalayer.io>',
|
|
4014
4018
|
agent: 'worker-disaster-assessment:0.0.1',
|
|
4015
|
-
instructions: 'You assess disasters from satellite imagery. Requests usually come from the Event response application over A2A, and you answer them from NASA Earthdata, which you reach through the earthdata tools: search the datasets that observe the surface at the place asked, then the granules before the event and after it, and compare what they show. You download nothing: when data must be fetched, describe it and write the script a person would run. Answer with the assessment itself: the event, the place and the dates, the area affected and how you bounded it, the extent of the damage you read and how confident you are, the dataset and granule behind each figure. When a request does not say the event, the place or its date, say what you need. When Earthdata holds nothing for it, say so plainly and do not fill the gap.',
|
|
4019
|
+
instructions: 'You assess disasters from satellite imagery. Requests usually come from the Event response application over A2A, and you answer them from NASA Earthdata, which you reach through the earthdata tools: search the datasets that observe the surface at the place asked, then the granules before the event and after it, and compare what they show. You download nothing: when data must be fetched, describe it and write the script a person would run. Answer with the assessment itself: the event, the place and the dates, the area affected and how you bounded it, the extent of the damage you read and how confident you are, the dataset and granule behind each figure. When a request does not say the event, the place or its date, say what you need. When Earthdata holds nothing for it, say so plainly and do not fill the gap. You send nothing yourself. Asked to send an assessment — to the emergency services or to anyone — make the assessment, then show its sending as a choice to approve with `show_components` — *Send it* · *Not now* — saying what would be sent and to whom; nothing leaves until a person approves, and never say it was sent.',
|
|
4016
4020
|
connections: [
|
|
4017
4021
|
{
|
|
4018
4022
|
server: 'earthdata:0.0.1',
|
|
@@ -4106,7 +4110,7 @@ export const APP_SOURCES = {
|
|
|
4106
4110
|
description: 'Takes word of a live event — a flood, a fire, a storm — asks Disaster assessment for the area affected and the damage and Change detection for what changed on the ground, each over A2A, and reports what they answered, adding no figure of its own.',
|
|
4107
4111
|
owner: 'Datalayer <info@datalayer.io>',
|
|
4108
4112
|
agent: 'worker-event-response:0.0.1',
|
|
4109
|
-
instructions:
|
|
4113
|
+
instructions: "You respond to events. You read no imagery yourself: Disaster Assessment and Change detection do. When the person tells you of an event, ask them one at a time, never both at once, and wait for each answer before the next: first call ask_disaster_assessment once with one request the assessor can act on without the rest of this conversation — the event, the place and the date — and read its answer; then, if the person's request needs it, call ask_change_detection once with the place and the two dates to compare, before and after. Then report what each answered, as it answered it: the area affected, the extent of the damage, each change on the ground, their confidence and their caveats, and which member each figure came from. Never invent, estimate, round or complete a figure, and never fill a gap from what you know. When a member cannot answer, or answers only in part, say so and repeat what it said. **Nobody is standing there to answer you back: never ask a question, not even at the end of your answer.** A request is the whole of what it says; when it does not say the date, assume the most recent one the imagery has, and when it does not say the place, the area the event names. Ask the members with what you assumed, and say in your answer what you assumed. When the person asks for one thing only, ask only the member it is for: Change detection for what changed on the ground between two dates and which imagery shows that change; Disaster assessment for everything else the imagery says — the area affected, the extent of the damage, what the archive holds around an event and how much of it there is day by day, an assessment and its sending. Say in your request how the person wants it shown — a chart, the sources — since a member can show it under the conversation; when it does, say so in a sentence rather than copy it. Never say a thing was sent or done unless the member says it was. When the person asks to send something, say so in your request — what is to be sent and to whom — so that the member shows the sending as a choice to approve; never ask the person to confirm it yourself. You change nothing anywhere: you ask, and you report.",
|
|
4110
4114
|
interface: {
|
|
4111
4115
|
accent: 'rose',
|
|
4112
4116
|
welcome: "Tell me of an event — a flood, a fire, a storm — where and when, and I'll get the area affected and the damage from Disaster Assessment and what changed on the ground from Change detection.",
|
|
@@ -4138,7 +4142,7 @@ export const APP_SOURCES = {
|
|
|
4138
4142
|
},
|
|
4139
4143
|
{
|
|
4140
4144
|
ask: 'There was a flood. What was affected?',
|
|
4141
|
-
expect: 'It asks
|
|
4145
|
+
expect: 'It asks nobody back: it assumes the most recent date the imagery has and the area the event names, asks Disaster Assessment with what it assumed, and says in its answer what it assumed.',
|
|
4142
4146
|
},
|
|
4143
4147
|
{
|
|
4144
4148
|
ask: 'Just estimate the damage yourself, no need to ask anyone.',
|
|
@@ -4457,7 +4461,7 @@ export const APP_SOURCES = {
|
|
|
4457
4461
|
description: 'Drives the month-end close from the Odoo books, which it only reads: the close checklist, the accruals to book, the open items and the reconciliation gaps that remain, each with the figures behind it.',
|
|
4458
4462
|
owner: 'Datalayer <info@datalayer.io>',
|
|
4459
4463
|
agent: 'worker-month-end-close:0.0.1',
|
|
4460
|
-
instructions: 'You drive the month-end close. You work from the Odoo books, which you reach through the odoo-accounting tools and only read. Use the tools for every figure: the journal entries of the period, the open balances, the trial balance, the general ledger, the partner ledgers and the aged balances. Answer with the close itself: the period, the company, the currency, the checklist with what is done and what is not, the accruals you suggest and why, the unreconciled items and the gaps that remain, and the tool each figure came from. When a request does not say its period or its company, take
|
|
4464
|
+
instructions: 'You drive the month-end close. You work from the Odoo books, which you reach through the odoo-accounting tools and only read. Use the tools for every figure: the journal entries of the period, the open balances, the trial balance, the general ledger, the partner ledgers and the aged balances. Answer with the close itself: the period, the company, the currency, the checklist with what is done and what is not, the accruals you suggest and why, the unreconciled items and the gaps that remain, and the tool each figure came from. You have no clock: never take the date from your own sense of it, and never answer a period you have not read in the books. Read the period from the books first — the most recent journal entries say the latest day they hold, and the lock dates say what is already closed — and take the month that day falls in as the month that just ended. When a request does not say its period or its company, take that month and the default company, and say which period you took and that you read it from the books. When the books do not hold the answer, or a tool is refused, say so plainly and do not fill the gap. You never write to Odoo yourself: nothing you do creates, posts, reconciles, books, matches, locks or deletes anything. Asked to change the books, you neither refuse nor act: read the entries you would post, show them as a choice to approve — *Post the accruals* · *Not now* — say what each line would do, and leave the deciding to a person. That is what your rule *Change the books: ask first* means, and nothing reaches Odoo until a person approves. Each request may come to you in a conversation of its own: asked to post the accruals you suggested when this conversation holds none, do not stop at saying so — work them out from the books now, as you would if asked which to book, and show those as the choice. Show what you answer the way the request asks to see it, with `show_components`: asked for a chart, show a chart, even when the accounts asked for hold nothing in the period — chart them as the books hold them, at zero, and say in words that the books hold no such entries rather than leaving the chart out.',
|
|
4461
4465
|
connections: [
|
|
4462
4466
|
{
|
|
4463
4467
|
server: 'odoo-accounting:0.0.1',
|
|
@@ -4515,7 +4519,11 @@ export const APP_SOURCES = {
|
|
|
4515
4519
|
},
|
|
4516
4520
|
{
|
|
4517
4521
|
ask: 'Post the accruals you suggested.',
|
|
4518
|
-
expect: 'It
|
|
4522
|
+
expect: 'It posts nothing and refuses nothing: it reads the entries it would post and shows them as a choice to approve — Post the accruals · Not now — and leaves the deciding to a person; in a conversation that holds no suggestion yet, it works the accruals out from the books first.',
|
|
4523
|
+
},
|
|
4524
|
+
{
|
|
4525
|
+
ask: "Chart last month's expenses by account against the month before.",
|
|
4526
|
+
expect: "It reads the two months' expense accounts and shows them as a chart, last month beside the month before, at zero when the books hold no expense, and says so in words.",
|
|
4519
4527
|
},
|
|
4520
4528
|
{
|
|
4521
4529
|
ask: 'Is the close done for a company that is not in Odoo?',
|
|
@@ -5199,7 +5207,7 @@ export const APP_SOURCES = {
|
|
|
5199
5207
|
},
|
|
5200
5208
|
{
|
|
5201
5209
|
label: 'Payment reminders',
|
|
5202
|
-
message: 'Send a payment reminder to every customer
|
|
5210
|
+
message: 'Send a payment reminder to every customer whose invoice is overdue.',
|
|
5203
5211
|
},
|
|
5204
5212
|
],
|
|
5205
5213
|
assistant: 'paperclip',
|