@datalayer/agent-runtimes 1.3.81 → 1.3.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/lib/chat/assistant/SpeechBalloon.js +16 -1
  2. package/lib/chat/base/ChatBase.js +40 -4
  3. package/lib/chat/indicators/McpStatusIndicator.d.ts +1 -1
  4. package/lib/chat/indicators/McpStatusIndicator.js +13 -6
  5. package/lib/chat/indicators/SkillsStatusIndicator.d.ts +1 -1
  6. package/lib/chat/indicators/SkillsStatusIndicator.js +7 -3
  7. package/lib/chat/prompt/InputPrompt.d.ts +4 -1
  8. package/lib/chat/prompt/InputPrompt.js +2 -2
  9. package/lib/chat/prompt/InputPromptBase.d.ts +4 -1
  10. package/lib/chat/prompt/InputPromptBase.js +2 -2
  11. package/lib/chat/prompt/InputPromptLexical.d.ts +7 -1
  12. package/lib/chat/prompt/InputPromptLexical.js +5 -3
  13. package/lib/components/teams/index.d.ts +1 -0
  14. package/lib/components/teams/index.js +1 -0
  15. package/lib/components/teams/usePeerSandbox.d.ts +13 -0
  16. package/lib/components/teams/usePeerSandbox.js +59 -0
  17. package/lib/examples/AgentA2ATeamExample.js +7 -2
  18. package/lib/loop/apps/AppComposer.d.ts +31 -0
  19. package/lib/loop/apps/AppComposer.js +67 -0
  20. package/lib/loop/apps/AppRenderer.d.ts +16 -5
  21. package/lib/loop/apps/AppRenderer.js +49 -16
  22. package/lib/loop/apps/appspec.d.ts +8 -1
  23. package/lib/loop/apps/appspec.js +87 -0
  24. package/lib/loop/apps/checks.js +108 -1
  25. package/lib/loop/apps/composer.d.ts +37 -0
  26. package/lib/loop/apps/composer.js +61 -0
  27. package/lib/loop/apps/index.d.ts +2 -0
  28. package/lib/loop/apps/index.js +2 -0
  29. package/lib/loop/core/index.d.ts +31 -0
  30. package/lib/loop/core/index.js +22 -0
  31. package/lib/loop/embed/AppEmbed.d.ts +46 -7
  32. package/lib/loop/embed/AppEmbed.js +108 -26
  33. package/lib/loop/embed/LoopEmbed.js +2 -0
  34. package/lib/loop/embed/element.js +19 -5
  35. package/lib/loop/embed/embedConfig.d.ts +10 -1
  36. package/lib/loop/embed/embedConfig.js +12 -1
  37. package/lib/loop/embed/embedSession.d.ts +79 -0
  38. package/lib/loop/embed/embedSession.js +136 -0
  39. package/lib/loop/embed/embedTheme.d.ts +12 -9
  40. package/lib/loop/embed/embedTheme.js +32 -22
  41. package/lib/loop/index.d.ts +4 -3
  42. package/lib/loop/index.js +4 -3
  43. package/lib/loop/plugins/agent-capacity/index.d.ts +3 -1
  44. package/lib/loop/plugins/agent-capacity/index.js +2 -1
  45. package/lib/loop/plugins/chat/ChatView.js +31 -4
  46. package/lib/loop/plugins/chat/index.d.ts +6 -1
  47. package/lib/loop/presets.d.ts +3 -1
  48. package/lib/loop/presets.js +2 -1
  49. package/lib/loop/shell/LoopWorkspace.js +3 -0
  50. package/lib/protocols/BrowserAgentAdapter.d.ts +18 -0
  51. package/lib/protocols/BrowserAgentAdapter.js +35 -2
  52. package/lib/runtimes/__tests__/fixtures/accounting-agent-card.json +8 -0
  53. package/lib/runtimes/browser/a2aPeer.d.ts +11 -0
  54. package/lib/runtimes/browser/a2aPeer.js +15 -1
  55. package/lib/runtimes/browser/protocol.d.ts +3 -0
  56. package/lib/runtimes/browser/protocol.js +2 -1
  57. package/lib/specs/apps.js +28 -0
  58. package/lib/specs/appspecSchema.js +191 -0
  59. package/lib/types/agentspecs.d.ts +58 -0
  60. package/lib/types/chat.d.ts +29 -1
  61. package/package.json +1 -1
  62. package/scripts/codegen/generate_agents.py +4 -2
  63. package/scripts/codegen/generate_backend_tools.py +4 -12
  64. package/scripts/codegen/generate_cogs.py +3 -1
  65. package/scripts/codegen/generate_ui_plugins.py +22 -9
  66. package/scripts/voice/transcribe.mjs +29 -21
