pi-system-one 1.2.0 → 1.2.3

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
@@ -114,6 +114,13 @@ const INSTRUCTIONS_ALIASES = [
114
114
  "task",
115
115
  ];
116
116
 
117
+ /**
118
+ * Names that can hold the question map. Inside a question map one of these
119
+ * is a leftover echo of the argument wrapper, never a question in its own
120
+ * right — the value is what matters.
121
+ */
122
+ const QUESTION_NAME_ALIASES = new Set(["questions", "question", "queries"]);
123
+
117
124
  /**
118
125
  * Question-level keys that are never part of the schema (naming metadata
119
126
  * from array-form questions, or hallucinated documentation fields).
@@ -176,18 +183,293 @@ function pickAlias(
176
183
  return undefined;
177
184
  }
178
185
 
186
+ /**
187
+ * An ordered rubric is an ARRAY. Models overwhelmingly hand it over as an
188
+ * object instead, in one of two shapes:
189
+ *
190
+ * {"item": ["low", "high"]} a single wrapper key around the levels
191
+ * {"low": "desc", "high": "desc"} the levels as keys of a map
192
+ *
193
+ * Both are representation-only differences: a rubric's meaning is its ordered
194
+ * levels, and JS object key order is insertion order for string keys, so
195
+ * folding recovers exactly that order. The single-key wrapper case is only
196
+ * unwrapped when its value really is an array — a one-level rubric
197
+ * (`{"low": "..."}`) keeps its level instead of being unwrapped to a string.
198
+ *
199
+ * Level labels survive as far as the shape allows: a described level keeps its
200
+ * description, and a bare level (`{"low": null}`) falls back to its key.
201
+ */
202
+ function foldScoreCriteria(criteria: unknown): unknown {
203
+ if (Array.isArray(criteria) || !isRecord(criteria)) return criteria;
204
+ const keys = Object.keys(criteria);
205
+ if (keys.length === 0) return criteria;
206
+ if (keys.length === 1) {
207
+ const [only] = keys;
208
+ const wrapped = criteria[only];
209
+ if (Array.isArray(wrapped) && wrapped.length >= 2) return wrapped;
210
+ // The same wrapper, one level deeper: `{"item": {"item": [...]}}`. A key
211
+ // that holds a single nested object names a container, not a level, so
212
+ // keep peeling until an array or a real multi-level map is reached. Only
213
+ // records are peeled, so a one-level rubric (`{"low": "bad"}`) is left
214
+ // alone and still reports the one-level error rather than being unwrapped
215
+ // into a bare string.
216
+ if (isRecord(wrapped)) {
217
+ const peeled = foldScoreCriteria(wrapped);
218
+ if (Array.isArray(peeled) && peeled.length >= 2) return peeled;
219
+ }
220
+ }
221
+ // TypeSafe's own levels stand on their own: the cookbook writes them as
222
+ // "Moderate: access and correction rights, but limited means to act", not
223
+ // as a bare "moderate". So when a model gives both a label and a
224
+ // description, keep both — dropping the label for the description alone
225
+ // loses which level the judge picked.
226
+ const levels: unknown[] = [];
227
+ for (const key of keys) {
228
+ const value = criteria[key];
229
+ if (value === null || value === undefined) {
230
+ levels.push(key);
231
+ continue;
232
+ }
233
+ if (typeof value === "string") {
234
+ const text = value.trim();
235
+ // An empty or absent description means the key IS the level; a
236
+ // description that already names its level is left alone rather than
237
+ // prefixed twice.
238
+ if (text === "" || key === text || text.includes(key)) {
239
+ levels.push(text === "" ? key : text);
240
+ } else {
241
+ levels.push(`${key}: ${text}`);
242
+ }
243
+ continue;
244
+ }
245
+ levels.push(value);
246
+ }
247
+ return levels;
248
+ }
249
+
250
+ /**
251
+ * Copy-on-write canonicality check for one question. Returns true when
252
+ * running the builder below would produce a key-for-key, value-for-value
253
+ * identical record — in which case the caller reuses the input by
254
+ * reference instead of allocating a copy. Any doubt returns false (the
255
+ * builder then produces the correct output); false negatives only cost an
256
+ * allocation, never correctness.
257
+ *
258
+ * Deliberately allocation-free itself (own-key probes, no entries/keys
259
+ * arrays) so the defensive second normalization in execute() stays cheap.
260
+ */
261
+ function isCanonicalQuestion(input: Record<string, unknown>): boolean {
262
+ const hasType = Object.hasOwn(input, "type");
263
+ // Probe only: an exact canonical spelling (alias-map value identical to
264
+ // the key) needs no work. Near-miss spellings report non-canonical
265
+ // WITHOUT running full normalization here — the builder below runs it
266
+ // exactly once. Non-string types pass (the builder copies them verbatim),
267
+ // so doubt always errs toward a wasted rebuild, never a wrong share.
268
+ if (
269
+ hasType &&
270
+ typeof input.type === "string" &&
271
+ QUESTION_TYPE_ALIASES.get(input.type) !== input.type
272
+ ) {
273
+ return false;
274
+ }
275
+ // Strings reaching here are already canonical, so the raw type doubles
276
+ // as the normalized one for the criteria decisions below.
277
+ const normalizedType = hasType ? input.type : undefined;
278
+ const instructions = pickAlias(input, INSTRUCTIONS_ALIASES);
279
+ // NOTE: no `else` for an own defined "instructions" slot — "instructions"
280
+ // is the first alias, so pickAlias would have returned it above; reaching
281
+ // here with one means it is undefined and the builder omits it too.
282
+ if (instructions !== undefined) {
283
+ // The builder emits own "instructions" with this value; canonical only
284
+ // if it already sits there and no sibling alias key lingers (dropped).
285
+ if (
286
+ !Object.hasOwn(input, "instructions") ||
287
+ input.instructions !== instructions
288
+ ) {
289
+ return false;
290
+ }
291
+ }
292
+ for (const alias of INSTRUCTIONS_ALIASES) {
293
+ if (alias !== "instructions" && Object.hasOwn(input, alias)) return false;
294
+ }
295
+ let criteria = pickAlias(input, CRITERIA_ALIASES);
296
+ if (normalizedType === "noul" && criteria === null) {
297
+ // The builder drops it; canonical only if no own alias key is dropped.
298
+ for (const alias of CRITERIA_ALIASES) {
299
+ if (Object.hasOwn(input, alias)) return false;
300
+ }
301
+ criteria = undefined;
302
+ }
303
+ if (
304
+ normalizedType === "choice" &&
305
+ Array.isArray(criteria) &&
306
+ (criteria as unknown[]).every((item) => typeof item === "string")
307
+ ) {
308
+ return false; // The builder folds string arrays to an object.
309
+ }
310
+ if (normalizedType === "score" && isRecord(criteria)) {
311
+ return false; // The builder folds an object rubric to an ordered array.
312
+ }
313
+ if (
314
+ normalizedType === "noul" &&
315
+ (Object.hasOwn(input, "true") || Object.hasOwn(input, "false"))
316
+ ) {
317
+ return false; // The builder lifts bare true/false keys into criteria.
318
+ }
319
+ if (criteria !== undefined) {
320
+ if (!Object.hasOwn(input, "criteria") || input.criteria !== criteria) {
321
+ return false;
322
+ }
323
+ } else if (Object.hasOwn(input, "criteria")) {
324
+ return false; // Own undefined criteria is dropped by the builder.
325
+ }
326
+ for (const alias of CRITERIA_ALIASES) {
327
+ if (alias !== "criteria" && Object.hasOwn(input, alias)) return false;
328
+ }
329
+ for (const key of QUESTION_JUNK_KEYS) {
330
+ if (Object.hasOwn(input, key)) return false;
331
+ }
332
+ for (const key of Object.keys(input)) {
333
+ // Any extra is merged (choice labels) or dropped, and a missing `type`
334
+ // may be salvaged from one.
335
+ if (isQuestionExtra(key)) return false;
336
+ }
337
+ // `true`/`false` are lifted into `criteria` on a noul question and mean
338
+ // nothing anywhere else. On any other type the builder drops them, so a
339
+ // question carrying them is not already canonical and must be rebuilt -
340
+ // otherwise the copy-on-write fast path would ship them to the provider.
341
+ if (
342
+ normalizedType !== "noul" &&
343
+ (Object.hasOwn(input, "true") || Object.hasOwn(input, "false"))
344
+ ) {
345
+ return false;
346
+ }
347
+ // Any other own key is preserved by reference, so it cannot differ.
348
+ return true;
349
+ }
350
+
351
+ /**
352
+ * A sibling key carrying no value. Models leave these beside a question for
353
+ * two different reasons, and which reason applies is decided by the type.
354
+ */
355
+ function isEmptyValue(value: unknown): boolean {
356
+ return value === "" || value === null || value === undefined;
357
+ }
358
+
359
+ /**
360
+ * A question object holds exactly `type`, `instructions` and `criteria`.
361
+ * Anything else is an extra: the model's own name for the question, a
362
+ * prediction, or metadata it invented. The provider can never interpret one,
363
+ * so extras are dropped rather than rejected — a rejected extra teaches the
364
+ * model nothing it did not already know and costs a retry.
365
+ */
366
+ /**
367
+ * The names `state` can travel under at the top level of a request. A model
368
+ * that puts one of them INSIDE a question meant it as the request state, not
369
+ * as part of the question: a question is judged, it never carries what it is
370
+ * judged against. Kept through normalization so the value survives long enough
371
+ * to be lifted back out.
372
+ */
373
+ const STATE_KEYS = ["state", "context", "evidence"];
374
+
375
+ /**
376
+ * A question object holds exactly `type`, `instructions` and `criteria`.
377
+ * Anything else is an extra: the model's own name for the question, a
378
+ * prediction, or metadata it invented. The provider can never interpret one,
379
+ * so extras are dropped rather than rejected — a rejected extra teaches the
380
+ * model nothing it did not already know and costs a retry.
381
+ */
382
+ function isQuestionExtra(key: string): boolean {
383
+ return (
384
+ key !== "type" &&
385
+ !INSTRUCTIONS_ALIASES.includes(key) &&
386
+ !CRITERIA_ALIASES.includes(key) &&
387
+ !QUESTION_JUNK_KEYS.has(key) &&
388
+ !STATE_KEYS.includes(key) &&
389
+ key !== "true" &&
390
+ key !== "false"
391
+ );
392
+ }
393
+
394
+ /**
395
+ * Repair option labels the model spilled out of `criteria`.
396
+ *
397
+ * A choice question that ends up with empty-valued siblings is the model
398
+ * listing its options one level too high: `criteria: {"sre_oncall": ""}` with
399
+ * `account_team: ""` and `ingest_service_owner: ""` beside it. Those siblings
400
+ * are options, not metadata, so they go back where the contract says options
401
+ * live. A label already present in `criteria` keeps its existing value.
402
+ */
403
+ function mergeSpilledChoiceLabels(
404
+ siblings: Array<[string, unknown]>,
405
+ criteria: unknown,
406
+ ): unknown {
407
+ const base: Record<string, unknown> = isRecord(criteria) ? criteria : {};
408
+ let changed = !isRecord(criteria);
409
+ for (const [label, value] of siblings) {
410
+ if (Object.hasOwn(base, label)) continue;
411
+ setOwn(base, label, value);
412
+ changed = true;
413
+ }
414
+ return changed ? base : criteria;
415
+ }
416
+
417
+ /**
418
+ * Some model providers serialize nested tool arguments as JSON-encoded
419
+ * strings (e.g. `questions: "{\"q1\": {...}}"`). If the string looks like
420
+ * a JSON object or array, decode it so it can be normalized; anything else
421
+ * passes through untouched and still reports a clear validation error.
422
+ */
423
+ function tryParseJson(input: string): unknown {
424
+ try {
425
+ return JSON.parse(input);
426
+ } catch {
427
+ return input;
428
+ }
429
+ }
430
+
431
+ /** Decode a JSON-encoded question value; any non-string passes through. */
432
+ function decodeJsonInput(value: unknown): unknown {
433
+ return typeof value === "string" ? tryParseJson(value) : value;
434
+ }
435
+
179
436
  /**
180
437
  * Coerce one question into the flat schema shape: canonical `type`,
181
438
  * `instructions` / `criteria` resolved through aliases, array-form choice
182
439
  * criteria (`["a", "b"]`) folded to `{"a": null, "b": null}`, and
183
440
  * naming/junk keys stripped.
441
+ *
442
+ * Pure, idempotent, non-mutating, with structural sharing: canonical input
443
+ * is returned by reference; only repaired questions allocate.
184
444
  */
