@cr1ms0n/pi-subagent 0.8.1
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/CHANGELOG.md +352 -0
- package/LICENSE +21 -0
- package/README.md +543 -0
- package/docs/ARCHITECTURE.md +125 -0
- package/docs/COST-ACCOUNTING.md +66 -0
- package/docs/PLAN.md +325 -0
- package/docs/RELEASING.md +32 -0
- package/docs/ROADMAP.md +252 -0
- package/docs/SECURITY.md +85 -0
- package/docs/UI-OVERHAUL.md +186 -0
- package/docs/UX.md +141 -0
- package/extensions/subagent.ts +1 -0
- package/package.json +58 -0
- package/skills/subagent/SKILL.md +103 -0
- package/src/agents.ts +285 -0
- package/src/backend.ts +146 -0
- package/src/backends/claude.ts +384 -0
- package/src/backends/codex.ts +330 -0
- package/src/backends/index.ts +26 -0
- package/src/backends/pi.ts +94 -0
- package/src/btw.ts +34 -0
- package/src/config.ts +254 -0
- package/src/distill.ts +222 -0
- package/src/extension.ts +1527 -0
- package/src/format.ts +365 -0
- package/src/index.ts +60 -0
- package/src/launch.ts +120 -0
- package/src/maintenance.ts +6 -0
- package/src/model-policy.ts +157 -0
- package/src/notifications.ts +106 -0
- package/src/orchestrator.ts +247 -0
- package/src/output.ts +124 -0
- package/src/persistence.ts +334 -0
- package/src/policy.ts +500 -0
- package/src/process-lock.ts +687 -0
- package/src/protocol.ts +290 -0
- package/src/registry.ts +632 -0
- package/src/runner.ts +850 -0
- package/src/schema.ts +166 -0
- package/src/semaphore.ts +123 -0
- package/src/structured.ts +169 -0
- package/src/transcript.ts +360 -0
- package/src/types.ts +197 -0
- package/src/ui.ts +545 -0
- package/src/usage.ts +274 -0
- package/src/worktree.ts +753 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
2
|
+
|
|
3
|
+
const ThinkingLevel = Type.Union([
|
|
4
|
+
Type.Literal("off"),
|
|
5
|
+
Type.Literal("minimal"),
|
|
6
|
+
Type.Literal("low"),
|
|
7
|
+
Type.Literal("medium"),
|
|
8
|
+
Type.Literal("high"),
|
|
9
|
+
Type.Literal("xhigh"),
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const OutputMode = Type.Union([Type.Literal("inline"), Type.Literal("file-only")]);
|
|
13
|
+
const Profile = Type.Union([Type.Literal("explore"), Type.Literal("review"), Type.Literal("general")]);
|
|
14
|
+
const Isolation = Type.Union([Type.Literal("shared"), Type.Literal("worktree")]);
|
|
15
|
+
const Backend = Type.Union([Type.Literal("pi"), Type.Literal("codex"), Type.Literal("claude")]);
|
|
16
|
+
const Action = Type.Union([
|
|
17
|
+
Type.Literal("status"),
|
|
18
|
+
Type.Literal("wait"),
|
|
19
|
+
Type.Literal("cancel"),
|
|
20
|
+
Type.Literal("steer"),
|
|
21
|
+
Type.Literal("diff"),
|
|
22
|
+
Type.Literal("apply"),
|
|
23
|
+
Type.Literal("discard"),
|
|
24
|
+
Type.Literal("plan"),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** Shared optional task configuration fields. */
|
|
28
|
+
export const TaskFields = {
|
|
29
|
+
backend: Type.Optional({ ...Backend, description: "Agent CLI powering the child. pi (default) supports every feature. codex uses codex exec with an OS-level read-only sandbox, but reports no cost (max_cost is refused) and cannot be steered. claude uses the claude CLI, reports cost, and cannot be steered. Unsupported combinations are refused with an explanation rather than silently ignored." }),
|
|
30
|
+
agent: Type.Optional(Type.String({ minLength: 1, description: "Named agent to use (from .pi/agents/<name>.md). Supplies persona system prompt and defaults; explicit params still override." })),
|
|
31
|
+
description: Type.Optional(Type.String({ description: "Short human label (3-5 words) shown in UIs and result indexes." })),
|
|
32
|
+
system_prompt: Type.Optional(Type.String({ description: "Extra system prompt appended to the child's prompt (does not replace it)." })),
|
|
33
|
+
model: Type.Optional(Type.String({ description: "**REQUIRED for every spawn call (task/tasks).** Exact model id from modelPolicy, in provider/model-id form. Calls without an explicit model are rejected; agent-file model, taskDefaults, and parent-session inheritance are ignored. Management actions (status/wait/cancel/steer/diff/apply/discard) do not need it." })),
|
|
34
|
+
thinking: Type.Optional({ ...ThinkingLevel, description: "Reasoning effort for the child. Defaults to profile config default, then the parent's level." }),
|
|
35
|
+
tools: Type.Optional(Type.Array(Type.String(), { description: "Optional tool allowlist. explore/review profiles reject write-capable tools." })),
|
|
36
|
+
profile: Type.Optional({ ...Profile, description: "Capability profile: explore/review are strictly read-only; general inherits the parent's active tools and may write." }),
|
|
37
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the child process." })),
|
|
38
|
+
timeout_ms: Type.Optional(Type.Number({ minimum: 1, maximum: 24 * 60 * 60_000, description: "Total budget in milliseconds including queue time. Timed-out runs report which phase timed out." })),
|
|
39
|
+
max_turns: Type.Optional(Type.Number({ minimum: 1, maximum: 500, description: "Budget: at this many turns the child is steered to wrap up and given grace turns for a final answer; ends as 'partial' with output preserved." })),
|
|
40
|
+
max_cost: Type.Optional(Type.Number({ minimum: 0, description: "Soft cost ceiling in dollars; checked after each turn, triggers the same wrap-up flow as max_turns." })),
|
|
41
|
+
grace_turns: Type.Optional(Type.Number({ minimum: 0, maximum: 20, description: "Wrap-up turns allowed after a budget breach before hard stop. 0 = immediate stop. Default from config (2)." })),
|
|
42
|
+
fallback_models: Type.Optional(Type.Array(Type.String(), { maxItems: 5, description: "Ordered backup models tried automatically on transient failures (provider error, stall, queue timeout)." })),
|
|
43
|
+
max_retries: Type.Optional(Type.Number({ minimum: 0, maximum: 5, description: "Extra attempts on transient failures. Defaults to config maxRetries (1). Task-quality failures never retry." })),
|
|
44
|
+
context: Type.Optional(
|
|
45
|
+
Type.Union([Type.Literal("fresh"), Type.Literal("fork")], {
|
|
46
|
+
description: "fork starts the child from a branched copy of the parent conversation (needs a persisted parent session); fresh (default) starts clean. Fork is single-task only.",
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
output: Type.Optional(Type.String({ description: "File path to write final output." })),
|
|
50
|
+
output_schema: Type.Optional(
|
|
51
|
+
Type.Unsafe<Record<string, unknown>>(
|
|
52
|
+
Type.Object({}, {
|
|
53
|
+
additionalProperties: true,
|
|
54
|
+
description: "JSON Schema the child's final result must satisfy. The child ends with a fenced json:result block; validation failures get one steer-based repair round, then end 'partial' with the errors reported.",
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
output_mode: Type.Optional({ ...OutputMode, description: "file-only returns a pointer instead of inline text; use for large reports." }),
|
|
59
|
+
resume: Type.Optional(Type.String({ description: "Child session id to continue." })),
|
|
60
|
+
fork_resume: Type.Optional(Type.Boolean({ description: "Fork the resumed session instead of direct resume." })),
|
|
61
|
+
isolation: Type.Optional({ ...Isolation, description: "worktree runs the task in an isolated git worktree; changed work is preserved on a branch." }),
|
|
62
|
+
include_wip: Type.Optional(
|
|
63
|
+
Type.Boolean({ description: "Seed a worktree with the parent checkout's uncommitted changes (staged + unstaged + untracked). Only valid with isolation:'worktree'." }),
|
|
64
|
+
),
|
|
65
|
+
allow_shared_writes: Type.Optional(
|
|
66
|
+
Type.Boolean({ description: "Unsafe opt-in for parallel writers sharing one checkout." }),
|
|
67
|
+
),
|
|
68
|
+
keep_background: Type.Optional(
|
|
69
|
+
Type.Boolean({ description: "Keep processes the child backgrounded (e.g. dev servers) alive after a clean exit." }),
|
|
70
|
+
),
|
|
71
|
+
} as const;
|
|
72
|
+
|
|
73
|
+
export const ParallelTaskItem = Type.Object(
|
|
74
|
+
{
|
|
75
|
+
task: Type.String({ minLength: 1, description: "Task text for one parallel worker." }),
|
|
76
|
+
...TaskFields,
|
|
77
|
+
},
|
|
78
|
+
{ additionalProperties: false },
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Provider-facing tool parameters.
|
|
83
|
+
*
|
|
84
|
+
* IMPORTANT: LLM tool APIs (OpenAI-compatible / OpenRouter / Anthropic via many
|
|
85
|
+
* providers) require the top-level parameters schema to be JSON Schema
|
|
86
|
+
* `type: "object"`. A Type.Union serializes as `anyOf` without `type: "object"`,
|
|
87
|
+
* which surfaces as:
|
|
88
|
+
* Invalid schema for function 'subagent': schema must be a JSON Schema of
|
|
89
|
+
* 'type: "object"', got 'type: "None"'.
|
|
90
|
+
*
|
|
91
|
+
* Mode exclusivity (action vs task vs tasks) is enforced in policy validation,
|
|
92
|
+
* not at the JSON Schema layer.
|
|
93
|
+
*/
|
|
94
|
+
export const SubagentParamsSchema = Type.Object(
|
|
95
|
+
{
|
|
96
|
+
// Management actions
|
|
97
|
+
action: Type.Optional({ ...Action, description: "Management action on an existing run: status, wait, cancel, steer (inject guidance into a running child), diff/apply/discard (worktree results). action:\"plan\" dry-runs validation + preflight with task/tasks — no spawn." }),
|
|
98
|
+
id: Type.Optional(Type.String({ minLength: 1, description: "Run id (or unique prefix) for management actions." })),
|
|
99
|
+
message: Type.Optional(Type.String({ minLength: 1, description: "Steering message injected into the running child (action: steer)." })),
|
|
100
|
+
index: Type.Optional(Type.Number({ minimum: 0, description: "Task index within a parallel run for steer/diff/apply/discard. Defaults to the only eligible task." })),
|
|
101
|
+
|
|
102
|
+
// Single-task mode
|
|
103
|
+
task: Type.Optional(Type.String({ minLength: 1, description: "Task to delegate (single mode)." })),
|
|
104
|
+
...TaskFields,
|
|
105
|
+
async: Type.Optional(Type.Boolean({ description: "Run in the background and return a handle immediately." })),
|
|
106
|
+
|
|
107
|
+
// Parallel mode
|
|
108
|
+
tasks: Type.Optional(
|
|
109
|
+
Type.Array(ParallelTaskItem, {
|
|
110
|
+
minItems: 1,
|
|
111
|
+
maxItems: 8,
|
|
112
|
+
description: "Array of independent tasks for parallel mode. Parallel tasks default to the read-only explore profile; parallel writers need isolation:'worktree'.",
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
synthesis: Type.Optional(
|
|
116
|
+
Type.String({
|
|
117
|
+
minLength: 1,
|
|
118
|
+
description: "Optional synthesis prompt for parallel mode: after all tasks finish, one read-only child folds their outputs using this instruction and its result is delivered first.",
|
|
119
|
+
}),
|
|
120
|
+
),
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
additionalProperties: false,
|
|
124
|
+
description: "Subagent request: single, parallel, status, wait, or cancel.",
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
export type SubagentParams = Static<typeof SubagentParamsSchema>;
|
|
129
|
+
export type ParallelTaskInput = Static<typeof ParallelTaskItem>;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Dedicated blocking-wait tool.
|
|
133
|
+
*
|
|
134
|
+
* `subagent` already exposes wait via `action: "wait"`, but collecting a
|
|
135
|
+
* background run is the one management step a model reaches for reflexively
|
|
136
|
+
* mid-flow, and burying it behind an action union costs a discovery step.
|
|
137
|
+
* This mirrors the highest-adoption package in the ecosystem (`pi-subagents`
|
|
138
|
+
* ships `subagent` + `subagent_wait` for the same reason). It is a thin
|
|
139
|
+
* front-end over the identical handler — no second delivery path.
|
|
140
|
+
*/
|
|
141
|
+
export const SubagentWaitParamsSchema = Type.Object(
|
|
142
|
+
{
|
|
143
|
+
id: Type.String({ minLength: 1, description: "Run id (or unique prefix) of the background run to collect." }),
|
|
144
|
+
timeout_ms: Type.Optional(
|
|
145
|
+
Type.Number({
|
|
146
|
+
minimum: 1,
|
|
147
|
+
maximum: 24 * 60 * 60_000,
|
|
148
|
+
description: "Give up waiting after this long and return a still-running notice. The run is NOT cancelled; collect it later with subagent_wait or action:'status'. Omit to wait until the run settles.",
|
|
149
|
+
}),
|
|
150
|
+
),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
additionalProperties: false,
|
|
154
|
+
description: "Block until a background subagent run settles, then deliver its output.",
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
export type SubagentWaitParams = Static<typeof SubagentWaitParamsSchema>;
|
|
159
|
+
|
|
160
|
+
/** Runtime guard used by tests/docs to assert provider compatibility. */
|
|
161
|
+
export function assertObjectToolSchema(schema: unknown): asserts schema is { type: "object" } {
|
|
162
|
+
if (!schema || typeof schema !== "object" || (schema as { type?: unknown }).type !== "object") {
|
|
163
|
+
const type = schema && typeof schema === "object" ? (schema as { type?: unknown }).type : typeof schema;
|
|
164
|
+
throw new Error(`Tool parameters must be JSON Schema type "object", got ${JSON.stringify(type ?? "None")}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/semaphore.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fair, abort-aware semaphore for limiting concurrent Pi subagent processes.
|
|
3
|
+
*
|
|
4
|
+
* - maxActive: maximum concurrent processes
|
|
5
|
+
* - maxQueued: maximum tasks waiting for a slot
|
|
6
|
+
* - FIFO queue
|
|
7
|
+
* - Aborted waiters are removed without ever starting work
|
|
8
|
+
*/
|
|
9
|
+
export class Semaphore {
|
|
10
|
+
private readonly maxActive: number;
|
|
11
|
+
private readonly maxQueued: number;
|
|
12
|
+
private active = 0;
|
|
13
|
+
private queue: Array<{
|
|
14
|
+
resolve: () => void;
|
|
15
|
+
reject: (reason?: unknown) => void;
|
|
16
|
+
onAbort?: () => void;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
}> = [];
|
|
19
|
+
private releaseListeners = new Set<() => void>();
|
|
20
|
+
|
|
21
|
+
constructor(maxActive = 4, maxQueued = 32) {
|
|
22
|
+
this.maxActive = Math.max(1, maxActive);
|
|
23
|
+
this.maxQueued = Math.max(0, maxQueued);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Acquire a slot. Rejects immediately if the queue is full.
|
|
28
|
+
* If `signal` is already aborted (or aborts while waiting), rejects without starting work.
|
|
29
|
+
*/
|
|
30
|
+
async acquire(signal?: AbortSignal): Promise<void> {
|
|
31
|
+
if (signal?.aborted) {
|
|
32
|
+
throw new Error("Semaphore acquisition aborted before enqueue");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (this.active < this.maxActive) {
|
|
36
|
+
this.active++;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (this.queue.length >= this.maxQueued) {
|
|
41
|
+
throw new Error(`Semaphore queue full (${this.maxQueued})`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return new Promise<void>((resolve, reject) => {
|
|
45
|
+
const entry: {
|
|
46
|
+
resolve: () => void;
|
|
47
|
+
reject: (reason?: unknown) => void;
|
|
48
|
+
onAbort?: () => void;
|
|
49
|
+
signal?: AbortSignal;
|
|
50
|
+
} = {
|
|
51
|
+
resolve: () => {
|
|
52
|
+
this.detachAbort(entry);
|
|
53
|
+
resolve();
|
|
54
|
+
},
|
|
55
|
+
reject: (reason?: unknown) => {
|
|
56
|
+
this.detachAbort(entry);
|
|
57
|
+
reject(reason);
|
|
58
|
+
},
|
|
59
|
+
signal,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (signal) {
|
|
63
|
+
entry.onAbort = () => {
|
|
64
|
+
const idx = this.queue.indexOf(entry);
|
|
65
|
+
if (idx === -1) return;
|
|
66
|
+
this.queue.splice(idx, 1);
|
|
67
|
+
entry.reject(new Error("Semaphore acquisition aborted before spawn"));
|
|
68
|
+
};
|
|
69
|
+
signal.addEventListener("abort", entry.onAbort, { once: true });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.queue.push(entry);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Release a slot and wake the next non-aborted waiter. */
|
|
77
|
+
release(): void {
|
|
78
|
+
this.active = Math.max(0, this.active - 1);
|
|
79
|
+
|
|
80
|
+
while (this.queue.length > 0 && this.active < this.maxActive) {
|
|
81
|
+
const next = this.queue.shift()!;
|
|
82
|
+
if (next.signal?.aborted) {
|
|
83
|
+
next.reject(new Error("Semaphore acquisition aborted before spawn"));
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
this.active++;
|
|
87
|
+
next.resolve();
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const listener of this.releaseListeners) listener();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Lightweight EventEmitter-compatible API used by tests. */
|
|
95
|
+
on(event: "release", listener: () => void): this {
|
|
96
|
+
if (event === "release") this.releaseListeners.add(listener);
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
off(event: "release", listener: () => void): this {
|
|
101
|
+
if (event === "release") this.releaseListeners.delete(listener);
|
|
102
|
+
return this;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
getStats() {
|
|
106
|
+
return {
|
|
107
|
+
active: this.active,
|
|
108
|
+
queued: this.queue.length,
|
|
109
|
+
maxActive: this.maxActive,
|
|
110
|
+
maxQueued: this.maxQueued,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private detachAbort(entry: {
|
|
115
|
+
onAbort?: () => void;
|
|
116
|
+
signal?: AbortSignal;
|
|
117
|
+
}): void {
|
|
118
|
+
if (entry.signal && entry.onAbort) {
|
|
119
|
+
entry.signal.removeEventListener("abort", entry.onAbort);
|
|
120
|
+
}
|
|
121
|
+
entry.onAbort = undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured output contract: a task may declare an `output_schema` (JSON
|
|
3
|
+
* Schema subset). The child is instructed to end its final message with a
|
|
4
|
+
* fenced ```json:result block; the parent extracts and validates it on this
|
|
5
|
+
* side of the process boundary.
|
|
6
|
+
*
|
|
7
|
+
* Validation is a dependency-free JSON-Schema *subset* (type / properties /
|
|
8
|
+
* required / items / enum / const / nested combinations). Unknown keywords are
|
|
9
|
+
* ignored rather than rejected so callers can pass richer schemas; we enforce
|
|
10
|
+
* what we understand and never fail on what we don't.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface SchemaCheckResult {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
errors: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TYPE_CHECKS: Record<string, (value: unknown) => boolean> = {
|
|
19
|
+
object: (value) => typeof value === "object" && value !== null && !Array.isArray(value),
|
|
20
|
+
array: (value) => Array.isArray(value),
|
|
21
|
+
string: (value) => typeof value === "string",
|
|
22
|
+
number: (value) => typeof value === "number" && Number.isFinite(value),
|
|
23
|
+
integer: (value) => typeof value === "number" && Number.isInteger(value),
|
|
24
|
+
boolean: (value) => typeof value === "boolean",
|
|
25
|
+
null: (value) => value === null,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Validate that a caller-supplied schema is a plausible JSON Schema object. */
|
|
29
|
+
export function isPlausibleSchema(schema: unknown): schema is Record<string, unknown> {
|
|
30
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return false;
|
|
31
|
+
const s = schema as Record<string, unknown>;
|
|
32
|
+
if (s.type !== undefined && typeof s.type !== "string" && !Array.isArray(s.type)) return false;
|
|
33
|
+
if (s.properties !== undefined && (typeof s.properties !== "object" || s.properties === null)) return false;
|
|
34
|
+
if (s.required !== undefined && !Array.isArray(s.required)) return false;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Structural validation against the supported JSON-Schema subset. */
|
|
39
|
+
export function checkAgainstSchema(value: unknown, schema: unknown, path = "$"): SchemaCheckResult {
|
|
40
|
+
const errors: string[] = [];
|
|
41
|
+
if (!schema || typeof schema !== "object") return { ok: true, errors };
|
|
42
|
+
const s = schema as Record<string, any>;
|
|
43
|
+
|
|
44
|
+
if (s.const !== undefined && JSON.stringify(value) !== JSON.stringify(s.const)) {
|
|
45
|
+
errors.push(`${path}: expected const ${JSON.stringify(s.const)}`);
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(s.enum) && !s.enum.some((candidate: unknown) => JSON.stringify(candidate) === JSON.stringify(value))) {
|
|
48
|
+
errors.push(`${path}: value not in enum [${s.enum.map((e: unknown) => JSON.stringify(e)).join(", ")}]`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const types: string[] = typeof s.type === "string" ? [s.type] : Array.isArray(s.type) ? s.type : [];
|
|
52
|
+
if (types.length) {
|
|
53
|
+
const matched = types.some((type) => TYPE_CHECKS[type]?.(value));
|
|
54
|
+
if (!matched) {
|
|
55
|
+
errors.push(`${path}: expected type ${types.join("|")}, got ${Array.isArray(value) ? "array" : value === null ? "null" : typeof value}`);
|
|
56
|
+
return { ok: false, errors }; // wrong type: nested checks would be noise
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (TYPE_CHECKS.object(value)) {
|
|
61
|
+
const record = value as Record<string, unknown>;
|
|
62
|
+
for (const key of Array.isArray(s.required) ? s.required : []) {
|
|
63
|
+
if (typeof key === "string" && !(key in record)) errors.push(`${path}: missing required property "${key}"`);
|
|
64
|
+
}
|
|
65
|
+
if (s.properties && typeof s.properties === "object") {
|
|
66
|
+
for (const [key, propSchema] of Object.entries(s.properties as Record<string, unknown>)) {
|
|
67
|
+
if (key in record) {
|
|
68
|
+
errors.push(...checkAgainstSchema(record[key], propSchema, `${path}.${key}`).errors);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (Array.isArray(value) && s.items && typeof s.items === "object" && !Array.isArray(s.items)) {
|
|
75
|
+
value.forEach((item, index) => {
|
|
76
|
+
errors.push(...checkAgainstSchema(item, s.items, `${path}[${index}]`).errors);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { ok: errors.length === 0, errors };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extract the structured result from the child's final assistant text.
|
|
85
|
+
* Preference order: last ```json:result fence → last plain ```json fence →
|
|
86
|
+
* trailing bare JSON object/array. Returns undefined when nothing parses.
|
|
87
|
+
*/
|
|
88
|
+
export function extractStructuredResult(text: string | undefined): { value?: unknown; raw?: string } {
|
|
89
|
+
if (!text) return {};
|
|
90
|
+
const fences = [/```json:result\s*\n([\s\S]*?)```/g, /```json\s*\n([\s\S]*?)```/g];
|
|
91
|
+
for (const pattern of fences) {
|
|
92
|
+
let last: string | undefined;
|
|
93
|
+
for (const match of text.matchAll(pattern)) last = match[1];
|
|
94
|
+
if (last !== undefined) {
|
|
95
|
+
try {
|
|
96
|
+
return { value: JSON.parse(last), raw: last.trim() };
|
|
97
|
+
} catch {
|
|
98
|
+
return { raw: last.trim() }; // fence found but unparseable: report it
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Trailing bare JSON object/array (last non-empty chunk starting with { or [).
|
|
103
|
+
const trimmed = text.trimEnd();
|
|
104
|
+
const start = Math.max(trimmed.lastIndexOf("\n{"), trimmed.lastIndexOf("\n["));
|
|
105
|
+
const candidate = start >= 0 ? trimmed.slice(start + 1) : trimmed.startsWith("{") || trimmed.startsWith("[") ? trimmed : undefined;
|
|
106
|
+
if (candidate) {
|
|
107
|
+
try {
|
|
108
|
+
return { value: JSON.parse(candidate), raw: candidate };
|
|
109
|
+
} catch {
|
|
110
|
+
/* not JSON */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The contract appended to the child's system prompt (after the persona). */
|
|
117
|
+
export function schemaContract(schema: Record<string, unknown>): string {
|
|
118
|
+
return [
|
|
119
|
+
"STRUCTURED OUTPUT CONTRACT",
|
|
120
|
+
"Your FINAL message MUST end with a fenced code block tagged json:result containing ONLY a JSON value that validates against this schema:",
|
|
121
|
+
"```json",
|
|
122
|
+
JSON.stringify(schema, null, 2),
|
|
123
|
+
"```",
|
|
124
|
+
"Rules: the fenced block is your machine-readable result — narrative goes before it, never inside. Do not wrap the JSON in prose or stringify it. Example shape:",
|
|
125
|
+
"```json:result",
|
|
126
|
+
'{ "your": "result here" }',
|
|
127
|
+
"```",
|
|
128
|
+
].join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Steering message for one repair round after failed validation. */
|
|
132
|
+
export function repairMessage(errors: string[]): string {
|
|
133
|
+
const detail = errors.slice(0, 10).join("; ");
|
|
134
|
+
return (
|
|
135
|
+
`Your structured result failed validation: ${detail}. ` +
|
|
136
|
+
"Re-emit your COMPLETE final result now as a single fenced ```json:result block that validates against the schema from your instructions. Output only the corrected block."
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Arg repair (double-encoded JSON de-mangling) ─────────────────────────────
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* LLMs sometimes double-encode natural-language string fields (the value
|
|
144
|
+
* arrives as a JSON-string-inside-a-string: `"line1\\nline2"` with literal
|
|
145
|
+
* backslash escapes). Detect structural escape patterns and decode ONCE when
|
|
146
|
+
* the decoded form is a plausible improvement. Never applied to identifier
|
|
147
|
+
* fields (agent, model, id) or arrays — only free-text fields.
|
|
148
|
+
*/
|
|
149
|
+
export function repairDoubleEncodedText(value: string): string {
|
|
150
|
+
if (value.length < 4) return value;
|
|
151
|
+
// High-signal escapes only: literal \n or \" strongly indicate a
|
|
152
|
+
// JSON-string-inside-a-string. \t and \\ alone are NOT sufficient — they
|
|
153
|
+
// occur naturally in Windows paths (C:\temp) and regex/code snippets.
|
|
154
|
+
const highSignal = /\\n|\\"/.test(value);
|
|
155
|
+
if (!highSignal) return value;
|
|
156
|
+
const hasRealNewlines = value.includes("\n");
|
|
157
|
+
if (hasRealNewlines) return value; // mixed content: too ambiguous, leave it
|
|
158
|
+
// A lone backslash before a non-escape char (e.g. C:\Users mixed with \n)
|
|
159
|
+
// would make JSON.parse fail or corrupt — require every backslash to start
|
|
160
|
+
// a valid JSON escape sequence.
|
|
161
|
+
if (/\\(?![nrtbf"\\/u])/.test(value)) return value;
|
|
162
|
+
try {
|
|
163
|
+
const decoded = JSON.parse(`"${value.replace(/(?<!\\)"/g, '\\"')}"`);
|
|
164
|
+
if (typeof decoded === "string" && decoded !== value && decoded.length > 0) return decoded;
|
|
165
|
+
} catch {
|
|
166
|
+
/* not decodable: leave as-is */
|
|
167
|
+
}
|
|
168
|
+
return value;
|
|
169
|
+
}
|