@@ -108,6 +108,12 @@ export type AppRendererProps = Omit<LoopEmbedProps, 'agentId'> & {
108
108
  * `instance.appUid`.
109
109
  */
110
110
  sidebar?: boolean;
111
+ /**
112
+ * Draw its computer alone beside its page (R-23): what a deployment's
113
+ * owner and the editors of its application see of it at its address — its
114
+ * runtime shows it to them only (decided 2026-10-06). Implied by `sidebar`.
115
+ */
116
+ computer?: boolean;
111
117
  /**
112
118
  * Draw the application in a window (the `window-frame` plugin), its title
113
119
  * the application's face and name. A host's own `frameTitle` wins.
@@ -157,11 +163,13 @@ export type AppPreset = {
157
163
  * its page (`app-page`) when it has one, with the Canvas's block plugins
158
164
  * but those of the UI plugins its organization turned off (`pluginsOff`),
159
165
  * whose contributions are the blocks it may draw (R-01b); a thumb and a
160
- * comment on each answer when its record keeps feedback (V-18); what it
166
+ * comment on each answer when its record keeps feedback (V-18); its
167
+ * commands in the composer's `/` menu and its modes beside it (P-19); what it
161
168
  * keeps and for how long, under its prompt before the first message, when
162
169
  * `kept` says it (R-31); with
163
170
  * `sidebar`, its rules and approvals card, its activity feed and its
164
- * computer (R-01b, R-23);
171
+ * computer (R-01b, R-23); with `computer`, its computer alone — at its
172
+ * address, for its owner and the editors of its application;
165
173
  * laid out as `interface.layout` says;
166
174
  * - a **decision**: the page its host draws (`page`), as the one view of a
167
175
  * workspace without a conversation (R-02).
@@ -174,6 +182,8 @@ export declare function appPreset(app: AppSpec, options?: {
174
182
  pluginsOff?: readonly string[];
175
183
  /** Its rules and approvals, its activity and its computer, beside its page. */
176
184
  sidebar?: boolean;
185
+ /** Its computer alone beside its page (R-23): for its owner and editors at its address. */
186
+ computer?: boolean;
177
187
  /** What its record is kept under: its activity's. */
178
188
  appUid?: string;
179
189
  /**
@@ -207,8 +217,9 @@ export declare const DATALAYER_BOOTSTRAP_AGENTSPEC = "example-simple";
207
217
  * The application's accent over the theme its conversation wears, in both
208
218
  * modes (LOOP T-05): its bubbles and its one button in its own colour, and
209
219
  * not in the theme's default — the chat sets its theme again inside it, so
210
- * what the page around it set does not reach in.
220
+ * what the page around it set does not reach in. The accents are `loop`'s
221
+ * colours: over another theme (T-30) nothing is laid, the theme's own rule.
211
222
  */
212
- export declare function appThemeOverrides(app: AppSpec): ThemeOverrides | undefined;
213
- export declare function AppRenderer({ app, plugins, instance, onPresence, onSaying, paused, sidebar, frame, page, pluginsOff, ...embed }: AppRendererProps): React.JSX.Element;
223
+ export declare function appThemeOverrides(app: AppSpec, variant: string): ThemeOverrides | undefined;
224
+ export declare function AppRenderer({ app, plugins, instance, onPresence, onSaying, paused, sidebar, computer, frame, page, pluginsOff, ...embed }: AppRendererProps): React.JSX.Element;
214
225
  export default AppRenderer;
@@ -45,7 +45,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
45
45
  * @module loop/apps/AppRenderer
46
46
  */
47
47
  import { useMemo } from 'react';
48
- import { loopAccentStyles } from '@datalayer/primer-addons';
48
+ import { loopAccentStyles, useSystemColorMode, useThemeStore, } from '@datalayer/primer-addons';
49
49
  import { defineAgentCapacityPlugin } from '../plugins/agent-capacity';
50
50
  import { APP_PAGE_SURFACE, defineAppHostPagePlugin, defineAppPagePlugin, hasAppPage, } from '../plugins/app-page';
51
51
  import { CANVAS_BLOCK_PLUGINS, canvasBlocksPluginName, } from '../plugins/canvas-blocks';
@@ -54,6 +54,8 @@ import { dumpAppspec } from './appspec';
54
54
  import { defineAppActivityPlugin } from '../plugins/app-activity';
55
55
  import { defineAppComputerPlugin } from '../plugins/app-computer';
56
56
  import { defineAppRulesPlugin } from '../plugins/app-rules';
57
+ import { defineAppComposerPlugin } from './AppComposer';
58
+ import { modeEffect } from './composer';
57
59
  import { defineAppFeedbackPlugin } from './AppFeedback';
58
60
  import { defineAppKeptPlugin } from './AppKept';
59
61
  import { keepsFeedback } from './feedback';
@@ -105,6 +107,14 @@ export function defineAppPlugin(app) {
105
107
  // application's model when it names one (`loop/apps/agent`).
106
108
  instructions: app.instructions,
107
109
  model: app.model,
110
+ // Its modes (LOOP P-19), as the session API applies them: the
111
+ // instructions of the options chosen and the model one names, for the
112
+ // run they are sent with.
113
+ ...((app.interface.modes ?? []).length > 0
114
+ ? {
115
+ modeEffect: (chosen) => modeEffect(app, chosen),
116
+ }
117
+ : {}),
108
118
  });
