pi-system-one 1.1.4 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tool.ts CHANGED
@@ -10,130 +10,425 @@ import { Type } from "typebox";
10
10
  import { renderSystemOneResult } from "./render.ts";
11
11
 
12
12
  /**
13
- * JSON value accepted by System One.
13
+ * Public tool parameters (the LLM-facing boundary schema).
14
14
  *
15
- * Arrays/objects use Type.Any() recursively because tool arguments already
16
- * cross a JSON boundary. Keeping the schema non-recursive also tends to be
17
- * easier for tool-calling models to consume.
15
+ * The LLM-facing tool contract is intentionally more permissive than the
16
+ * System One domain model. The schema answers only "can I safely accept
17
+ * and inspect this?" — every field is optional, extra keys are allowed,
18
+ * and `questions` accepts any JSON so near-miss shapes (singular
19
+ * `question`, arrays, unwrapped singles, alias spellings like `Choice`,
20
+ * `rating`, or `Yes/No`) survive Pi's pre-execution validation on every
21
+ * Pi version, including older ones without `prepareArguments`.
22
+ * Normalization (`prepareSystemOneArgs`) repairs representation only;
23
+ * semantic validation in `execute()` (via `assertAnswerableQuestions`)
24
+ * establishes the canonical System One contract — `{type: "choice" |
25
+ * "noul" | "score", instructions, criteria}` per question — and rejects
26
+ * what cannot be repaired with an actionable error.
18
27
  */
