@datalayer/agent-runtimes 1.3.82 → 1.3.84

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 (52) hide show
  1. package/lib/chat/base/ChatBase.js +16 -0
  2. package/lib/chat/base/loopElement.d.ts +53 -0
  3. package/lib/chat/base/loopElement.js +78 -0
  4. package/lib/chat/base/loopWindow.d.ts +31 -0
  5. package/lib/chat/base/loopWindow.js +74 -0
  6. package/lib/components/a2ui/datalayer/FileUpload.d.ts +10 -2
  7. package/lib/components/a2ui/datalayer/FileUpload.js +10 -7
  8. package/lib/components/a2ui/datalayer/Form.d.ts +2 -0
  9. package/lib/components/a2ui/datalayer/Form.js +26 -2
  10. package/lib/components/a2ui/datalayer/formWidgets.d.ts +19 -0
  11. package/lib/components/a2ui/datalayer/formWidgets.js +51 -0
  12. package/lib/loop/apps/AppComposer.d.ts +39 -4
  13. package/lib/loop/apps/AppComposer.js +191 -19
  14. package/lib/loop/apps/AppRenderer.d.ts +20 -3
  15. package/lib/loop/apps/AppRenderer.js +58 -27
  16. package/lib/loop/apps/appspec.d.ts +2 -0
  17. package/lib/loop/apps/appspec.js +196 -39
  18. package/lib/loop/apps/checks.d.ts +6 -0
  19. package/lib/loop/apps/checks.js +187 -8
  20. package/lib/loop/apps/composer.d.ts +21 -10
  21. package/lib/loop/apps/composer.js +41 -8
  22. package/lib/loop/apps/language.d.ts +68 -0
  23. package/lib/loop/apps/language.js +275 -0
  24. package/lib/loop/apps/settingsInputs.d.ts +29 -0
  25. package/lib/loop/apps/settingsInputs.js +147 -0
  26. package/lib/loop/apps/uploads.d.ts +43 -0
  27. package/lib/loop/apps/uploads.js +66 -0
  28. package/lib/loop/core/index.d.ts +14 -1
  29. package/lib/loop/embed/AppEmbed.d.ts +14 -0
  30. package/lib/loop/embed/AppEmbed.js +64 -2
  31. package/lib/loop/embed/element.js +23 -0
  32. package/lib/loop/embed/embedSession.d.ts +14 -0
  33. package/lib/loop/embed/embedSession.js +34 -0
  34. package/lib/loop/embed/hostBridge.d.ts +22 -1
  35. package/lib/loop/plugins/app-elements/AppElements.d.ts +27 -0
  36. package/lib/loop/plugins/app-elements/AppElements.js +76 -0
  37. package/lib/loop/plugins/app-elements/index.d.ts +20 -0
  38. package/lib/loop/plugins/app-elements/index.js +43 -0
  39. package/lib/loop/plugins/app-page/appPageModel.d.ts +14 -5
  40. package/lib/loop/plugins/app-page/appPageModel.js +82 -88
  41. package/lib/loop/plugins/chat/ChatView.js +10 -2
  42. package/lib/protocols/BrowserAgentAdapter.d.ts +8 -6
  43. package/lib/protocols/BrowserAgentAdapter.js +13 -10
  44. package/lib/runtimes/browser/protocol.d.ts +1 -1
  45. package/lib/specs/agents/agents.js +6 -3
  46. package/lib/specs/apps.js +337 -176
  47. package/lib/specs/appspecSchema.js +455 -88
  48. package/lib/specs/cogs.js +3 -2
  49. package/lib/specs/uiPlugins.js +6 -1
  50. package/lib/types/agentspecs.d.ts +167 -11
  51. package/package.json +1 -1
  52. package/scripts/codegen/generate_apps.py +16 -1
@@ -3,8 +3,11 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
  import { BEHAVIOURS } from './rules';
6
+ import { DEFAULT_MAX_FILES, DEFAULT_UPLOAD_MB } from './uploads';
6
7
  /** The version of the spec itself. */
7
8
  export const APP_SCHEMA = 'loop.app/v1';
9
+ /** The language an application's own words are in, unless it says (LOOP P-26). */
10
+ export const DEFAULT_LANGUAGE = 'en';
8
11
  /** The face of an application that has not chosen one. */
9
12
  export const DEFAULT_EMOJI = '\u{1F440}';
10
13
  export const APP_KINDS = ['chat', 'widget', 'decision', 'worker'];
@@ -124,7 +127,9 @@ export function emptyAppspec(kind = 'chat') {
124
127
  starters: [],
125
128
  commands: [],
126
129
  modes: [],
127
- settings: [],
130
+ profiles: [],
131
+ language: DEFAULT_LANGUAGE,
132
+ translations: {},
128
133
  components: [],
129
134
  outputs: [],
130
135
  },