109
119
  }
110
120
  /**
@@ -146,11 +156,13 @@ export function appLayoutOptions(app) {
146
156
  * its page (`app-page`) when it has one, with the Canvas's block plugins
147
157
  * but those of the UI plugins its organization turned off (`pluginsOff`),
148
158
  * whose contributions are the blocks it may draw (R-01b); a thumb and a
149
- * comment on each answer when its record keeps feedback (V-18); what it
159
+ * comment on each answer when its record keeps feedback (V-18); its
160
+ * commands in the composer's `/` menu and its modes beside it (P-19); what it
150
161
  * keeps and for how long, under its prompt before the first message, when
151
162
  * `kept` says it (R-31); with
152
163
  * `sidebar`, its rules and approvals card, its activity feed and its
153
- * computer (R-01b, R-23);
164
+ * computer (R-01b, R-23); with `computer`, its computer alone — at its
165
+ * address, for its owner and the editors of its application;
154
166
  * laid out as `interface.layout` says;
155
167
  * - a **decision**: the page its host draws (`page`), as the one view of a
156
168
  * workspace without a conversation (R-02).
@@ -180,6 +192,12 @@ export function appPreset(app, options = {}) {
180
192
  plugins: [
181
193
  defineAppPlugin(app),
182
194
  ...(withPage ? [defineAppPagePlugin(app), ...blocks] : []),
195
+ // Its commands in the composer's `/` menu and its modes beside the
196
+ // composer (LOOP P-19): only for an application that has some.
197
+ ...((app.interface.commands ?? []).length > 0 ||
198
+ (app.interface.modes ?? []).length > 0
199
+ ? [defineAppComposerPlugin(app)]
200
+ : []),
183
201
  // A thumb and a comment on each answer, kept in its record (LOOP
184
202
  // V-18): only for an application whose record keeps feedback.
185
203
  ...(keepsFeedback(app) ? [defineAppFeedbackPlugin(app)] : []),
@@ -196,7 +214,9 @@ export function appPreset(app, options = {}) {
196
214
  // Its computer, live (R-23).
197
215
  defineAppComputerPlugin(app),
198
216
  ]
199
- : []),
217
+ : options.computer
218
+ ? [defineAppComputerPlugin(app)]
219
+ : []),
200
220
  ],
201
221
  workspace: appLayoutOptions(app),
202
222
  };
@@ -249,18 +269,19 @@ const NO_PLUGINS = [];
249
269
  * The application's accent over the theme its conversation wears, in both
250
270
  * modes (LOOP T-05): its bubbles and its one button in its own colour, and
251
271
  * not in the theme's default — the chat sets its theme again inside it, so
252
- * what the page around it set does not reach in.
272
+ * what the page around it set does not reach in. The accents are `loop`'s
273
+ * colours: over another theme (T-30) nothing is laid, the theme's own rule.
253
274
  */
