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/README.md +4 -1
- package/demo.gif +0 -0
- package/package.json +1 -1
- package/skills/system-one/SKILL.md +147 -9
- package/src/render.ts +70 -5
- package/src/tool.ts +622 -60
- package/tests/properties.test.ts +565 -0
- package/tests/render.test.ts +47 -0
- package/tests/tool.test.ts +1015 -53
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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 (
|
|
289
|
-
//
|
|
290
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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 (
|
|
856
|
+
if (isNestedQuestionMap(question)) {
|
|
365
857
|
dirty = true;
|
|
366
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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()
|
|
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
|
-
|
|
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 (
|
|
421
|
-
|
|
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
|
|
538
|
-
"Call system_one instead of
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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(
|
|
1161
|
+
text: renderSystemOneResult(
|
|
1162
|
+
response,
|
|
1163
|
+
collectDiscardedQuestions(params),
|
|
1164
|
+
),
|
|
603
1165
|
},
|
|
604
1166
|
],
|
|
605
1167
|
|