pi-system-one 1.2.1 → 1.2.4

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,6 +183,70 @@ 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
+
179
250
  /**
180
251
  * Copy-on-write canonicality check for one question. Returns true when
181
252
  * running the builder below would produce a key-for-key, value-for-value
@@ -236,6 +307,15 @@ function isCanonicalQuestion(input: Record<string, unknown>): boolean {
236
307
  ) {
237
308
  return false; // The builder folds string arrays to an object.
238
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
+ }
239
319
  if (criteria !== undefined) {
240
320
  if (!Object.hasOwn(input, "criteria") || input.criteria !== criteria) {
241
321
  return false;
@@ -249,10 +329,110 @@ function isCanonicalQuestion(input: Record<string, unknown>): boolean {
249
329
  for (const key of QUESTION_JUNK_KEYS) {
250
330
  if (Object.hasOwn(input, key)) return false;
251
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
+ }
252
347
  // Any other own key is preserved by reference, so it cannot differ.
253
348
  return true;
254
349
  }
255
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
+
256
436
  /**
257
437
  * Coerce one question into the flat schema shape: canonical `type`,
258
438
  * `instructions` / `criteria` resolved through aliases, array-form choice
@@ -266,9 +446,30 @@ function normalizeQuestion(input: unknown): unknown {
266
446
  if (!isRecord(input)) return input;
267
447
  if (isCanonicalQuestion(input)) return input;
268
448
  const out: Record<string, unknown> = {};
269
- if (Object.hasOwn(input, "type"))
270
- out.type = normalizeQuestionType(input.type);
271
- 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
+ }
272
473
  if (instructions !== undefined) out.instructions = instructions;
273
474
  else if (Object.hasOwn(input, "instructions"))
274
475
  out.instructions = input.instructions;
@@ -276,6 +477,20 @@ function normalizeQuestion(input: unknown): unknown {
276
477
  // `null` is this schema's idiom for "no content"; on noul it reads as
277
478
  // "omitted" rather than a degenerate value worth a backend round trip.
278
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
+ }
279
494
  if (
280
495
  out.type === "choice" &&
281
496
  Array.isArray(criteria) &&
@@ -285,24 +500,275 @@ function normalizeQuestion(input: unknown): unknown {
285
500
  for (const item of criteria as string[]) setOwn(folded, item, null);
286
501
  criteria = folded;
287
502
  }
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.
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]> = [];
291
507
  for (const [key, value] of Object.entries(input)) {
292
- 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) =>
293
632
  key === "type" ||
294
633
  INSTRUCTIONS_ALIASES.includes(key) ||
295
- CRITERIA_ALIASES.includes(key) ||
296
- 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
297
701
  ) {
298
- continue;
702
+ out.push(...flattenQuestionMap(child, depth + 1));
703
+ } else {
704
+ out.push([name, child]);
299
705
  }
300
- setOwn(out, key, value);
301
706
  }
302
707
  return out;
303
708
  }
304
709
 
305
- 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
+ }
306
772
  if (Array.isArray(input)) {
307
773
  const record: Record<string, unknown> = {};
308
774
  const used = new Set<string>();
@@ -314,21 +780,39 @@ function normalizeQuestions(input: unknown): unknown {
314
780
  used.add(candidate);
315
781
  return candidate;
316
782
  };
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
- }
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)];
331
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
+ }
332
816
  return record;
333
817
  }
334
818
  if (isRecord(input)) {
@@ -357,20 +841,57 @@ function normalizeQuestions(input: unknown): unknown {
357
841
  // nothing needs repair; otherwise rebuild with repaired questions and
358
842
  // unaffected siblings shared by reference. The scan probes
359
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]> = [];
360
852
  let dirty = false;
361
853
  for (const name in input) {
362
854
  if (!Object.hasOwn(input, name)) continue;
363
855
  const question = input[name];
364
- if (!isRecord(question) || !isCanonicalQuestion(question)) {
856
+ if (isNestedQuestionMap(question)) {
365
857
  dirty = true;
366
- break;
858
+ entries.push(...flattenQuestionMap(question));
859
+ continue;
367
860
  }
861
+ if (!isRecord(question) || !isCanonicalQuestion(question)) dirty = true;
862
+ entries.push([name, question]);
368
863
  }
369
864
  if (!dirty) return input;
370
865
  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]));
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);
374
895
  }
375
896
  return record;
376
897
  }
@@ -383,16 +904,53 @@ function normalizeQuestions(input: unknown): unknown {
383
904
  * re-applies it defensively for direct calls that bypass validation.
384
905
  */
385
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 {
386
925
  if (!isRecord(args)) return args as SystemOneParams;
387
926
  const questions =
388
927
  args.questions ?? args.question ?? args.queries ?? undefined;
389
928
  const normalizedQuestions =
390
- questions !== undefined ? normalizeQuestions(questions) : undefined;
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;
391
938
  // 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.
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).
394
952
  const questionsCanonical =