254
- export function appThemeOverrides(app) {
275
+ export function appThemeOverrides(app, variant) {
255
276
  const accent = app.interface?.accent;
256
- return accent
277
+ return accent && variant === 'loop'
257
278
  ? {
258
279
  light: loopAccentStyles(accent, 'light'),
259
280
  dark: loopAccentStyles(accent, 'dark'),
260
281
  }
261
282
  : undefined;
262
283
  }
263
- export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, onSaying, paused = false, sidebar = false, frame = false, page, pluginsOff, ...embed }) {
284
+ export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, onSaying, paused = false, sidebar = false, computer = false, frame = false, page, pluginsOff, ...embed }) {
264
285
  /*
265
286
  * The kind's plugins, made once per application. `LoopEmbed` rebuilds its
266
287
  * whole reactor when its plugins change, so a new plugin on every render
@@ -282,6 +303,7 @@ export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, o
282
303
  page,
283
304
  pluginsOff,
284
305
  sidebar,
306
+ computer,
285
307
  appUid: instance?.appUid,
286
308
  ...(deployed ? { kept: { visitor } } : {}),
287
309
  });
@@ -293,7 +315,7 @@ export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, o
293
315
  }
294
316
  },
295
317
  // eslint-disable-next-line react-hooks/exhaustive-deps
296
- [source, page, off, sidebar, instance?.appUid, deployed, visitor]);
318
+ [source, page, off, sidebar, computer, instance?.appUid, deployed, visitor]);
297
319
  const allPlugins = useMemo(() => ('problem' in preset ? plugins : [...preset.plugins, ...plugins]), [preset, plugins]);
298
320
  /*
299
321
  * On Datalayer, the application runs on a runtime: allocated with a plain
@@ -311,10 +333,24 @@ export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, o
311
333
  instance?.deploymentUid,
312
334
  instance?.version,
313
335
  ]);
336
+ /*
337
+ * The theme it runs in (T-30): the one its host says, else the one its
338
+ * Appspec names, else the page's — the person's own. Its mode likewise;
339
+ * `auto` is the device's.
340
+ */
341
+ const pageTheme = useThemeStore(state => state.theme);
342
+ const system = useSystemColorMode();
343
+ const named = app.interface?.theme;
344
+ const worn = embed.themeVariant ?? named?.variant ?? pageTheme;
345
+ const namedMode = named?.mode === 'auto'
346
+ ? system
347
+ : named?.mode === 'light' || named?.mode === 'dark'
348
+ ? named.mode
349
+ : undefined;
314
350
  const accent = app.interface?.accent;
315
- const themeOverrides = useMemo(() => appThemeOverrides(app),
351
+ const themeOverrides = useMemo(() => appThemeOverrides(app, worn),
316
352
  // eslint-disable-next-line react-hooks/exhaustive-deps
317
- [accent]);
353
+ [accent, worn]);
318
354
  if ('problem' in preset) {
319
355
  return (_jsx("div", { role: "status", style: { padding: 16 }, children: preset.problem }));
320
356
  }
@@ -330,13 +366,10 @@ export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, o
330
366
  }
331
367
  : {}),
332
368
  // Its rules, activity and computer on a rail, one at a time (T-07).
333
- sidebarRail: sidebar, teamPicker: false, showAgentVariants: false, graph: false, pluginsPanel: false, datalayerAgentSpecId: DATALAYER_BOOTSTRAP_AGENTSPEC,
369
+ sidebarRail: sidebar || computer, teamPicker: false, showAgentVariants: false, graph: false, pluginsPanel: false, datalayerAgentSpecId: DATALAYER_BOOTSTRAP_AGENTSPEC,
334
370
  // Where an application runs is decided by its host — the Studio's
335
371
  // Preview, the hosted page — not offered to its user.
336
- targetFixed: true,
337
- // Applications first (LOOP T-12): an application's conversation wears
338
- // the `loop` theme; a host may say otherwise.
339
- themeVariant: "loop", themeOverrides: themeOverrides,
372
+ targetFixed: true, ...(named ? { themeVariant: named.variant } : {}), ...(namedMode ? { colorMode: namedMode } : {}), themeOverrides: themeOverrides,
340
373
  // Its own face, name and welcome in the chat (T-08); no counters: a
341
374
  // person using an application is not asking about tokens.
