primitive-admin 1.1.0-alpha.85 → 1.1.0-alpha.86

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 (73) hide show
  1. package/README.md +34 -0
  2. package/assets/skill/skills/primitive-platform/SKILL.md +8 -0
  3. package/dist/src/commands/analytics.js +13 -1
  4. package/dist/src/commands/analytics.js.map +1 -1
  5. package/dist/src/commands/collections.js +648 -1
  6. package/dist/src/commands/collections.js.map +1 -1
  7. package/dist/src/commands/database-type-configs.js +1 -1
  8. package/dist/src/commands/database-type-configs.js.map +1 -1
  9. package/dist/src/commands/databases.js +11 -2
  10. package/dist/src/commands/databases.js.map +1 -1
  11. package/dist/src/commands/documents.d.ts +20 -0
  12. package/dist/src/commands/documents.js +213 -17
  13. package/dist/src/commands/documents.js.map +1 -1
  14. package/dist/src/commands/functions.js +56 -5
  15. package/dist/src/commands/functions.js.map +1 -1
  16. package/dist/src/commands/integrations.js +4 -0
  17. package/dist/src/commands/integrations.js.map +1 -1
  18. package/dist/src/commands/sync.d.ts +73 -41
  19. package/dist/src/commands/sync.js +527 -80
  20. package/dist/src/commands/sync.js.map +1 -1
  21. package/dist/src/commands/users.js +3 -1
  22. package/dist/src/commands/users.js.map +1 -1
  23. package/dist/src/lib/api-client.d.ts +93 -7
  24. package/dist/src/lib/api-client.js +72 -19
  25. package/dist/src/lib/api-client.js.map +1 -1
  26. package/dist/src/lib/collection-export.d.ts +184 -0
  27. package/dist/src/lib/collection-export.js +252 -0
  28. package/dist/src/lib/collection-export.js.map +1 -0
  29. package/dist/src/lib/config-object-descriptor.js +17 -3
  30. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  31. package/dist/src/lib/config-payload.d.ts +8 -1
  32. package/dist/src/lib/config-payload.js +29 -9
  33. package/dist/src/lib/config-payload.js.map +1 -1
  34. package/dist/src/lib/config-surface.js +11 -7
  35. package/dist/src/lib/config-surface.js.map +1 -1
  36. package/dist/src/lib/deprecation.d.ts +22 -0
  37. package/dist/src/lib/deprecation.js +43 -0
  38. package/dist/src/lib/deprecation.js.map +1 -0
  39. package/dist/src/lib/function-db-types.d.ts +14 -3
  40. package/dist/src/lib/function-db-types.js +55 -11
  41. package/dist/src/lib/function-db-types.js.map +1 -1
  42. package/dist/src/lib/function-trigger-listing.d.ts +27 -0
  43. package/dist/src/lib/function-trigger-listing.js +70 -0
  44. package/dist/src/lib/function-trigger-listing.js.map +1 -0
  45. package/dist/src/lib/generated-config-surfaces.d.ts +540 -10
  46. package/dist/src/lib/generated-config-surfaces.js +1914 -155
  47. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  48. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  49. package/dist/src/lib/generated-sdk-types.js +1 -1
  50. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  51. package/dist/src/lib/log-inspection.d.ts +18 -1
  52. package/dist/src/lib/log-inspection.js +5 -0
  53. package/dist/src/lib/log-inspection.js.map +1 -1
  54. package/dist/src/lib/prompt-cost-format.d.ts +11 -0
  55. package/dist/src/lib/prompt-cost-format.js +41 -0
  56. package/dist/src/lib/prompt-cost-format.js.map +1 -0
  57. package/dist/src/lib/prompt-schema-codegen.d.ts +53 -0
  58. package/dist/src/lib/prompt-schema-codegen.js +253 -3
  59. package/dist/src/lib/prompt-schema-codegen.js.map +1 -1
  60. package/dist/src/lib/swift-codegen/agentGenerator.d.ts +42 -0
  61. package/dist/src/lib/swift-codegen/agentGenerator.js +118 -0
  62. package/dist/src/lib/swift-codegen/agentGenerator.js.map +1 -0
  63. package/dist/src/lib/swift-codegen/banners.d.ts +6 -0
  64. package/dist/src/lib/swift-codegen/banners.js +6 -0
  65. package/dist/src/lib/swift-codegen/banners.js.map +1 -1
  66. package/dist/src/lib/swift-codegen/dbGenerator.js +11 -3
  67. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
  68. package/dist/src/lib/swift-codegen/functionGenerator.d.ts +7 -0
  69. package/dist/src/lib/swift-codegen/functionGenerator.js +41 -3
  70. package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
  71. package/dist/src/lib/swift-codegen/generator.js +8 -2
  72. package/dist/src/lib/swift-codegen/generator.js.map +1 -1
  73. package/package.json +2 -2
