pi-freeflow 1.36.0 → 1.36.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tool-args.ts CHANGED
@@ -9,9 +9,12 @@
9
9
  * `items: { item: [...] }`. Sibling free models invented whole vocabularies
10
10
  * (`phases` / `name` / `tasks`) the schema never declared.
11
11
  *
12
- * Two families are mechanically decidable from the CALLER's own declared
12
+ * Three families are mechanically decidable from the CALLER's own declared
13
13
  * schema, and this module repairs exactly those:
14
14
  *
15
+ * - Prefix-glue salvage: an argument string carrying the tail of the previous
16
+ * call in front of the caller's own object is recovered (see parseArguments).
17
+ *
15
18
  * - Envelope unwrap: a declared array that arrives wrapped in a single-key
16
19
  * object (`items: { item: [...] }`) is unwrapped.
17
20
  * - JSON-string decode: a declared object/array that arrives as a JSON string
@@ -38,6 +41,14 @@ import { canonicalizeTool } from "./tool-translation.ts";
38
41
  /** Bound on recursive repair/validation; real host schemas nest 3-4 deep. */
39
42
  const MAX_DEPTH = 8;
40
43
 
44
+ /**
45
+ * Text ending in a bare numeric literal (`"timeout":18`, `"n":1.`, `"n":1e`).
46
+ * A complete JSON value is never followed by a digit, so this can only be a
47
+ * literal the model was still writing when the stream cut — see
48
+ * {@link appendClosers}.
49
+ */
50
+ const CUT_NUMBER_TAIL = /[\d.eE+-]$/;
51
+
41
52
  /** Declared JSON Schema type, tolerating union types like ["array","null"]. */