342
375
  presence: {
@@ -24,13 +24,20 @@
24
24
  *
25
25
  * @module loop/apps/appspec
26
26
  */
27
- import type { AppAccent, AppAssistantCharacter, AppEmbedMode, AppVoiceSpec, AppKind, AppLayout, AppSpec } from '../../types/agentspecs';
27
+ import type { AppAccent, AppThemeMode, AppThemeVariant, AppAssistantCharacter, AppEmbedMode, AppVoiceSpec, AppKind, AppLayout, AppSpec } from '../../types/agentspecs';
28
28
  /** The version of the spec itself. */
29
29
  export declare const APP_SCHEMA = "loop.app/v1";
30
30
  /** The face of an application that has not chosen one. */
31
31
  export declare const DEFAULT_EMOJI = "\uD83D\uDC40";
32
32
  export declare const APP_KINDS: AppKind[];
33
33
  export declare const APP_ACCENTS: AppAccent[];
34
+ /**
35
+ * The themes an application may run in (LOOP T-30): Appearance's, as
36
+ * primer-addons lists them (`themeVariants`), in its order.
37
+ */
38
+ export declare const APP_THEME_VARIANTS: AppThemeVariant[];
39
+ /** The colour modes a theme is worn in. */
40
+ export declare const APP_THEME_MODES: AppThemeMode[];
34
41
  /** The four ways an application sits in another product's page (LOOP D-07). */
35
42
  export declare const APP_EMBED_MODES: AppEmbedMode[];
36
43
  /**
@@ -16,6 +16,23 @@ export const APP_ACCENTS = [
16
16
  'sun',
17
17
  'violet',
18
18
  ];
19
+ /**
20
+ * The themes an application may run in (LOOP T-30): Appearance's, as
21
+ * primer-addons lists them (`themeVariants`), in its order.
22
+ */
23
+ export const APP_THEME_VARIANTS = [
24
+ 'datalayer',
25
+ 'spatial',
26
+ 'lovely',
27
+ 'matrix',
28
+ 'earth',
29
+ 'sand',
30
+ 'ivory',
31
+ 'sun',
32
+ 'loop',
33
+ ];
34
+ /** The colour modes a theme is worn in. */
35
+ export const APP_THEME_MODES = ['light', 'dark', 'auto'];
19
36
  /** The four ways an application sits in another product's page (LOOP D-07). */
20
37
  export const APP_EMBED_MODES = [
21
38
  'inline',
@@ -105,6 +122,8 @@ export function emptyAppspec(kind = 'chat') {
105
122
  accent: 'green',
106
123
  welcome: '',
107
124
  starters: [],
125
+ commands: [],
126
+ modes: [],
108
127
  settings: [],
109
128
  components: [],
110
129
  outputs: [],
@@ -190,6 +209,39 @@ function parseRule(data) {
190
209
  behaviour: oneOf(data.behaviour, BEHAVIOURS, 'ask_first'),
191
210
  };
192
211
  }
212
+ /** A slash command (LOOP P-19); a wrong one is said by the checks. */
213
+ function parseCommand(data) {
214
+ return {
215
+ name: text(data.name),
216
+ description: text(data.description),
217
+ prompt: text(data.prompt),
218
+ };
219
+ }
220
+ /** One position of a mode switch, what it does said only when said. */
221
+ function parseModeOption(data) {
222
+ const option = {
223
+ id: text(data.id),
224
+ label: text(data.label),
225
+ };
226
+ for (const key of ['description', 'instructions', 'model']) {
227
+ if (typeof data[key] === 'string' && data[key]) {
228
+ option[key] = data[key];
229
+ }
230
+ }
231
+ return option;
232
+ }
233
+ /** A mode switch (LOOP P-19). */
234
+ function parseMode(data) {
235
+ const mode = {
236
+ id: text(data.id),
237
+ label: text(data.label),
238
+ options: records(data.options).map(parseModeOption),
239
+ };
240
+ if (typeof data.default === 'string' && data.default) {
241
+ mode.default = data.default;
242
+ }
243
+ return mode;
244
+ }
193
245
  function parseSetting(data) {
194
246
  const setting = {
195
247
  id: text(data.id),
@@ -246,6 +298,8 @@ function parseInterface(data, kind) {
246
298
  label: text(starter.label),
247
299
  message: text(starter.message),
248
300
  })),
301
+ commands: records(data.commands).map(parseCommand),
302
+ modes: records(data.modes).map(parseMode),
249
303
  settings: records(data.settings).map(parseSetting),
250
304
  components: texts(data.components),
251
305
  voice: parseVoice(data.voice),
@@ -260,6 +314,14 @@ function parseInterface(data, kind) {
260
314
  if (data.balloon === 'history' || data.balloon === 'current') {
261
315
  parsed.balloon = data.balloon;
262
316
  }
317
+ // A theme of Appearance's, or none: an unknown one is said by the checks.
318
+ if (isData(data.theme) &&
319
+ APP_THEME_VARIANTS.includes(data.theme.variant)) {
320
+ parsed.theme = { variant: data.theme.variant };
321
+ if (APP_THEME_MODES.includes(data.theme.mode)) {
322
+ parsed.theme.mode = data.theme.mode;
323
+ }
324
+ }
263
325
  return parsed;
264
326
  }
265
327
  function parseCriterion(data) {
@@ -516,6 +578,26 @@ function dumpInterface(spec, kind) {
516
578
  label: starter.label,
517
579
  message: starter.message,
518
580
  })))