@@ -372,13 +372,26 @@ export function handlerReferences(table) {
372
372
  // functions are the whole mechanism: everything else — the pull projection, the
373
373
  // push payload builder, the CLI's unknown-key and declared-type checks — reads
374
374
  // the declaration through them.
375
+ /**
376
+ * The blocks a field is authored under, in declaration order: `[]` for a root
377
+ * key, one name for most grouped keys, several for a key two blocks share
378
+ * (#3798). Every reader of `tomlGroup` goes through this.
379
+ */
380
+ export function tomlGroupsOf(field) {
381
+ const group = field.tomlGroup;
382
+ if (group === undefined)
383
+ return [];
384
+ return typeof group === "string" ? [group] : group;
385
+ }
375
386
  /** The group names this table declares, sorted. Empty for an ungrouped table. */
376
387
  export function tomlGroupNames(table) {
377
388
  return Object.keys(table.tomlGroups ?? {}).sort();
378
389
  }
379
390
  /** The TOML keys accepted INSIDE `group`: every field that declares it. */
380
391
  export function acceptedGroupTomlKeys(table, group) {
381
- return new Set(table.fields.filter((f) => f.tomlGroup === group).map((f) => f.tomlKey));
392
+ return new Set(table.fields
393
+ .filter((f) => tomlGroupsOf(f).includes(group))
394
+ .map((f) => f.tomlKey));
382
395
  }
383
396
  /**
384
397
  * TOML keys this table accepts AT ITS ROOT: the UNGROUPED fields, the declared
@@ -437,13 +450,13 @@ export function findUnknownGroupedTomlKeys(tomlTable, table) {
437
450
  */
438
451
  export function findBadTomlGroupDeclarations(table) {
439
452
  const declared = new Set(tomlGroupNames(table));
440
- const used = new Set(table.fields
441
- .map((f) => f.tomlGroup)
442
- .filter((g) => typeof g === "string"));
453
+ const used = new Set(table.fields.flatMap((f) => [...tomlGroupsOf(f)]));
443
454
  const bad = [];
444
455
  for (const field of table.fields) {
445
- if (field.tomlGroup && !declared.has(field.tomlGroup)) {
446
- bad.push(`${field.field}: tomlGroup "${field.tomlGroup}" is not declared in tomlGroups`);
456
+ for (const group of tomlGroupsOf(field)) {
457
+ if (!declared.has(group)) {
458
+ bad.push(`${field.field}: tomlGroup "${group}" is not declared in tomlGroups`);
459
+ }
447
460
  }
448
461
  }
449
462
  for (const [group, entry] of Object.entries(table.tomlGroups ?? {})) {
@@ -2146,8 +2159,9 @@ export function requestSchemaKeys(table, mode) {
2146
2159
  */
2147
2160
  export function configRequestSchema(table, mode) {
2148
2161
  const properties = {};
2162
+ const deprecatedFields = new Set(table.fields.filter((f) => f.deprecated).map((f) => f.field));
2149
2163
  for (const key of requestSchemaKeys(table, mode)) {
2150
- properties[key] = {};
2164
+ properties[key] = deprecatedFields.has(key) ? { deprecated: true } : {};
2151
2165
  }
2152
2166
  const schema = {
2153
2167
  type: "object",
@@ -2210,8 +2224,9 @@ export function retiredRequestKeys(schema) {
2210
2224
  export function mergeRequestSchemas(...schemas) {
2211
2225
  const properties = {};
2212
2226
  for (const schema of schemas) {
2213
- for (const key of Object.keys(schema.properties))
2214
- properties[key] = {};
2227
+ for (const [key, property] of Object.entries(schema.properties)) {
2228
+ properties[key] = { ...(properties[key] ?? {}), ...property };
2229
+ }
2215
2230
  }
2216
2231
  // The union's guidance is the union of its halves'. A key one table retired
2217
2232
  // and another accepts is admitted by the merged key set, so `rememberRetiredKeys`
@@ -4859,7 +4874,7 @@ function geminiResolution(model, effort, budget) {
4859
4874
  /**
4860
4875
  * What a prompt's KIND admits — issue #3626.
4861
4876
  *
4862
- * A prompt declares `[prompt] kind = "chat" | "decisions"`. The kind decides
4877
+ * A prompt declares `[prompt] kind = "chat" | "decisions" | "agent"`. The kind decides
4863
4878
  * the input envelope (`variables` rendered into templates for chat, a `state`
4864
4879
  * value for decisions), the provider endpoint the executor posts to, and which
4865
4880
  * `[configs.<kind>]` block a `[[configs]]` entry may carry. Adding a kind is a
@@ -4876,13 +4891,22 @@ function geminiResolution(model, effort, budget) {
4876
4891
  *
4877
4892
  * Pure and dependency-free, like every module under `src/config-surface/`.
4878
4893
  */
4879
- /** The kinds a prompt may declare. An omitted `kind` is `chat`. */
4880
- export const PROMPT_KINDS = ["chat", "decisions"];
4881
4894
  /**
4882
- * The nine settings that describe a CHAT COMPLETION and nothing else. They are
4883
- * authored under `[configs.chat]`; the flat spelling is deprecated (#3642).
4895
+ * The kinds a prompt may declare. An omitted `kind` is `chat`.
4896
+ *
4897
+ * #3798 — `agent` is a prompt run turn by turn in a session: no template, no
4898
+ * output schema, never run single-shot. Its tools and events are declared in
4899
+ * `[prompt.agent]` (`agent-declaration.ts`); its configs carry
4900
+ * `[configs.agent]`.
4884
4901
  */
4885
- export const CHAT_CONFIG_KEYS = [
4902
+ export const PROMPT_KINDS = ["chat", "decisions", "agent"];
4903
+ /**
4904
+ * The nine chat settings that existed before `[configs.chat]` did. Only these
4905
+ * keep the deprecated FLAT spelling (#3642): a key added to the block later was
4906
+ * never written flat by any file, so it has none (the `questions` posture,
4907
+ * SO3626-013).
4908
+ */
4909
+ export const LEGACY_FLAT_CHAT_KEYS = [
4886
4910
  "systemPrompt",
4887
4911
  "userPromptTemplate",
4888
4912
  "temperature",
@@ -4893,8 +4917,35 @@ export const CHAT_CONFIG_KEYS = [
4893
4917
  "reasoningEffort",
4894
4918
  "reasoningBudget",
4895
4919
  ];
4920
+ /**
4921
+ * The settings that describe a CHAT COMPLETION and nothing else. They are
4922
+ * authored under `[configs.chat]`; the flat spelling of the nine legacy keys
4923
+ * is deprecated (#3642).
4924
+ *
4925
+ * #3801 — `strictOutput`, the opt-in that sends `[prompt].outputSchema` as a
4926
+ * strict `json_schema` on OpenRouter (`prompt-strict-output.ts`). Not an agent
4927
+ * key (§D1: an agent has no output schema), so the agent arm refuses it.
4928
+ */
4929
+ export const CHAT_CONFIG_KEYS = [
4930
+ ...LEGACY_FLAT_CHAT_KEYS,
4931
+ "strictOutput",
4932
+ ];
4896
4933
  /** The settings that describe a DECISIONS request. */
4897
4934
  export const DECISIONS_CONFIG_KEYS = ["questions"];
4935
+ /**
4936
+ * #3798 — the settings of an AGENT's model round: the chat block less
4937
+ * `userPromptTemplate` (the model sees each message as sent), `outputFormat`
4938
+ * and `outputSchema` (an agent's final answer is text; anything structured is
4939
+ * a tool call). The same wire keys as chat, authored under `[configs.agent]`.
4940
+ */
4941
+ export const AGENT_CONFIG_KEYS = [
4942
+ "systemPrompt",
4943
+ "temperature",
4944
+ "topP",
4945
+ "maxTokens",
4946
+ "reasoningEffort",
4947
+ "reasoningBudget",
4948
+ ];
4898
4949
  /**
4899
4950
  * The WIRE field names each kind's block holds. The config surface declares the
4900
4951
  * same grouping through `ConfigField.tomlGroup`, and the drift guard asserts
@@ -4905,11 +4956,26 @@ export const DECISIONS_CONFIG_KEYS = ["questions"];
4905
4956
  export const PROMPT_KIND_BLOCKS = {
4906
4957
  chat: CHAT_CONFIG_KEYS,
4907
4958
  decisions: DECISIONS_CONFIG_KEYS,
4959
+ agent: AGENT_CONFIG_KEYS,
4908
4960
  };
4909
4961
  /** The question types TypeSafe's System One models answer. */
4910
4962
  export const DECISIONS_QUESTION_TYPES = ["choice", "score", "noul"];
4911
4963
  /** A question name the endpoint accepts, and that reads as a JSON key. */
4912
4964
  const QUESTION_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
4965
+ /**
4966
+ * #3813 — where a `choice` question's options come from. `"static"`: the
4967
+ * config's `criteria` table. `"dynamic"`: each run supplies the table as
4968
+ * `variables.criteria.<question>`. Required on every `choice` question, with
4969
+ * no default, and a platform key: it is removed before the provider request.
4970
+ */
4971
+ export const DECISIONS_CRITERIA_SOURCES = ["static", "dynamic"];
4972
+ /**
4973
+ * #3813 — the code a decisions run carries when the options it was asked to
4974
+ * choose between are refused before the provider call: malformed per-run
4975
+ * criteria, criteria for a question that does not take them, or a stored
4976
+ * `choice` question that does not say where its options come from.
4977
+ */
4978
+ export const PROMPT_CRITERIA_INVALID = "PROMPT_CRITERIA_INVALID";
4913
4979
  /**
4914
4980
  * The prompt's kind, normalized.
4915
4981
  *
@@ -4961,20 +5027,96 @@ function parseQuestions(value) {
4961
5027
  function chatKeysSet(config) {
4962
5028
  return CHAT_CONFIG_KEYS.filter((key) => !isUnset(config[key]));
4963
5029
  }
5030
+ /**
5031
+ * The block a stray key is authored under: `questions` is decisions', every
5032
+ * other key chat's (the agent block's keys are chat keys too, so a key an
5033
+ * agent refuses is one only the chat block holds).
5034
+ */
5035
+ function owningBlock(key) {
5036
+ return DECISIONS_CONFIG_KEYS.includes(key)
5037
+ ? "decisions"
5038
+ : "chat";
5039
+ }
4964
5040
  /**
4965
5041
  * One rule replaces the nine per-key refusals: a `[[configs]]` entry may carry
4966
5042
  * only the block named by its prompt's kind.
5043
+ *
5044
+ * #3798 — the offending keys are named with the block each belongs to. Keys
5045
+ * from ONE block read exactly as they did before a third kind existed.
4967
5046
  */
4968
5047
  function wrongBlockError(kind, offending) {
4969
- const wrongKind = kind === "chat" ? "decisions" : "chat";
4970
- return (`prompt kind is "${kind}", so a [[configs]] entry may carry only a ` +
4971
- `[configs.${kind}] block; this one sets ${offending.join(", ")} ` +
4972
- `([configs.${wrongKind}] in TOML). Move them to a ${wrongKind} prompt or ` +
4973
- "remove them.");
5048
+ const blocks = [...new Set(offending.map(owningBlock))];
5049
+ const head = `prompt kind is "${kind}", so a [[configs]] entry may carry only a ` +
5050
+ `[configs.${kind}] block; this one sets `;
5051
+ if (blocks.length === 1) {
5052
+ const wrongKind = blocks[0];
5053
+ return (head +
5054
+ `${offending.join(", ")} ([configs.${wrongKind}] in TOML). Move them ` +
5055
+ `to a ${wrongKind} prompt or remove them.`);
5056
+ }
5057
+ const named = blocks
5058
+ .map((block) => `${offending.filter((key) => owningBlock(key) === block).join(", ")} ` +
5059
+ `([configs.${block}] in TOML)`)
5060
+ .join("; ");
5061
+ return head + `${named}. Move them to a prompt of that kind or remove them.`;
4974
5062
  }
4975
5063
  function questionError(name, detail) {
4976
5064
  return `questions.${name} ${detail}`;
4977
5065
  }
5066
+ /**
5067
+ * #3813 — the one rule for a `choice` question's options table, whoever
5068
+ * supplies it: the config (`criteriaSource = "static"`) or the run
5069
+ * (`"dynamic"`). `null` when the table is usable, otherwise the detail the
5070
+ * caller prefixes with where the table came from.
5071
+ *
5072
+ * At least two options; each key non-empty with no leading or trailing
5073
+ * whitespace (the endpoint answers `choice` as the key, so a padded key is an
5074
+ * answer no caller would match); each description a non-empty string or a
5075
+ * non-empty JSON object, which is how a run describes a structured option such
5076
+ * as a previous transaction.
5077
+ */
5078
+ function criteriaTableError(table) {
5079
+ if (!isPlainObject(table)) {
5080
+ return "must be a table of option key = description";
5081
+ }
5082
+ const entries = Object.entries(table);
5083
+ if (entries.length < 2) {
5084
+ return ("needs at least two options; a choice between one option is not a " +
5085
+ "choice");
5086
+ }
5087
+ for (const [key, description] of entries) {
5088
+ if (key.trim() === "" || key !== key.trim()) {
5089
+ return (`has the option key ${JSON.stringify(key)}, which is empty or starts ` +
5090
+ "or ends with whitespace");
5091
+ }
5092
+ const usable = isPlainObject(description)
5093
+ ? Object.keys(description).length > 0
5094
+ : isNonEmptyString(description);
5095
+ if (!usable) {
5096
+ return (`option '${key}' needs a non-empty description (a string or a JSON ` +
5097
+ "object)");
5098
+ }
5099
+ }
5100
+ return null;
5101
+ }
5102
+ /** `criteriaSource` as the question declares it, or `undefined` when unset. */
5103
+ function criteriaSourceOf(question) {
5104
+ return question.criteriaSource;
5105
+ }
5106
+ function isCriteriaSource(value) {
5107
+ return (typeof value === "string" &&
5108
+ DECISIONS_CRITERIA_SOURCES.includes(value));
5109
+ }
5110
+ /** The refusal for a `choice` question whose options' source is unusable. */
5111
+ function criteriaSourceError(name, source) {
5112
+ if (source === undefined) {
5113
+ return questionError(name, 'is a choice question, so it needs criteriaSource = "static" (its ' +
5114
+ 'options in a criteria table here) or "dynamic" (each run supplies ' +
5115
+ `them as variables.criteria.${name})`);
5116
+ }
5117
+ return questionError(name, `criteriaSource must be "static" or "dynamic"; found ` +
5118
+ JSON.stringify(source));
5119
+ }
4978
5120
  /** Every refusal a single decisions question can earn, or `null`. */
4979
5121
  function decisionsQuestionError(name, question) {
4980
5122
  if (!QUESTION_NAME.test(name)) {
@@ -4992,20 +5134,32 @@ function decisionsQuestionError(name, question) {
4992
5134
  if (!isNonEmptyString(question.instructions)) {
4993
5135
  return questionError(name, "needs instructions — the sentence the model answers");
4994
5136
  }
4995
- if (type === "choice") {
4996
- const criteria = question.criteria;
4997
- if (!isPlainObject(criteria)) {
4998
- return questionError(name, "is a choice question, so it needs a criteria table of option key = " +
4999
- "description");
5137
+ const source = criteriaSourceOf(question);
5138
+ if (type !== "choice") {
5139
+ // #3813 — only a choice question has options to source.
5140
+ if (source !== undefined) {
5141
+ return questionError(name, `is a ${type} question; criteriaSource applies only to a choice ` +
5142
+ "question");
5000
5143
  }
5001
- const entries = Object.entries(criteria);
5002
- if (entries.length < 2) {
5003
- return questionError(name, "needs at least two criteria entries; a choice between one option is " +
5004
- "not a choice");
5144
+ }
5145
+ else {
5146
+ if (!isCriteriaSource(source))
5147
+ return criteriaSourceError(name, source);
5148
+ const criteria = question.criteria;
5149
+ if (source === "dynamic") {
5150
+ if (criteria !== undefined && criteria !== null) {
5151
+ return questionError(name, 'has criteriaSource = "dynamic", so its options come from each ' +
5152
+ `run as variables.criteria.${name}; remove its criteria table`);
5153
+ }
5005
5154
  }
5006
- const blank = entries.find(([, value]) => !isNonEmptyString(value));
5007
- if (blank) {
5008
- return questionError(name, `criteria.${blank[0]} needs a non-empty description`);
5155
+ else {
5156
+ if (!isPlainObject(criteria)) {
5157
+ return questionError(name, 'has criteriaSource = "static", so it needs a criteria table of ' +
5158
+ "option key = description");
5159
+ }
5160
+ const tableError = criteriaTableError(criteria);
5161
+ if (tableError)
5162
+ return questionError(name, `criteria ${tableError}`);
5009
5163
  }
5010
5164
  }
5011
5165
  // Every other key on a question — `score`'s array `criteria`, anything
@@ -5035,6 +5189,23 @@ export function resolvePromptKind(input) {
5035
5189
  kind = text;
5036
5190
  }
5037
5191
  const config = input.config || {};
5192
+ // ── agent (#3798) ──────────────────────────────────────────────────────
5193
+ // No template: the model sees each message as sent. Either provider; the
5194
+ // capability checks are the provider mapping's (#3799), not the kind's.
5195
+ if (kind === "agent") {
5196
+ const stray = [
5197
+ ...CHAT_CONFIG_KEYS.filter((key) => !AGENT_CONFIG_KEYS.includes(key)),
5198
+ ...DECISIONS_CONFIG_KEYS,
5199
+ ].filter((key) => !isUnset(config[key]));
5200
+ if (stray.length > 0) {
5201
+ return {
5202
+ ok: false,
5203
+ error: wrongBlockError("agent", stray),
5204
+ code: "AGENT_CONFIG_KEY_NOT_ALLOWED",
5205
+ };
5206
+ }
5207
+ return { ok: true, kind: "agent" };
5208
+ }
5038
5209
  if (kind === "chat") {
5039
5210
  if (!isUnset(config.questions)) {
5040
5211
  return { ok: false, error: wrongBlockError("chat", ["questions"]) };
@@ -5072,6 +5243,126 @@ export function resolvePromptKind(input) {
5072
5243
  }
5073
5244
  return { ok: true, kind: "decisions", questions };
5074
5245
  }
5246
+ /** A run-time refusal, naming the question the way the executor's errors do. */
5247
+ function runQuestionError(name, detail) {
5248
+ return `Decisions question '${name}' ${detail}`;
5249
+ }
5250
+ /**
5251
+ * #3813 — the questions one run sends to the provider.
5252
+ *
5253
+ * `questions` is the RAN config's declaration, as stored; `runCriteria` is
5254
+ * the run's `variables.criteria` (`undefined` when the run sent none). A
5255
+ * `"dynamic"` question is given the run's table as its `criteria`; every
5256
+ * question loses `criteriaSource`, which is the platform's key and not the
5257
+ * endpoint's. The config still owns each question's name, type and
5258
+ * instructions: a run can supply options only for a question declared
5259
+ * `"dynamic"`, so nothing a run sends can change what is being asked.
5260
+ *
5261
+ * Refuses, before any provider call: a `variables.criteria` that is not an
5262
+ * object; criteria for a name the config does not declare, for a `"static"`
5263
+ * question, or for a `score` or `noul` one; a `"dynamic"` question with no
5264
+ * table or a table the criteria rule refuses; and a stored `choice` question
5265
+ * with no usable `criteriaSource` — a config written before the key existed,
5266
+ * which is refused rather than given a silent default.
5267
+ *
5268
+ * Pure: the stored questions object is never mutated.
5269
+ */
5270
+ export function resolveDecisionsRunQuestions(questions, runCriteria) {
5271
+ if (runCriteria !== undefined && !isPlainObject(runCriteria)) {
5272
+ return {
5273
+ ok: false,
5274
+ error: "variables.criteria must be an object keyed by question name, each " +
5275
+ "value a table of option key = description; found " +
5276
+ (Array.isArray(runCriteria)
5277
+ ? "an array"
5278
+ : runCriteria === null
5279
+ ? "null"
5280
+ : `a ${typeof runCriteria}`),
5281
+ };
5282
+ }
5283
+ const supplied = isPlainObject(runCriteria)
5284
+ ? runCriteria
5285
+ : {};
5286
+ for (const name of Object.keys(supplied)) {
5287
+ // Own keys only: a question name such as `__proto__` passes QUESTION_NAME,
5288
+ // and an inherited property is not a declared question.
5289
+ const question = Object.prototype.hasOwnProperty.call(questions, name)
5290
+ ? questions[name]
5291
+ : undefined;
5292
+ if (!isPlainObject(question)) {
5293
+ return {
5294
+ ok: false,
5295
+ error: runQuestionError(name, `is not a question this config declares, so variables.criteria.` +
5296
+ `${name} cannot be supplied (the config does not declare it)`),
5297
+ };
5298
+ }
5299
+ if (question.type !== "choice") {
5300
+ return {
5301
+ ok: false,
5302
+ error: runQuestionError(name, `is a ${String(question.type)} question; only a choice question ` +
5303
+ 'declared with criteriaSource = "dynamic" takes ' +
5304
+ `variables.criteria.${name}`),
5305
+ };
5306
+ }
5307
+ if (criteriaSourceOf(question) === "static") {
5308
+ return {
5309
+ ok: false,
5310
+ error: runQuestionError(name, 'takes its criteria from the config (criteriaSource = "static"), ' +
5311
+ `so variables.criteria.${name} cannot be supplied`),
5312
+ };
5313
+ }
5314
+ }
5315
+ const resolved = {};
5316
+ // Defined, not assigned: `resolved.__proto__ = …` would set the prototype and
5317
+ // the question would vanish from the provider request.
5318
+ const keep = (name, value) => Object.defineProperty(resolved, name, {
5319
+ value,
5320
+ enumerable: true,
5321
+ writable: true,
5322
+ configurable: true,
5323
+ });
5324
+ for (const [name, question] of Object.entries(questions)) {
5325
+ if (!isPlainObject(question)) {
5326
+ keep(name, question);
5327
+ continue;
5328
+ }
5329
+ const { criteriaSource: source, ...sent } = question;
5330
+ if (question.type === "choice") {
5331
+ if (!isCriteriaSource(source)) {
5332
+ return {
5333
+ ok: false,
5334
+ error: runQuestionError(name, source === undefined
5335
+ ? 'is a choice question with no criteriaSource; declare ' +
5336
+ 'criteriaSource = "static" or "dynamic" on it and push the ' +
5337
+ "prompt again"
5338
+ : `has criteriaSource ${JSON.stringify(source)}; it must be ` +
5339
+ '"static" or "dynamic"'),
5340
+ };
5341
+ }
5342
+ if (source === "dynamic") {
5343
+ if (!Object.prototype.hasOwnProperty.call(supplied, name)) {
5344
+ return {
5345
+ ok: false,
5346
+ error: runQuestionError(name, "takes its criteria from the run (criteriaSource = " +
5347
+ `"dynamic"), and variables.criteria.${name} is missing`),
5348
+ };
5349
+ }
5350
+ const table = supplied[name];
5351
+ const tableError = criteriaTableError(table);
5352
+ if (tableError) {
5353
+ return {
5354
+ ok: false,
5355
+ error: runQuestionError(name, `takes its criteria from the run, and variables.criteria.` +
5356
+ `${name} ${tableError}`),
5357
+ };
5358
+ }
5359
+ sent.criteria = { ...table };
5360
+ }
5361
+ }
5362
+ keep(name, sent);
5363
+ }
5364
+ return { ok: true, questions: resolved };
5365
+ }
5075
5366
  /**
5076
5367
  * Move the deprecated flat chat keys of one `[[configs]]` entry under `chat`
5077
5368
  * — #3626 §4, the blob-bucket `accessPolicy` precedent made reusable.
@@ -5093,7 +5384,9 @@ export function hoistDeprecatedChatKeys(entry) {
5093
5384
  const group = existingGroup ?? {};
5094
5385
  const moved = [];
5095
5386
  const conflicts = [];
5096
- for (const key of CHAT_CONFIG_KEYS) {
5387
+ // #3801 — only the legacy keys have a flat spelling to hoist; `strictOutput`
5388
+ // at the root is an unknown key naming its block.
5389
+ for (const key of LEGACY_FLAT_CHAT_KEYS) {
5097
5390
  if (!(key in out))
5098
5391
  continue;
5099
5392
  if (existingGroup && key in existingGroup) {
@@ -5109,128 +5402,1557 @@ export function hoistDeprecatedChatKeys(entry) {
5109
5402
  out.chat = group;
5110
5403
  return { entry: out, moved: moved.sort(), conflicts: conflicts.sort() };
5111
5404
  }
5112
- // ── src/config-surface/prompt.ts ─────────────────────────────────────────
5405
+ // ── src/config-surface/prompt-strict-output.ts ───────────────────────────
5406
+ /**
5407
+ * Strict output for a chat config — issue #3801 (project `agents`, §D6).
5408
+ *
5409
+ * A chat prompt's OpenRouter request asks for JSON mode (`outputFormat =
5410
+ * "json"`) and the answer is checked AFTERWARDS against `[prompt].outputSchema`
5411
+ * (`derivePromptRunEnvelope`). A config that sets `[configs.chat].strictOutput
5412
+ * = true` asks the provider to constrain the answer instead: the prompt-level
5413
+ * schema is sent as `response_format: { type: "json_schema", json_schema: {
5414
+ * name: "output", strict: true, schema } }`, with `provider: {
5415
+ * require_parameters: true }` so OpenRouter refuses an endpoint that would
5416
+ * ignore it rather than dropping it in silence (the #3358 posture).
5417
+ *
5418
+ * This module is the whole rule, and it is read in both places a config is
5419
+ * written: the admin routes, and (vendored) the CLI's push preflight, so a
5420
+ * refusal at push and at the route cannot disagree — `resolvePromptReasoning`
5421
+ * and `resolvePromptKind`'s posture.
5422
+ *
5423
+ * Which schema is sent is never ambiguous: always the PROMPT's (§D6), so a
5424
+ * config that also declares its own `outputSchema` is refused. Five refusals,
5425
+ * in this order, each with its own code:
5426
+ *
5427
+ * - `PROMPT_STRICT_OUTPUT_INVALID` — the value is not a boolean.
5428
+ * - `PROMPT_STRICT_OUTPUT_SCHEMA_CONFLICT` — the config declares its own
5429
+ * `outputSchema` too.
5430
+ * - `PROMPT_STRICT_OUTPUT_PROVIDER_UNSUPPORTED` — not OpenRouter. A Gemini
5431
+ * config already constrains its request through `responseSchema`.
5432
+ * - `PROMPT_STRICT_OUTPUT_FORMAT_CONFLICT` — `outputFormat = "text"`.
5433
+ * - `PROMPT_STRICT_OUTPUT_SCHEMA_MISSING` — the prompt declares no usable
5434
+ * `[prompt].outputSchema`.
5435
+ *
5436
+ * The first four are about the config itself and apply whatever its status.
5437
+ * The last depends on PROMPT-level state that can change under a config, so it
5438
+ * is skipped for an ARCHIVED config (DSO-3801-002): a retired strict config may
5439
+ * be pulled, pushed and renamed after its prompt dropped the schema, and the
5440
+ * requirement is enforced when a config is or becomes active — the #3799
5441
+ * posture, "an archive is never checked".
5442
+ *
5443
+ * Pure and dependency-free, like every module under `src/config-surface/`.
5444
+ */
5445
+ /** Every code the rule answers, in the order it checks. */
5446
+ export const PROMPT_STRICT_OUTPUT_CODES = [
5447
+ "PROMPT_STRICT_OUTPUT_INVALID",
5448
+ "PROMPT_STRICT_OUTPUT_SCHEMA_CONFLICT",
5449
+ "PROMPT_STRICT_OUTPUT_PROVIDER_UNSUPPORTED",
5450
+ "PROMPT_STRICT_OUTPUT_FORMAT_CONFLICT",
5451
+ "PROMPT_STRICT_OUTPUT_SCHEMA_MISSING",
5452
+ ];
5113
5453
  /**
5114
- * The `prompt` configuration object's definition (issue #2644, phase 2).
5454
+ * The one code a RUN can also carry: the executor refuses a strict config whose
5455
+ * stored prompt schema is absent or unusable before any provider call, rather
5456
+ * than downgrading the request to JSON mode.
5457
+ */
5458
+ export const PROMPT_STRICT_OUTPUT_SCHEMA_MISSING = "PROMPT_STRICT_OUTPUT_SCHEMA_MISSING";
5459
+ /**
5460
+ * `json_schema.name`. The OpenAI-shaped API requires one and the executor has
5461
+ * no prompt key to hand, so it is fixed — the same request for the same schema.
5462
+ */
5463
+ export const STRICT_OUTPUT_SCHEMA_NAME = "output";
5464
+ /** `null`, `undefined` and `""` are absence; `false` is a value. */
5465
+ function isStrictOutputUnset(value) {
5466
+ if (value === null || value === undefined)
5467
+ return true;
5468
+ return typeof value === "string" && value.trim() === "";
5469
+ }
5470
+ function isSchemaObject(value) {
5471
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5472
+ }
5473
+ /** Does this config opt in? Only a literal `true` does. */
5474
+ export function isStrictOutput(value) {
5475
+ return value === true;
5476
+ }
5477
+ /**
5478
+ * The schema strict output sends, from a stored or authored
5479
+ * `[prompt].outputSchema`: a JSON object (or its text), else `null`.
5480
+ */
5481
+ export function strictOutputSchemaOf(value) {
5482
+ if (isSchemaObject(value))
5483
+ return value;
5484
+ if (typeof value !== "string" || value.trim() === "")
5485
+ return null;
5486
+ try {
5487
+ const parsed = JSON.parse(value);
5488
+ return isSchemaObject(parsed) ? parsed : null;
5489
+ }
5490
+ catch {
5491
+ return null;
5492
+ }
5493
+ }
5494
+ /**
5495
+ * Is this (provider, strictOutput, outputFormat, schemas, status) a config the
5496
+ * platform can run as strict — or not strict at all?
5497
+ */
5498
+ export function resolvePromptStrictOutput(input) {
5499
+ const value = input.strictOutput;
5500
+ if (isStrictOutputUnset(value) || value === false)
5501
+ return { ok: true, strict: false };
5502
+ if (value !== true) {
5503
+ return {
5504
+ ok: false,
5505
+ code: "PROMPT_STRICT_OUTPUT_INVALID",
5506
+ error: "strictOutput must be true or false; found " +
5507
+ `${JSON.stringify(value)} (${typeof value}). Write strictOutput = true ` +
5508
+ "without quotes, or remove the line.",
5509
+ };
5510
+ }
5511
+ if (!isStrictOutputUnset(input.configOutputSchema)) {
5512
+ return {
5513
+ ok: false,
5514
+ code: "PROMPT_STRICT_OUTPUT_SCHEMA_CONFLICT",
5515
+ error: "strictOutput = true sends the prompt's [prompt].outputSchema as the " +
5516
+ "strict json_schema, and this config also declares its own " +
5517
+ "[configs.chat].outputSchema, so which schema is sent would be " +
5518
+ "ambiguous. Remove the config's outputSchema (declare the schema on " +
5519
+ "[prompt]), or remove strictOutput.",
5520
+ };
5521
+ }
5522
+ const provider = typeof input.provider === "string" ? input.provider.trim().toLowerCase() : "";
5523
+ if (provider && provider !== "openrouter") {
5524
+ return {
5525
+ ok: false,
5526
+ code: "PROMPT_STRICT_OUTPUT_PROVIDER_UNSUPPORTED",
5527
+ error: `strictOutput = true is an OpenRouter option; this config runs on ` +
5528
+ `"${provider}". A gemini config already constrains its request with ` +
5529
+ "[prompt].outputSchema as responseSchema — remove strictOutput.",
5530
+ };
5531
+ }
5532
+ const outputFormat = typeof input.outputFormat === "string"
5533
+ ? input.outputFormat.trim().toLowerCase()
5534
+ : "";
5535
+ if (outputFormat === "text") {
5536
+ return {
5537
+ ok: false,
5538
+ code: "PROMPT_STRICT_OUTPUT_FORMAT_CONFLICT",
5539
+ error: 'strictOutput = true asks for JSON matching [prompt].outputSchema, and ' +
5540
+ 'this config says outputFormat = "text". Set outputFormat = "json" (or ' +
5541
+ "remove it), or remove strictOutput.",
5542
+ };
5543
+ }
5544
+ const status = typeof input.status === "string" ? input.status.trim().toLowerCase() : "";
5545
+ if (status !== "archived" && strictOutputSchemaOf(input.promptOutputSchema) === null) {
5546
+ return {
5547
+ ok: false,
5548
+ code: "PROMPT_STRICT_OUTPUT_SCHEMA_MISSING",
5549
+ error: "strictOutput = true sends the prompt's [prompt].outputSchema as the " +
5550
+ "strict json_schema, and this prompt declares none that is a JSON " +
5551
+ "object. Declare [prompt].outputSchema, or remove strictOutput.",
5552
+ };
5553
+ }
5554
+ return { ok: true, strict: true };
5555
+ }
5556
+ // ── src/config-surface/agent-declaration.ts ──────────────────────────────
5557
+ /**
5558
+ * What an agent prompt may declare — issue #3798 (project `agents`, §D1).
5115
5559
  *
5116
- * `prompts/<key>.toml` carries TWO field tables: the `[prompt]` table backed by
5117
- * `AppPrompt`, and the repeated `[[configs]]` table backed by `AppPromptConfig`
5118
- * — one entry per named LLM configuration. Both are declared here, so a field
5119
- * added to either model is covered by the same guard.
5560
+ * An agent is a prompt of `kind = "agent"`. Its configs carry `[configs.agent]`
5561
+ * (the rule for those is `resolvePromptKind`); its contract lives in ONE
5562
+ * prompt-level block, `[prompt.agent]`: the tools the model may call, the event
5563
+ * kinds members record, the turn context, how history is selected, who answers
5564
+ * a paused call, and the limits an agent may lower but never raise.
5120
5565
  *
5121
- * The `[[configs]]` entries are the reason prompts have two tables rather than
5122
- * a `structural` note: they are a real field surface the CLI pushes
5123
- * (`createPromptConfig` / `updatePromptConfig`) and pulls, not an opaque blob.
5566
+ * This module is the whole rule for that block. `resolveAgentDeclaration` is
5567
+ * called by the CLI's push preflight through the vendored artifact and by the
5568
+ * admin prompt routes, so a refusal at push and a refusal at the route cannot
5569
+ * disagree — the `resolvePromptKind` posture of #3626.
5570
+ *
5571
+ * STORED AS WRITTEN (DSO-3798-001). The resolver returns the AUTHORED value —
5572
+ * a block written as one JSON string is parsed to the object it encodes, and
5573
+ * nothing is added. `config diff` compares a `json` field by value with no
5574
+ * defaults applied, so a stored declaration with defaults filled in would read
5575
+ * Modified after every push, and `config pull` would write keys the author
5576
+ * never did. The defaults live in one reader instead, `normalizeAgentDeclaration`,
5577
+ * which every consumer (the executor, codegen, the turn engine) calls.
5578
+ *
5579
+ * Every refusal carries a stable code (intent criterion 22) and a refusal lists
5580
+ * EVERY offending item, so an author fixes a declaration in one pass.
5581
+ *
5582
+ * Pure and dependency-free, like every module under `src/config-surface/`.
5124
5583
  */
5125
- const PROMPT_HANDLER = "src/admin-api.ts";
5584
+ /** The stable codes every agent agentRefusal this project adds carries. */
5585
+ export const AGENT_ERROR_CODES = [
5586
+ "AGENT_DECLARATION_INVALID",
5587
+ "AGENT_TOOL_FUNCTION_MISSING",
5588
+ "AGENT_TOOL_FUNCTION_SCHEMA_MISSING",
5589
+ "AGENT_OUTPUT_SCHEMA_NOT_ALLOWED",
5590
+ "AGENT_CONFIG_KEY_NOT_ALLOWED",
5591
+ "AGENT_NOT_RUNNABLE",
5592
+ "AGENT_TEST_CASE_NOT_SUPPORTED",
5593
+ // #3799 §7 — a tool or event schema that cannot be mapped without loss to
5594
+ // the provider of a config that is not archived (`provider-schema.ts`).
5595
+ "AGENT_SCHEMA_NOT_TRANSLATABLE",
5596
+ // #3799 §7 — the capability rule (`src/llm/model-capabilities.ts`). The
5597
+ // catalog does not list the model; it lists it and it lacks a capability an
5598
+ // agent needs; or the capability could not be established at all
5599
+ // (DSO-3799-002 — a refusal, not a pass).
5600
+ "AGENT_MODEL_UNKNOWN",
5601
+ "AGENT_MODEL_CAPABILITY_MISSING",
5602
+ "AGENT_MODEL_CAPABILITIES_UNAVAILABLE",
5603
+ // #3800 §3 — the agent round. A round asked of a prompt that is not an
5604
+ // agent; a round whose input cannot mean anything; a call naming a tool the
5605
+ // round did not declare; a call whose arguments fail its tool's schema; a
5606
+ // response cut off at the token limit; a response with nothing in it.
5607
+ "AGENT_ROUND_NOT_AGENT",
5608
+ "AGENT_ROUND_INPUT_INVALID",
5609
+ "AGENT_TOOL_UNKNOWN",
5610
+ "AGENT_TOOL_ARGUMENTS_INVALID",
5611
+ "AGENT_ROUND_TRUNCATED",
5612
+ "AGENT_ROUND_EMPTY",
5613
+ ];
5126
5614
  /**
5127
- * #3626 §4 — the nine chat keys, still accepted FLAT under `[[configs]]`.
5128
- *
5129
- * The blob-bucket `accessPolicy` precedent: declared `deprecated`, so
5130
- * `config push` keeps taking a file written before the `[configs.chat]` block
5131
- * existed, while `config pull` never writes the spelling again. One
5132
- * pull-then-push migrates a file. `hoistDeprecatedChatKeys` moves them into the
5133
- * group before the payload is built; rejecting the flat spelling outright is
5134
- * #3642.
5615
+ * The platform maximums. An agent may lower `maxSteps` and `waitLimitSeconds`,
5616
+ * never raise them; the tool and event counts bound what one declaration can
5617
+ * make every turn carry (principle 2).
5135
5618
  */
5136
- const DEPRECATED_FLAT_CHAT_KEYS = Object.fromEntries(CHAT_CONFIG_KEYS.map((key) => [
5137
- key,
5138
- {
5139
- kind: "deprecated",
5140
- note: `#3626 — the pre-kind spelling of \`[configs.chat].${key}\`. Still ` +
5141
- "accepted flat so no existing prompt file breaks (push hoists it " +
5142
- "into the block and warns); `config pull` writes only the grouped " +
5143
- "spelling, so one pull-then-push migrates the file. Rejecting the " +
5144
- "flat spelling is #3642.",
5145
- },
5146
- ]));
5147
- export const PROMPT_SURFACE = {
5148
- label: "prompt",
5149
- tables: [
5150
- {
5151
- tomlPath: ["prompt"],
5152
- repeated: false,
5153
- model: "AppPrompt",
5154
- // Declaration order reproduces the pre-#2644 serializer's key order.
5155
- fields: [
5156
- // #3626 — WHAT this prompt runs. `chat` is today's prompt (a chat
5157
- // completion rendered from templates); `decisions` names OpenRouter's
5158
- // `/api/alpha/decisions` endpoint, which takes a `state` value plus
5159
- // named typed questions. It lives on the PROMPT because it decides the
5160
- // input envelope, the output type, the meaning of `inputSchema` /
5161
- // `outputSchema` and the shape of every test case's `inputVariables` —
5162
- // all of which are asserted per prompt KEY.
5163
- //
5164
- // `emit: "always"` so a pulled file says which kind it is; an omitted
5165
- // key is `chat`, so no file written before this changes meaning, and
5166
- // `serverDefault` is what lets push send `chat` for an absent key and
5167
- // `config diff` read both spellings as one value.
5168
- //
5169
- // CREATE_ONLY, and immutable in the strong sense (D3626-008): changing
5170
- // a prompt's kind under its key would change the contract of every
5171
- // caller compiled against it, so the update handler never reads it and
5172
- // the push gate declines the change as `immutable` — the `ttlTier`
5173
- // precedent. The prompt comparator projects `[prompt]` in CREATE mode
5174
- // for exactly that reason (SO3626-008).
5175
- {
5176
- field: "kind",
5177
- tomlKey: "kind",
5178
- type: "string",
5179
- emit: "always",
5180
- writableOn: CREATE_ONLY,
5181
- serverDefault: "chat",
5182
- validation: handledBy("src/config-surface/prompt-kinds.ts", "resolvePromptKind", "apply"),
5183
- },
5184
- {
5185
- field: "displayName",
5186
- tomlKey: "displayName",
5187
- type: "string",
5188
- emit: "always",
5189
- writableOn: BOTH,
5190
- validation: "passthrough",
5191
- },
5192
- {
5193
- field: "description",
5194
- tomlKey: "description",
5195
- type: "string",
5196
- emit: "whenSet",
5197
- writableOn: BOTH,
5198
- validation: "passthrough",
5199
- },
5200
- // JSON Schema objects. Stored as JSON strings, returned parsed; TOML
5201
- // may present them as an inline table or a JSON string.
5202
- {
5203
- field: "inputSchema",
5204
- tomlKey: "inputSchema",
5205
- type: "json",
5206
- emit: "whenSet",
5207
- writableOn: BOTH,
5208
- validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
5209
- },
5210
- // #2644 — accepted by both handlers and returned by the detail, but
5211
- // never written to TOML before this: a prompt's declared output shape
5212
- // was invisible to config-as-code.
5213
- {
5214
- field: "outputSchema",
5215
- tomlKey: "outputSchema",
5216
- type: "json",
5217
- emit: "whenSet",
5218
- writableOn: BOTH,
5219
- validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
5220
- },
5221
- // #2652 — the CEL rule gating who may execute the prompt. Required at
5222
- // create; an explicit null/"" on update clears it (fail-closed). This
5223
- // declaration is the whole authoring channel: the admin routes' unknown
5224
- // key guard reads the same table, so it is what makes `accessRule` an
5225
- // accepted body key as well as a pulled and pushed TOML line.
5226
- {
5227
- field: "accessRule",
5228
- tomlKey: "accessRule",
5229
- type: "string",
5619
+ export const AGENT_LIMITS = {
5620
+ maxSteps: 25,
5621
+ waitLimitSeconds: 604800,
5622
+ tools: 64,
5623
+ events: 64,
5624
+ };
5625
+ /**
5626
+ * A tool or event name: the intersection of OpenRouter's and Gemini's
5627
+ * function-name rules, so one declaration maps onto either provider.
5628
+ */
5629
+ export const AGENT_NAME = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
5630
+ export const AGENT_ANSWERED_BY = ["initiator", "participants"];
5631
+ export const AGENT_TOOL_INVOKE = ["request", "task"];
5632
+ export const AGENT_TOOL_HISTORY_SCOPES = ["session", "turn"];
5633
+ const AGENT_TOP_LEVEL_KEYS = [
5634
+ "answeredBy",
5635
+ "maxSteps",
5636
+ "waitLimitSeconds",
5637
+ "turnContext",
5638
+ "history",
5639
+ "tools",
5640
+ "events",
5641
+ ];
5642
+ const AGENT_TURN_CONTEXT_KEYS = ["schema", "function"];
5643
+ const AGENT_HISTORY_KEYS = ["function", "maxHistoryChars"];
5644
+ const AGENT_TOOL_KEYS = [
5645
+ "name",
5646
+ "description",
5647
+ "function",
5648
+ "invoke",
5649
+ "runs",
5650
+ "approval",
5651
+ "statusText",
5652
+ "historyScope",
5653
+ "inputSchema",
5654
+ "outputSchema",
5655
+ ];
5656
+ const AGENT_EVENT_KEYS = ["name", "schema"];
5657
+ /** Where every message points: the author's own spelling of the block. */
5658
+ const AGENT_BLOCK = "[prompt.agent]";
5659
+ /**
5660
+ * The same label, for the sibling rules that refuse a declaration from their
5661
+ * own module (#3799's `provider-schema.ts`), so every agent refusal points at
5662
+ * the block the author wrote.
5663
+ */
5664
+ export const AGENT_BLOCK_LABEL = AGENT_BLOCK;
5665
+ function agentRefusal(code, message) {
5666
+ return { code, message };
5667
+ }
5668
+ function agentInvalid(path, detail) {
5669
+ return agentRefusal("AGENT_DECLARATION_INVALID", `${AGENT_BLOCK} ${path ? `${path} ` : ""}${detail}`);
5670
+ }
5671
+ /** `null`, `undefined` and `""` are absence; `false` and `0` are values. */
5672
+ function agentIsUnset(value) {
5673
+ if (value === null || value === undefined)
5674
+ return true;
5675
+ return typeof value === "string" && value.trim() === "";
5676
+ }
5677
+ function agentIsPlainObject(value) {
5678
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5679
+ }
5680
+ function agentIsNonEmptyString(value) {
5681
+ return typeof value === "string" && value.trim() !== "";
5682
+ }
5683
+ function agentKindOf(kind) {
5684
+ return agentIsUnset(kind) ? "chat" : String(kind).trim();
5685
+ }
5686
+ /**
5687
+ * The block as a value: a table as it is, a JSON string parsed. `undefined`
5688
+ * for anything that is not a JSON object, which the resolver refuses.
5689
+ */
5690
+ function parseAgentBlock(agent) {
5691
+ if (agentIsPlainObject(agent))
5692
+ return agent;
5693
+ if (typeof agent === "string") {
5694
+ try {
5695
+ const parsed = JSON.parse(agent);
5696
+ return agentIsPlainObject(parsed) ? parsed : undefined;
5697
+ }
5698
+ catch {
5699
+ return undefined;
5700
+ }
5701
+ }
5702
+ return undefined;
5703
+ }
5704
+ function agentUnknownKeys(value, allowed, path, out) {
5705
+ for (const key of Object.keys(value)) {
5706
+ if (!allowed.includes(key)) {
5707
+ out.push(agentInvalid(path ? `${path}.${key}` : key, `is not a key an agent declares; allowed: ${allowed.join(", ")}`));
5708
+ }
5709
+ }
5710
+ }
5711
+ /**
5712
+ * A schema position holds an OBJECT. A JSON string there is refused rather
5713
+ * than parsed: the stored value must equal the pushed one, and a nested string
5714
+ * parsed server-side would not (DSO-3798-001).
5715
+ */
5716
+ function agentSchemaError(value, path) {
5717
+ if (agentIsPlainObject(value))
5718
+ return null;
5719
+ const found = typeof value === "string"
5720
+ ? "a string"
5721
+ : Array.isArray(value)
5722
+ ? "a list"
5723
+ : typeof value;
5724
+ return agentInvalid(path, `must be a table (a JSON Schema object); found ${found}. Write it as ` +
5725
+ "TOML tables, or as an object inside a block written as one JSON string");
5726
+ }
5727
+ function agentIntegerInRange(value, path, max, out) {
5728
+ if (typeof value !== "number" || !Number.isInteger(value)) {
5729
+ out.push(agentInvalid(path, `must be an integer from 1 to ${max}`));
5730
+ return;
5731
+ }
5732
+ if (value < 1 || value > max) {
5733
+ out.push(agentInvalid(path, `must be from 1 to ${max} (the platform maximum); found ${value}`));
5734
+ }
5735
+ }
5736
+ function checkAgentName(value, path, seen, what, out) {
5737
+ if (typeof value !== "string" || !AGENT_NAME.test(value)) {
5738
+ out.push(agentInvalid(path, `is not a usable ${what} name; names match ` +
5739
+ "[A-Za-z_][A-Za-z0-9_-]{0,63} (the rule both providers accept)"));
5740
+ return;
5741
+ }
5742
+ if (seen.has(value)) {
5743
+ out.push(agentInvalid(path, `repeats the ${what} name "${value}"; names are unique`));
5744
+ return;
5745
+ }
5746
+ seen.add(value);
5747
+ }
5748
+ function checkAgentTool(tool, index, names, out) {
5749
+ const path = `tools[${index}]`;
5750
+ if (!agentIsPlainObject(tool)) {
5751
+ out.push(agentInvalid(path, "must be a table declaring the tool"));
5752
+ return;
5753
+ }
5754
+ agentUnknownKeys(tool, AGENT_TOOL_KEYS, path, out);
5755
+ checkAgentName(tool.name, `${path}.name`, names, "tool", out);
5756
+ if (!agentIsNonEmptyString(tool.description)) {
5757
+ out.push(agentInvalid(`${path}.description`, "is required — the sentence the model reads to decide when to call it"));
5758
+ }
5759
+ const hasFunction = tool.function !== undefined;
5760
+ const hasRuns = tool.runs !== undefined;
5761
+ if (hasFunction === hasRuns) {
5762
+ out.push(agentInvalid(path, hasFunction
5763
+ ? 'sets both function and runs; a tool is a server tool (function = "<key>") or a client tool (runs = "client"), not both'
5764
+ : 'needs function = "<key>" (a server tool) or runs = "client" (a client tool)'));
5765
+ }
5766
+ if (hasRuns && tool.runs !== "client") {
5767
+ out.push(agentInvalid(`${path}.runs`, `must be "client"; found ${JSON.stringify(tool.runs)}`));
5768
+ }
5769
+ if (hasFunction && !agentIsNonEmptyString(tool.function)) {
5770
+ out.push(agentInvalid(`${path}.function`, "must name a server function by key"));
5771
+ }
5772
+ const isClient = hasRuns && !hasFunction;
5773
+ if (tool.invoke !== undefined) {
5774
+ if (isClient) {
5775
+ out.push(agentInvalid(`${path}.invoke`, "is for server tools; a client tool is answered by a member's client"));
5776
+ }
5777
+ else if (!AGENT_TOOL_INVOKE.includes(tool.invoke)) {
5778
+ out.push(agentInvalid(`${path}.invoke`, `must be one of ${AGENT_TOOL_INVOKE.join(" | ")}`));
5779
+ }
5780
+ }
5781
+ for (const key of ["inputSchema", "outputSchema"]) {
5782
+ const schemaPath = `${path}.${key}`;
5783
+ if (isClient) {
5784
+ if (tool[key] === undefined) {
5785
+ out.push(agentInvalid(schemaPath, "is required on a client tool"));
5786
+ continue;
5787
+ }
5788
+ const error = agentSchemaError(tool[key], schemaPath);
5789
+ if (error)
5790
+ out.push(error);
5791
+ }
5792
+ else if (hasFunction && tool[key] !== undefined) {
5793
+ out.push(agentInvalid(schemaPath, "is not declared on a server tool; its function's schemas are the " +
5794
+ "tool's (one source of truth) — remove it"));
5795
+ }
5796
+ }
5797
+ if (tool.approval !== undefined && typeof tool.approval !== "boolean") {
5798
+ out.push(agentInvalid(`${path}.approval`, "must be true or false"));
5799
+ }
5800
+ if (tool.statusText !== undefined && !agentIsNonEmptyString(tool.statusText)) {
5801
+ out.push(agentInvalid(`${path}.statusText`, "must be a non-empty string"));
5802
+ }
5803
+ if (tool.historyScope !== undefined &&
5804
+ !AGENT_TOOL_HISTORY_SCOPES.includes(tool.historyScope)) {
5805
+ out.push(agentInvalid(`${path}.historyScope`, `must be one of ${AGENT_TOOL_HISTORY_SCOPES.join(" | ")}`));
5806
+ }
5807
+ }
5808
+ function checkAgentEvent(event, index, names, out) {
5809
+ const path = `events[${index}]`;
5810
+ if (!agentIsPlainObject(event)) {
5811
+ out.push(agentInvalid(path, "must be a table declaring the event kind"));
5812
+ return;
5813
+ }
5814
+ agentUnknownKeys(event, AGENT_EVENT_KEYS, path, out);
5815
+ checkAgentName(event.name, `${path}.name`, names, "event", out);
5816
+ if (event.schema === undefined) {
5817
+ out.push(agentInvalid(`${path}.schema`, "is required — the shape of the event's payload"));
5818
+ }
5819
+ else {
5820
+ const error = agentSchemaError(event.schema, `${path}.schema`);
5821
+ if (error)
5822
+ out.push(error);
5823
+ }
5824
+ }
5825
+ function checkAgentList(value, path, max, check, out) {
5826
+ if (value === undefined)
5827
+ return;
5828
+ if (!Array.isArray(value)) {
5829
+ out.push(agentInvalid(path, `must be a list of tables ([[prompt.agent.${path}]])`));
5830
+ return;
5831
+ }
5832
+ if (value.length > max) {
5833
+ out.push(agentInvalid(path, `declares ${value.length}; at most ${max} are allowed`));
5834
+ return;
5835
+ }
5836
+ const names = new Set();
5837
+ value.forEach((item, index) => check(item, index, names, out));
5838
+ }
5839
+ /** Every file-decidable agentRefusal of a parsed block. */
5840
+ function agentShapeRefusals(agent) {
5841
+ const out = [];
5842
+ agentUnknownKeys(agent, AGENT_TOP_LEVEL_KEYS, "", out);
5843
+ if (agent.answeredBy !== undefined &&
5844
+ !AGENT_ANSWERED_BY.includes(agent.answeredBy)) {
5845
+ out.push(agentInvalid("answeredBy", `must be one of ${AGENT_ANSWERED_BY.join(" | ")}`));
5846
+ }
5847
+ if (agent.maxSteps !== undefined) {
5848
+ agentIntegerInRange(agent.maxSteps, "maxSteps", AGENT_LIMITS.maxSteps, out);
5849
+ }
5850
+ if (agent.waitLimitSeconds !== undefined) {
5851
+ agentIntegerInRange(agent.waitLimitSeconds, "waitLimitSeconds", AGENT_LIMITS.waitLimitSeconds, out);
5852
+ }
5853
+ if (agent.turnContext !== undefined) {
5854
+ const turnContext = agent.turnContext;
5855
+ if (!agentIsPlainObject(turnContext)) {
5856
+ out.push(agentInvalid("turnContext", "must be a table: { schema, function? }"));
5857
+ }
5858
+ else {
5859
+ agentUnknownKeys(turnContext, AGENT_TURN_CONTEXT_KEYS, "turnContext", out);
5860
+ if (turnContext.schema === undefined) {
5861
+ out.push(agentInvalid("turnContext.schema", "is required — the shape of the context a send carries"));
5862
+ }
5863
+ else {
5864
+ const error = agentSchemaError(turnContext.schema, "turnContext.schema");
5865
+ if (error)
5866
+ out.push(error);
5867
+ }
5868
+ if (turnContext.function !== undefined &&
5869
+ !agentIsNonEmptyString(turnContext.function)) {
5870
+ out.push(agentInvalid("turnContext.function", "must name a server function by key"));
5871
+ }
5872
+ }
5873
+ }
5874
+ if (agent.history !== undefined) {
5875
+ const history = agent.history;
5876
+ if (!agentIsPlainObject(history)) {
5877
+ out.push(agentInvalid("history", "must be a table: { function } or { maxHistoryChars }"));
5878
+ }
5879
+ else {
5880
+ agentUnknownKeys(history, AGENT_HISTORY_KEYS, "history", out);
5881
+ const hasFunction = history.function !== undefined;
5882
+ const hasCap = history.maxHistoryChars !== undefined;
5883
+ if (hasFunction === hasCap) {
5884
+ out.push(agentInvalid("history", hasFunction
5885
+ ? "sets both function and maxHistoryChars; a hook selects history or a cap trims it, not both"
5886
+ : "needs function = \"<key>\" (a hook) or maxHistoryChars (a cap)"));
5887
+ }
5888
+ if (hasFunction && !agentIsNonEmptyString(history.function)) {
5889
+ out.push(agentInvalid("history.function", "must name a server function by key"));
5890
+ }
5891
+ if (hasCap &&
5892
+ (typeof history.maxHistoryChars !== "number" ||
5893
+ !Number.isInteger(history.maxHistoryChars) ||
5894
+ history.maxHistoryChars < 1)) {
5895
+ out.push(agentInvalid("history.maxHistoryChars", "must be a positive integer"));
5896
+ }
5897
+ }
5898
+ }
5899
+ checkAgentList(agent.tools, "tools", AGENT_LIMITS.tools, checkAgentTool, out);
5900
+ checkAgentList(agent.events, "events", AGENT_LIMITS.events, checkAgentEvent, out);
5901
+ return out;
5902
+ }
5903
+ /** Every function reference, with the path that makes it. */
5904
+ function agentFunctionReferences(agent) {
5905
+ const refs = [];
5906
+ if (Array.isArray(agent.tools)) {
5907
+ agent.tools.forEach((tool, index) => {
5908
+ if (agentIsPlainObject(tool) && agentIsNonEmptyString(tool.function)) {
5909
+ refs.push({
5910
+ key: tool.function,
5911
+ tool: typeof tool.name === "string" ? tool.name : undefined,
5912
+ path: `tools[${index}].function`,
5913
+ needsSchemas: true,
5914
+ });
5915
+ }
5916
+ });
5917
+ }
5918
+ const turnContext = agent.turnContext;
5919
+ if (agentIsPlainObject(turnContext) && agentIsNonEmptyString(turnContext.function)) {
5920
+ refs.push({
5921
+ key: turnContext.function,
5922
+ path: "turnContext.function",
5923
+ needsSchemas: false,
5924
+ });
5925
+ }
5926
+ const history = agent.history;
5927
+ if (agentIsPlainObject(history) && agentIsNonEmptyString(history.function)) {
5928
+ refs.push({ key: history.function, path: "history.function", needsSchemas: false });
5929
+ }
5930
+ return refs;
5931
+ }
5932
+ /**
5933
+ * The function keys a declaration references (tools, turn context, history),
5934
+ * each once, in declaration order. What a caller resolves into the
5935
+ * `functions` facts before calling the resolver. `[]` for a block that is
5936
+ * absent or does not parse — the resolver refuses the latter itself.
5937
+ */
5938
+ /**
5939
+ * `[prompt.agent]` as a value: a table as it is, one JSON string parsed,
5940
+ * `undefined` for absence or for anything that is not a JSON object (which
5941
+ * `resolveAgentDeclaration` refuses on its own).
5942
+ *
5943
+ * Exported for the sibling rules that read the same block — #3799's
5944
+ * `agentSchemaTranslationRefusals` — so a declaration written as one JSON
5945
+ * string is read identically by every rule that consumes it.
5946
+ */
5947
+ export function parseAgentDeclarationBlock(agent) {
5948
+ return parseAgentBlock(agent);
5949
+ }
5950
+ export function agentDeclarationFunctionKeys(agent) {
5951
+ const parsed = parseAgentBlock(agent);
5952
+ if (!parsed)
5953
+ return [];
5954
+ return [...new Set(agentFunctionReferences(parsed).map((ref) => ref.key))];
5955
+ }
5956
+ function agentFunctionRefusals(agent, functions) {
5957
+ const out = [];
5958
+ for (const ref of agentFunctionReferences(agent)) {
5959
+ const subject = ref.tool
5960
+ ? `tool "${ref.tool}" (${ref.path})`
5961
+ : ref.path;
5962
+ const facts = Object.prototype.hasOwnProperty.call(functions, ref.key)
5963
+ ? functions[ref.key]
5964
+ : null;
5965
+ if (!facts) {
5966
+ out.push(agentRefusal("AGENT_TOOL_FUNCTION_MISSING", `${AGENT_BLOCK} ${subject} names function "${ref.key}", which this app ` +
5967
+ "does not have. Push the function first, or include its file in " +
5968
+ "this push"));
5969
+ continue;
5970
+ }
5971
+ if (!ref.needsSchemas)
5972
+ continue;
5973
+ const missing = [
5974
+ facts.hasInputSchema ? null : "inputSchema",
5975
+ facts.hasOutputSchema ? null : "outputSchema",
5976
+ ].filter((name) => name !== null);
5977
+ if (missing.length > 0) {
5978
+ out.push(agentRefusal("AGENT_TOOL_FUNCTION_SCHEMA_MISSING", `${AGENT_BLOCK} ${subject} names function "${ref.key}", which declares ` +
5979
+ `no ${missing.join(" or ")}. A tool's schemas are its function's: ` +
5980
+ `declare ${missing.join(" and ")} in functions/${ref.key}.toml`));
5981
+ }
5982
+ }
5983
+ return out;
5984
+ }
5985
+ /**
5986
+ * The one rule: is this `[prompt.agent]` declaration (with the prompt's kind
5987
+ * and `outputSchema`) something the platform can run?
5988
+ *
5989
+ * `{ ok: true, declaration }` carries the AUTHORED value — parsed when the
5990
+ * block was one JSON string, otherwise unchanged — or `null` for an absent
5991
+ * block. Callers store exactly this.
5992
+ */
5993
+ export function resolveAgentDeclaration(input) {
5994
+ const kind = agentKindOf(input.kind);
5995
+ const refusals = [];
5996
+ if (kind !== "agent") {
5997
+ if (agentIsUnset(input.agent))
5998
+ return { ok: true, declaration: null };
5999
+ return {
6000
+ ok: false,
6001
+ refusals: [
6002
+ agentInvalid("", `is declared on a prompt whose kind is "${kind}"; only a ` +
6003
+ 'kind = "agent" prompt carries it. Remove the block, or create ' +
6004
+ "the agent under a new key"),
6005
+ ],
6006
+ };
6007
+ }
6008
+ if (!agentIsUnset(input.outputSchema)) {
6009
+ refusals.push(agentRefusal("AGENT_OUTPUT_SCHEMA_NOT_ALLOWED", "[prompt].outputSchema is not declared on an agent: its final answer " +
6010
+ "is text for people, and anything structured is a tool call. Remove " +
6011
+ "outputSchema, or declare a tool whose schema carries the structure"));
6012
+ }
6013
+ let declaration = null;
6014
+ if (!agentIsUnset(input.agent)) {
6015
+ const parsed = parseAgentBlock(input.agent);
6016
+ if (!parsed) {
6017
+ refusals.push(agentInvalid("", "must be a table (or one JSON string encoding a table) declaring " +
6018
+ "the agent's tools, events, turn context, history and limits"));
6019
+ }
6020
+ else {
6021
+ declaration = parsed;
6022
+ refusals.push(...agentShapeRefusals(parsed));
6023
+ if (input.functions) {
6024
+ refusals.push(...agentFunctionRefusals(parsed, input.functions));
6025
+ }
6026
+ }
6027
+ }
6028
+ if (refusals.length > 0)
6029
+ return { ok: false, refusals };
6030
+ return { ok: true, declaration: declaration };
6031
+ }
6032
+ /**
6033
+ * The refusal of a single-shot run of an agent — `ctx.prompts.run`, the member
6034
+ * execute route, the admin execute and preview routes, the workflow
6035
+ * `prompt.execute` step and the executor itself (§D1: an agent runs turn by
6036
+ * turn in a session, never once).
6037
+ */
6038
+ export function agentNotRunnableRefusal(promptKey) {
6039
+ const subject = promptKey === undefined ? "This prompt" : `Prompt "${promptKey}"`;
6040
+ return agentRefusal("AGENT_NOT_RUNNABLE", `${subject} is an agent (kind = "agent"), which runs turn by turn in a ` +
6041
+ "session and cannot be run single-shot. Run a chat or decisions " +
6042
+ "prompt here instead");
6043
+ }
6044
+ /**
6045
+ * The refusal of an agent round asked of a prompt that is not an agent —
6046
+ * #3800 (`PromptExecutionService.resolveAgentTurnConfig` and
6047
+ * `executeAgentRound`). The mirror of `agentNotRunnableRefusal`.
6048
+ */
6049
+ export function agentRoundNotAgentRefusal(promptKey) {
6050
+ return agentRefusal("AGENT_ROUND_NOT_AGENT", `Prompt "${promptKey}" is not an agent (kind = "agent"), so it has no ` +
6051
+ "agent rounds. Run it with ctx.prompts.run, or name an agent prompt");
6052
+ }
6053
+ /**
6054
+ * The refusal of a prompt test case that involves an agent (§D1: prompt test
6055
+ * cases are refused for the kind in v1; multi-turn tests are deferred).
6056
+ * `subject` is a case written ON the agent, `evaluator` a case that names the
6057
+ * agent as its judge.
6058
+ */
6059
+ export function agentTestCaseRefusal(promptKey, role) {
6060
+ return agentRefusal("AGENT_TEST_CASE_NOT_SUPPORTED", role === "subject"
6061
+ ? `Prompt "${promptKey}" is an agent (kind = "agent"); prompt test ` +
6062
+ "cases are not supported for agents yet. Test its tools as functions"
6063
+ : `Prompt "${promptKey}" is an agent (kind = "agent") and cannot be an ` +
6064
+ "evaluator: an evaluator is run single-shot. Name a chat prompt");
6065
+ }
6066
+ /** A deep copy, so a reader's defaults never reach the caller's value. */
6067
+ function agentCopy(value) {
6068
+ return value === undefined ? value : JSON.parse(JSON.stringify(value));
6069
+ }
6070
+ /**
6071
+ * The declaration with every default applied: `answeredBy = "initiator"`, the
6072
+ * platform maximums for the limits, and per tool `invoke = "request"` (server
6073
+ * tools), `approval = false` and `historyScope = "session"`.
6074
+ *
6075
+ * The ONLY accessor for defaults — the stored value never carries them
6076
+ * (DSO-3798-001). Takes a value `resolveAgentDeclaration` accepted (or `null`)
6077
+ * and leaves it untouched.
6078
+ */
6079
+ export function normalizeAgentDeclaration(declaration) {
6080
+ const source = agentCopy(declaration ?? {});
6081
+ return {
6082
+ answeredBy: source.answeredBy ?? "initiator",
6083
+ maxSteps: source.maxSteps ?? AGENT_LIMITS.maxSteps,
6084
+ waitLimitSeconds: source.waitLimitSeconds ?? AGENT_LIMITS.waitLimitSeconds,
6085
+ turnContext: source.turnContext ?? null,
6086
+ history: source.history ?? null,
6087
+ tools: (source.tools ?? []).map((tool) => ({
6088
+ ...tool,
6089
+ ...(tool.runs === "client" ? {} : { invoke: tool.invoke ?? "request" }),
6090
+ approval: tool.approval ?? false,
6091
+ historyScope: tool.historyScope ?? "session",
6092
+ })),
6093
+ events: source.events ?? [],
6094
+ };
6095
+ }
6096
+ /**
6097
+ * The keywords that constrain only a value of one type, and so belong to that
6098
+ * type's member when a type union is rewritten as `anyOf` (CSO-3799-003). A
6099
+ * bare `{ type: "object" }` or `{ type: "array" }` member would be the
6100
+ * free-form object and the untyped array Gemini cannot carry.
6101
+ */
6102
+ const GEMINI_UNION_MEMBER_KEYWORDS = {
6103
+ object: [
6104
+ "properties",
6105
+ "required",
6106
+ "minProperties",
6107
+ "maxProperties",
6108
+ "propertyOrdering",
6109
+ ],
6110
+ array: ["items", "minItems", "maxItems"],
6111
+ };
6112
+ /** The six `type` names Gemini's function `Schema` accepts. */
6113
+ const GEMINI_TYPES = [
6114
+ "string",
6115
+ "number",
6116
+ "integer",
6117
+ "boolean",
6118
+ "object",
6119
+ "array",
6120
+ ];
6121
+ /**
6122
+ * Keywords Gemini spells the same way, with the same meaning. `required` is
6123
+ * here rather than under the structural keywords because Gemini reads it
6124
+ * verbatim.
6125
+ */
6126
+ const GEMINI_PASS_KEYWORDS = [
6127
+ "description",
6128
+ "title",
6129
+ "default",
6130
+ "minimum",
6131
+ "maximum",
6132
+ "minLength",
6133
+ "maxLength",
6134
+ "pattern",
6135
+ "minItems",
6136
+ "maxItems",
6137
+ "minProperties",
6138
+ "maxProperties",
6139
+ "required",
6140
+ // Gemini's own spellings, accepted as authored.
6141
+ "nullable",
6142
+ "propertyOrdering",
6143
+ "example",
6144
+ ];
6145
+ /** Annotations with no value meaning: dropped in silence (spec §3). */
6146
+ const GEMINI_DROPPED_KEYWORDS = ["$schema", "$id", "$comment"];
6147
+ /** Keywords handled by their own rule below, not by the tables. */
6148
+ const GEMINI_STRUCTURAL_KEYWORDS = [
6149
+ "type",
6150
+ "enum",
6151
+ "const",
6152
+ "format",
6153
+ "properties",
6154
+ "items",
6155
+ "anyOf",
6156
+ "oneOf",
6157
+ "additionalProperties",
6158
+ ];
6159
+ /** `format` values Gemini documents. Anything else is refused. */
6160
+ const GEMINI_FORMATS = [
6161
+ "date-time",
6162
+ "enum",
6163
+ "int32",
6164
+ "int64",
6165
+ "float",
6166
+ "double",
6167
+ ];
6168
+ /**
6169
+ * Keywords Gemini's function `Schema` has no spelling for. Each is refused
6170
+ * rather than dropped: dropping any of them widens what the model may produce.
6171
+ */
6172
+ const GEMINI_REFUSED_KEYWORDS = {
6173
+ $ref: "Gemini's function Schema has no $ref — inline the referenced schema",
6174
+ $defs: "Gemini's function Schema has no $defs — inline each definition where it is used",
6175
+ definitions: "Gemini's function Schema has no definitions — inline each definition where it is used",
6176
+ allOf: "Gemini's function Schema has no allOf — merge the members into one schema",
6177
+ not: "Gemini's function Schema has no not — state what is allowed instead",
6178
+ if: "Gemini's function Schema has no conditional schemas (if/then/else) — declare a discriminated oneOf instead",
6179
+ then: "Gemini's function Schema has no conditional schemas (if/then/else) — declare a discriminated oneOf instead",
6180
+ else: "Gemini's function Schema has no conditional schemas (if/then/else) — declare a discriminated oneOf instead",
6181
+ patternProperties: "Gemini's function Schema has no patternProperties — name the properties",
6182
+ propertyNames: "Gemini's function Schema has no propertyNames — name the properties",
6183
+ dependencies: "Gemini's function Schema has no property dependencies — declare a discriminated oneOf instead",
6184
+ dependentRequired: "Gemini's function Schema has no property dependencies — declare a discriminated oneOf instead",
6185
+ dependentSchemas: "Gemini's function Schema has no property dependencies — declare a discriminated oneOf instead",
6186
+ prefixItems: "Gemini's function Schema has no tuple items (prefixItems) — declare one items schema",
6187
+ contains: "Gemini's function Schema has no contains — declare items instead",
6188
+ uniqueItems: "Gemini's function Schema has no uniqueItems, and dropping it would let the model repeat an entry",
6189
+ multipleOf: "Gemini's function Schema has no multipleOf, and dropping it would widen the allowed numbers",
6190
+ exclusiveMinimum: "Gemini's function Schema has only minimum (inclusive), and dropping the exclusive bound would widen the allowed numbers",
6191
+ exclusiveMaximum: "Gemini's function Schema has only maximum (inclusive), and dropping the exclusive bound would widen the allowed numbers",
6192
+ examples: "Gemini's function Schema spells a sample as example (singular) — use example",
6193
+ contentMediaType: "Gemini's function Schema has no contentMediaType — describe the encoding in description",
6194
+ contentEncoding: "Gemini's function Schema has no contentEncoding — describe the encoding in description",
6195
+ readOnly: "Gemini's function Schema has no readOnly — drop it, or say so in description",
6196
+ writeOnly: "Gemini's function Schema has no writeOnly — drop it, or say so in description",
6197
+ deprecated: "Gemini's function Schema has no deprecated — say so in description",
6198
+ };
6199
+ /**
6200
+ * Sibling keywords that make a `oneOf` node a MIXED schema rather than a clean
6201
+ * tagged union. Exactly `describeSchema`'s list
6202
+ * (`src/workflows/schema-descriptor.ts`) — the platform's own rule for what a
6203
+ * discriminated union is, restated here because that module is vendored
6204
+ * separately and cannot be imported from `src/config-surface/`.
6205
+ */
6206
+ const ONEOF_SIBLING_KEYWORDS = [
6207
+ "type",
6208
+ "properties",
6209
+ "required",
6210
+ "additionalProperties",
6211
+ "items",
6212
+ "enum",
6213
+ ];
6214
+ function isPlainSchemaObject(value) {
6215
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6216
+ }
6217
+ function schemaCopy(value) {
6218
+ return value === undefined ? value : JSON.parse(JSON.stringify(value));
6219
+ }
6220
+ /** A tag literal the platform validator can branch on with strict `===`. */
6221
+ function isScalarTag(value) {
6222
+ return (typeof value === "string" ||
6223
+ typeof value === "number" ||
6224
+ typeof value === "boolean");
6225
+ }
6226
+ /** `properties.amount` under `items` is `items.properties.amount`. */
6227
+ function childPath(path, step) {
6228
+ return path === "" ? step : `${path}.${step}`;
6229
+ }
6230
+ /** What a message calls the root. */
6231
+ export function schemaPathLabel(path) {
6232
+ return path === "" ? "the schema root" : path;
6233
+ }
6234
+ /**
6235
+ * The single-literal-tag properties of one `oneOf` member: `{ name → literal }`
6236
+ * for every property declared as a one-entry `enum` or a scalar `const`.
6237
+ */
6238
+ function memberTagLiterals(member) {
6239
+ const tags = new Map();
6240
+ const properties = member.properties;
6241
+ if (!isPlainSchemaObject(properties))
6242
+ return tags;
6243
+ for (const [name, sub] of Object.entries(properties)) {
6244
+ if (!isPlainSchemaObject(sub))
6245
+ continue;
6246
+ if (Array.isArray(sub.enum) && sub.enum.length === 1 && isScalarTag(sub.enum[0])) {
6247
+ tags.set(name, sub.enum[0]);
6248
+ continue;
6249
+ }
6250
+ if (Object.prototype.hasOwnProperty.call(sub, "const") && isScalarTag(sub.const)) {
6251
+ tags.set(name, sub.const);
6252
+ }
6253
+ }
6254
+ return tags;
6255
+ }
6256
+ /**
6257
+ * Is this `oneOf` a discriminated union whose members are mutually exclusive
6258
+ * BY CONSTRUCTION, so that `anyOf` of the same members admits exactly the same
6259
+ * values (DSO-3799-003)?
6260
+ *
6261
+ * `describeSchema`'s rule, plus the one thing it does not check: the
6262
+ * discriminant must be listed under `required` in every member. Without that,
6263
+ * two members with optional tags both admit `{}`, which the authored `oneOf`
6264
+ * and the platform validator reject but an `anyOf` of the same members accepts
6265
+ * — so the rewrite would widen what the model may produce. The platform
6266
+ * validator already rejects a value whose tag is missing
6267
+ * (`src/workflows/schema-validation.ts`), so requiring it refuses nothing the
6268
+ * platform accepts at run time.
6269
+ */
6270
+ function discriminatedOneOf(node, members) {
6271
+ const objects = members.filter((member) => isPlainSchemaObject(member) && member.type === "object");
6272
+ if (objects.length !== members.length || members.length < 2) {
6273
+ return {
6274
+ ok: false,
6275
+ reason: "Gemini's function Schema has no oneOf. Only a DISCRIMINATED oneOf can " +
6276
+ "be rewritten as anyOf without widening it, and that needs at least two " +
6277
+ 'members, every one of them type = "object"',
6278
+ };
6279
+ }
6280
+ const sibling = ONEOF_SIBLING_KEYWORDS.filter((key) => Object.prototype.hasOwnProperty.call(node, key));
6281
+ if (sibling.length > 0) {
6282
+ return {
6283
+ ok: false,
6284
+ reason: "Gemini's function Schema has no oneOf, and this one is not a clean " +
6285
+ `discriminated union: it carries the sibling constraint${sibling.length > 1 ? "s" : ""} ` +
6286
+ `${sibling.join(", ")}. Move the shared constraints into each member`,
6287
+ };
6288
+ }
6289
+ if (Object.prototype.hasOwnProperty.call(node, "anyOf")) {
6290
+ return {
6291
+ ok: false,
6292
+ reason: "Gemini's function Schema has no oneOf, and this node already carries " +
6293
+ "an anyOf the rewrite would replace. Declare one or the other",
6294
+ };
6295
+ }
6296
+ const tags = objects.map(memberTagLiterals);
6297
+ const candidates = [];
6298
+ for (const name of tags[0].keys()) {
6299
+ if (!tags.every((map) => map.has(name)))
6300
+ continue;
6301
+ const literals = tags.map((map) => map.get(name));
6302
+ const distinct = new Set(literals.map((literal) => JSON.stringify(literal)));
6303
+ if (distinct.size === literals.length)
6304
+ candidates.push(name);
6305
+ }
6306
+ if (candidates.length !== 1) {
6307
+ return {
6308
+ ok: false,
6309
+ reason: candidates.length === 0
6310
+ ? "Gemini's function Schema has no oneOf, and this one is not " +
6311
+ "discriminated: every member must share exactly one property " +
6312
+ "declared as a distinct single-literal enum (or const)"
6313
+ : "Gemini's function Schema has no oneOf, and this one's " +
6314
+ `discriminant is ambiguous: ${candidates.join(", ")} all qualify. ` +
6315
+ "Exactly one is required",
6316
+ };
6317
+ }
6318
+ const discriminant = candidates[0];
6319
+ const omitting = objects
6320
+ .map((member, index) => Array.isArray(member.required) && member.required.includes(discriminant)
6321
+ ? null
6322
+ : index)
6323
+ .filter((index) => index !== null);
6324
+ if (omitting.length > 0) {
6325
+ return {
6326
+ ok: false,
6327
+ reason: `Gemini's function Schema has no oneOf. Rewriting this one as anyOf ` +
6328
+ `would widen it, because member${omitting.length > 1 ? "s" : ""} ` +
6329
+ `${omitting.join(", ")} do${omitting.length > 1 ? "" : "es"} not list ` +
6330
+ `the discriminant "${discriminant}" under required, so a value with no ` +
6331
+ `"${discriminant}" would match every member. List "${discriminant}" ` +
6332
+ "under required in every member",
6333
+ };
6334
+ }
6335
+ return { ok: true, discriminant };
6336
+ }
6337
+ /**
6338
+ * Translate one node into Gemini's `Schema`, appending every refusal it holds
6339
+ * to `out`. Returns the translated node, or `null` when the node itself is not
6340
+ * a schema object.
6341
+ */
6342
+ function translateGeminiNode(node, path, out) {
6343
+ if (!isPlainSchemaObject(node)) {
6344
+ out.push({
6345
+ path,
6346
+ keyword: "(schema)",
6347
+ reason: "is not a JSON Schema object; Gemini's function Schema needs a table " +
6348
+ "declaring at least a type",
6349
+ });
6350
+ return null;
6351
+ }
6352
+ const result = {};
6353
+ /** Faults raised AT THIS NODE — nested ones must not suppress its own rules. */
6354
+ let nodeFaults = 0;
6355
+ const refuse = (keyword, reason) => {
6356
+ nodeFaults += 1;
6357
+ out.push({ path, keyword, reason });
6358
+ };
6359
+ // ── Pass one: the tables, in the author's own key order ────────────────
6360
+ const deferred = new Set();
6361
+ for (const key of Object.keys(node)) {
6362
+ if (GEMINI_DROPPED_KEYWORDS.includes(key))
6363
+ continue;
6364
+ if (key === "additionalProperties") {
6365
+ // Dropped for `false` and `true` alike: Gemini has no closed-object
6366
+ // spelling, and the platform validates the ORIGINAL schema (DSO-3799-001).
6367
+ const value = node[key];
6368
+ if (value === false || value === true)
6369
+ continue;
6370
+ refuse(key, isPlainSchemaObject(value)
6371
+ ? "Gemini's function Schema has no additionalProperties, so a schema " +
6372
+ "for the extra properties cannot be carried — name the properties"
6373
+ : "additionalProperties must be true or false (Gemini carries neither, " +
6374
+ "but the platform validates the original schema)");
6375
+ continue;
6376
+ }
6377
+ if (Object.prototype.hasOwnProperty.call(GEMINI_REFUSED_KEYWORDS, key)) {
6378
+ refuse(key, GEMINI_REFUSED_KEYWORDS[key]);
6379
+ continue;
6380
+ }
6381
+ if (GEMINI_PASS_KEYWORDS.includes(key)) {
6382
+ result[key] = schemaCopy(node[key]);
6383
+ continue;
6384
+ }
6385
+ if (GEMINI_STRUCTURAL_KEYWORDS.includes(key)) {
6386
+ deferred.add(key);
6387
+ continue;
6388
+ }
6389
+ // Fail closed: a keyword nobody classified could mean anything.
6390
+ refuse(key, "is not a keyword Gemini's function Schema carries, and dropping a " +
6391
+ "keyword whose meaning the platform does not know could widen what the " +
6392
+ "model may produce");
6393
+ }
6394
+ // ── Pass two: the structural keywords, in a fixed order ────────────────
6395
+ if (deferred.has("enum")) {
6396
+ const values = node.enum;
6397
+ if (!Array.isArray(values) || values.length === 0) {
6398
+ refuse("enum", "must be a non-empty list of strings");
6399
+ }
6400
+ else if (values.every((value) => typeof value === "string")) {
6401
+ result.enum = schemaCopy(values);
6402
+ }
6403
+ else {
6404
+ refuse("enum", "Gemini's function Schema takes only STRING enum values; write the " +
6405
+ "allowed values as strings, or state the constraint in description");
6406
+ }
6407
+ }
6408
+ if (deferred.has("const")) {
6409
+ const value = node.const;
6410
+ if (typeof value !== "string") {
6411
+ refuse("const", "Gemini's function Schema has no const, and only a string one can be " +
6412
+ 'rewritten as enum: ["…"]');
6413
+ }
6414
+ else if (deferred.has("enum")) {
6415
+ refuse("const", "Gemini's function Schema has no const; it is rewritten as a " +
6416
+ "single-literal enum, which this node already declares. Keep one of the two");
6417
+ }
6418
+ else {
6419
+ result.enum = [value];
6420
+ }
6421
+ }
6422
+ if (deferred.has("format")) {
6423
+ const value = node.format;
6424
+ if (typeof value === "string" &&
6425
+ GEMINI_FORMATS.includes(value)) {
6426
+ result.format = value;
6427
+ }
6428
+ else {
6429
+ refuse("format", `Gemini's function Schema documents only ${GEMINI_FORMATS.join(", ")}; ` +
6430
+ "state anything else in description");
6431
+ }
6432
+ }
6433
+ /** A type union to rewrite as `anyOf`, once its members' keywords are translated. */
6434
+ let unionTypes = null;
6435
+ if (deferred.has("type")) {
6436
+ const typeSpec = node.type;
6437
+ if (typeof typeSpec === "string") {
6438
+ if (GEMINI_TYPES.includes(typeSpec)) {
6439
+ result.type = typeSpec;
6440
+ }
6441
+ else {
6442
+ refuse("type", `Gemini's function Schema takes one of ${GEMINI_TYPES.join(", ")}; ` +
6443
+ `found ${JSON.stringify(typeSpec)}` +
6444
+ (typeSpec === "null"
6445
+ ? '. A nullable value is spelled type: [T, "null"]'
6446
+ : ""));
6447
+ }
6448
+ }
6449
+ else if (Array.isArray(typeSpec)) {
6450
+ const nullable = typeSpec.includes("null");
6451
+ const named = typeSpec.filter((entry) => entry !== "null");
6452
+ const unknown = named.filter((entry) => typeof entry !== "string" ||
6453
+ !GEMINI_TYPES.includes(entry));
6454
+ if (unknown.length > 0) {
6455
+ refuse("type", `Gemini's function Schema takes one of ${GEMINI_TYPES.join(", ")}; ` +
6456
+ `found ${unknown.map((entry) => JSON.stringify(entry)).join(", ")}`);
6457
+ }
6458
+ else if (named.length === 0) {
6459
+ refuse("type", "Gemini's function Schema has no null type on its own; a nullable " +
6460
+ 'value is spelled type: [T, "null"]');
6461
+ }
6462
+ else if (named.length === 1) {
6463
+ result.type = named[0];
6464
+ if (nullable)
6465
+ result.nullable = true;
6466
+ }
6467
+ else if (deferred.has("anyOf")) {
6468
+ refuse("type", "a type union is rewritten as anyOf of the single types, which this " +
6469
+ "node already declares. Write the union inside the anyOf instead");
6470
+ }
6471
+ else {
6472
+ unionTypes = named;
6473
+ if (nullable)
6474
+ result.nullable = true;
6475
+ }
6476
+ }
6477
+ else {
6478
+ refuse("type", `must be a type name or a list of them; found ${typeof typeSpec}`);
6479
+ }
6480
+ }
6481
+ if (deferred.has("properties")) {
6482
+ const properties = node.properties;
6483
+ if (!isPlainSchemaObject(properties)) {
6484
+ refuse("properties", "must be a table of property name to schema");
6485
+ }
6486
+ else {
6487
+ const translated = {};
6488
+ for (const [name, sub] of Object.entries(properties)) {
6489
+ const child = translateGeminiNode(sub, childPath(path, `properties.${name}`), out);
6490
+ // Defined, not assigned: a JSON-parsed schema may name a property
6491
+ // `__proto__`, and assigning it would set the prototype and drop the
6492
+ // property from what Gemini is sent (CSO-3799-006).
6493
+ if (child) {
6494
+ Object.defineProperty(translated, name, {
6495
+ value: child,
6496
+ enumerable: true,
6497
+ writable: true,
6498
+ configurable: true,
6499
+ });
6500
+ }
6501
+ }
6502
+ result.properties = translated;
6503
+ }
6504
+ }
6505
+ if (deferred.has("items")) {
6506
+ const child = translateGeminiNode(node.items, childPath(path, "items"), out);
6507
+ if (child)
6508
+ result.items = child;
6509
+ }
6510
+ if (deferred.has("anyOf")) {
6511
+ const members = node.anyOf;
6512
+ if (!Array.isArray(members) || members.length === 0) {
6513
+ refuse("anyOf", "must be a non-empty list of schemas");
6514
+ }
6515
+ else {
6516
+ const translated = [];
6517
+ members.forEach((member, index) => {
6518
+ const child = translateGeminiNode(member, childPath(path, `anyOf[${index}]`), out);
6519
+ if (child)
6520
+ translated.push(child);
6521
+ });
6522
+ result.anyOf = translated;
6523
+ }
6524
+ }
6525
+ if (deferred.has("oneOf")) {
6526
+ const members = node.oneOf;
6527
+ if (!Array.isArray(members)) {
6528
+ refuse("oneOf", "must be a list of schemas");
6529
+ }
6530
+ else {
6531
+ const verdict = discriminatedOneOf(node, members);
6532
+ if (verdict.ok === false) {
6533
+ refuse("oneOf", verdict.reason);
6534
+ }
6535
+ else {
6536
+ // A discriminated oneOf whose members all require their tag is mutually
6537
+ // exclusive by construction, so anyOf of the same members admits
6538
+ // exactly the same values (DSO-3799-003).
6539
+ const translated = [];
6540
+ members.forEach((member, index) => {
6541
+ const child = translateGeminiNode(member, childPath(path, `oneOf[${index}]`), out);
6542
+ if (child)
6543
+ translated.push(child);
6544
+ });
6545
+ result.anyOf = translated;
6546
+ }
6547
+ }
6548
+ }
6549
+ // A type union becomes `anyOf` of the single types, each member carrying the
6550
+ // keywords that constrain its type (CSO-3799-003): `{ type: ["object",
6551
+ // "string"], properties }` means an object WITH those properties, or a
6552
+ // string. Everything else stays on the node, beside the `anyOf`.
6553
+ if (unionTypes) {
6554
+ const members = unionTypes.map((type) => {
6555
+ const member = { type };
6556
+ for (const key of GEMINI_UNION_MEMBER_KEYWORDS[type] ?? []) {
6557
+ if (result[key] !== undefined)
6558
+ member[key] = result[key];
6559
+ }
6560
+ return member;
6561
+ });
6562
+ for (const type of unionTypes) {
6563
+ for (const key of GEMINI_UNION_MEMBER_KEYWORDS[type] ?? []) {
6564
+ delete result[key];
6565
+ }
6566
+ }
6567
+ result.anyOf = members;
6568
+ }
6569
+ // ── Pass three: what Gemini requires of a complete node ────────────────
6570
+ //
6571
+ // Only when this node raised no fault of its own: a node refused for `$ref`
6572
+ // or `allOf` would otherwise be refused a second time for the type that
6573
+ // keyword was carrying.
6574
+ if (nodeFaults === 0) {
6575
+ const hasBranches = deferred.has("anyOf") || deferred.has("oneOf");
6576
+ // A STRING enum carries its own type — every value it admits is a string —
6577
+ // so a node declaring one needs no `type` beside it. That is what keeps the
6578
+ // platform's own discriminated-union dialect inside Gemini's reach: a tag is
6579
+ // spelled `{ enum: ["cash"] }` with no type (`schema-descriptor.ts`), and
6580
+ // Gemini accepts exactly that on a `functionDeclarations[].parameters`
6581
+ // property (verified live, #3799 behavior 23). `result.enum` also covers a
6582
+ // string `const` rewritten above.
6583
+ const hasEnum = result.enum !== undefined;
6584
+ if (!deferred.has("type") && !hasBranches && !hasEnum) {
6585
+ refuse("type", "Gemini's function Schema needs a type on every schema (one of " +
6586
+ `${GEMINI_TYPES.join(", ")}), or an anyOf of typed members`);
6587
+ }
6588
+ else if (result.type === "object") {
6589
+ const properties = result.properties;
6590
+ if (!isPlainSchemaObject(properties) || Object.keys(properties).length === 0) {
6591
+ refuse("properties", "Gemini needs the object's properties: its function Schema has no " +
6592
+ "free-form object, and the existing clean-up rewrote one as a " +
6593
+ "string, which changes what the model may produce. Name the " +
6594
+ "properties the model may send");
6595
+ }
6596
+ }
6597
+ else if (result.type === "array" && result.items === undefined) {
6598
+ refuse("items", "Gemini needs the array's items: its function Schema has no untyped array");
6599
+ }
6600
+ // The members a type union was rewritten into are held to the same rule
6601
+ // as a single-typed node, so a union cannot carry the free-form object or
6602
+ // the untyped array past it (CSO-3799-003).
6603
+ if (unionTypes) {
6604
+ const members = result.anyOf;
6605
+ for (const member of members) {
6606
+ if (member.type === "object" &&
6607
+ (!isPlainSchemaObject(member.properties) ||
6608
+ Object.keys(member.properties).length === 0)) {
6609
+ refuse("properties", "Gemini needs the object's properties, for the object member of " +
6610
+ "this type union too: its function Schema has no free-form " +
6611
+ "object. Name the properties the model may send");
6612
+ }
6613
+ else if (member.type === "array" && member.items === undefined) {
6614
+ refuse("items", "Gemini needs the array's items, for the array member of this type " +
6615
+ "union too: its function Schema has no untyped array");
6616
+ }
6617
+ }
6618
+ }
6619
+ }
6620
+ return result;
6621
+ }
6622
+ /**
6623
+ * Translate one schema for one provider, or refuse it naming every keyword it
6624
+ * cannot carry.
6625
+ *
6626
+ * OpenRouter is identity — a deep copy of the input, so a caller that edits the
6627
+ * result never reaches the stored original. Gemini follows the table in spec
6628
+ * §4. Every refusal of one schema is returned at once, so an author fixes it in
6629
+ * one pass (principle 6).
6630
+ */
6631
+ export function translateSchemaForProvider(schema, provider) {
6632
+ if (provider !== "gemini") {
6633
+ // Identity: the request carries the schema as declared and the model's
6634
+ // upstream owns its dialect.
6635
+ if (!isPlainSchemaObject(schema)) {
6636
+ return {
6637
+ ok: false,
6638
+ refusals: [
6639
+ {
6640
+ path: "",
6641
+ keyword: "(schema)",
6642
+ reason: "is not a JSON Schema object",
6643
+ },
6644
+ ],
6645
+ };
6646
+ }
6647
+ return { ok: true, schema: schemaCopy(schema) };
6648
+ }
6649
+ const refusals = [];
6650
+ const translated = translateGeminiNode(schema, "", refusals);
6651
+ if (refusals.length > 0)
6652
+ return { ok: false, refusals };
6653
+ return { ok: true, schema: translated };
6654
+ }
6655
+ /** `{ gemini: ["default", "slow"] }` — the non-archived configs, by provider. */
6656
+ function configsByProvider(configs) {
6657
+ const order = [];
6658
+ const names = new Map();
6659
+ for (const config of configs) {
6660
+ if (String(config?.status ?? "active") === "archived")
6661
+ continue;
6662
+ const provider = String(config?.provider ?? "").toLowerCase();
6663
+ if (!provider)
6664
+ continue;
6665
+ if (!names.has(provider)) {
6666
+ names.set(provider, []);
6667
+ order.push(provider);
6668
+ }
6669
+ names.get(provider).push(String(config.configName ?? ""));
6670
+ }
6671
+ return order.map((provider) => ({
6672
+ provider,
6673
+ configNames: names.get(provider),
6674
+ }));
6675
+ }
6676
+ /** A stored schema value: an object as it is, a JSON string parsed. */
6677
+ function readStoredSchema(value) {
6678
+ if (value === undefined || value === null)
6679
+ return null;
6680
+ if (typeof value === "string") {
6681
+ if (value.trim() === "")
6682
+ return null;
6683
+ try {
6684
+ return { ok: true, schema: JSON.parse(value) };
6685
+ }
6686
+ catch {
6687
+ return { ok: false };
6688
+ }
6689
+ }
6690
+ return { ok: true, schema: value };
6691
+ }
6692
+ /**
6693
+ * Every schema of an agent that the model reads: each tool's `inputSchema` and
6694
+ * `outputSchema` (from the TOML for a client tool, from the function's own
6695
+ * schemas for a server tool) and each event's `schema`.
6696
+ */
6697
+ function agentSchemaSubjects(declaration, functions) {
6698
+ const subjects = [];
6699
+ const tools = Array.isArray(declaration.tools) ? declaration.tools : [];
6700
+ tools.forEach((tool) => {
6701
+ if (!isPlainSchemaObject(tool))
6702
+ return;
6703
+ const name = typeof tool.name === "string" ? tool.name : "(unnamed)";
6704
+ const functionKey = typeof tool.function === "string" && tool.function.trim() !== ""
6705
+ ? tool.function.trim()
6706
+ : null;
6707
+ if (functionKey === null) {
6708
+ // A client tool: its schemas are in the declaration.
6709
+ for (const which of ["inputSchema", "outputSchema"]) {
6710
+ if (tool[which] === undefined)
6711
+ continue;
6712
+ subjects.push({ subject: `tool "${name}"`, which, schema: tool[which] });
6713
+ }
6714
+ return;
6715
+ }
6716
+ // A server tool: its schemas are its function's. `undefined` facts are the
6717
+ // function-missing and schema-missing refusals of #3798, not this rule's.
6718
+ if (!functions)
6719
+ return;
6720
+ const facts = Object.prototype.hasOwnProperty.call(functions, functionKey)
6721
+ ? functions[functionKey]
6722
+ : null;
6723
+ if (!facts)
6724
+ return;
6725
+ const subject = `tool "${name}" (function "${functionKey}")`;
6726
+ for (const which of ["inputSchema", "outputSchema"]) {
6727
+ const read = readStoredSchema(which === "inputSchema" ? facts.inputSchema : facts.outputSchema);
6728
+ if (read === null)
6729
+ continue;
6730
+ if (read.ok === false) {
6731
+ subjects.push({ subject, which, unparsable: functionKey });
6732
+ continue;
6733
+ }
6734
+ subjects.push({ subject, which, schema: read.schema });
6735
+ }
6736
+ });
6737
+ const events = Array.isArray(declaration.events) ? declaration.events : [];
6738
+ events.forEach((event) => {
6739
+ if (!isPlainSchemaObject(event))
6740
+ return;
6741
+ if (event.schema === undefined)
6742
+ return;
6743
+ const name = typeof event.name === "string" ? event.name : "(unnamed)";
6744
+ subjects.push({
6745
+ subject: `event "${name}"`,
6746
+ which: "schema",
6747
+ schema: event.schema,
6748
+ });
6749
+ });
6750
+ return subjects;
6751
+ }
6752
+ /** `config "default"` / `configs "default", "slow"`. */
6753
+ function configList(configNames) {
6754
+ const quoted = configNames.map((name) => `"${name}"`).join(", ");
6755
+ return `${configNames.length === 1 ? "config" : "configs"} ${quoted}`;
6756
+ }
6757
+ /**
6758
+ * Every `AGENT_SCHEMA_NOT_TRANSLATABLE` refusal an agent's declaration owes,
6759
+ * checked against the provider of every config that is not archived
6760
+ * (intent criterion 2).
6761
+ *
6762
+ * One refusal per (schema, path, keyword, provider): it names the tool or
6763
+ * event, which schema, the dotted path, the keyword, the provider, the config
6764
+ * names on that provider, and the remedy. Two configs on the same provider
6765
+ * produce ONE refusal naming both — the author's fix is the same either way.
6766
+ *
6767
+ * A declaration with no tools and no events reads no function schema at all.
6768
+ */
6769
+ export function agentSchemaTranslationRefusals(input) {
6770
+ const declaration = parseAgentDeclarationBlock(input.declaration);
6771
+ if (!declaration)
6772
+ return [];
6773
+ const providers = configsByProvider(input.configs ?? []);
6774
+ if (providers.length === 0)
6775
+ return [];
6776
+ const subjects = agentSchemaSubjects(declaration, input.functions);
6777
+ if (subjects.length === 0)
6778
+ return [];
6779
+ const out = [];
6780
+ for (const subject of subjects) {
6781
+ if (subject.unparsable !== undefined) {
6782
+ out.push({
6783
+ code: "AGENT_SCHEMA_NOT_TRANSLATABLE",
6784
+ message: `${AGENT_BLOCK_LABEL} ${subject.subject} ${subject.which} is not a ` +
6785
+ `JSON Schema the platform can read, so it cannot be translated for ` +
6786
+ `any provider. Fix ${subject.which} in ` +
6787
+ `functions/${subject.unparsable}.toml`,
6788
+ });
6789
+ continue;
6790
+ }
6791
+ for (const { provider, configNames } of providers) {
6792
+ const result = translateSchemaForProvider(subject.schema, provider);
6793
+ if (result.ok === true)
6794
+ continue;
6795
+ for (const refusal of result.refusals) {
6796
+ out.push({
6797
+ code: "AGENT_SCHEMA_NOT_TRANSLATABLE",
6798
+ message: `${AGENT_BLOCK_LABEL} ${subject.subject} ${subject.which} at ` +
6799
+ `${schemaPathLabel(refusal.path)}: ${refusal.keyword} cannot be ` +
6800
+ `translated for ${provider} without losing meaning — ` +
6801
+ `${refusal.reason}. ${configList(configNames)} run on ${provider}; ` +
6802
+ `rewrite the schema, or archive every ${provider} config of this prompt`,
6803
+ });
6804
+ }
6805
+ }
6806
+ }
6807
+ return out;
6808
+ }
6809
+ // ── src/config-surface/prompt.ts ─────────────────────────────────────────
6810
+ /**
6811
+ * The `prompt` configuration object's definition (issue #2644, phase 2).
6812
+ *
6813
+ * `prompts/<key>.toml` carries TWO field tables: the `[prompt]` table backed by
6814
+ * `AppPrompt`, and the repeated `[[configs]]` table backed by `AppPromptConfig`
6815
+ * — one entry per named LLM configuration. Both are declared here, so a field
6816
+ * added to either model is covered by the same guard.
6817
+ *
6818
+ * The `[[configs]]` entries are the reason prompts have two tables rather than
6819
+ * a `structural` note: they are a real field surface the CLI pushes
6820
+ * (`createPromptConfig` / `updatePromptConfig`) and pulls, not an opaque blob.
6821
+ */
6822
+ const PROMPT_HANDLER = "src/admin-api.ts";
6823
+ /**
6824
+ * #3798 — the blocks a model setting both kinds use is authored under: the
6825
+ * chat block, and the agent block that is the chat block less its template
6826
+ * and output keys (`AGENT_CONFIG_KEYS`).
6827
+ */
6828
+ const SHARED_WITH_AGENT = ["chat", "agent"];
6829
+ /**
6830
+ * #3626 §4 — the nine chat keys, still accepted FLAT under `[[configs]]`.
6831
+ *
6832
+ * The blob-bucket `accessPolicy` precedent: declared `deprecated`, so
6833
+ * `config push` keeps taking a file written before the `[configs.chat]` block
6834
+ * existed, while `config pull` never writes the spelling again. One
6835
+ * pull-then-push migrates a file. `hoistDeprecatedChatKeys` moves them into the
6836
+ * group before the payload is built; rejecting the flat spelling outright is
6837
+ * #3642. #3801 — only the nine: a chat key added later (`strictOutput`) was
6838
+ * never written flat, so it has no deprecated spelling to accept.
6839
+ */
6840
+ const DEPRECATED_FLAT_CHAT_KEYS = Object.fromEntries(LEGACY_FLAT_CHAT_KEYS.map((key) => [
6841
+ key,
6842
+ {
6843
+ kind: "deprecated",
6844
+ note: `#3626 — the pre-kind spelling of \`[configs.chat].${key}\`. Still ` +
6845
+ "accepted flat so no existing prompt file breaks (push hoists it " +
6846
+ "into the block and warns); `config pull` writes only the grouped " +
6847
+ "spelling, so one pull-then-push migrates the file. Rejecting the " +
6848
+ "flat spelling is #3642.",
6849
+ },
6850
+ ]));
6851
+ export const PROMPT_SURFACE = {
6852
+ label: "prompt",
6853
+ tables: [
6854
+ {
6855
+ tomlPath: ["prompt"],
6856
+ repeated: false,
6857
+ model: "AppPrompt",
6858
+ // Declaration order reproduces the pre-#2644 serializer's key order.
6859
+ fields: [
6860
+ // #3626 — WHAT this prompt runs. `chat` is today's prompt (a chat
6861
+ // completion rendered from templates); `decisions` names OpenRouter's
6862
+ // `/api/alpha/decisions` endpoint, which takes a `state` value plus
6863
+ // named typed questions. It lives on the PROMPT because it decides the
6864
+ // input envelope, the output type, the meaning of `inputSchema` /
6865
+ // `outputSchema` and the shape of every test case's `inputVariables` —
6866
+ // all of which are asserted per prompt KEY.
6867
+ //
6868
+ // `emit: "always"` so a pulled file says which kind it is; an omitted
6869
+ // key is `chat`, so no file written before this changes meaning, and
6870
+ // `serverDefault` is what lets push send `chat` for an absent key and
6871
+ // `config diff` read both spellings as one value.
6872
+ //
6873
+ // CREATE_ONLY, and immutable in the strong sense (D3626-008): changing
6874
+ // a prompt's kind under its key would change the contract of every
6875
+ // caller compiled against it, so the update handler never reads it and
6876
+ // the push gate declines the change as `immutable` — the `ttlTier`
6877
+ // precedent. The prompt comparator projects `[prompt]` in CREATE mode
6878
+ // for exactly that reason (SO3626-008).
6879
+ {
6880
+ field: "kind",
6881
+ tomlKey: "kind",
6882
+ type: "string",
6883
+ emit: "always",
6884
+ writableOn: CREATE_ONLY,
6885
+ serverDefault: "chat",
6886
+ validation: handledBy("src/config-surface/prompt-kinds.ts", "resolvePromptKind", "apply"),
6887
+ },
6888
+ {
6889
+ field: "displayName",
6890
+ tomlKey: "displayName",
6891
+ type: "string",
6892
+ emit: "always",
6893
+ writableOn: BOTH,
6894
+ validation: "passthrough",
6895
+ },
6896
+ {
6897
+ field: "description",
6898
+ tomlKey: "description",
6899
+ type: "string",
6900
+ emit: "whenSet",
6901
+ writableOn: BOTH,
6902
+ validation: "passthrough",
6903
+ },
6904
+ // JSON Schema objects. Stored as JSON strings, returned parsed; TOML
6905
+ // may present them as an inline table or a JSON string.
6906
+ {
6907
+ field: "inputSchema",
6908
+ tomlKey: "inputSchema",
6909
+ type: "json",
6910
+ emit: "whenSet",
6911
+ writableOn: BOTH,
6912
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
6913
+ },
6914
+ // #2644 — accepted by both handlers and returned by the detail, but
6915
+ // never written to TOML before this: a prompt's declared output shape
6916
+ // was invisible to config-as-code.
6917
+ {
6918
+ field: "outputSchema",
6919
+ tomlKey: "outputSchema",
6920
+ type: "json",
6921
+ emit: "whenSet",
6922
+ writableOn: BOTH,
6923
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
6924
+ },
6925
+ // #2652 — the CEL rule gating who may execute the prompt. Required at
6926
+ // create; an explicit null/"" on update clears it (fail-closed). This
6927
+ // declaration is the whole authoring channel: the admin routes' unknown
6928
+ // key guard reads the same table, so it is what makes `accessRule` an
6929
+ // accepted body key as well as a pulled and pushed TOML line.
6930
+ {
6931
+ field: "accessRule",
6932
+ tomlKey: "accessRule",
6933
+ type: "string",
5230
6934
  emit: "whenSet",
5231
6935
  writableOn: BOTH,
5232
6936
  validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
5233
6937
  },
6938
+ // #3798 — an AGENT's contract: its tools, event kinds, turn context,
6939
+ // history selection, who answers a paused call, and its limits. One
6940
+ // prompt-level block because they belong to the prompt, like
6941
+ // `inputSchema`, not to a named config (intent §D1).
6942
+ //
6943
+ // A `json` field like the schemas: authored as nested tables
6944
+ // (`[prompt.agent]`, `[[prompt.agent.tools]]`) or as one JSON string,
6945
+ // stored as the AUTHORED value with no defaults filled in and returned
6946
+ // parsed, so `config diff`'s value comparison reads a pushed file
6947
+ // Synced (DSO-3798-001). Only a `kind = "agent"` prompt may carry it.
6948
+ {
6949
+ field: "agent",
6950
+ tomlKey: "agent",
6951
+ type: "json",
6952
+ emit: "whenSet",
6953
+ writableOn: BOTH,
6954
+ validation: handledBy("src/config-surface/agent-declaration.ts", "resolveAgentDeclaration", "apply"),
6955
+ },
5234
6956
  ],
5235
6957
  notExposed: {
5236
6958
  promptId: {
@@ -5342,6 +7064,12 @@ export const PROMPT_SURFACE = {
5342
7064
  "named typed questions OpenRouter's /api/alpha/decisions endpoint " +
5343
7065
  "answers. Authored under `[configs.decisions.questions.<name>]`.",
5344
7066
  },
7067
+ agent: {
7068
+ note: "#3798 — the model settings of a `kind = \"agent\"` prompt: the " +
7069
+ "six keys it shares with chat (systemPrompt, temperature, topP, " +
7070
+ "maxTokens, reasoningEffort, reasoningBudget), and no template, " +
7071
+ "outputFormat or outputSchema. Authored under `[configs.agent]`.",
7072
+ },
5345
7073
  },
5346
7074
  fields: [
5347
7075
  {
@@ -5356,9 +7084,16 @@ export const PROMPT_SURFACE = {
5356
7084
  // #2645 — a config's status is configuration: `prompts configs update
5357
7085
  // --status archived` used to be the only way to retire one, and pull
5358
7086
  // emitted nothing, so the file could not say which of its configs the
5359
- // server still runs. Update-only: create hard-codes "active" and
5360
- // ignores a supplied status, same statement `writableOn` makes for
5361
- // every create-assigned field.
7087
+ // server still runs.
7088
+ //
7089
+ // #3799 (DSO-3799-005) — writable on CREATE too. It was update-only,
7090
+ // which meant `config push` created every config live and archived it
7091
+ // afterwards; a pulled tree whose retired config is on a provider the
7092
+ // new schema-translation check refuses could therefore not be pushed
7093
+ // into a fresh environment at all, because the refusal fired on a state
7094
+ // the file never asked for. Both create paths honor `active |
7095
+ // archived` now (default `active`, so a body that omits it is
7096
+ // unchanged), and the post-create status patches are retired.
5362
7097
  //
5363
7098
  // #3201 — emitted only when it is NOT the create default, so a pulled
5364
7099
  // file says `status = "archived"` about a retired config and says
@@ -5373,7 +7108,7 @@ export const PROMPT_SURFACE = {
5373
7108
  tomlKey: "status",
5374
7109
  type: "string",
5375
7110
  emit: "whenSet",
5376
- writableOn: UPDATE_ONLY,
7111
+ writableOn: BOTH,
5377
7112
  validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
5378
7113
  serverDefault: "active",
5379
7114
  },
@@ -5405,7 +7140,7 @@ export const PROMPT_SURFACE = {
5405
7140
  {
5406
7141
  field: "systemPrompt",
5407
7142
  tomlKey: "systemPrompt",
5408
- tomlGroup: "chat",
7143
+ tomlGroup: SHARED_WITH_AGENT,
5409
7144
  type: "string",
5410
7145
  emit: "whenSet",
5411
7146
  writableOn: BOTH,
@@ -5425,7 +7160,7 @@ export const PROMPT_SURFACE = {
5425
7160
  {
5426
7161
  field: "temperature",
5427
7162
  tomlKey: "temperature",
5428
- tomlGroup: "chat",
7163
+ tomlGroup: SHARED_WITH_AGENT,
5429
7164
  type: "number",
5430
7165
  emit: "whenSet",
5431
7166
  writableOn: BOTH,
@@ -5443,7 +7178,7 @@ export const PROMPT_SURFACE = {
5443
7178
  {
5444
7179
  field: "topP",
5445
7180
  tomlKey: "topP",
5446
- tomlGroup: "chat",
7181
+ tomlGroup: SHARED_WITH_AGENT,
5447
7182
  type: "number",
5448
7183
  emit: "whenSet",
5449
7184
  writableOn: BOTH,
@@ -5455,7 +7190,7 @@ export const PROMPT_SURFACE = {
5455
7190
  {
5456
7191
  field: "maxTokens",
5457
7192
  tomlKey: "maxTokens",
5458
- tomlGroup: "chat",
7193
+ tomlGroup: SHARED_WITH_AGENT,
5459
7194
  type: "number",
5460
7195
  emit: "whenSet",
5461
7196
  writableOn: BOTH,
@@ -5504,7 +7239,7 @@ export const PROMPT_SURFACE = {
5504
7239
  {
5505
7240
  field: "reasoningEffort",
5506
7241
  tomlKey: "reasoningEffort",
5507
- tomlGroup: "chat",
7242
+ tomlGroup: SHARED_WITH_AGENT,
5508
7243
  type: "string",
5509
7244
  emit: "whenSet",
5510
7245
  writableOn: BOTH,
@@ -5513,12 +7248,29 @@ export const PROMPT_SURFACE = {
5513
7248
  {
5514
7249
  field: "reasoningBudget",
5515
7250
  tomlKey: "reasoningBudget",
5516
- tomlGroup: "chat",
7251
+ tomlGroup: SHARED_WITH_AGENT,
5517
7252
  type: "number",
5518
7253
  emit: "whenSet",
5519
7254
  writableOn: BOTH,
5520
7255
  validation: handledBy("src/config-surface/prompt-reasoning.ts", "resolvePromptReasoning", "apply"),
5521
7256
  },
7257
+ // #3801 — the opt-in that sends `[prompt].outputSchema` as a strict
7258
+ // `json_schema` on OpenRouter instead of JSON mode checked afterwards
7259
+ // (§D6). A chat key only: an agent has no output schema (§D1), and a
7260
+ // decisions config's answer is typed by its questions. No flat
7261
+ // spelling. The rule — one boolean, never beside a config-level
7262
+ // `outputSchema`, OpenRouter only, never with `outputFormat = "text"`,
7263
+ // and a prompt schema to send when the config is not archived — is
7264
+ // `resolvePromptStrictOutput`, read by the routes and the preflight.
7265
+ {
7266
+ field: "strictOutput",
7267
+ tomlKey: "strictOutput",
7268
+ tomlGroup: "chat",
7269
+ type: "boolean",
7270
+ emit: "whenSet",
7271
+ writableOn: BOTH,
7272
+ validation: handledBy("src/config-surface/prompt-strict-output.ts", "resolvePromptStrictOutput", "apply"),
7273
+ },
5522
7274
  // #3626 — the named typed questions a DECISIONS config asks. A `json`
5523
7275
  // field like `outputSchema`: stored as a JSON string, returned parsed,
5524
7276
  // and authored either as nested tables
@@ -6776,6 +8528,11 @@ export const DATABASE_TYPE_SURFACE = {
6776
8528
  writableOn: BOTH,
6777
8529
  // CEL, parsed before storing (#1336). The legacy `metadataAccess`
6778
8530
  // spelling stays readable on push — see tomlOnlyKeys.
8531
+ deprecated: {
8532
+ note: "one CEL expression gates both read and update of the database " +
8533
+ "CEL context; use a resource metadata category, which has " +
8534
+ "separate readRule and writeRule",
8535
+ },
6779
8536
  validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
6780
8537
  },
6781
8538
  {
@@ -8578,6 +10335,7 @@ export const GENERATED_CONFIG_MODEL_FIELDS = {
8578
10335
  "kind",
8579
10336
  "displayName",
8580
10337
  "description",
10338
+ "agent",
8581
10339
  "copiedFromCatalogId",
8582
10340
  "inputSchema",
8583
10341
  "outputSchema",
@@ -8611,6 +10369,7 @@ export const GENERATED_CONFIG_MODEL_FIELDS = {
8611
10369
  "questions",
8612
10370
  "reasoningEffort",
8613
10371
  "reasoningBudget",
10372
+ "strictOutput",
8614
10373
  "status",
8615
10374
  "createdBy",
8616
10375
  "createdAt",