395
- normalizedQuestions === questions &&
953
+ finalQuestions === questions &&
396
954
  (questions === undefined
397
955
  ? !Object.hasOwn(args, "questions") &&
398
956
  !Object.hasOwn(args, "question") &&
@@ -417,14 +975,8 @@ export function prepareSystemOneArgs(args: unknown): SystemOneParams {
417
975
  if (knownKeysOnly) return args as SystemOneParams;
418
976
  }
419
977
  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;
978
+ if (topState !== undefined) out.state = topState;
979
+ if (finalQuestions !== undefined) out.questions = finalQuestions;
428
980
  // Drop unknown top-level keys; a missing `state` / `questions` still
429
981
  // produces a clear error from semantic validation in execute().
430
982
  return out as SystemOneParams;
@@ -453,8 +1005,6 @@ export interface SystemOneDetails {
453
1005
  * is the final arbiter for anything stricter). Every error states what
454
1006
  * was wrong, which question it was in, and what valid shape looks like.
455
1007
  */
456
- const KNOWN_QUESTION_KEYS = new Set(["type", "instructions", "criteria"]);
457
-
458
1008
  function assertAnswerableQuestions(questions: unknown): void {
459
1009
  if (questions === undefined) {
460
1010
  throw new Error(
@@ -479,13 +1029,6 @@ function assertAnswerableQuestions(questions: unknown): void {
479
1029
  `system_one: question "${name}" must be an object with "type", "instructions", and "criteria".`,
480
1030
  );
481
1031
  }
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
1032
  if (value.instructions === undefined || value.instructions === null) {
490
1033
  throw new Error(
491
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?"}.`,
@@ -534,10 +1077,10 @@ export function buildSystemOneTool(deps: {
534
1077
  label: "System One",
535
1078
 
536
1079
  description:
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. " +
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. " +
539
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. " +
540
- "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. " +
541
1084
  "Questions that share the same state can be evaluated together in one call. " +
542
1085
  'Each question is {"type": "choice" | "noul" | "score", "instructions": "...", "criteria": ...}: ' +
543
1086
  'choice criteria is an object keyed by choice label ({"a": null, "b": null}), ' +
@@ -547,14 +1090,26 @@ export function buildSystemOneTool(deps: {
547
1090
  parameters: systemOneParams,
548
1091
 
549
1092
  promptSnippet:
550
- "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",
551
1094
 
552
1095
  promptGuidelines: [
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.",
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
+
554
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
+
555
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
+
556
1108
  "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.",
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.",
1111
+
1112
+ "Use system_one when you need to make fast decisions in bulk",
558
1113
  ],
559
1114
 
560
1115
  prepareArguments: (args) => prepareSystemOneArgs(args),
@@ -568,11 +1123,12 @@ export function buildSystemOneTool(deps: {
568
1123
  * `prepareArguments` above is an optimization for newer Pi only: it
569
1124
  * runs before schema validation there, but older Pi versions ignore
570
1125
  * it and validate the raw model output against the permissive
571
- * schema instead. Correctness never depends on it — re-normalize
1126
+ * schema instead. Correctness never depends on it - re-normalize
572
1127
  * here so every path (new Pi, old Pi, direct calls) converges on
573
1128
  * the same canonical shape before semantic validation.
574
1129
  */
575
1130
  const normalized = prepareSystemOneArgs(params);
1131
+
576
1132
  // prepareSystemOneArgs passes non-objects through untouched; direct
577
1133
  // calls bypassing Pi validation must get an actionable error, not a
578
1134
  // TypeError from property access on null.
@@ -581,12 +1137,15 @@ export function buildSystemOneTool(deps: {
581
1137
  'system_one: request must be an object with "state" and "questions".',
582
1138
  );
583
1139
  }
1140
+
584
1141
  if (normalized.state === undefined) {
585
1142
  throw new Error(
586
1143
  'system_one: state is required. Put the material under judgment in "state" (string or object), e.g. {"state": "<evidence>", "questions": {...}}.',
587
1144
  );
588
1145
  }
1146
+
589
1147
  assertAnswerableQuestions(normalized.questions);
1148
+
590
1149
  const response = await systemOne.evaluate(
591
1150
  {
592
1151
  state: normalized.state as SystemOneState,
@@ -599,7 +1158,10 @@ export function buildSystemOneTool(deps: {
599
1158
  content: [
600
1159
  {
601
1160
  type: "text" as const,
602
- text: renderSystemOneResult(response),
1161
+ text: renderSystemOneResult(
1162
+ response,
1163
+ collectDiscardedQuestions(params),
1164
+ ),
603
1165
  },
604
1166
  ],
605
1167