42
53
  function schemaType(schema: Record<string, unknown>): string {
43
54
  const declared = schema.type;
@@ -49,10 +60,47 @@ function schemaType(schema: Record<string, unknown>): string {
49
60
  return "";
50
61
  }
51
62
 
52
- /** A schema property worth repairing: it holds an object or an array. */
53
- function structuralProperty(schema: Record<string, unknown>): boolean {
54
- const type = schemaType(schema);
55
- return type === "object" || type === "array";
63
+
64
+ /**
65
+ * Does a value already satisfy the declared schema? Type, then required keys,
66
+ * then each present property recursively. A present property the schema does
67
+ * not declare is ignored (never fails): hosts decide their own extras policy,
68
+ * and this only gates whether a promotion is safe.
69
+ *
70
+ * Deliberately reads five keywords — `type`, `enum`, `properties`, `items`,
71
+ * `required` — and nothing else. `minItems`, `minLength`, `pattern`,
72
+ * `additionalProperties` and `strict` are NOT enforced, on purpose: this
73
+ * function only decides whether to hand the repaired value to the host, and
74
+ * the host validates the same keywords itself. A payload that violates
75
+ * `minItems` is rejected by the host whether or not the repair accepted it, so
76
+ * enforcing it here could only ever DISABLE a repair that would have been
77
+ * accepted downstream — strictly worse, never better. The trade-off this does
78
+ * leave: repair never rescues a call whose only fault is a constraint like
79
+ * `minItems`, because the shape was never the problem.
80
+ */
81
+ /**
82
+ * Is this property allowed to be absent?
83
+ *
84
+ * Both hosts rewrite a tool's schema for strict mode by listing EVERY property
85
+ * key in `required` and wrapping each optional one in
86
+ * `anyOf: [<schema>, { type: "null" }]` (OMP: packages/ai CONSTRAINTS.md:34-49;
87
+ * Pi: core/constrained-sampling.ts:118-123). So "required" there does not mean
88
+ * "must be present" — it means "must be addressable". Reading it literally made
89
+ * this gate refuse every well-formed call on a strict tool, which silently
90
+ * disabled the whole repair for `bash` and the strict edit modes: the glued
91
+ * payload the host rejects is exactly the one this module exists to fix.
92
+ */
93
+ function isNullableProperty(prop: unknown): boolean {
94
+ if (prop === null || typeof prop !== "object" || Array.isArray(prop)) return false;
95
+ const rec = prop as Record<string, unknown>;
96
+ if (Array.isArray(rec.type)) return (rec.type as unknown[]).includes("null");
97
+ const anyOf = rec.anyOf;
98
+ if (Array.isArray(anyOf)) {
99
+ for (const branch of anyOf) {
100
+ if (branch !== null && typeof branch === "object" && !Array.isArray(branch) && (branch as Record<string, unknown>).type === "null") return true;
101
+ }
102
+ }
103
+ return false;
56
104
  }
57
105
 
58
106
  /**
@@ -60,38 +108,74 @@ function structuralProperty(schema: Record<string, unknown>): boolean {
60
108
  * then each present property recursively. A present property the schema does
61
109
  * not declare is ignored (never fails): hosts decide their own extras policy,
62
110
  * and this only gates whether a promotion is safe.
111
+ *
112
+ * Deliberately reads `type`, `enum`, `properties`, `items`, `required` — and
113
+ * nothing else. `minItems`, `minLength`, `pattern`, `additionalProperties` and
114
+ * `strict` are NOT enforced, on purpose: this function only decides whether to
115
+ * hand the repaired value to the host, and the host validates the same
116
+ * keywords itself. A payload that violates `minItems` is rejected by the host
117
+ * whether or not the repair accepted it, so enforcing it here could only ever
118
+ * DISABLE a repair the host would otherwise have accepted — strictly worse,
119
+ * never better. The trade-off this leaves: repair never rescues a call whose
120
+ * only fault is a constraint like `minItems`, because the shape was never the
121
+ * problem.
63
122
  */
64
123
  function matchesSchema(value: unknown, schema: Record<string, unknown>, depth: number): boolean {
65
- if (depth > MAX_DEPTH) return false;
66
- const type = schemaType(schema);
67
- if (type === "array") {
68
- if (!Array.isArray(value)) return false;
69
- const items = schema.items;
70
- if (items === null || typeof items !== "object" || Array.isArray(items)) return true;
71
- return value.every((element) => matchesSchema(element, items as Record<string, unknown>, depth + 1));
72
- }
73
- if (type === "object") {
74
- if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
75
- const rec = value as Record<string, unknown>;
76
- if (Array.isArray(schema.required)) {
77
- for (const key of schema.required) {
78
- if (typeof key === "string" && !(key in rec)) return false;
79
- }
80
- }
81
- const props = schema.properties;
82
- if (props === null || typeof props !== "object" || Array.isArray(props)) return true;
83
- for (const [key, entry] of Object.entries(rec)) {
84
- const propSchema = (props as Record<string, unknown>)[key];
85
- if (propSchema === null || typeof propSchema !== "object" || Array.isArray(propSchema)) continue;
86
- if (!matchesSchema(entry, propSchema as Record<string, unknown>, depth + 1)) return false;
124
+ if (depth > MAX_DEPTH) return false;
125
+ const type = schemaType(schema);
126
+ if (type === "array") {
127
+ if (!Array.isArray(value)) return false;
128
+ const items = schema.items;
129
+ if (items === null || typeof items !== "object" || Array.isArray(items)) return true;
130
+ return value.every((element) => matchesSchema(element, items as Record<string, unknown>, depth + 1));
131
+ }
132
+ if (type === "object") {
133
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
134
+ const rec = value as Record<string, unknown>;
135
+ const props = schema.properties;
136
+ const declared = props !== null && typeof props === "object" && !Array.isArray(props) ? (props as Record<string, unknown>) : null;
137
+ if (Array.isArray(schema.required)) {
138
+ for (const key of schema.required) {
139
+ if (typeof key !== "string" || key in rec) continue;
140
+ // Strict-mode hosts list every key here and mark the optional ones nullable,
141
+ // so a nullable key that is absent is correct, not a violation.
142
+ if (declared !== null && isNullableProperty(declared[key])) continue;
143
+ return false;
87
144
  }
88
- return true;
89
145
  }
90
- if (type === "string") return typeof value === "string";
91
- if (type === "number") return typeof value === "number" && Number.isFinite(value);
92
- if (type === "boolean") return typeof value === "boolean";
93
- if (Array.isArray(schema.enum)) return schema.enum.some((member) => member === value);
146
+ if (declared === null) return true;
147
+ for (const [key, entry] of Object.entries(rec)) {
148
+ const propSchema = declared[key];
149
+ if (propSchema === null || typeof propSchema !== "object" || Array.isArray(propSchema)) continue;
150
+ if (!matchesSchema(entry, propSchema as Record<string, unknown>, depth + 1)) return false;
151
+ }
94
152
  return true;
153
+ }
154
+ if (type === "string") return typeof value === "string";
155
+ if (type === "number") return typeof value === "number" && Number.isFinite(value);
156
+ if (type === "boolean") return typeof value === "boolean";
157
+ if (Array.isArray(schema.enum)) return schema.enum.some((member) => member === value);
158
+ return true;
159
+ }
160
+
161
+ /** The spellings free models use for "no value" when the slot is nullable. */
162
+ const NULL_SENTINELS = new Set(["null", "none", "undefined", "nil", "n/a"]);
163
+
164
+ function isNullSentinel(text: string): boolean {
165
+ return NULL_SENTINELS.has(text.trim().toLowerCase());
166
+ }
167
+
168
+ /** The non-null branch of a nullable declaration, or null when it is not one. */
169
+ function nonNullBranch(prop: Record<string, unknown>): Record<string, unknown> | null {
170
+ const anyOf = prop.anyOf;
171
+ if (!Array.isArray(anyOf)) return null;
172
+ for (const branch of anyOf) {
173
+ if (branch === null || typeof branch !== "object" || Array.isArray(branch)) continue;
174
+ const rec = branch as Record<string, unknown>;
175
+ if (rec.type === "null") continue;
176
+ return rec;
177
+ }
178
+ return null;
95
179
  }
96
180
 
97
181
  /**
@@ -164,6 +248,24 @@ function repairValue(
164
248
  out[key] = entry;
165
249
  continue;
166
250
  }
251
+ // A strict host makes optional keys `anyOf: [T, null]`, and free models
252
+ // routinely answer that slot with the SENTINEL AS TEXT — `"timeout":"null"`,
253
+ // `"pty":"None"` (measured across 4 of 37 catalog models). The host rejects
254
+ // the call because a string is not a number and not null.
255
+ //
256
+ // Restricted to non-string inner types on purpose: when T is `string`,
257
+ // `"null"` could be a genuine value and rewriting it would destroy data.
258
+ // With T = number/boolean/object/array the property can never legitimately
259
+ // hold a bare string, so collapsing a null sentinel loses nothing and makes
260
+ // the call one the host accepts.
261
+ if (typeof entry === "string" && isNullSentinel(entry)) {
262
+ const nonNull = nonNullBranch(propSchema as Record<string, unknown>);
263
+ if (nonNull !== null && schemaType(nonNull) !== "string") {
264
+ out[key] = null;
265
+ changed = true;
266
+ continue;
267
+ }
268
+ }
167
269
  const repaired = repairValue(entry, propSchema as Record<string, unknown>, depth + 1);
168
270
  if (repaired.changed) changed = true;
169
271
  out[key] = repaired.value;
@@ -318,28 +420,29 @@ function promoteStringArray(
318
420
 
319
421
  /**
320
422
  * Per-request map of caller tool name (lowercased) to the schema its
321
- * arguments are repaired against. Only tools whose parameters declare at least
322
- * one object or array property are kept: those are the only ones a repair can
323
- * act on, and an empty map means the request streams byte-identically.
423
+ * arguments are repaired against. Every tool that declares object parameters
424
+ * is kept, NOT only those with an object/array property: the prefix-glue
425
+ * salvage in {@link parseArguments} needs only the caller's own schema to
426
+ * confirm the recovered object, and it fires most often on scalar-only tools.
427
+ * `bash` is the live case — OMP's `bashSchemaBase` declares only `command`,
428
+ * `timeout`, `cwd`, `pty` (reference/oh-my-pi tools/bash.ts:327), so a
429
+ * structural-only filter left the most-used tool unrepairable and the host
430
+ * rejected the glued payload outright.
324
431
  *
325
432
  * Keyed by CALLER name, which is what arrives downstream after the casing and
326
433
  * find->glob restores. Injected fingerprint placeholders are absent by
327
434
  * construction, so they are never repaired.
328
435
  */
329
436
  export function buildArgRepairSchemas(tools: unknown[]): Map<string, Record<string, unknown>> {
330
- const schemas = new Map<string, Record<string, unknown>>();
331
- for (const tool of tools) {
332
- const canon = canonicalizeTool(tool);
333
- if (canon === null) continue;
334
- const props = canon.parameters.properties;
335
- if (props === null || typeof props !== "object" || Array.isArray(props)) continue;
336
- const hasStructural = Object.values(props as Record<string, unknown>).some(
337
- (prop) => prop !== null && typeof prop === "object" && !Array.isArray(prop) && structuralProperty(prop as Record<string, unknown>),
338
- );
339
- if (!hasStructural) continue;
340
- schemas.set(canon.name.trim().toLowerCase(), canon.parameters);
341
- }
342
- return schemas;
437
+ const schemas = new Map<string, Record<string, unknown>>();
438
+ for (const tool of tools) {
439
+ const canon = canonicalizeTool(tool);
440
+ if (canon === null) continue;
441
+ const props = canon.parameters.properties;
442
+ if (props === null || typeof props !== "object" || Array.isArray(props)) continue;
443
+ schemas.set(canon.name.trim().toLowerCase(), canon.parameters);
444
+ }
445
+ return schemas;
343
446
  }
344
447
 
345
448
 
@@ -374,8 +477,13 @@ function parseArguments(argsJson: string): { value: Record<string, unknown>; sal
374
477
  } catch {
375
478
  // fall through to the salvage scan
376
479
  }
377
- for (let i = argsJson.indexOf("{"); i !== -1; i = argsJson.indexOf("{", i + 1)) {
378
- const candidate = argsJson.slice(i);
480
+ // Only a brace that OPENS a top-level object can start the caller's call: a
481
+ // brace nested inside an already-unterminated object belongs to that outer
482
+ // object, so promoting it would drop the declared key that wraps it
483
+ // (`{"opts":{"a":1` must not salvage to `{"a":1}`).
484
+ for (const start of topLevelObjectStarts(argsJson)) {
485
+ const candidate = argsJson.slice(start);
486
+ const i = start;
379
487
  // Two shapes to try, in order: the candidate as written, then the candidate
380
488
  // with its missing closers appended. The closer is only offered at the FIRST
381
489
  // brace and only when something precedes it — that fragment is the evidence
@@ -395,7 +503,35 @@ function parseArguments(argsJson: string): { value: Record<string, unknown>; sal
395
503
  }
396
504
  }
397
505
  }
398
- return undefined;
506
+ return undefined;
507
+ }
508
+
509
+ /**
510
+ * Offsets of every `{` that could open the caller's own object.
511
+ *
512
+ * Depth counts braces only, clamped at zero, and deliberately ignores string
513
+ * state. The junk in front is itself a cut fragment: it usually ends
514
+ * mid-string (`…/freelane-proxy"`), so string tracking would classify the whole
515
+ * real call as "inside a string" and find no start at all — which is exactly
516
+ * the live failure this scan has to recover from.
517
+ *
518
+ * A nested brace is a candidate only when no key precedes it: `{"opts":{"a":1}`
519
+ * must NOT promote its inner `{"a":1}` (a `:` sits between, so the outer
520
+ * object is a real one and its declared key would be dropped), while the
521
+ * observed prefix junk `}{` must still reach the real call that follows it.
522
+ */
523
+ function topLevelObjectStarts(text: string): number[] {
524
+ const starts: number[] = [];
525
+ const openAt: number[] = [];
526
+ for (let i = 0; i < text.length; i++) {
527
+ if (text[i] === "{") {
528
+ if (openAt.length === 0 || !text.slice(openAt[openAt.length - 1] + 1, i).includes(":")) starts.push(i);
529
+ openAt.push(i);
530
+ continue;
531
+ }
532
+ if (text[i] === "}" && openAt.length > 0) openAt.pop();
533
+ }
534
+ return starts;
399
535
  }
400
536
 
401
537
  /**
@@ -404,11 +540,15 @@ function parseArguments(argsJson: string): { value: Record<string, unknown>; sal
404
540
  *
405
541
  * Only ever called on a candidate that starts at a `{` preceded by a fragment
406
542
  * of the previous call, and only when the candidate's own contents already
407
- * end cleanly. Two refusals keep this honest:
543
+ * end cleanly. Three refusals keep this honest:
408
544
  *
409
545
  * - Cut inside a string literal (an unterminated `"`) returns undefined. The
410
546
  * value itself is incomplete, so any closing brace would silently shorten
411
547
  * it — and these arguments carry shell commands.
548
+ * - Cut inside a number literal returns undefined. `{"timeout":18` may have
549
+ * been `180`, `18.5`, or `18`; completing it hands the host a confident wrong
550
+ * value with nothing to signal the guess. (`timeout` is a declared number on
551
+ * both hosts' `bash`.)
412
552
  * - Nothing left open returns undefined, so a well-formed candidate never
413
553
  * reaches this path.
414
554
  *
@@ -433,6 +573,8 @@ function appendClosers(text: string): string | undefined {
433
573
  }
434
574
  // Inside a string at the end: the value was cut mid-token, so refuse.
435
575
  if (inString || open.length === 0) return undefined;
576
+ // A bare number at the end is a cut literal (`18` of `180`), so refuse.
577
+ if (CUT_NUMBER_TAIL.test(text)) return undefined;
436
578
  return text + open.reverse().join("");
437
579
  }
438
580
  /**