581
+ .list('commands', spec.commands.map(command => ({
582
+ name: command.name,
583
+ description: command.description,
584
+ prompt: command.prompt,
585
+ })))
586
+ .list('modes', spec.modes.map(mode => {
587
+ const written = new Writer()
588
+ .text('id', mode.id, '\u0000')
589
+ .text('label', mode.label, '\u0000')
590
+ .list('options', mode.options.map(option => new Writer()
591
+ .text('id', option.id, '\u0000')
592
+ .text('label', option.label, '\u0000')
593
+ .text('description', option.description ?? '')
594
+ .text('instructions', option.instructions ?? '')
595
+ .text('model', option.model ?? '').data));
596
+ if (mode.default) {
597
+ written.data.default = mode.default;
598
+ }
599
+ return written.data;
600
+ }))
519
601
  .list('settings', spec.settings.map(setting => {
520
602
  const written = new Writer()
521
603
  .text('id', setting.id, '\u0000')
@@ -539,6 +621,11 @@ function dumpInterface(spec, kind) {
539
621
  if (spec.balloon) {
540
622
  writer.data.balloon = spec.balloon;
541
623
  }
624
+ if (spec.theme) {
625
+ writer.data.theme = spec.theme.mode
626
+ ? { variant: spec.theme.variant, mode: spec.theme.mode }
627
+ : { variant: spec.theme.variant };
628
+ }
542
629
  // Its voice, when it says one (VO-41): a spec made before voice has none.
543
630
  if (spec.voice) {
544
631
  writer.part('voice', new Writer()
@@ -39,8 +39,9 @@ import { getTeamSpec } from '../../specs/teams';
39
39
  import { getBackendToolSpec } from '../../specs/backendTools';
40
40
  import { getTrack } from '../../specs/tracks';
41
41
  import { pluginsOffSetupNotes } from '../plugins/canvas-blocks';
42
- import { isAssistantCharacterId, parseAppspec } from './appspec';
42
+ import { APP_THEME_MODES, APP_THEME_VARIANTS, isAssistantCharacterId, parseAppspec, } from './appspec';
43
43
  import { classesOf, splitRef, toolBehaviours } from './rules';
44
+ import { COMMAND_INPUT, COMMAND_NAME, MODE_ID } from './composer';
44
45
  import { HOST_NAME, hostToolsOf } from './hostTools';
45
46
  export const NOT_READY = 'Not ready';
46
47
  export const NEEDS_ATTENTION = 'Needs attention';
@@ -269,6 +270,14 @@ function referenceProblems(app, organizationFrames) {
269
270
  if (app.model && !getModel(app.model)) {
270
271
  missing('model', app.model);
271
272
  }
273
+ // The model a mode runs on is the catalogue's (LOOP P-19).
274
+ for (const mode of app.interface.modes ?? []) {
275
+ for (const option of mode.options) {
276
+ if (option.model && !getModel(option.model)) {
277
+ problems.push(`The mode “${mode.id}” runs “${option.id}” on “${option.model}”, which is no model.`);
278
+ }
279
+ }
280
+ }
272
281
  // The components it may use, and those its surface uses, are the catalog's (C-13).
273
282
  for (const name of app.interface.components ?? []) {
274
283
  if (!componentNamed(name)) {
@@ -477,6 +486,8 @@ const ENUMS = {
477
486
  visibility: ['private', 'invited', 'organization', 'link', 'public'],
478
487
  mode: ['inline', 'bubble', 'panel', 'assistant'],
479
488
  balloon: ['history', 'current'],
489
+ themeVariant: APP_THEME_VARIANTS,
490
+ themeMode: APP_THEME_MODES,
480
491
  trigger: ['schedule', 'event', 'once'],
481
492
  criterion: ['metric', 'noul', 'choice', 'score'],
482
493
  direction: ['higher', 'lower'],
@@ -534,6 +545,13 @@ export function documentShapeProblems(document) {
534
545
  if (item[key] === undefined)
535
546
  at(`${where}.${key}`, 'is missing');
536
547
  };
548
+ const unknownKeys = (item, known, where, what) => {
549
+ for (const key of Object.keys(item)) {
550
+ if (!known.includes(key)) {
551
+ at(`${where}.${key}`, `is not a key of ${what}: ${known.join(', ')}`);
552
+ }
553
+ }
554
+ };
537
555
  const d = document;
538
556
  for (const key of [
539
557
  'id',
@@ -605,6 +623,17 @@ export function documentShapeProblems(document) {
605
623
  oneOf(ui.layout, ENUMS.layout, 'interface.layout');
606
624
  oneOf(ui.accent, ENUMS.accent, 'interface.accent');
607
625
  oneOf(ui.balloon, ENUMS.balloon, 'interface.balloon');
626
+ // The theme it runs in by default (T-30): a theme of Appearance's, a mode.
627
+ mapping(ui.theme, 'interface.theme', theme => {
628
+ required(theme, 'variant', 'interface.theme');
629
+ oneOf(theme.variant, ENUMS.themeVariant, 'interface.theme.variant');
630
+ oneOf(theme.mode, ENUMS.themeMode, 'interface.theme.mode');
631
+ for (const key of Object.keys(theme)) {
632
+ if (key !== 'variant' && key !== 'mode') {
633
+ at(`interface.theme.${key}`, 'is not a key of a theme: variant, mode');
634
+ }
635
+ }
636
+ });
608
637
  if (ui.assistant !== undefined && !isAssistantCharacterId(ui.assistant)) {
609
638
  at('interface.assistant', 'names a character by the id a plugin contributes it under, lowercase words joined by a hyphen: `paperclip`, `acme-owl`');
610
639
  }
@@ -614,6 +643,84 @@ export function documentShapeProblems(document) {
614
643
  required(item, 'label', where);
615
644
  required(item, 'message', where);
616
645
  });
646
+ // Its commands and modes in the composer (LOOP P-19), as agentspecs
647
+ // refuses them: names and ids slash-safe and said once, no key unknown.
648
+ const named = new Set();
649
+ records(ui.commands, 'interface.commands', (item, where) => {
650
+ for (const key of ['name', 'description', 'prompt']) {
651
+ required(item, key, where);
652
+ text(item[key], `${where}.${key}`);
653
+ }
654
+ unknownKeys(item, ['name', 'description', 'prompt'], where, 'a command');
655
+ if (typeof item.name === 'string') {
656
+ if (!COMMAND_NAME.test(item.name)) {
657
+ at(`${where}.name`, 'is what follows the slash: lower-case letters, digits and hyphens, a letter first');
658
+ }
659
+ if (named.has(item.name)) {
660
+ at(`${where}.name`, `names /${item.name} a second time`);
661
+ }
662
+ named.add(item.name);
663
+ }
664
+ if (typeof item.prompt === 'string') {
665
+ for (const placeholder of item.prompt.match(/\{[^{}]*\}/g) ?? []) {
666
+ if (placeholder !== COMMAND_INPUT) {
667
+ at(`${where}.prompt`, `takes the words typed after the command as ${COMMAND_INPUT}, not ${placeholder}`);
668
+ }
669
+ }
670
+ }
671
+ });
672
+ const modeIds = new Set();
673
+ const choosingModel = [];
674
+ records(ui.modes, 'interface.modes', (mode, where) => {
675
+ required(mode, 'id', where);
676
+ required(mode, 'label', where);
677
+ required(mode, 'options', where);
678
+ text(mode.label, `${where}.label`);
679
+ text(mode.default, `${where}.default`);
680
+ unknownKeys(mode, ['id', 'label', 'options', 'default'], where, 'a mode');
681
+ if (typeof mode.id === 'string') {
682
+ if (!MODE_ID.test(mode.id)) {
683
+ at(`${where}.id`, 'is lower-case letters, digits, `_` and `-`, a letter first');
684
+ }
685
+ if (modeIds.has(mode.id)) {
686
+ at(`${where}.id`, `names the mode ${mode.id} a second time`);
687
+ }
688
+ modeIds.add(mode.id);
689
+ }
690
+ const optionIds = [];
691
+ let model = false;
692
+ records(mode.options, `${where}.options`, (option, place) => {
693
+ required(option, 'id', place);
694
+ required(option, 'label', place);
695
+ for (const key of ['label', 'description', 'instructions', 'model']) {
696
+ text(option[key], `${place}.${key}`);
697
+ }
698
+ unknownKeys(option, ['id', 'label', 'description', 'instructions', 'model'], place, 'an option');
699
+ if (typeof option.id === 'string') {
700
+ if (!MODE_ID.test(option.id)) {
701
+ at(`${place}.id`, 'is lower-case letters, digits, `_` and `-`, a letter first');
702
+ }
703
+ if (optionIds.includes(option.id)) {
704
+ at(`${place}.id`, `names the option ${option.id} a second time`);
705
+ }
706
+ optionIds.push(option.id);
707
+ }
708
+ model ||= typeof option.model === 'string' && option.model !== '';
709
+ });
710
+ if (Array.isArray(mode.options) && mode.options.length < 2) {
711
+ at(`${where}.options`, 'are two at least');
712
+ }
713
+ if (typeof mode.default === 'string' &&
714
+ !optionIds.includes(mode.default)) {
715
+ at(`${where}.default`, 'is one of its options');
716
+ }
717
+ if (model) {
718
+ choosingModel.push(String(mode.id));
719
+ }
720
+ });
721
+ if (choosingModel.length > 1) {
722
+ at('interface.modes', `let only one mode choose the model, not ${choosingModel.join(', ')}`);
723
+ }
617
724
  records(ui.settings, 'interface.settings', (item, where) => {
618
725
  required(item, 'id', where);
619
726
  required(item, 'type', where);
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Commands and modes in the composer (LOOP P-19), as rules: what a command
3
+ * sends, and which option of each mode a run is in. agentspecs' own
4
+ * (`command_prompt`, `AppInterface.mode_choice`, `mode_effect`), said again
5
+ * for the page.
6
+ *
7
+ * Pure: no React, no network.
8
+ *
9
+ * @module loop/apps/composer
10
+ */
11
+ import type { AppCommandSpec, AppModeEffect, AppSpec } from '../../types/agentspecs';
12
+ /** Where a command's prompt takes the words typed after it. */
13
+ export declare const COMMAND_INPUT = "{input}";
14
+ /** What follows the slash: lower-case letters, digits and hyphens, a letter first. */
15
+ export declare const COMMAND_NAME: RegExp;
16
+ /** The id of a mode or of one of its options. */
17
+ export declare const MODE_ID: RegExp;
18
+ /**
19
+ * What a command sends: its prompt, the words typed after it in place of
20
+ * `{input}` — or after the prompt, when it has no `{input}`.
21
+ */
22
+ export declare function commandPrompt(command: Pick<AppCommandSpec, 'prompt'>, words?: string): string;
23
+ /** The option each mode starts on: its default, else its first. */
24
+ export declare function modeDefaults(app: Pick<AppSpec, 'interface'>): Record<string, string>;
25
+ /**
26
+ * The option of every mode a run is in: what was chosen, else where each
27
+ * starts. A mode or an option the application does not have is refused, in a
28
+ * sentence, as the runtime refuses it.
29
+ */
30
+ export declare function modeChoice(app: Pick<AppSpec, 'name' | 'interface'>, chosen?: Record<string, string>): Record<string, string>;
31
+ /**
32
+ * What a run in the modes chosen is told, and the model it runs on —
33
+ * agentspecs' `mode_effect`: the instructions of every option chosen, in the
34
+ * order of the modes, and the model of the first that names one. Refused as
35
+ * {@link modeChoice} refuses.
36
+ */
37
+ export declare function modeEffect(app: Pick<AppSpec, 'name' | 'interface'>, chosen?: Record<string, string>): AppModeEffect;
@@ -0,0 +1,61 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /** Where a command's prompt takes the words typed after it. */
6
+ export const COMMAND_INPUT = '{input}';
7
+ /** What follows the slash: lower-case letters, digits and hyphens, a letter first. */
8
+ export const COMMAND_NAME = /^[a-z](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
9
+ /** The id of a mode or of one of its options. */
10
+ export const MODE_ID = /^[a-z][a-z0-9_-]{0,39}$/;
11
+ /**
12
+ * What a command sends: its prompt, the words typed after it in place of
13
+ * `{input}` — or after the prompt, when it has no `{input}`.
14
+ */
15
+ export function commandPrompt(command, words = '') {
16
+ const said = words.trim();
17
+ if (command.prompt.includes(COMMAND_INPUT)) {
18
+ return command.prompt.split(COMMAND_INPUT).join(said).trim();
19
+ }
20
+ return said ? `${command.prompt}\n\n${said}` : command.prompt;
21
+ }
22
+ /** The option each mode starts on: its default, else its first. */
23
+ export function modeDefaults(app) {
24
+ return Object.fromEntries((app.interface.modes ?? [])
25
+ .filter(mode => mode.options.length > 0)
26
+ .map(mode => [mode.id, mode.default || mode.options[0].id]));
27
+ }
28
+ /**
29
+ * The option of every mode a run is in: what was chosen, else where each
30
+ * starts. A mode or an option the application does not have is refused, in a
31
+ * sentence, as the runtime refuses it.
32
+ */
33
+ export function modeChoice(app, chosen = {}) {
34
+ const modes = app.interface.modes ?? [];
35
+ for (const [modeId, optionId] of Object.entries(chosen)) {
36
+ const mode = modes.find(candidate => candidate.id === modeId);
37
+ if (!mode) {
38
+ throw new Error(`${app.name} has no mode “${modeId}”.`);
39
+ }
40
+ if (!mode.options.some(option => option.id === optionId)) {
41
+ throw new Error(`The mode ${mode.label} has no option “${optionId}”.`);
42
+ }
43
+ }
44
+ return { ...modeDefaults(app), ...chosen };
45
+ }
46
+ /**
47
+ * What a run in the modes chosen is told, and the model it runs on —
48
+ * agentspecs' `mode_effect`: the instructions of every option chosen, in the
49
+ * order of the modes, and the model of the first that names one. Refused as
50
+ * {@link modeChoice} refuses.
51
+ */
52
+ export function modeEffect(app, chosen = {}) {
53
+ const choice = modeChoice(app, chosen);
54
+ const options = (app.interface.modes ?? []).flatMap(mode => mode.options.filter(option => option.id === choice[mode.id]));
55
+ const instructions = options
56
+ .map(option => (option.instructions ?? '').trim())
57
+ .filter(Boolean)
58
+ .join('\n\n');
59
+ const model = options.find(option => option.model)?.model ?? undefined;
60
+ return model ? { instructions, model } : { instructions };
61
+ }
@@ -4,11 +4,13 @@
4
4
  * @module loop/apps
5
5
  */
6
6
  export * from './agent';
7
+ export * from './AppComposer';
7
8
  export * from './AppFeedback';
8
9
  export * from './AppKept';
9
10
  export * from './AppRenderer';
10
11
  export * from './appspec';
11
12
  export * from './checks';
13
+ export * from './composer';
12
14
  export * from './computer';
13
15
  export * from './feedback';
14
16
  export * from './kept';
@@ -8,11 +8,13 @@
8
8
  * @module loop/apps
9
9
  */
10
10
  export * from './agent';
11
+ export * from './AppComposer';
11
12
  export * from './AppFeedback';
12
13
  export * from './AppKept';
13
14
  export * from './AppRenderer';
14
15
  export * from './appspec';
15
16
  export * from './checks';
17
+ export * from './composer';
16
18
  export * from './computer';
17
19
  export * from './feedback';
18
20
  export * from './kept';