@@ -148,6 +153,7 @@ export function emptyAppspec(kind = 'chat') {
148
153
  notifications: [],
149
154
  setup: [],
150
155
  enabled: true,
156
+ unavailable_because: '',
151
157
  tags: [],
152
158
  icon: DEFAULT_ICON,
153
159
  emoji: DEFAULT_EMOJI,
@@ -170,6 +176,7 @@ const KNOWN_KEYS = [
170
176
  'model',
171
177
  'skills',
172
178
  'backend_tools',
179
+ 'tools',
173
180
  'context',
174
181
  'contents',
175
182
  'connections',
@@ -186,12 +193,26 @@ const KNOWN_KEYS = [
186
193
  'notifications',
187
194
  'decision',
188
195
  'enabled',
196
+ 'unavailable_because',
189
197
  'tags',
190
198
  'icon',
191
199
  'emoji',
192
200
  'avatar',
193
201
  'banner',
194
202
  ];
203
+ /** Where a check of its code runs (LOOP P-06). */
204
+ const CHECK_STAGES = ['answer', 'tool_call'];
205
+ /** A tool of its own, written in its code (LOOP P-06); a wrong one is said by the checks. */
206
+ function parseTool(data) {
207
+ return {
208
+ name: text(data.name),
209
+ description: text(data.description),
210
+ parameters: isData(data.parameters)
211
+ ? data.parameters
212
+ : { ...NO_HOST_PARAMETERS },
213
+ does: texts(data.does),
214
+ };
215
+ }
195
216
  function parseConnection(data) {
196
217
  return {
197
218
  server: text(data.server),
@@ -242,23 +263,66 @@ function parseMode(data) {
242
263
  }
243
264
  return mode;
244
265
  }
245
- function parseSetting(data) {
246
- const setting = {
247
- id: text(data.id),
248
- type: oneOf(data.type, ['select', 'text', 'toggle', 'slider', 'number'], 'text'),
266
+ /** A starter, its category said only when it has one (LOOP P-20). */
267
+ function parseStarter(data) {
268
+ const starter = {
249
269
  label: text(data.label),
250
- options: texts(data.options),
270
+ message: text(data.message),
251
271
  };
252
- if (['string', 'boolean', 'number'].includes(typeof data.default)) {
253
- setting.default = data.default;
254
- }
255
- if (typeof data.min === 'number') {
256
- setting.min = data.min;
272
+ if (typeof data.category === 'string' && data.category) {
273
+ starter.category = data.category;
257
274
  }
258
- if (typeof data.max === 'number') {
259
- setting.max = data.max;
260
- }
261
- return setting;
275
+ return starter;
276
+ }
277
+ /** One of several assistants in one application (LOOP P-20). */
278
+ function parseProfile(data) {
279
+ return {
280
+ id: text(data.id),
281
+ label: text(data.label),
282
+ description: text(data.description),
283
+ instructions: text(data.instructions),
284
+ model: typeof data.model === 'string' && data.model ? data.model : null,
285
+ starters: records(data.starters).map(parseStarter),
286
+ };
287
+ }
288
+ /** Words by key, what is not a word left out. */
289
+ function wordsBy(value) {
290
+ return Object.fromEntries(Object.entries(isData(value) ? value : {}).filter((entry) => typeof entry[1] === 'string'));
291
+ }
292
+ /** Records by key, what is not one left out. */
293
+ function recordsBy(value) {
294
+ return Object.fromEntries(Object.entries(isData(value) ? value : {}).filter((entry) => isData(entry[1])));
295
+ }
296
+ const mapValues = (value, map) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, map(item)]));
297
+ /** An application's words in one other language (LOOP P-26), every key present. */
298
+ function parseTranslation(data) {
299
+ return {
300
+ name: text(data.name),
301
+ description: text(data.description),
302
+ welcome: text(data.welcome),
303
+ starters: mapValues(recordsBy(data.starters), starter => ({
304
+ label: text(starter.label),
305
+ message: text(starter.message),
306
+ })),
307
+ categories: wordsBy(data.categories),
308
+ settings: mapValues(recordsBy(data.settings), field => ({
309
+ title: text(field.title),
310
+ description: text(field.description),
311
+ options: wordsBy(field.options),
312
+ })),
313
+ commands: wordsBy(data.commands),
314
+ modes: mapValues(recordsBy(data.modes), mode => ({
315
+ label: text(mode.label),
316
+ options: mapValues(recordsBy(mode.options), option => ({
317
+ label: text(option.label),
318
+ description: text(option.description),
319
+ })),
320
+ })),
321
+ profiles: mapValues(recordsBy(data.profiles), profile => ({
322
+ label: text(profile.label),
323
+ description: text(profile.description),
324
+ })),
325
+ };
262
326
  }
263
327
  function parseSurface(data) {
264
328
  return {
@@ -289,22 +353,45 @@ function parseVoice(data) {
289
353
  where: oneOf(voice.where, ['auto', 'device', 'server'], DEFAULT_VOICE.where),
290
354
  };
291
355
  }
356
+ /** What a person may send without being asked (P-21), as the spec says it. */
357
+ function parseUploads(data) {
358
+ return {
359
+ kinds: records(data.kinds).map(kind => ({
360
+ type: text(kind.type),
361
+ maxMb: numberOf(kind.max_mb, DEFAULT_UPLOAD_MB),
362
+ })),
363
+ maxFiles: numberOf(data.max_files, DEFAULT_MAX_FILES),
364
+ };
365
+ }
292
366
  function parseInterface(data, kind) {
293
367
  const parsed = {
294
368
  layout: oneOf(data.layout, ['chat', 'page', 'split'], DEFAULT_LAYOUTS[kind]),
295
369
  accent: oneOf(data.accent, APP_ACCENTS, 'green'),
296
370
  welcome: text(data.welcome),
297
- starters: records(data.starters).map(starter => ({
298
- label: text(starter.label),
299
- message: text(starter.message),
300
- })),
371
+ starters: records(data.starters).map(parseStarter),
301
372
  commands: records(data.commands).map(parseCommand),
302
373
  modes: records(data.modes).map(parseMode),
303
- settings: records(data.settings).map(parseSetting),
374
+ profiles: records(data.profiles).map(parseProfile),
375
+ language: text(data.language, DEFAULT_LANGUAGE),
376
+ translations: mapValues(recordsBy(data.translations), parseTranslation),
304
377
  components: texts(data.components),
305
378
  voice: parseVoice(data.voice),
306
379
  outputs: texts(data.outputs),
307
380
  };
381
+ // Its settings are the JSON Schema of a form (C-16), kept as written: what
382
+ // is not one — the list of settings of earlier versions among it — is not
383
+ // read, and is said by the checks.
384
+ if (isData(data.settings)) {
385
+ parsed.settings = JSON.parse(JSON.stringify(data.settings));
386
+ }
387
+ // How its settings' fields are drawn (P-20), kept as written: a wrong
388
+ // widget is said by the checks.
389
+ if (isData(data.settings_ui)) {
390
+ parsed.settingsUi = JSON.parse(JSON.stringify(data.settings_ui));
391
+ }
392
+ if (isData(data.uploads)) {
393
+ parsed.uploads = parseUploads(data.uploads);
394
+ }
308
395
  if (isData(data.surface)) {
309
396
  parsed.surface = parseSurface(data.surface);
310
397
  }
@@ -428,6 +515,12 @@ export function parseAppspec(document) {
428
515
  const verified = isData(tests.verified) ? tests.verified : {};
429
516
  const record = isData(data.record) ? data.record : {};
430
517
  const checks = isData(data.checks) ? data.checks : {};
518
+ // A check of its code runs on an answer or a tool call (LOOP P-06).
519
+ for (const check of records(checks.code)) {
520
+ if (!CHECK_STAGES.includes(check.on)) {
521
+ problems.push(`The check \`${text(check.name)}\` of its code runs on answer or tool_call, not \`${String(check.on)}\`.`);
522
+ }
523
+ }
431
524
  const keepFor = text(record.keep_for, DEFAULT_KEEP_FOR);
432
525
  if (retentionDays(keepFor) === 0) {
433
526
  problems.push(`Cannot read the retention \`${keepFor}\`: write 90_days, 18_months or 1_years.`);
@@ -447,6 +540,7 @@ export function parseAppspec(document) {
447
540
  model: text(data.model),
448
541
  skills: texts(data.skills),
449
542
  backendTools: texts(data.backend_tools),
543
+ tools: records(data.tools).map(parseTool),
450
544
  context: texts(data.context),
451
545
  contents: texts(data.contents),
452
546
  connections: records(data.connections).map(parseConnection),
@@ -469,6 +563,8 @@ export function parseAppspec(document) {
469
563
  cases: records(tests.cases).map(testCase => ({
470
564
  ask: text(testCase.ask),
471
565
  expect: text(testCase.expect),
566
+ // Decided by its code (LOOP P-06), when it names the function.
567
+ ...(text(testCase.code) ? { code: text(testCase.code) } : {}),
472
568
  })),
473
569
  verified: {
474
570
  live: texts(verified.live),
@@ -488,6 +584,11 @@ export function parseAppspec(document) {
488
584
  guards: texts(checks.guards),
489
585
  gates: texts(checks.gates),
490
586
  track: text(checks.track),
587
+ code: records(checks.code).map(check => ({
588
+ name: text(check.name),
589
+ on: oneOf(check.on, CHECK_STAGES, 'answer'),
590
+ description: text(check.description),
591
+ })),
491
592
  },
492
593
  deployment: parseDeployment(isData(data.deployment) ? data.deployment : {}),
493
594
  goal: text(data.goal),
@@ -495,6 +596,7 @@ export function parseAppspec(document) {
495
596
  memory: text(data.memory),
496
597
  notifications: texts(data.notifications),
497
598
  enabled: flag(data.enabled, true),
599
+ unavailable_because: text(data.unavailable_because),
498
600
  tags: texts(data.tags),
499
601
  icon: text(data.icon, DEFAULT_ICON),
500
602
  emoji: text(data.emoji, DEFAULT_EMOJI) || DEFAULT_EMOJI,
@@ -568,16 +670,44 @@ function dumpSurface(surface) {
568
670
  .text('composed_by', surface.composedBy)
569
671
  .text('composed_at', surface.composedAt).data;
570
672
  }
673
+ function dumpStarter(starter) {
674
+ return new Writer()
675
+ .text('label', starter.label, '\u0000')
676
+ .text('message', starter.message, '\u0000')
677
+ .text('category', starter.category ?? '').data;
678
+ }
679
+ /** Words by key, what is empty left out. */
680
+ const saidWords = (words) => Object.fromEntries(Object.entries(words).filter(([, said]) => said !== ''));
681
+ /** One translation as it is written (P-26): only what it says. */
682
+ function dumpTranslation(words) {
683
+ const parts = (value, dump) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, dump(item)]));
684
+ return new Writer()
685
+ .text('name', words.name)
686
+ .text('description', words.description)
687
+ .text('welcome', words.welcome)
688
+ .part('starters', parts(words.starters, starter => new Writer()
689
+ .text('label', starter.label)
690
+ .text('message', starter.message).data))
691
+ .part('categories', saidWords(words.categories))
692
+ .part('settings', parts(words.settings, field => new Writer()
693
+ .text('title', field.title)
694
+ .text('description', field.description)
695
+ .part('options', saidWords(field.options)).data))
696
+ .part('commands', saidWords(words.commands))
697
+ .part('modes', parts(words.modes, mode => new Writer().text('label', mode.label).part('options', parts(mode.options, option => new Writer()
698
+ .text('label', option.label)
699
+ .text('description', option.description).data)).data))
700
+ .part('profiles', parts(words.profiles, profile => new Writer()
701
+ .text('label', profile.label)
702
+ .text('description', profile.description).data)).data;
703
+ }
571
704
  function dumpInterface(spec, kind) {
572
705
  const writer = new Writer()
573
706
  // A layout is written when it is not the one its kind starts with.
574
707
  .value('layout', spec.layout, DEFAULT_LAYOUTS[kind])
575
708
  .value('accent', spec.accent, 'green')
576
709
  .text('welcome', spec.welcome)
577
- .list('starters', spec.starters.map(starter => ({
578
- label: starter.label,
579
- message: starter.message,
580
- })))
710
+ .list('starters', spec.starters.map(dumpStarter))
581
711
  .list('commands', spec.commands.map(command => ({
582
712
  name: command.name,
583
713
  description: command.description,
@@ -598,20 +728,30 @@ function dumpInterface(spec, kind) {
598
728
  }
599
729
  return written.data;
600
730
  }))
601
- .list('settings', spec.settings.map(setting => {
602
- const written = new Writer()
603
- .text('id', setting.id, '\u0000')
604
- .text('type', setting.type, '\u0000')
605
- .text('label', setting.label, '\u0000')
606
- .list('options', setting.options);
607
- for (const key of ['default', 'min', 'max']) {
608
- if (setting[key] !== undefined) {
609
- written.data[key] = setting[key];
610
- }
611
- }
612
- return written.data;
613
- }))
614
- .list('components', spec.components);
731
+ .list('profiles', (spec.profiles ?? []).map(profile => new Writer()
732
+ .text('id', profile.id, '\u0000')
733
+ .text('label', profile.label, '\u0000')
734
+ .text('description', profile.description)
735
+ .text('instructions', profile.instructions)
736
+ .text('model', profile.model ?? '')
737
+ .list('starters', profile.starters.map(dumpStarter)).data))
738
+ .part('settings', spec.settings ? { ...spec.settings } : {});
739
+ // How its settings' fields are drawn (P-20): written when said.
740
+ if (spec.settingsUi) {
741
+ writer.data.settings_ui = { ...spec.settingsUi };
742
+ }
743
+ writer
744
+ .text('language', spec.language ?? DEFAULT_LANGUAGE, DEFAULT_LANGUAGE)
745
+ .part('translations', mapValues(spec.translations ?? {}, dumpTranslation));
746
+ // What a person may send without being asked (P-21): written when said.
747
+ if (spec.uploads) {
748
+ writer.data.uploads = new Writer()
749
+ .list('kinds', spec.uploads.kinds.map(kind => new Writer()
750
+ .text('type', kind.type, '\u0000')
751
+ .value('max_mb', kind.maxMb, DEFAULT_UPLOAD_MB).data))
752
+ .value('max_files', spec.uploads.maxFiles, DEFAULT_MAX_FILES).data;
753
+ }
754
+ writer.list('components', spec.components);
615
755
  if (spec.surface) {
616
756
  writer.data.surface = dumpSurface(spec.surface);
617
757
  }
@@ -687,6 +827,16 @@ export function dumpAppspec(app) {
687
827
  .text('model', app.model)
688
828
  .list('skills', app.skills)
689
829
  .list('backend_tools', app.backendTools)
830
+ .list('tools', (app.tools ?? []).map(tool => ({
831
+ name: tool.name,
832
+ description: tool.description,
833
+ // No arguments is what it is unless said.
834
+ ...(JSON.stringify(tool.parameters) ===
835
+ JSON.stringify(NO_HOST_PARAMETERS)
836
+ ? {}
837
+ : { parameters: tool.parameters }),
838
+ does: tool.does,
839
+ })))
690
840
  .list('context', app.context)
691
841
  .list('contents', app.contents)
692
842
  .list('connections', app.connections.map(connection => new Writer()
@@ -715,6 +865,7 @@ export function dumpAppspec(app) {
715
865
  .list('cases', app.tests.cases.map(testCase => ({
716
866
  ask: testCase.ask,
717
867
  expect: testCase.expect,
868
+ ...(testCase.code ? { code: testCase.code } : {}),
718
869
  })))
719
870
  .part('verified', new Writer()
720
871
  .list('live', app.tests.verified.live)
@@ -727,7 +878,12 @@ export function dumpAppspec(app) {
727
878
  .part('checks', new Writer()
728
879
  .list('guards', app.checks.guards)
729
880
  .list('gates', app.checks.gates)
730
- .text('track', app.checks.track).data)
881
+ .text('track', app.checks.track)
882
+ .list('code', (app.checks.code ?? []).map(check => ({
883
+ name: check.name,
884
+ on: check.on,
885
+ description: check.description,
886
+ }))).data)
731
887
  .part('deployment', new Writer()
732
888
  .present('hosted', app.deployment.hosted &&
733
889
  new Writer()
@@ -765,6 +921,7 @@ export function dumpAppspec(app) {
765
921
  }
766
922
  writer
767
923
  .value('enabled', app.enabled, true)
924
+ .text('unavailable_because', app.unavailable_because)
768
925
  .list('tags', app.tags)
769
926
  .text('icon', app.icon ?? DEFAULT_ICON, DEFAULT_ICON)
770
927
  .text('emoji', app.emoji, DEFAULT_EMOJI)
@@ -14,6 +14,12 @@ export interface AppCheck {
14
14
  * UI plugins, by the name a surface gives it.
15
15
  */
16
16
  export declare const componentNamed: (name: string) => ComponentSpec | undefined;
17
+ /**
18
+ * What its code declares that agentspecs refuses (LOOP P-06): its tools,
19
+ * its checks and the tests it decides, each by a Python name, each name
20
+ * once; a tool says what it does, by class, each class once.
21
+ */
22
+ export declare function ownCodeProblems(app: AppSpec): string[];
17
23
  /** How an organization's own context is named: never a catalogue id (agentspecs' `ORGANIZATION_FRAME_PREFIX`). */
18
24
  /**
19
25
  * What stops a Form block from asking (LOOP C-16), in agentspecs'
@@ -43,6 +43,9 @@ import { APP_THEME_MODES, APP_THEME_VARIANTS, isAssistantCharacterId, parseAppsp
43
43
  import { classesOf, splitRef, toolBehaviours } from './rules';
44
44
  import { COMMAND_INPUT, COMMAND_NAME, MODE_ID } from './composer';
45
45
  import { HOST_NAME, hostToolsOf } from './hostTools';
46
+ import { MAX_UPLOAD_MB, UPLOAD_KIND } from './uploads';
47
+ import { formUiProblems } from './settingsInputs';
48
+ import { translationProblems } from './language';
46
49
  export const NOT_READY = 'Not ready';
47
50
  export const NEEDS_ATTENTION = 'Needs attention';
48
51
  export const PASSES = 'Passes the instant checks';
@@ -80,6 +83,54 @@ const matches = (name, pattern) => new RegExp('^' +
80
83
  ? '[\\s\\S]'
81
84
  : c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('') +
82
85
  '$').test(name);
86
+ /** The name of a function of an application's code (agentspecs' `CODE_NAME`). */
87
+ const CODE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
88
+ /**
89
+ * What its code declares that agentspecs refuses (LOOP P-06): its tools,
90
+ * its checks and the tests it decides, each by a Python name, each name
91
+ * once; a tool says what it does, by class, each class once.
92
+ */
93
+ export function ownCodeProblems(app) {
94
+ const problems = [];
95
+ const tools = app.tools ?? [];
96
+ for (const tool of tools) {
97
+ if (!CODE_NAME.test(tool.name)) {
98
+ problems.push(`Cannot use “${tool.name}” as a tool's name: letters, digits and \`_\`, as in Python.`);
99
+ }
100
+ if (!tool.description.trim()) {
101
+ problems.push(`The tool “${tool.name}” says what it does, for the agent.`);
102
+ }
103
+ if (tool.does.length === 0) {
104
+ problems.push(`The tool “${tool.name}” says what it does: read, write, send, buy, delete or publish.`);
105
+ }
106
+ for (const action of tool.does) {
107
+ if (!CLASS_NAMES.has(action)) {
108
+ problems.push(`The tool “${tool.name}” does “${action}”, which is no class of action.`);
109
+ }
110
+ }
111
+ if (new Set(tool.does).size !== tool.does.length) {
112
+ problems.push('A tool says each thing it does once.');
113
+ }
114
+ }
115
+ const names = tools.map(tool => tool.name);
116
+ if (new Set(names).size !== names.length) {
117
+ problems.push('Two of its tools have the same name.');
118
+ }
119
+ const checks = (app.checks.code ?? []).map(check => check.name);
120
+ for (const name of checks) {
121
+ if (!CODE_NAME.test(name)) {
122
+ problems.push(`Cannot use “${name}” as a check's name: letters, digits and \`_\`, as in Python.`);
123
+ }
124
+ }
125
+ if (new Set(checks).size !== checks.length) {
126
+ problems.push('Two checks of its code have the same name.');
127
+ }
128
+ const decided = app.tests.cases.flatMap(testCase => testCase.code ? [testCase.code] : []);
129
+ if (new Set(decided).size !== decided.length) {
130
+ problems.push('Two tests are decided by the same function of its code.');
131
+ }
132
+ return problems;
133
+ }
83
134
  /** What the spec says of itself that the tolerant reader lets through. */
84
135
  function shapeProblems(app) {
85
136
  const problems = [];
@@ -95,6 +146,14 @@ function shapeProblems(app) {
95
146
  if (Boolean(app.agent) === Boolean(app.team)) {
96
147
  problems.push('An application names who does the work: an `agent`, or a `team`, and not both.');
97
148
  }
149
+ // agentspecs refuses either way round (LOOP E-01): off says why, on says nothing.
150
+ const because = (app.unavailable_because ?? '').trim();
151
+ if (!app.enabled && !because) {
152
+ problems.push('An application that is not offered says why, under `unavailable_because`.');
153
+ }
154
+ if (app.enabled && because) {
155
+ problems.push('An application offered today is available: remove `unavailable_because`, or set `enabled: false`.');
156
+ }
98
157
  if (app.kind === 'decision' && !app.decision) {
99
158
  problems.push('A decision application says what it decides, under `decision`.');
100
159
  }
@@ -112,6 +171,36 @@ function shapeProblems(app) {
112
171
  else if (app.triggers.length > 0) {
113
172
  problems.push(`A ${app.kind} application starts when somebody opens it: \`triggers\` are a worker's.`);
114
173
  }
174
+ // Its profiles (LOOP P-20): two at least, or none; each id slash-safe and
175
+ // said once, each with a label.
176
+ const profiles = app.interface.profiles ?? [];
177
+ if (profiles.length === 1) {
178
+ problems.push('One profile is the application itself: say two at least, or put its instructions, model and starters on the application.');
179
+ }
180
+ const profileIds = new Set();
181
+ for (const profile of profiles) {
182
+ if (!MODE_ID.test(profile.id)) {
183
+ problems.push(`Cannot use “${profile.id}” as a profile's id: lower-case letters, digits, \`_\` and \`-\`, a letter first.`);
184
+ }
185
+ if (!profile.label.trim()) {
186
+ problems.push(`The profile “${profile.id}” has no label.`);
187
+ }
188
+ if (profileIds.has(profile.id)) {
189
+ problems.push(`Two profiles are “${profile.id}”.`);
190
+ }
191
+ profileIds.add(profile.id);
192
+ }
193
+ // How its settings' fields are drawn (P-20): by a widget that draws each.
194
+ if (app.interface.settingsUi) {
195
+ if (!app.interface.settings) {
196
+ problems.push('`settings_ui` draws the settings’ fields: say `settings` first.');
197
+ }
198
+ else {
199
+ problems.push(...formUiProblems('The form “settings”', app.interface.settings, app.interface.settingsUi));
200
+ }
201
+ }
202
+ // Its words in other languages (P-26): of languages, of what it says.
203
+ problems.push(...translationProblems(app.interface));
115
204
  // What the host page passes and offers (D-10): named once each, in words
116
205
  // its agent's tools can carry, each function saying what it does.
117
206
  const host = app.deployment.embedded?.host;
@@ -146,6 +235,8 @@ function shapeProblems(app) {
146
235
  }
147
236
  }
148
237
  }
238
+ // What its code declares (LOOP P-06), as agentspecs refuses it.
239
+ problems.push(...ownCodeProblems(app));
149
240
  const servers = app.connections.map(connection => idOf(connection.server));
150
241
  if (new Set(servers).size !== servers.length) {
151
242
  problems.push('The application connects to the same server twice.');
@@ -204,6 +295,10 @@ export function formProblems(node) {
204
295
  if (missing.length > 0) {
205
296
  problems.push(`${said} requires ${missing.map(name => `“${String(name)}”`).join(', ')}, which it does not ask.`);
206
297
  }
298
+ // How its fields are drawn (P-20): by a widget that draws each.
299
+ if (node.ui !== undefined && node.ui !== null) {
300
+ problems.push(...formUiProblems(said, schema, node.ui));
301
+ }
207
302
  return problems;
208
303
  }
209
304
  const isPlainRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -270,6 +365,12 @@ function referenceProblems(app, organizationFrames) {
270
365
  if (app.model && !getModel(app.model)) {
271
366
  missing('model', app.model);
272
367
  }
368
+ // The model a profile runs on is the catalogue's (LOOP P-20).
369
+ for (const profile of app.interface.profiles ?? []) {
370
+ if (profile.model && !getModel(profile.model)) {
371
+ problems.push(`The profile “${profile.id}” runs on “${profile.model}”, which is no model.`);
372
+ }
373
+ }
273
374
  // The model a mode runs on is the catalogue's (LOOP P-19).
274
375
  for (const mode of app.interface.modes ?? []) {
275
376
  for (const option of mode.options) {
@@ -328,6 +429,9 @@ function referenceProblems(app, organizationFrames) {
328
429
  if (server === undefined) {
329
430
  if (hostTools.includes(target))
330
431
  continue;
432
+ // A tool of its own, written in its code (LOOP P-06).
433
+ if ((app.tools ?? []).some(tool => tool.name === target))
434
+ continue;
331
435
  if (!getBackendToolSpec(name)) {
332
436
  problems.push(`The rule “${rule.action}” names the tool “${target}”, which the catalogue does not have.`);
333
437
  }
@@ -472,7 +576,6 @@ const ENUMS = {
472
576
  behaviour: ['do_it', 'if_asked', 'ask_first', 'leave_to_me'],
473
577
  layout: ['chat', 'page', 'split'],
474
578
  accent: ['green', 'rose', 'sky', 'lime', 'sun', 'violet'],
475
- settingType: ['select', 'text', 'toggle', 'slider', 'number'],
476
579
  record: [
477
580
  'conversations',
478
581
  'actions',
@@ -639,9 +742,44 @@ export function documentShapeProblems(document) {
639
742
  }
640
743
  text(ui.welcome, 'interface.welcome');
641
744
  texts(ui.components, 'interface.components');
642
- records(ui.starters, 'interface.starters', (item, where) => {
745
+ const starter = (item, where) => {
643
746
  required(item, 'label', where);
644
747
  required(item, 'message', where);
748
+ text(item.category, `${where}.category`);
749
+ unknownKeys(item, ['label', 'message', 'category'], where, 'a starter');
750
+ };
751
+ records(ui.starters, 'interface.starters', starter);
752
+ // Its profiles (LOOP P-20), as agentspecs refuses them: no key unknown.
753
+ records(ui.profiles, 'interface.profiles', (profile, where) => {
754
+ required(profile, 'id', where);
755
+ required(profile, 'label', where);
756
+ for (const key of [
757
+ 'id',
758
+ 'label',
759
+ 'description',
760
+ 'instructions',
761
+ 'model',
762
+ ]) {
763
+ text(profile[key], `${where}.${key}`);
764
+ }
765
+ unknownKeys(profile, ['id', 'label', 'description', 'instructions', 'model', 'starters'], where, 'a profile');
766
+ records(profile.starters, `${where}.starters`, starter);
767
+ });
768
+ text(ui.language, 'interface.language');
769
+ mapping(ui.translations, 'interface.translations', translations => {
770
+ for (const [tag, words] of Object.entries(translations)) {
771
+ mapping(words, `interface.translations.${tag}`, said => unknownKeys(said, [
772
+ 'name',
773
+ 'description',
774
+ 'welcome',
775
+ 'starters',
776
+ 'categories',
777
+ 'settings',
778
+ 'commands',
779
+ 'modes',
780
+ 'profiles',
781
+ ], `interface.translations.${tag}`, 'a translation'));
782
+ }
645
783
  });
646
784
  // Its commands and modes in the composer (LOOP P-19), as agentspecs
647
785
  // refuses them: names and ids slash-safe and said once, no key unknown.
@@ -721,12 +859,53 @@ export function documentShapeProblems(document) {
721
859
  if (choosingModel.length > 1) {
722
860
  at('interface.modes', `let only one mode choose the model, not ${choosingModel.join(', ')}`);
723
861
  }
724
- records(ui.settings, 'interface.settings', (item, where) => {
725
- required(item, 'id', where);
726
- required(item, 'type', where);
727
- required(item, 'label', where);
728
- oneOf(item.type, ENUMS.settingType, `${where}.type`);
729
- texts(item.options, `${where}.options`);
862
+ // Its settings are the JSON Schema of a form (C-16), checked as a Form's
863
+ // schema is; the list of settings of earlier versions is refused.
864
+ if (Array.isArray(ui.settings)) {
865
+ at('interface.settings', 'is the JSON Schema of a form, an object of named fields (LOOP C-16), not a list of settings: say each one as a property, its title and its default');
866
+ }
867
+ else {
868
+ mapping(ui.settings, 'interface.settings', settings => {
869
+ problems.push(...formProblems({ id: 'settings', schema: settings }));
870
+ });
871
+ }
872
+ // What a person may send without being asked (LOOP P-21), as agentspecs
873
+ // refuses it: a kind at least, each a type said once, sizes it takes.
874
+ mapping(ui.uploads, 'interface.uploads', uploads => {
875
+ unknownKeys(uploads, ['kinds', 'max_files'], 'interface.uploads', 'uploads');
876
+ required(uploads, 'kinds', 'interface.uploads');
877
+ const types = new Set();
878
+ records(uploads.kinds, 'interface.uploads.kinds', (kind, where) => {
879
+ required(kind, 'type', where);
880
+ unknownKeys(kind, ['type', 'max_mb'], where, 'a kind of upload');
881
+ if (kind.type !== undefined) {
882
+ if (typeof kind.type !== 'string' || !UPLOAD_KIND.test(kind.type)) {
883
+ at(`${where}.type`, 'is a media type (application/pdf), a family (image/*) or an extension (.csv), lowercase');
884
+ }
885
+ else if (types.has(kind.type)) {
886
+ at(`${where}.type`, `names ${kind.type} a second time`);
887
+ }
888
+ else {
889
+ types.add(kind.type);
890
+ }
891
+ }
892
+ const size = kind.max_mb;
893
+ if (size !== undefined &&
894
+ (typeof size !== 'number' || size <= 0 || size > MAX_UPLOAD_MB)) {
895
+ at(`${where}.max_mb`, `is more than 0 and ${MAX_UPLOAD_MB} at most`);
896
+ }
897
+ });
898
+ if (Array.isArray(uploads.kinds) && uploads.kinds.length === 0) {
899
+ at('interface.uploads.kinds', 'are one at least');
900
+ }
901
+ const most = uploads.max_files;
902
+ if (most !== undefined &&
903
+ (typeof most !== 'number' ||
904
+ !Number.isInteger(most) ||
905
+ most < 1 ||
906
+ most > 20)) {
907
+ at('interface.uploads.max_files', 'is a whole number from 1 to 20');
908
+ }
730
909
  });
731
910
  mapping(ui.surface, 'interface.surface', surface => {
732
911
  records(surface.components, 'interface.surface.components', (item, where) => {