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/README.md +20 -1
- package/demo.gif +0 -0
- package/package.json +7 -2
- package/skills/system-one/SKILL.md +147 -9
- package/src/render.ts +70 -5
- package/src/tool.ts +740 -52
- package/tests/packaging.test.ts +13 -0
- package/tests/properties.test.ts +565 -0
- package/tests/render.test.ts +47 -0
- package/tests/tool.test.ts +1263 -62
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 (
|
|
208
|
-
//
|
|
209
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
|
410
|
-
"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. " +
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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(
|
|
1159
|
+
text: renderSystemOneResult(
|
|
1160
|
+
response,
|
|
1161
|
+
collectDiscardedQuestions(params),
|
|
1162
|
+
),
|
|
475
1163
|
},
|
|
476
1164
|
],
|
|
477
1165
|
|