19
- const JsonValue = Type.Union(
20
- [
21
- Type.String(),
22
- Type.Number(),
23
- Type.Boolean(),
24
- Type.Null(),
25
- Type.Array(Type.Any()),
26
- Type.Record(Type.String(), Type.Any()),
27
- ],
28
- {
29
- description:
30
- "A JSON value: string, number, boolean, null, object, or array.",
31
- },
32
- );
33
-
34
- const ChoiceQ = Type.Object(
28
+ export const systemOneParams = Type.Object(
35
29
  {
36
- type: Type.Literal("choice", {
37
- description: "Select exactly one outcome from a bounded set.",
38
- }),
30
+ state: Type.Optional(
31
+ Type.Any({
32
+ description:
33
+ "Shared state or evidence that all questions should be evaluated against. Usually a string or object. Aliases: context, evidence.",
34
+ }),
35
+ ),
39
36
 
40
- instructions: JsonValue,
37
+ context: Type.Optional(Type.Any()),
38
+ evidence: Type.Optional(Type.Any()),
41
39
 
42
- criteria: Type.Record(Type.String(), JsonValue, {
43
- minProperties: 1,
44
- description:
45
- "Available choices keyed by choice label. Each value describes that choice; use null when the label is self-explanatory. The keys are the choices; do not provide a separate options field.",
46
- }),
40
+ questions: Type.Optional(
41
+ Type.Any({
42
+ description:
43
+ 'One or more named decision questions. "choice" selects one outcome from a bounded set (criteria is an object keyed by label); "noul" estimates a yes/no likelihood (criteria usually omitted); "score" scores against an ordered rubric (criteria is an array of at least two levels). Accepts a name-keyed object, an array of questions, or a single unwrapped question. Aliases: question, queries; near-miss type spellings are normalized.',
44
+ }),
45
+ ),
46
+ question: Type.Optional(Type.Any()),
47
+ queries: Type.Optional(Type.Any()),
47
48
  },
48
49
  {
49
- additionalProperties: false,
50
+ additionalProperties: true,
50
51
  description:
51
- "A closed-set categorical decision. The keys of criteria are the available choices.",
52
+ 'Evaluate bounded decision questions against shared state. Example: {"state": "...", "questions": {"q1": {"type": "choice", "instructions": "...", "criteria": {"a": null, "b": null}}}}.',
52
53
  },
53
54
  );
54
55
 
55
- const NoulCriteria = Type.Object(
56
- {
57
- true: Type.Optional(JsonValue),
58
- false: Type.Optional(JsonValue),
59
- },
60
- {
61
- additionalProperties: false,
62
- minProperties: 1,
63
- description: "Optional descriptions of the true/yes and false/no outcomes.",
64
- },
65
- );
56
+ export type SystemOneParams = Static<typeof systemOneParams>;
66
57
 
67
- const NoulQ = Type.Object(
68
- {
69
- type: Type.Literal("noul", {
70
- description: "Estimate the likelihood of a yes/true outcome.",
71
- }),
58
+ /**
59
+ * Near-miss spellings low-quality models emit for the `type` discriminator.
60
+ * Lookup is case-insensitive; unlisted values pass through untouched so
61
+ * validation still reports a clear error for genuinely unknown kinds.
62
+ *
63
+ * Every entry here must be SAFE: a representation-only difference whose
64
+ * meaning is unambiguous. Deliberately NOT aliased (rejected instead):
65
+ * - "probability" / "likelihood": describe an output statistic, not a task.
66
+ * They could mean a noul question, a choice with probabilities, or a
67
+ * free-floating number. Guessing noul would silently convert the wrong
68
+ * task into a valid but semantically incorrect request — a believable
69
+ * wrong answer is worse than a validation error the model can retry.
70
+ * - "ranking": ordering items relative to each other is not scoring each
71
+ * against a rubric. Mapping it to score would corrupt the task.
72
+ */
73
+ const QUESTION_TYPE_ALIASES = new Map<string, string>([
74
+ ["choice", "choice"],
75
+ ["choose", "choice"],
76
+ ["multiple_choice", "choice"],
77
+ ["categorical", "choice"],
78
+ ["select", "choice"],
79
+ ["classify", "choice"],
80
+ ["classification", "choice"],
81
+ ["noul", "noul"],
82
+ ["boolean", "noul"],
83
+ ["bool", "noul"],
84
+ ["yes_no", "noul"],
85
+ ["yesno", "noul"],
86
+ ["score", "score"],
87
+ ["rating", "score"],
88
+ ["rate", "score"],
89
+ ["scale", "score"],
90
+ ["rubric", "score"],
91
+ ["grade", "score"],
92
+ ]);
72
93
 
73
- instructions: JsonValue,
94
+ /** Field aliases models use instead of `criteria`. First hit wins. */
95
+ const CRITERIA_ALIASES = [
96
+ "criteria",
97
+ "options",
98
+ "choices",
99
+ "levels",
100
+ "rubric",
101
+ "labels",
102
+ "candidates",
103
+ "outcomes",
104
+ ];
74
105
 
75
- criteria: Type.Optional(NoulCriteria),
76
- },
77
- {
78
- additionalProperties: false,
79
- description:
80
- "A probabilistic yes/no judgment. Omit criteria unless the true or false outcomes need clarification.",
81
- },
82
- );
106
+ /** Field aliases models use instead of `instructions`. First hit wins. */
107
+ const INSTRUCTIONS_ALIASES = [
108
+ "instructions",
109
+ "instruction",
110
+ "prompt",
111
+ "question",
112
+ "text",
113
+ "query",
114
+ "task",
115
+ ];
83
116
 
84
- const ScoreQ = Type.Object(
85
- {
86
- type: Type.Literal("score", {
87
- description: "Score the state against an ordered rubric.",
88
- }),
117
+ /**
118
+ * Question-level keys that are never part of the schema (naming metadata
119
+ * from array-form questions, or hallucinated documentation fields).
120
+ * Dropped so they don't trip `additionalProperties: false`.
121
+ */
122
+ const QUESTION_JUNK_KEYS = new Set([
123
+ "description",
124
+ "title",
125
+ "detail",
126
+ "details",
127
+ "example",
128
+ "examples",
129
+ "id",
130
+ "key",
131
+ "name",
132
+ "label",
133
+ ]);
89
134
 
90
- instructions: JsonValue,
135
+ function isRecord(value: unknown): value is Record<string, unknown> {
136
+ return typeof value === "object" && value !== null && !Array.isArray(value);
137
+ }
91
138
 
92
- criteria: Type.Array(JsonValue, {
93
- minItems: 2,
94
- description:
95
- "Ordered score levels from lowest to highest. Each array item defines one level of the rubric.",
96
- }),
97
- },
98
- {
99
- additionalProperties: false,
100
- description:
101
- "An ordered rubric decision. Criteria are ordered from lowest to highest.",
102
- },
103
- );
139
+ /**
140
+ * Assign an externally controlled key. Plain `obj[key] = value` with
141
+ * `key === "__proto__"` would reparent the object instead of storing an
142
+ * entry; defineProperty always creates an ordinary own data property.
143
+ */
144
+ function setOwn(
145
+ obj: Record<string, unknown>,
146
+ key: string,
147
+ value: unknown,
148
+ ): void {
149
+ Object.defineProperty(obj, key, {
150
+ value,
151
+ enumerable: true,
152
+ writable: true,
153
+ configurable: true,
154
+ });
155
+ }
104
156
 
105
- const Question = Type.Union([ChoiceQ, NoulQ, ScoreQ]);
157
+ function normalizeQuestionType(raw: unknown): unknown {
158
+ if (typeof raw !== "string") return raw;
159
+ const key = raw
160
+ .trim()
161
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
162
+ .toLowerCase()
163
+ .replace(/[-\s/.]+/g, "_");
164
+ // Map lookup (not a plain object) so inherited names like "__proto__"
165
+ // or "constructor" can never accidentally resolve to an alias.
166
+ return QUESTION_TYPE_ALIASES.get(key) ?? raw;
167
+ }
106
168
 
107
- export const systemOneParams = Type.Object(
108
- {
109
- state: Type.Union(
110
- [
111
- Type.String(),
112
- Type.Number(),
113
- Type.Boolean(),
114
- Type.Null(),
115
- Type.Record(Type.String(), Type.Any()),
116
- Type.Array(Type.Any()),
117
- ],
118
- {
119
- description:
120
- "Shared state or evidence that all questions should be evaluated against.",
121
- },
122
- ),
169
+ function pickAlias(
170
+ input: Record<string, unknown>,
171
+ aliases: readonly string[],
172
+ ): unknown {
173
+ for (const alias of aliases) {
174
+ if (input[alias] !== undefined) return input[alias];
175
+ }
176
+ return undefined;
177
+ }
123
178
 
124
- questions: Type.Record(Type.String(), Question, {
125
- minProperties: 1,
126
- description:
127
- "One or more named decision questions. Questions sharing the same state should be sent together.",
128
- }),
129
- },
130
- {
131
- additionalProperties: false,
132
- description: "Evaluate bounded decision questions against shared state.",
133
- },
134
- );
179
+ /**
180
+ * Copy-on-write canonicality check for one question. Returns true when
181
+ * running the builder below would produce a key-for-key, value-for-value
182
+ * identical record — in which case the caller reuses the input by
183
+ * reference instead of allocating a copy. Any doubt returns false (the
184
+ * builder then produces the correct output); false negatives only cost an
185
+ * allocation, never correctness.
186
+ *
187
+ * Deliberately allocation-free itself (own-key probes, no entries/keys
188
+ * arrays) so the defensive second normalization in execute() stays cheap.
189
+ */
190
+ function isCanonicalQuestion(input: Record<string, unknown>): boolean {
191
+ const hasType = Object.hasOwn(input, "type");
192
+ // Probe only: an exact canonical spelling (alias-map value identical to
193
+ // the key) needs no work. Near-miss spellings report non-canonical
194
+ // WITHOUT running full normalization here — the builder below runs it
195
+ // exactly once. Non-string types pass (the builder copies them verbatim),
196
+ // so doubt always errs toward a wasted rebuild, never a wrong share.
197
+ if (
198
+ hasType &&
199
+ typeof input.type === "string" &&
200
+ QUESTION_TYPE_ALIASES.get(input.type) !== input.type
201
+ ) {
202
+ return false;
203
+ }
204
+ // Strings reaching here are already canonical, so the raw type doubles
205
+ // as the normalized one for the criteria decisions below.
206
+ const normalizedType = hasType ? input.type : undefined;
207
+ const instructions = pickAlias(input, INSTRUCTIONS_ALIASES);
208
+ // NOTE: no `else` for an own defined "instructions" slot — "instructions"
209
+ // is the first alias, so pickAlias would have returned it above; reaching
210
+ // here with one means it is undefined and the builder omits it too.
211
+ if (instructions !== undefined) {
212
+ // The builder emits own "instructions" with this value; canonical only
213
+ // if it already sits there and no sibling alias key lingers (dropped).
214
+ if (
215
+ !Object.hasOwn(input, "instructions") ||
216
+ input.instructions !== instructions
217
+ ) {
218
+ return false;
219
+ }
220
+ }
221
+ for (const alias of INSTRUCTIONS_ALIASES) {
222
+ if (alias !== "instructions" && Object.hasOwn(input, alias)) return false;
223
+ }
224
+ let criteria = pickAlias(input, CRITERIA_ALIASES);
225
+ if (normalizedType === "noul" && criteria === null) {
226
+ // The builder drops it; canonical only if no own alias key is dropped.
227
+ for (const alias of CRITERIA_ALIASES) {
228
+ if (Object.hasOwn(input, alias)) return false;
229
+ }
230
+ criteria = undefined;
231
+ }
232
+ if (
233
+ normalizedType === "choice" &&
234
+ Array.isArray(criteria) &&
235
+ (criteria as unknown[]).every((item) => typeof item === "string")
236
+ ) {
237
+ return false; // The builder folds string arrays to an object.
238
+ }
239
+ if (criteria !== undefined) {
240
+ if (!Object.hasOwn(input, "criteria") || input.criteria !== criteria) {
241
+ return false;
242
+ }
243
+ } else if (Object.hasOwn(input, "criteria")) {
244
+ return false; // Own undefined criteria is dropped by the builder.
245
+ }
246
+ for (const alias of CRITERIA_ALIASES) {
247
+ if (alias !== "criteria" && Object.hasOwn(input, alias)) return false;
248
+ }
249
+ for (const key of QUESTION_JUNK_KEYS) {
250
+ if (Object.hasOwn(input, key)) return false;
251
+ }
252
+ // Any other own key is preserved by reference, so it cannot differ.
253
+ return true;
254
+ }
135
255
 
136
- export type SystemOneParams = Static<typeof systemOneParams>;
256
+ /**
257
+ * Coerce one question into the flat schema shape: canonical `type`,
258
+ * `instructions` / `criteria` resolved through aliases, array-form choice
259
+ * criteria (`["a", "b"]`) folded to `{"a": null, "b": null}`, and
260
+ * naming/junk keys stripped.
261
+ *
262
+ * Pure, idempotent, non-mutating, with structural sharing: canonical input
263
+ * is returned by reference; only repaired questions allocate.
264
+ */
265
+ function normalizeQuestion(input: unknown): unknown {
266
+ if (!isRecord(input)) return input;
267
+ if (isCanonicalQuestion(input)) return input;
268
+ const out: Record<string, unknown> = {};
269
+ if (Object.hasOwn(input, "type"))
270
+ out.type = normalizeQuestionType(input.type);
271
+ const instructions = pickAlias(input, INSTRUCTIONS_ALIASES);
272
+ if (instructions !== undefined) out.instructions = instructions;
273
+ else if (Object.hasOwn(input, "instructions"))
274
+ out.instructions = input.instructions;
275
+ let criteria = pickAlias(input, CRITERIA_ALIASES);
276
+ // `null` is this schema's idiom for "no content"; on noul it reads as
277
+ // "omitted" rather than a degenerate value worth a backend round trip.
278
+ if (out.type === "noul" && criteria === null) criteria = undefined;
279
+ if (
280
+ out.type === "choice" &&
281
+ Array.isArray(criteria) &&
282
+ criteria.every((item) => typeof item === "string")
283
+ ) {
284
+ const folded: Record<string, unknown> = {};
285
+ for (const item of criteria as string[]) setOwn(folded, item, null);
286
+ criteria = folded;
287
+ }
288
+ if (criteria !== undefined) out.criteria = criteria;
289
+ // Preserve any other non-junk keys so genuinely new fields still surface
290
+ // in validation instead of being silently dropped.
291
+ for (const [key, value] of Object.entries(input)) {
292
+ if (
293
+ key === "type" ||
294
+ INSTRUCTIONS_ALIASES.includes(key) ||
295
+ CRITERIA_ALIASES.includes(key) ||
296
+ QUESTION_JUNK_KEYS.has(key)
297
+ ) {
298
+ continue;
299
+ }
300
+ setOwn(out, key, value);
301
+ }
302
+ return out;
303
+ }
304
+
305
+ function normalizeQuestions(input: unknown): unknown {
306
+ if (Array.isArray(input)) {
307
+ const record: Record<string, unknown> = {};
308
+ const used = new Set<string>();
309
+ // Duplicate names would silently overwrite; suffix instead so every
310
+ // requested question reaches the backend.
311
+ const claimName = (base: string): string => {
312
+ let candidate = base;
313
+ for (let n = 2; used.has(candidate); n += 1) candidate = `${base}_${n}`;
314
+ used.add(candidate);
315
+ return candidate;
316
+ };
317
+ input.forEach((entry, index) => {
318
+ if (isRecord(entry)) {
319
+ // No intermediate `{...rest}` copy: normalizeQuestion() already
320
+ // drops name/id/key as junk, so passing the entry through repairs
321
+ // naming metadata and aliases in a single construction pass.
322
+ const rawName = entry.name ?? entry.id ?? entry.key;
323
+ const base =
324
+ typeof rawName === "string" && rawName.trim() !== ""
325
+ ? rawName
326
+ : `q${index + 1}`;
327
+ setOwn(record, claimName(base), normalizeQuestion(entry));
328
+ } else {
329
+ setOwn(record, claimName(`q${index + 1}`), entry);
330
+ }
331
+ });
332
+ return record;
333
+ }
334
+ if (isRecord(input)) {
335
+ // Single question sent without a wrapping name (`questions: {type, ...}`)
336
+ // instead of (`questions: {q1: {type, ...}}`). Unwrap on a known kind so
337
+ // a hallucinated extra field still reports against the question (not as
338
+ // phantom questions named "type"/"instructions"); fall back to the
339
+ // vocabulary check for unknown types so those report precisely too. A
340
+ // record that merely contains a question *named* "type" has an object
341
+ // there, never a string, so it is never misread.
342
+ if (typeof input.type === "string") {
343
+ const kind = normalizeQuestionType(input.type);
344
+ const knownKind =
345
+ kind === "choice" || kind === "noul" || kind === "score";
346
+ const keys = Object.keys(input);
347
+ const allQuestionKeys = keys.every(
348
+ (key) =>
349
+ key === "type" ||
350
+ INSTRUCTIONS_ALIASES.includes(key) ||
351
+ CRITERIA_ALIASES.includes(key) ||
352
+ QUESTION_JUNK_KEYS.has(key),
353
+ );
354
+ if (knownKind || allQuestionKeys) return { q: normalizeQuestion(input) };
355
+ }
356
+ // Copy-on-write: share the map (and each canonical question) when
357
+ // nothing needs repair; otherwise rebuild with repaired questions and
358
+ // unaffected siblings shared by reference. The scan probes
359
+ // canonicality only — it must never build (and discard) repairs.
360
+ let dirty = false;
361
+ for (const name in input) {
362
+ if (!Object.hasOwn(input, name)) continue;
363
+ const question = input[name];
364
+ if (!isRecord(question) || !isCanonicalQuestion(question)) {
365
+ dirty = true;
366
+ break;
367
+ }
368
+ }
369
+ if (!dirty) return input;
370
+ const record: Record<string, unknown> = {};
371
+ for (const name in input) {
372
+ if (!Object.hasOwn(input, name)) continue;
373
+ setOwn(record, name, normalizeQuestion(input[name]));
374
+ }
375
+ return record;
376
+ }
377
+ return input;
378
+ }
379
+
380
+ /**
381
+ * Compatibility shim for sloppy tool arguments. Runs before schema
382
+ * validation (wired as `prepareArguments`) and is idempotent, so `execute`
383
+ * re-applies it defensively for direct calls that bypass validation.
384
+ */
385
+ export function prepareSystemOneArgs(args: unknown): SystemOneParams {
386
+ if (!isRecord(args)) return args as SystemOneParams;
387
+ const questions =
388
+ args.questions ?? args.question ?? args.queries ?? undefined;
389
+ const normalizedQuestions =
390
+ questions !== undefined ? normalizeQuestions(questions) : undefined;
391
+ // Copy-on-write fast path: canonical state/questions slots, no alias keys
392
+ // to rename, no unknown keys to drop — return the input by reference so
393
+ // the defensive re-normalization in execute() allocates ~nothing.
394
+ const questionsCanonical =
395
+ normalizedQuestions === questions &&
396
+ (questions === undefined
397
+ ? !Object.hasOwn(args, "questions") &&
398
+ !Object.hasOwn(args, "question") &&
399
+ !Object.hasOwn(args, "queries")
400
+ : questions === args.questions &&
401
+ !Object.hasOwn(args, "question") &&
402
+ !Object.hasOwn(args, "queries"));
403
+ if (
404
+ questionsCanonical &&
405
+ Object.hasOwn(args, "state") &&
406
+ args.state !== undefined &&
407
+ !Object.hasOwn(args, "context") &&
408
+ !Object.hasOwn(args, "evidence")
409
+ ) {
410
+ let knownKeysOnly = true;
411
+ for (const key in args) {
412
+ if (Object.hasOwn(args, key) && key !== "state" && key !== "questions") {
413
+ knownKeysOnly = false;
414
+ break;
415
+ }
416
+ }
417
+ if (knownKeysOnly) return args as SystemOneParams;
418
+ }
419
+ const out: Record<string, unknown> = {};
420
+ if (args.state !== undefined) {
421
+ out.state = args.state;
422
+ } else if (args.context !== undefined) {
423
+ out.state = args.context;
424
+ } else if (args.evidence !== undefined) {
425
+ out.state = args.evidence;
426
+ }
427
+ if (normalizedQuestions !== undefined) out.questions = normalizedQuestions;
428
+ // Drop unknown top-level keys; a missing `state` / `questions` still
429
+ // produces a clear error from semantic validation in execute().
430
+ return out as SystemOneParams;
431
+ }
137
432
 
138
433
  export interface SystemOneDetails {
139
434
  answers: Record<string, unknown>;
@@ -149,6 +444,84 @@ export interface SystemOneDetails {
149
444
  };
150
445
  }
151
446
 
447
+ /**
448
+ * Runtime semantic validation: "is this actually a valid System One
449
+ * request?" The public schema lets repairable shapes through; this gate
450
+ * enforces the canonical contract the provider requires. Limits here are
451
+ * the ones the tool itself guarantees (verified against
452
+ * system-one-core, which enforces no option/level counts — the backend
453
+ * is the final arbiter for anything stricter). Every error states what
454
+ * was wrong, which question it was in, and what valid shape looks like.
455
+ */
456
+ const KNOWN_QUESTION_KEYS = new Set(["type", "instructions", "criteria"]);
457
+
458
+ function assertAnswerableQuestions(questions: unknown): void {
459
+ if (questions === undefined) {
460
+ throw new Error(
461
+ 'system_one: questions is required. Provide at least one named question, e.g. {"q1": {"type": "choice", "instructions": "...", "criteria": {"a": null}}}.',
462
+ );
463
+ }
464
+ // The semantic gate is authoritative: anything that is not a question
465
+ // map after normalization is rejected here, never sent to the provider.
466
+ if (!isRecord(questions)) {
467
+ throw new Error(
468
+ 'system_one: "questions" must resolve to a question map after normalization, e.g. {"q1": {"type": "choice", "instructions": "...", "criteria": {"a": null}}}.',
469
+ );
470
+ }
471
+ if (Object.keys(questions).length === 0) {
472
+ throw new Error(
473
+ "system_one: at least one question is required in questions.",
474
+ );
475
+ }
476
+ for (const [name, value] of Object.entries(questions)) {
477
+ if (!isRecord(value)) {
478
+ throw new Error(
479
+ `system_one: question "${name}" must be an object with "type", "instructions", and "criteria".`,
480
+ );
481
+ }
482
+ for (const key of Object.keys(value)) {
483
+ if (!KNOWN_QUESTION_KEYS.has(key)) {
484
+ throw new Error(
485
+ `system_one: question "${name}" has unknown field "${key}"; use only "type", "instructions", and "criteria".`,
486
+ );
487
+ }
488
+ }
489
+ if (value.instructions === undefined || value.instructions === null) {
490
+ throw new Error(
491
+ `system_one: question "${name}" needs "instructions" describing what to evaluate, e.g. {"type": "${typeof value.type === "string" ? value.type : "choice"}", "instructions": "Which option fits best?"}.`,
492
+ );
493
+ }
494
+ if (value.type === "choice") {
495
+ if (
496
+ !isRecord(value.criteria) ||
497
+ Object.keys(value.criteria).length === 0
498
+ ) {
499
+ throw new Error(
500
+ `system_one: choice question "${name}" needs criteria as an object keyed by choice label with at least one choice, e.g. {"a": null, "b": null}.`,
501
+ );
502
+ }
503
+ } else if (value.type === "score") {
504
+ if (!Array.isArray(value.criteria) || value.criteria.length < 2) {
505
+ throw new Error(
506
+ `system_one: score question "${name}" needs criteria as an ordered array of at least two levels from lowest to highest, e.g. ["low", "high"].`,
507
+ );
508
+ }
509
+ } else if (value.type === "noul") {
510
+ if (value.criteria !== undefined && !isRecord(value.criteria)) {
511
+ throw new Error(
512
+ `system_one: noul question "${name}" needs criteria omitted or as an object describing the true/false outcomes, e.g. {"true": "...", "false": "..."}.`,
513
+ );
514
+ }
515
+ } else {
516
+ const got =
517
+ typeof value.type === "string" ? `"${value.type}"` : "missing";
518
+ throw new Error(
519
+ `system_one: question "${name}" has unknown type ${got}; use "choice", "noul", or "score".`,
520
+ );
521
+ }
522
+ }
523
+ }
524
+
152
525
  export function buildSystemOneTool(deps: {
153
526
  provider: SystemOneProvider;
154
527
  }): ToolDefinition<typeof systemOneParams, SystemOneDetails> {
@@ -161,37 +534,63 @@ export function buildSystemOneTool(deps: {
161
534
  label: "System One",
162
535
 
163
536
  description:
164
- "Evaluate bounded choice, yes/no likelihood, and ordered score questions against shared state. " +
165
- "Use this when the possible outcomes or scoring rubric are known and structured probabilistic results are useful. " +
166
- "Do not use it for open-ended text generation. " +
167
- "Questions that share the same state can be evaluated together in one call.",
537
+ "Judge bounded decisions over explicitly supplied state with a dedicated judge model — calibrated probabilities conditional on that state (confidence for choice/score only; noul has no confidence field, its probability is the uncertainty measure). " +
538
+ "Call system_one instead of answering directly when the request is a pick from named options, a yes/no likelihood, or a rubric score over state already in context — the judge's state-conditional probabilities beat guesses from priors. " +
539
+ "The judge only weighs the state you supply: it cannot recall facts or browse, so retrieve evidence first and never ask it to recall facts. " +
540
+ "Do not use it for open-ended text generation or factual lookup. " +
541
+ "Questions that share the same state can be evaluated together in one call. " +
542
+ 'Each question is {"type": "choice" | "noul" | "score", "instructions": "...", "criteria": ...}: ' +
543
+ 'choice criteria is an object keyed by choice label ({"a": null, "b": null}), ' +
544
+ "noul usually omits criteria, " +
545
+ "score criteria is an ordered array of at least two levels.",
168
546
 
169
547
  parameters: systemOneParams,
170
548
 
171
549
  promptSnippet:
172
- "Evaluate bounded choices, yes/no likelihoods, and ordered scores from shared state",
550
+ "Use system_one for bounded choice, yes/no, and ordered-scale judgments over supplied evidence",
173
551
 
174
552
  promptGuidelines: [
175
- "Use system_one for closed-set classification, probabilistic yes/no judgments, or ordered rubric scoring.",
176
- "Do not use system_one for open-ended text generation or when there is no bounded answer space or rubric.",
177
- "Preserve user-specified choice labels, criteria, and score levels instead of inventing replacements.",
178
- "For choice questions, the keys of criteria are the available choices; never add a separate options field.",
179
- "For self-explanatory choice labels, prefer null criteria values instead of inventing definitions that could bias the decision.",
180
- "For noul questions, omit criteria unless the true/yes or false/no outcomes need clarification.",
181
- "For score questions, criteria must contain at least two ordered levels from lowest to highest.",
182
- "Batch independent questions into one system_one call when they share the same state.",
553
+ "Before answering a bounded judgment over supplied or retrieved evidence, call system_one instead of making the judgment yourself. A bounded judgment is: choosing among explicit alternatives, making a yes/no judgment, or rating something on an ordered scale.",
554
+ "Pick the system_one question type mechanically: unordered alternatives (frontend, backend, platform) -> choice; yes/no -> noul; ordered scale, rating, severity, risk, or grade -> score. Never use choice for an ordered scale merely because its levels have names: very low / low / moderate / high / very high is score, not choice.",
555
+ "Retrieve missing factual evidence into state first; never ask system_one to recall facts or browse. Use system_one for bounded judgments over available evidence, not as a replacement for factual lookup.",
556
+ "Batch independent system_one questions sharing the same state into one call instead of one call per question.",
557
+ "For system_one choice questions, the keys of criteria are the available choices — preserve user-specified labels and use null values when labels are self-explanatory; never add a separate options field.",
183
558
  ],
184
559
 
560
+ prepareArguments: (args) => prepareSystemOneArgs(args),
561
+
185
562
  async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
186
563
  /*
187
564
  * The LLM-facing TypeBox schema is intentionally separate from the
188
565
  * provider protocol types. Cast only at this boundary instead of
189
566
  * spreading `any` throughout the implementation.
567
+ *
568
+ * `prepareArguments` above is an optimization for newer Pi only: it
569
+ * runs before schema validation there, but older Pi versions ignore
570
+ * it and validate the raw model output against the permissive
571
+ * schema instead. Correctness never depends on it — re-normalize
572
+ * here so every path (new Pi, old Pi, direct calls) converges on
573
+ * the same canonical shape before semantic validation.
190
574
  */
575
+ const normalized = prepareSystemOneArgs(params);
576
+ // prepareSystemOneArgs passes non-objects through untouched; direct
577
+ // calls bypassing Pi validation must get an actionable error, not a
578
+ // TypeError from property access on null.
579
+ if (!isRecord(normalized)) {
580
+ throw new Error(
581
+ 'system_one: request must be an object with "state" and "questions".',
582
+ );
583
+ }
584
+ if (normalized.state === undefined) {
585
+ throw new Error(
586
+ 'system_one: state is required. Put the material under judgment in "state" (string or object), e.g. {"state": "<evidence>", "questions": {...}}.',
587
+ );
588
+ }
589
+ assertAnswerableQuestions(normalized.questions);
191
590
  const response = await systemOne.evaluate(
192
591
  {
193
- state: params.state as SystemOneState,
194
- questions: params.questions as QuestionMap,
592
+ state: normalized.state as SystemOneState,
593
+ questions: normalized.questions as QuestionMap,
195
594
  },
196
595
  { signal },
197
596
  );