185
445
  function normalizeQuestion(input: unknown): unknown {
186
446
  if (!isRecord(input)) return input;
447
+ if (isCanonicalQuestion(input)) return input;
187
448
  const out: Record<string, unknown> = {};
188
- if (Object.hasOwn(input, "type"))
189
- out.type = normalizeQuestionType(input.type);
190
- const instructions = pickAlias(input, INSTRUCTIONS_ALIASES);
449
+ // A question object holds exactly three keys, so its `type` is whatever an
450
+ // EXTRA key holding a known kind is worth: `{"approach": "yes_no", ...}`
451
+ // states the kind under a different name. Adopt it, then drop the key.
452
+ let rawType = input.type;
453
+ if (rawType === undefined) {
454
+ for (const [key, value] of Object.entries(input)) {
455
+ if (!isQuestionExtra(key) || typeof value !== "string") continue;
456
+ const kind = normalizeQuestionType(value);
457
+ if (kind === "choice" || kind === "noul" || kind === "score") {
458
+ rawType = value;
459
+ break;
460
+ }
461
+ }
462
+ }
463
+ if (rawType !== undefined) out.type = normalizeQuestionType(rawType);
464
+ let instructions = pickAlias(input, INSTRUCTIONS_ALIASES);
465
+ // `description` is naming metadata, so it is normally dropped. But a model
466
+ // that writes its question text under `description` and nowhere else HAS
467
+ // supplied instructions — and dropping it produces the actively misleading
468
+ // error "needs instructions" for a question that carries one. Only taken as
469
+ // a fallback, so a real `instructions` always wins.
470
+ if (instructions === undefined && input.description !== undefined) {
471
+ instructions = input.description;
472
+ }
191
473
  if (instructions !== undefined) out.instructions = instructions;
192
474
  else if (Object.hasOwn(input, "instructions"))
193
475
  out.instructions = input.instructions;
@@ -195,6 +477,20 @@ function normalizeQuestion(input: unknown): unknown {
195
477
  // `null` is this schema's idiom for "no content"; on noul it reads as
196
478
  // "omitted" rather than a degenerate value worth a backend round trip.
197
479
  if (out.type === "noul" && criteria === null) criteria = undefined;
480
+ // A noul question that spells its outcomes as bare `true`/`false` keys
481
+ // beside `instructions` means exactly the documented `criteria: {true,
482
+ // false}` — the same two keys, one level up. Lift them back down so the
483
+ // question object keeps its three-key shape.
484
+ if (
485
+ out.type === "noul" &&
486
+ criteria === undefined &&
487
+ (Object.hasOwn(input, "true") || Object.hasOwn(input, "false"))
488
+ ) {
489
+ const outcomes: Record<string, unknown> = {};
490
+ if (Object.hasOwn(input, "true")) setOwn(outcomes, "true", input.true);
491
+ if (Object.hasOwn(input, "false")) setOwn(outcomes, "false", input.false);
492
+ criteria = outcomes;
493
+ }
198
494
  if (
199
495
  out.type === "choice" &&
200
496
  Array.isArray(criteria) &&
@@ -204,24 +500,275 @@ function normalizeQuestion(input: unknown): unknown {
204
500
  for (const item of criteria as string[]) setOwn(folded, item, null);
205
501
  criteria = folded;
206
502
  }
207
- if (criteria !== undefined) out.criteria = criteria;
208
- // Preserve any other non-junk keys so genuinely new fields still surface
209
- // in validation instead of being silently dropped.
503
+ if (out.type === "score") criteria = foldScoreCriteria(criteria);
504
+ // Siblings that carry no value: option labels for a choice, the question's
505
+ // own name or a prediction for noul/score. Only the first kind is data.
506
+ const emptySiblings: Array<[string, unknown]> = [];
210
507
  for (const [key, value] of Object.entries(input)) {
211
- if (
508
+ if (!isQuestionExtra(key) || !isEmptyValue(value)) continue;
509
+ emptySiblings.push([key, value]);
510
+ }
511
+ if (emptySiblings.length > 0 && out.type === "choice") {
512
+ criteria = mergeSpilledChoiceLabels(emptySiblings, criteria);
513
+ }
514
+ if (criteria !== undefined) out.criteria = criteria;
515
+ // Everything not part of the three-key contract is dropped on purpose — with
516
+ // one exception: a `state` the model nested here is request state, and
517
+ // prepareSystemOneArgs lifts it back to the top level.
518
+ for (const key of STATE_KEYS) {
519
+ if (Object.hasOwn(input, key)) setOwn(out, key, input[key]);
520
+ }
521
+ return out;
522
+ }
523
+
524
+ /**
525
+ * Move a `state` the model nested inside a question up to the top level of the
526
+ * request, and remove it from the question it was found in.
527
+ *
528
+ * Only the question's own keys are examined. A `state` key nested inside
529
+ * `criteria` is user data — an option literally labelled "state" — and is left
530
+ * exactly where it is.
531
+ */
532
+ function liftNestedState(
533
+ questions: unknown,
534
+ fallback: unknown,
535
+ ): { questions: unknown; state: unknown } {
536
+ if (!isRecord(questions)) return { questions, state: fallback };
537
+ // Every state key is stripped from every question in one pass, and the first
538
+ // one found (by question order, then by STATE_KEYS priority) becomes the
539
+ // request state. Stripping only the match would take two passes for a
540
+ // question carrying two of them, which is not idempotent — and idempotence
541
+ // is what lets execute() re-normalize defensively for free.
542
+ let found: unknown;
543
+ const rebuilt: Record<string, unknown> = {};
544
+ let changed = false;
545
+ for (const [name, question] of Object.entries(questions)) {
546
+ if (!isRecord(question)) {
547
+ setOwn(rebuilt, name, question);
548
+ continue;
549
+ }
550
+ const kept: Record<string, unknown> = {};
551
+ for (const own of Object.keys(question)) {
552
+ if (STATE_KEYS.includes(own)) {
553
+ if (found === undefined) found = question[own];
554
+ changed = true;
555
+ continue;
556
+ }
557
+ setOwn(kept, own, question[own]);
558
+ }
559
+ setOwn(rebuilt, name, changed ? kept : question);
560
+ }
561
+ if (!changed) return { questions, state: fallback };
562
+ return {
563
+ questions: rebuilt,
564
+ state: fallback !== undefined ? fallback : found,
565
+ };
566
+ }
567
+
568
+ /**
569
+ * A record-valued question-map entry is only a question if it looks like one.
570
+ * A record holding none of the contract keys is a question FIELD that landed
571
+ * in the name slot (`{"choice_under_test": "...", "criteria": {...}}`), not a
572
+ * question.
573
+ */
574
+ function looksLikeQuestion(value: unknown): boolean {
575
+ if (!isRecord(value)) return false;
576
+ if (typeof value.type === "string") return true;
577
+ for (const alias of INSTRUCTIONS_ALIASES) {
578
+ if (Object.hasOwn(value, alias)) return true;
579
+ }
580
+ for (const alias of CRITERIA_ALIASES) {
581
+ if (Object.hasOwn(value, alias)) return true;
582
+ }
583
+ return false;
584
+ }
585
+
586
+ /**
587
+ * Record a question-map entry that had to be discarded, so the caller can be
588
+ * told rather than silently handed an answer to a smaller question set.
589
+ *
590
+ * The contract is one-directional: a choice cannot pick an option omitted
591
+ * from its criteria, and a batch cannot report on a question that was never
592
+ * sent. Every repair here is allowed to reshape or drop a malformed entry,
593
+ * but none of them may do it invisibly — a dropped entry means the model asked
594
+ * something that was quietly not judged, which is exactly the kind of mistake
595
+ * a typed output cannot surface.
596
+ */
597
+ function noteDrop(
598
+ drops: string[] | undefined,
599
+ name: string,
600
+ value: unknown,
601
+ ): void {
602
+ if (!drops) return;
603
+ const shown =
604
+ typeof value === "string" ? value : value === null ? "null" : "";
605
+ drops.push(shown === "" ? name : `${name} (${truncateForNote(shown)})`);
606
+ }
607
+
608
+ function truncateForNote(text: string): string {
609
+ const clean = text.replace(/\s+/g, " ").trim();
610
+ return clean.length <= 60 ? clean : `${clean.slice(0, 57)}...`;
611
+ }
612
+
613
+ /**
614
+ * Some models write a batch of questions as parallel arrays — one entry per
615
+ * field, aligned by position:
616
+ *
617
+ * {"item": ["a", "b"], "type": ["choice", "noul"],
618
+ * "instructions": ["...", "..."], "criteria": [{...}, null]}
619
+ *
620
+ * That is a question list with the nesting flattened one level. Transposing it
621
+ * loses nothing: position is the only thing tying the arrays together, and the
622
+ * model clearly meant `a` to be the first question. Only fires when every own
623
+ * value is an array of the same non-zero length AND at least one key is a
624
+ * contract key, so a name-to-array map (`{"fixes": [...], "risks": [...]}`)
625
+ * and a single wrapped array are both left to their own rules.
626
+ */
627
+ function transposeParallelQuestions(input: Record<string, unknown>): unknown {
628
+ const keys = Object.keys(input);
629
+ if (keys.length < 2) return undefined;
630
+ const hasContractKey = keys.some(
631
+ (key) =>
212
632
  key === "type" ||
213
633
  INSTRUCTIONS_ALIASES.includes(key) ||
214
- CRITERIA_ALIASES.includes(key) ||
215
- QUESTION_JUNK_KEYS.has(key)
634
+ CRITERIA_ALIASES.includes(key),
635
+ );
636
+ if (!hasContractKey) return undefined;
637
+ const columns = keys.map((key) => input[key]);
638
+ if (!columns.every(Array.isArray)) return undefined;
639
+ const width = (columns[0] as unknown[]).length;
640
+ if (width < 1) return undefined;
641
+ if (!columns.every((column) => (column as unknown[]).length === width)) {
642
+ return undefined;
643
+ }
644
+ const questions: Record<string, unknown> = {};
645
+ const used = new Set<string>();
646
+ for (let i = 0; i < width; i += 1) {
647
+ const parts: Record<string, unknown> = {};
648
+ for (const key of keys) {
649
+ if (key === "item" || key === "name" || key === "id" || key === "key") {
650
+ continue;
651
+ }
652
+ setOwn(parts, key, (input[key] as unknown[])[i]);
653
+ }
654
+ const nameKey = ["item", "name", "id", "key"].find((key) =>
655
+ Array.isArray(input[key]),
656
+ );
657
+ const raw = nameKey ? (input[nameKey] as unknown[])[i] : undefined;
658
+ let name = typeof raw === "string" && raw.trim() !== "" ? raw : `q${i + 1}`;
659
+ for (let n = 2; used.has(name); n += 1) name = `${raw ?? `q${i + 1}`}_${n}`;
660
+ used.add(name);
661
+ setOwn(questions, name, normalizeQuestion(parts));
662
+ }
663
+ return questions;
664
+ }
665
+
666
+ /**
667
+ * A record holding only questions, i.e. a question map that landed in a name
668
+ * slot, possibly more than one level deep. A question never holds a question,
669
+ * so this reading is unambiguous. Bounded so a pathological object graph
670
+ * cannot spin.
671
+ */
672
+ const MAX_NESTED_MAP_DEPTH = 4;
673
+
674
+ /** A question, or a (possibly nested) map whose every leaf is a question. */
675
+ function isQuestionTree(value: unknown, depth = 0): boolean {
676
+ if (looksLikeQuestion(value)) return true;
677
+ if (!isRecord(value) || depth >= MAX_NESTED_MAP_DEPTH) return false;
678
+ const keys = Object.keys(value);
679
+ return (
680
+ keys.length > 0 &&
681
+ keys.every((key) => isQuestionTree(value[key], depth + 1))
682
+ );
683
+ }
684
+
685
+ function isNestedQuestionMap(value: unknown): value is Record<string, unknown> {
686
+ return isRecord(value) && !looksLikeQuestion(value) && isQuestionTree(value);
687
+ }
688
+
689
+ /** Every leaf question in a (possibly nested) question map, with its name. */
690
+ function flattenQuestionMap(
691
+ value: Record<string, unknown>,
692
+ depth = 0,
693
+ ): Array<[string, unknown]> {
694
+ const out: Array<[string, unknown]> = [];
695
+ for (const name of Object.keys(value)) {
696
+ const child = value[name];
697
+ if (
698
+ isRecord(child) &&
699
+ !looksLikeQuestion(child) &&
700
+ depth < MAX_NESTED_MAP_DEPTH
216
701
  ) {
217
- continue;
702
+ out.push(...flattenQuestionMap(child, depth + 1));
703
+ } else {
704
+ out.push([name, child]);
218
705
  }
219
- setOwn(out, key, value);
220
706
  }
221
707
  return out;
222
708
  }
223
709
 
224
- function normalizeQuestions(input: unknown): unknown {
710
+ function normalizeQuestions(input: unknown, drops?: string[]): unknown {
711
+ if (typeof input === "string") input = tryParseJson(input);
712
+ if (isRecord(input)) {
713
+ // An array of questions wrapped in one key (`{"item": [...]}`) is the
714
+ // list the contract already accepts, one level too deep. Unwrap it and
715
+ // let the array path below name each entry the same way. Only when the
716
+ // wrapped array actually holds records: `{"q1": ["junk", "junk"]}` is a
717
+ // question map whose one entry is not a question, and unwrapping it
718
+ // renamed the junk on every pass.
719
+ const keys = Object.keys(input);
720
+ if (
721
+ keys.length === 1 &&
722
+ Array.isArray(input[keys[0]]) &&
723
+ (input[keys[0]] as unknown[]).some((entry) => isRecord(entry))
724
+ ) {
725
+ return normalizeQuestions(input[keys[0]], drops);
726
+ }
727
+ // One unwrapped question filed under the wrapper's own name, e.g.
728
+ // `{"questions": {"type": ..., "instructions": ...}}` — possibly beside
729
+ // other junk keys, as the model leaves them. The value is the question.
730
+ for (const key of keys) {
731
+ if (!QUESTION_NAME_ALIASES.has(key.toLowerCase())) continue;
732
+ const value = input[key];
733
+ if (isRecord(value) && typeof value.type === "string") {
734
+ // Every sibling is abandoned by this unwrap, and a sibling is often a
735
+ // real question: `{"questions": {"questions": {choice}, "risk":
736
+ // {noul}}}` is a two-question batch. Returning the aliased question
737
+ // alone answered half the batch and said nothing, so name what was
738
+ // left behind rather than letting the model believe it was answered.
739
+ for (const other of keys) {
740
+ if (other !== key) noteDrop(drops, other, input[other]);
741
+ }
742
+ return normalizeQuestions(value, drops);
743
+ }
744
+ }
745
+ // Parallel arrays standing in for a batch of questions.
746
+ const transposed = transposeParallelQuestions(input);
747
+ if (transposed !== undefined) return transposed;
748
+ // An array of questions the model sent as a name-to-array map. Naming is
749
+ // metadata, so take every question rather than stopping at the first:
750
+ // `{"fixes": [...], "risks": [...]}` is two questions, not one.
751
+ if (keys.length > 0 && keys.every((key) => Array.isArray(input[key]))) {
752
+ const entries = keys.flatMap((key) => input[key] as unknown[]);
753
+ // Only a map of question lists: `{q1: ["junk"]}` is a question map whose
754
+ // single entry is not a question, and flattening it renamed the junk.
755
+ if (!entries.some((entry) => isRecord(entry))) {
756
+ // fall through to the ordinary record handling
757
+ } else {
758
+ const merged: unknown[] = [];
759
+ for (const key of keys) {
760
+ for (const entry of input[key] as unknown[]) {
761
+ merged.push(
762
+ isRecord(entry) && entry.name === undefined
763
+ ? { name: key, ...entry }
764
+ : entry,
765
+ );
766
+ }
767
+ }
768
+ return normalizeQuestions(merged, drops);
769
+ }
770
+ }
771
+ }
225
772
  if (Array.isArray(input)) {
226
773
  const record: Record<string, unknown> = {};
227
774
  const used = new Set<string>();
@@ -233,19 +780,39 @@ function normalizeQuestions(input: unknown): unknown {
233
780
  used.add(candidate);
234
781
  return candidate;
235
782
  };
236
- input.forEach((entry, index) => {
237
- if (isRecord(entry)) {
238
- const { name, id, key, ...rest } = entry;
239
- const rawName = name ?? id ?? key;
240
- const base =
241
- typeof rawName === "string" && rawName.trim() !== ""
242
- ? rawName
243
- : `q${index + 1}`;
244
- setOwn(record, claimName(base), normalizeQuestion(rest));
245
- } else {
246
- setOwn(record, claimName(`q${index + 1}`), entry);
247
- }
783
+ // Same rule as the record branch below: a non-question entry is kept only
784
+ // while no real question exists. Applying it in one branch but not the
785
+ // other made normalization non-idempotent — `["junk", {a question}]`
786
+ // produced different maps on the first and second pass, and the second
787
+ // pass is the one execute() validates and sends.
788
+ //
789
+ // Question-ness is judged on the NORMALIZED entry, not the raw one: an
790
+ // entry like `{"description": "..."}` becomes a question only after
791
+ // normalization, so judging it raw makes the two passes disagree.
792
+ const pairs: Array<[string, unknown]> = input.map((entry, index) => {
793
+ // A question serialized as a JSON string (`["{\"type\":...}"]`) is the
794
+ // same provider quirk one level down; decode it so it can be normalized,
795
+ // and let a non-JSON string fall through as the junk it is.
796
+ const question = decodeJsonInput(entry);
797
+ if (!isRecord(question)) return [`q${index + 1}`, question];
798
+ // No intermediate `{...rest}` copy: normalizeQuestion() already drops
799
+ // name/id/key as junk, so passing the entry through repairs naming
800
+ // metadata and aliases in a single construction pass.
801
+ const rawName = question.name ?? question.id ?? question.key;
802
+ const base =
803
+ typeof rawName === "string" && rawName.trim() !== ""
804
+ ? rawName
805
+ : `q${index + 1}`;
806
+ return [base, normalizeQuestion(question)];
248
807
  });
808
+ const hasQuestion = pairs.some(([, value]) => looksLikeQuestion(value));
809
+ for (const [base, value] of pairs) {
810
+ if (hasQuestion && !looksLikeQuestion(value)) {
811
+ noteDrop(drops, base, value);
812
+ continue;
813
+ }
814
+ setOwn(record, claimName(base), value);
815
+ }
249
816
  return record;
250
817
  }
251
818
  if (isRecord(input)) {
@@ -270,9 +837,61 @@ function normalizeQuestions(input: unknown): unknown {
270
837
  );
271
838
  if (knownKind || allQuestionKeys) return { q: normalizeQuestion(input) };
272
839
  }
840
+ // Copy-on-write: share the map (and each canonical question) when
841
+ // nothing needs repair; otherwise rebuild with repaired questions and
842
+ // unaffected siblings shared by reference. The scan probes
843
+ // canonicality only — it must never build (and discard) repairs.
844
+ //
845
+ // A whole question MAP can land in a name slot, one level too deep:
846
+ // `{"Which library should we adopt?": {"pick": {type, instructions,
847
+ // criteria}}}`. The outer key is the question text, the value is a map
848
+ // whose every entry is a real question. Splice those entries up to this
849
+ // level. Provably safe: a question never holds a question, so a record
850
+ // whose values are ALL questions is a question map and nothing else.
851
+ const entries: Array<[string, unknown]> = [];
852
+ let dirty = false;
853
+ for (const name in input) {
854
+ if (!Object.hasOwn(input, name)) continue;
855
+ const question = input[name];
856
+ if (isNestedQuestionMap(question)) {
857
+ dirty = true;
858
+ entries.push(...flattenQuestionMap(question));
859
+ continue;
860
+ }
861
+ if (!isRecord(question) || !isCanonicalQuestion(question)) dirty = true;
862
+ entries.push([name, question]);
863
+ }
864
+ if (!dirty) return input;
273
865
  const record: Record<string, unknown> = {};
274
- for (const [name, question] of Object.entries(input)) {
275
- setOwn(record, name, normalizeQuestion(question));
866
+ // A question is always an object carrying a contract key. Anything else in
867
+ // a name slot is a label or a question field that landed a level too high
868
+ // (`{"deny": "refund it"}`, `{"criteria": {...}}`), so it is dropped — but
869
+ // only while a real question remains to answer with. A map with no real
870
+ // question carries none at all, and is left to report the precise error.
871
+ // Question-ness is judged on the normalized entry, matching the array
872
+ // branch: an entry that only becomes a question after normalization must
873
+ // still count, or the two passes disagree about what to keep.
874
+ const pairs: Array<[string, unknown]> = [];
875
+ for (const [name, value] of entries) {
876
+ const question = decodeJsonInput(value);
877
+ pairs.push([
878
+ name,
879
+ isRecord(question) ? normalizeQuestion(question) : question,
880
+ ]);
881
+ }
882
+ const hasQuestion = pairs.some(([, value]) => looksLikeQuestion(value));
883
+ for (const [name, question] of pairs) {
884
+ if (hasQuestion && !looksLikeQuestion(question)) {
885
+ noteDrop(drops, name, question);
886
+ continue;
887
+ }
888
+ // Two questions can only reach the same name if one was spliced up out
889
+ // of a nested map; suffix rather than silently drop the second.
890
+ let unique = name;
891
+ for (let n = 2; Object.hasOwn(record, unique); n += 1) {
892
+ unique = `${name}_${n}`;
893
+ }
894
+ setOwn(record, unique, question);
276
895
  }
277
896
  return record;
278
897
  }
@@ -285,18 +904,79 @@ function normalizeQuestions(input: unknown): unknown {
285
904
  * re-applies it defensively for direct calls that bypass validation.
286
905
  */
287
906
  export function prepareSystemOneArgs(args: unknown): SystemOneParams {
907
+ return normalizeArgs(args);
908
+ }
909
+
910
+ /**
911
+ * The same normalization, reporting any question-map entry it had to discard.
912
+ * `prepareSystemOneArgs` stays pure because Pi calls it on every tool call and
913
+ * the property harness relies on it being idempotent and reentrant; only the
914
+ * tool's own execute() needs to know what was thrown away, so only it pays for
915
+ * the second walk.
916
+ */
917
+ export function collectDiscardedQuestions(args: unknown): string[] {
918
+ const drops: string[] = [];
919
+ if (!isRecord(args)) return drops;
920
+ normalizeArgs(args, drops);
921
+ return drops;
922
+ }
923
+
924
+ function normalizeArgs(args: unknown, drops?: string[]): SystemOneParams {
288
925
  if (!isRecord(args)) return args as SystemOneParams;
289
- const out: Record<string, unknown> = {};
290
- if (args.state !== undefined) {
291
- out.state = args.state;
292
- } else if (args.context !== undefined) {
293
- out.state = args.context;
294
- } else if (args.evidence !== undefined) {
295
- out.state = args.evidence;
296
- }
297
926
  const questions =
298
927
  args.questions ?? args.question ?? args.queries ?? undefined;
299
- if (questions !== undefined) out.questions = normalizeQuestions(questions);
928
+ const normalizedQuestions =
929
+ questions !== undefined ? normalizeQuestions(questions, drops) : undefined;
930
+ // A state nested inside a question is request state; lift it unless a real
931
+ // top-level one was supplied, which always wins.
932
+ const lifted = liftNestedState(
933
+ normalizedQuestions,
934
+ args.state ?? args.context ?? args.evidence,
935
+ );
936
+ const topState = lifted.state;
937
+ const finalQuestions = lifted.questions;
938
+ // Copy-on-write fast path: canonical state/questions slots, no alias keys
939
+ // to rename, no unknown keys to drop — return the input by reference, so
940
+ // the defensive re-normalization in execute() never copies the question
941
+ // map or any question object, and retains nothing.
942
+ //
943
+ // The path is cheap to *copy* but no longer cheap to *decide*: the
944
+ // canonicality checks below now have to rule out all 13 repair rules
945
+ // before they can conclude "nothing to do". Measured on the canonical
946
+ // fixture in bench/normalize.bench.ts, against 8cf6ba1 on the same
947
+ // machine: 620 -> 1329 ns/op and 290 -> 1939 B/op generated, with
948
+ // retained memory ~0 on both sides. The cost is transient garbage from
949
+ // the analysis, not copies, and it is negligible against the network
950
+ // round trip to the judge. The repair path itself is unchanged
951
+ // (sloppy fixture: 1571 -> 1563 B/op generated).
952
+ const questionsCanonical =
953
+ finalQuestions === questions &&
954
+ (questions === undefined
955
+ ? !Object.hasOwn(args, "questions") &&
956
+ !Object.hasOwn(args, "question") &&
957
+ !Object.hasOwn(args, "queries")
958
+ : questions === args.questions &&
959
+ !Object.hasOwn(args, "question") &&
960
+ !Object.hasOwn(args, "queries"));
961
+ if (
962
+ questionsCanonical &&
963
+ Object.hasOwn(args, "state") &&
964
+ args.state !== undefined &&
965
+ !Object.hasOwn(args, "context") &&
966
+ !Object.hasOwn(args, "evidence")
967
+ ) {
968
+ let knownKeysOnly = true;
969
+ for (const key in args) {
970
+ if (Object.hasOwn(args, key) && key !== "state" && key !== "questions") {
971
+ knownKeysOnly = false;
972
+ break;
973
+ }
974
+ }
975
+ if (knownKeysOnly) return args as SystemOneParams;
976
+ }
977
+ const out: Record<string, unknown> = {};
978
+ if (topState !== undefined) out.state = topState;
979
+ if (finalQuestions !== undefined) out.questions = finalQuestions;
300
980
  // Drop unknown top-level keys; a missing `state` / `questions` still
301
981
  // produces a clear error from semantic validation in execute().
302
982
  return out as SystemOneParams;
@@ -325,8 +1005,6 @@ export interface SystemOneDetails {
325
1005
  * is the final arbiter for anything stricter). Every error states what
326
1006
  * was wrong, which question it was in, and what valid shape looks like.
327
1007
  */
328
- const KNOWN_QUESTION_KEYS = new Set(["type", "instructions", "criteria"]);
329
-
330
1008
  function assertAnswerableQuestions(questions: unknown): void {
331
1009
  if (questions === undefined) {
332
1010
  throw new Error(
@@ -351,13 +1029,6 @@ function assertAnswerableQuestions(questions: unknown): void {
351
1029
  `system_one: question "${name}" must be an object with "type", "instructions", and "criteria".`,
352
1030
  );
353
1031
  }
354
- for (const key of Object.keys(value)) {
355
- if (!KNOWN_QUESTION_KEYS.has(key)) {
356
- throw new Error(
357
- `system_one: question "${name}" has unknown field "${key}"; use only "type", "instructions", and "criteria".`,
358
- );
359
- }
360
- }
361
1032
  if (value.instructions === undefined || value.instructions === null) {
362
1033
  throw new Error(
363
1034
  `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?"}.`,
@@ -406,10 +1077,10 @@ export function buildSystemOneTool(deps: {
406
1077
  label: "System One",
407
1078
 
408
1079
  description:
409
- "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). " +
410
- "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. " +
1080
+ "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). " +
1081
+ "Call system_one instead of making a bounded judgment directly when the decision is a pick from named options, a yes/no likelihood, or a rubric score over state already in context. This applies both to final answers and intermediate agent decisions such as classification, routing, or choosing among plausible tools, skills, strategies, implementations, models, or providers. " +
411
1082
  "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. " +
412
- "Do not use it for open-ended text generation or factual lookup. " +
1083
+ "Do not use it for open-ended generation itself or factual lookup. A generative task may still contain bounded intermediate judgments where system_one is useful. " +
413
1084
  "Questions that share the same state can be evaluated together in one call. " +
414
1085
  'Each question is {"type": "choice" | "noul" | "score", "instructions": "...", "criteria": ...}: ' +
415
1086
  'choice criteria is an object keyed by choice label ({"a": null, "b": null}), ' +
@@ -419,14 +1090,24 @@ export function buildSystemOneTool(deps: {
419
1090
  parameters: systemOneParams,
420
1091
 
421
1092
  promptSnippet:
422
- "Use system_one for bounded choice, yes/no, and ordered-scale judgments over supplied evidence",
1093
+ "Use system_one for bounded judgments over supplied evidence, including classification, routing, and selecting among plausible tools, skills, strategies, implementations, models, or providers",
423
1094
 
424
1095
  promptGuidelines: [
425
- "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.",
1096
+ "Whenever you encounter a bounded judgment over supplied or retrieved evidence, including as an intermediate step in a larger task, 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.",
1097
+
1098
+ "Common bounded-choice patterns include classification into known labels, selecting among plausible candidate tools or skills, routing among models/providers, and choosing among plausible strategies or implementations. These are examples, not an exhaustive list; recognize other bounded decisions from their structure.",
1099
+
426
1100
  "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.",
1101
+
427
1102
  "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.",
1103
+
1104
+ "Do not use system_one to perform open-ended generation itself - writing, drafting, composing, generating code, or explaining. However, a generative task may contain bounded intermediate judgments where system_one is appropriate, such as classification, routing, or choosing among plausible tools, skills, strategies, implementations, models, or providers.",
1105
+
1106
+ "Do not use system_one for trivial or mechanically obvious selections where there is no meaningful judgment to make. Use it when multiple candidates are genuinely plausible and the choice depends on supplied or retrieved evidence.",
1107
+
428
1108
  "Batch independent system_one questions sharing the same state into one call instead of one call per question.",
429
- "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.",
1109
+
1110
+ "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.",
430
1111
  ],
431
1112
 
432
1113
  prepareArguments: (args) => prepareSystemOneArgs(args),
@@ -440,11 +1121,12 @@ export function buildSystemOneTool(deps: {
440
1121
  * `prepareArguments` above is an optimization for newer Pi only: it
441
1122
  * runs before schema validation there, but older Pi versions ignore
442
1123
  * it and validate the raw model output against the permissive
443
- * schema instead. Correctness never depends on it — re-normalize
1124
+ * schema instead. Correctness never depends on it - re-normalize
444
1125
  * here so every path (new Pi, old Pi, direct calls) converges on
445
1126
  * the same canonical shape before semantic validation.
446
1127
  */
447
1128
  const normalized = prepareSystemOneArgs(params);
1129
+
448
1130
  // prepareSystemOneArgs passes non-objects through untouched; direct
449
1131
  // calls bypassing Pi validation must get an actionable error, not a
450
1132
  // TypeError from property access on null.
@@ -453,12 +1135,15 @@ export function buildSystemOneTool(deps: {
453
1135
  'system_one: request must be an object with "state" and "questions".',
454
1136
  );
455
1137
  }
1138
+
456
1139
  if (normalized.state === undefined) {
457
1140
  throw new Error(
458
1141
  'system_one: state is required. Put the material under judgment in "state" (string or object), e.g. {"state": "<evidence>", "questions": {...}}.',
459
1142
  );
460
1143
  }
1144
+
461
1145
  assertAnswerableQuestions(normalized.questions);
1146
+
462
1147
  const response = await systemOne.evaluate(
463
1148
  {
464
1149
  state: normalized.state as SystemOneState,
@@ -471,7 +1156,10 @@ export function buildSystemOneTool(deps: {
471
1156
  content: [
472
1157
  {
473
1158
  type: "text" as const,
474
- text: renderSystemOneResult(response),
1159
+ text: renderSystemOneResult(
1160
+ response,
1161
+ collectDiscardedQuestions(params),
1162
+ ),
475
1163
  },
476
1164
  ],
477
1165