@cr1ms0n/pi-subagent 0.8.8 → 0.8.9
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 +3 -5
- package/README.md +7 -20
- package/docs/ARCHITECTURE.md +125 -132
- package/docs/SECURITY.md +88 -97
- package/package.json +1 -1
- package/skills/subagent/SKILL.md +113 -121
- package/src/agents.ts +282 -288
- package/src/extension.ts +6 -33
- package/src/orchestrator.ts +247 -312
- package/src/policy.ts +531 -561
- package/src/schema.ts +189 -189
- package/src/context-policy.ts +0 -169
package/src/schema.ts
CHANGED
|
@@ -1,189 +1,189 @@
|
|
|
1
|
-
import { Type, type Static } from "typebox";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Remove TypeBox's internal compositor metadata before a schema crosses into
|
|
5
|
-
* Pi's provider-facing tool catalog. The original schema is kept intact for
|
|
6
|
-
* local Value.Check/Value.Errors validation.
|
|
7
|
-
*/
|
|
8
|
-
export function sanitizeProviderSchema<T>(value: T): T {
|
|
9
|
-
if (Array.isArray(value)) {
|
|
10
|
-
return value.map((item) => sanitizeProviderSchema(item)) as T;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
if (value && typeof value === "object") {
|
|
14
|
-
const record = value as Record<string, unknown>;
|
|
15
|
-
return Object.fromEntries(
|
|
16
|
-
Object.entries(record)
|
|
17
|
-
.filter(([key]) => !key.startsWith("~"))
|
|
18
|
-
.map(([key, entry]) => [key, sanitizeProviderSchema(entry)] as const),
|
|
19
|
-
) as T;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
return value;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const ThinkingLevel = Type.String({
|
|
26
|
-
minLength: 1,
|
|
27
|
-
maxLength: 64,
|
|
28
|
-
description: "Opaque Pi thinking level passed through unchanged. Pi/model-specific values such as max are allowed; the active Pi process decides support.",
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
const OutputMode = Type.Union([Type.Literal("inline"), Type.Literal("file-only")]);
|
|
32
|
-
const Profile = Type.Union([Type.Literal("explore"), Type.Literal("review"), Type.Literal("general")]);
|
|
33
|
-
const Isolation = Type.Union([Type.Literal("shared"), Type.Literal("worktree")]);
|
|
34
|
-
const Backend = Type.Union([Type.Literal("pi"), Type.Literal("codex"), Type.Literal("claude")]);
|
|
35
|
-
const Action = Type.Union([
|
|
36
|
-
Type.Literal("status"),
|
|
37
|
-
Type.Literal("wait"),
|
|
38
|
-
Type.Literal("cancel"),
|
|
39
|
-
Type.Literal("steer"),
|
|
40
|
-
Type.Literal("diff"),
|
|
41
|
-
Type.Literal("apply"),
|
|
42
|
-
Type.Literal("discard"),
|
|
43
|
-
Type.Literal("plan"),
|
|
44
|
-
]);
|
|
45
|
-
|
|
46
|
-
/** Shared optional task configuration fields. */
|
|
47
|
-
export const TaskFields = {
|
|
48
|
-
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." }),
|
|
49
|
-
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." })),
|
|
50
|
-
description: Type.Optional(Type.String({ description: "Short human label (3-5 words) shown in UIs and result indexes." })),
|
|
51
|
-
system_prompt: Type.Optional(Type.String({ description: "Extra system prompt appended to the child's prompt (does not replace it)." })),
|
|
52
|
-
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." })),
|
|
53
|
-
thinking: Type.Optional({ ...ThinkingLevel, description: "Opaque Pi thinking level for the child. Values such as max are passed through unchanged; Pi/model support decides validity. Defaults to agent thinking, profile taskDefaults.thinking, modelPolicy route thinking, then the parent's level." }),
|
|
54
|
-
tools: Type.Optional(Type.Array(Type.String(), { description: "Optional tool allowlist. explore/review profiles reject project-writing tools
|
|
55
|
-
profile: Type.Optional({ ...Profile, description: "Capability profile: explore/review cannot write project files
|
|
56
|
-
cwd: Type.Optional(Type.String({ description: "Working directory for the child process." })),
|
|
57
|
-
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." })),
|
|
58
|
-
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." })),
|
|
59
|
-
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." })),
|
|
60
|
-
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)." })),
|
|
61
|
-
fallback_models: Type.Optional(Type.Array(Type.String(), { maxItems: 5, description: "Ordered backup models tried automatically on transient failures (provider error, stall, queue timeout)." })),
|
|
62
|
-
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." })),
|
|
63
|
-
context: Type.Optional(
|
|
64
|
-
Type.Union([Type.Literal("fresh"), Type.Literal("fork")], {
|
|
65
|
-
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.",
|
|
66
|
-
}),
|
|
67
|
-
),
|
|
68
|
-
output: Type.Optional(Type.String({ description: "File path to write final output." })),
|
|
69
|
-
output_schema: Type.Optional(
|
|
70
|
-
Type.Unsafe<Record<string, unknown>>(
|
|
71
|
-
Type.Object({}, {
|
|
72
|
-
additionalProperties: true,
|
|
73
|
-
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.",
|
|
74
|
-
}),
|
|
75
|
-
),
|
|
76
|
-
),
|
|
77
|
-
output_mode: Type.Optional({ ...OutputMode, description: "file-only returns a pointer instead of inline text; use for large reports." }),
|
|
78
|
-
resume: Type.Optional(Type.String({ description: "Child session id to continue." })),
|
|
79
|
-
fork_resume: Type.Optional(Type.Boolean({ description: "Fork the resumed session instead of direct resume." })),
|
|
80
|
-
isolation: Type.Optional({ ...Isolation, description: "worktree runs the task in an isolated git worktree; changed work is preserved on a branch." }),
|
|
81
|
-
include_wip: Type.Optional(
|
|
82
|
-
Type.Boolean({ description: "Seed a worktree with the parent checkout's uncommitted changes (staged + unstaged + untracked). Only valid with isolation:'worktree'." }),
|
|
83
|
-
),
|
|
84
|
-
allow_shared_writes: Type.Optional(
|
|
85
|
-
Type.Boolean({ description: "Unsafe opt-in for parallel writers sharing one checkout." }),
|
|
86
|
-
),
|
|
87
|
-
keep_background: Type.Optional(
|
|
88
|
-
Type.Boolean({ description: "Keep processes the child backgrounded (e.g. dev servers) alive after a clean exit." }),
|
|
89
|
-
),
|
|
90
|
-
} as const;
|
|
91
|
-
|
|
92
|
-
export const ParallelTaskItem = Type.Object(
|
|
93
|
-
{
|
|
94
|
-
task: Type.String({ minLength: 1, description: "Task text for one parallel worker." }),
|
|
95
|
-
...TaskFields,
|
|
96
|
-
},
|
|
97
|
-
{ additionalProperties: false },
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Provider-facing tool parameters.
|
|
102
|
-
*
|
|
103
|
-
* IMPORTANT: LLM tool APIs (OpenAI-compatible / OpenRouter / Anthropic via many
|
|
104
|
-
* providers) require the top-level parameters schema to be JSON Schema
|
|
105
|
-
* `type: "object"`. A Type.Union serializes as `anyOf` without `type: "object"`,
|
|
106
|
-
* which surfaces as:
|
|
107
|
-
* Invalid schema for function 'subagent': schema must be a JSON Schema of
|
|
108
|
-
* 'type: "object"', got 'type: "None"'.
|
|
109
|
-
*
|
|
110
|
-
* Mode exclusivity (action vs task vs tasks) is enforced in policy validation,
|
|
111
|
-
* not at the JSON Schema layer.
|
|
112
|
-
*/
|
|
113
|
-
export const SubagentParamsSchema = Type.Object(
|
|
114
|
-
{
|
|
115
|
-
// Management actions
|
|
116
|
-
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." }),
|
|
117
|
-
id: Type.Optional(Type.String({ minLength: 1, description: "Run id (or unique prefix) for management actions." })),
|
|
118
|
-
message: Type.Optional(Type.String({ minLength: 1, description: "Steering message injected into the running child (action: steer)." })),
|
|
119
|
-
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." })),
|
|
120
|
-
|
|
121
|
-
// Single-task mode
|
|
122
|
-
task: Type.Optional(Type.String({ minLength: 1, description: "Task to delegate (single mode)." })),
|
|
123
|
-
...TaskFields,
|
|
124
|
-
async: Type.Optional(Type.Boolean({ description: "Run in the background and return a handle immediately." })),
|
|
125
|
-
|
|
126
|
-
// Parallel mode
|
|
127
|
-
tasks: Type.Optional(
|
|
128
|
-
Type.Array(ParallelTaskItem, {
|
|
129
|
-
minItems: 1,
|
|
130
|
-
maxItems: 8,
|
|
131
|
-
description: "Array of independent tasks for parallel mode. Parallel tasks default to the read-only explore profile; parallel writers need isolation:'worktree'.",
|
|
132
|
-
}),
|
|
133
|
-
),
|
|
134
|
-
synthesis: Type.Optional(
|
|
135
|
-
Type.String({
|
|
136
|
-
minLength: 1,
|
|
137
|
-
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.",
|
|
138
|
-
}),
|
|
139
|
-
),
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
additionalProperties: false,
|
|
143
|
-
description: "Subagent request: single, parallel, status, wait, or cancel.",
|
|
144
|
-
},
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
export type SubagentParams = Static<typeof SubagentParamsSchema>;
|
|
148
|
-
export type ParallelTaskInput = Static<typeof ParallelTaskItem>;
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Dedicated blocking-wait tool.
|
|
152
|
-
*
|
|
153
|
-
* `subagent` already exposes wait via `action: "wait"`, but collecting a
|
|
154
|
-
* background run is the one management step a model reaches for reflexively
|
|
155
|
-
* mid-flow, and burying it behind an action union costs a discovery step.
|
|
156
|
-
* This mirrors the highest-adoption package in the ecosystem (`pi-subagents`
|
|
157
|
-
* ships `subagent` + `subagent_wait` for the same reason). It is a thin
|
|
158
|
-
* front-end over the identical handler — no second delivery path.
|
|
159
|
-
*/
|
|
160
|
-
export const SubagentWaitParamsSchema = Type.Object(
|
|
161
|
-
{
|
|
162
|
-
id: Type.String({ minLength: 1, description: "Run id (or unique prefix) of the background run to collect." }),
|
|
163
|
-
timeout_ms: Type.Optional(
|
|
164
|
-
Type.Number({
|
|
165
|
-
minimum: 1,
|
|
166
|
-
maximum: 24 * 60 * 60_000,
|
|
167
|
-
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.",
|
|
168
|
-
}),
|
|
169
|
-
),
|
|
170
|
-
},
|
|
171
|
-
{
|
|
172
|
-
additionalProperties: false,
|
|
173
|
-
description: "Block until a background subagent run settles, then deliver its output.",
|
|
174
|
-
},
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
/** Provider-facing projections without TypeBox's internal `~...` metadata. */
|
|
178
|
-
export const ProviderSubagentParamsSchema = sanitizeProviderSchema(SubagentParamsSchema);
|
|
179
|
-
export const ProviderSubagentWaitParamsSchema = sanitizeProviderSchema(SubagentWaitParamsSchema);
|
|
180
|
-
|
|
181
|
-
export type SubagentWaitParams = Static<typeof SubagentWaitParamsSchema>;
|
|
182
|
-
|
|
183
|
-
/** Runtime guard used by tests/docs to assert provider compatibility. */
|
|
184
|
-
export function assertObjectToolSchema(schema: unknown): asserts schema is { type: "object" } {
|
|
185
|
-
if (!schema || typeof schema !== "object" || (schema as { type?: unknown }).type !== "object") {
|
|
186
|
-
const type = schema && typeof schema === "object" ? (schema as { type?: unknown }).type : typeof schema;
|
|
187
|
-
throw new Error(`Tool parameters must be JSON Schema type "object", got ${JSON.stringify(type ?? "None")}`);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Remove TypeBox's internal compositor metadata before a schema crosses into
|
|
5
|
+
* Pi's provider-facing tool catalog. The original schema is kept intact for
|
|
6
|
+
* local Value.Check/Value.Errors validation.
|
|
7
|
+
*/
|
|
8
|
+
export function sanitizeProviderSchema<T>(value: T): T {
|
|
9
|
+
if (Array.isArray(value)) {
|
|
10
|
+
return value.map((item) => sanitizeProviderSchema(item)) as T;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (value && typeof value === "object") {
|
|
14
|
+
const record = value as Record<string, unknown>;
|
|
15
|
+
return Object.fromEntries(
|
|
16
|
+
Object.entries(record)
|
|
17
|
+
.filter(([key]) => !key.startsWith("~"))
|
|
18
|
+
.map(([key, entry]) => [key, sanitizeProviderSchema(entry)] as const),
|
|
19
|
+
) as T;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ThinkingLevel = Type.String({
|
|
26
|
+
minLength: 1,
|
|
27
|
+
maxLength: 64,
|
|
28
|
+
description: "Opaque Pi thinking level passed through unchanged. Pi/model-specific values such as max are allowed; the active Pi process decides support.",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const OutputMode = Type.Union([Type.Literal("inline"), Type.Literal("file-only")]);
|
|
32
|
+
const Profile = Type.Union([Type.Literal("explore"), Type.Literal("review"), Type.Literal("general")]);
|
|
33
|
+
const Isolation = Type.Union([Type.Literal("shared"), Type.Literal("worktree")]);
|
|
34
|
+
const Backend = Type.Union([Type.Literal("pi"), Type.Literal("codex"), Type.Literal("claude")]);
|
|
35
|
+
const Action = Type.Union([
|
|
36
|
+
Type.Literal("status"),
|
|
37
|
+
Type.Literal("wait"),
|
|
38
|
+
Type.Literal("cancel"),
|
|
39
|
+
Type.Literal("steer"),
|
|
40
|
+
Type.Literal("diff"),
|
|
41
|
+
Type.Literal("apply"),
|
|
42
|
+
Type.Literal("discard"),
|
|
43
|
+
Type.Literal("plan"),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** Shared optional task configuration fields. */
|
|
47
|
+
export const TaskFields = {
|
|
48
|
+
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." }),
|
|
49
|
+
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." })),
|
|
50
|
+
description: Type.Optional(Type.String({ description: "Short human label (3-5 words) shown in UIs and result indexes." })),
|
|
51
|
+
system_prompt: Type.Optional(Type.String({ description: "Extra system prompt appended to the child's prompt (does not replace it)." })),
|
|
52
|
+
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." })),
|
|
53
|
+
thinking: Type.Optional({ ...ThinkingLevel, description: "Opaque Pi thinking level for the child. Values such as max are passed through unchanged; Pi/model support decides validity. Defaults to agent thinking, profile taskDefaults.thinking, modelPolicy route thinking, then the parent's level." }),
|
|
54
|
+
tools: Type.Optional(Type.Array(Type.String(), { description: "Optional tool allowlist. explore/review profiles reject project-writing tools; Pi context-management tools remain available for context continuity." })),
|
|
55
|
+
profile: Type.Optional({ ...Profile, description: "Capability profile: explore/review cannot write project files but retain Pi context-management tools; general inherits the parent's active tools and may write." }),
|
|
56
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the child process." })),
|
|
57
|
+
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." })),
|
|
58
|
+
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." })),
|
|
59
|
+
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." })),
|
|
60
|
+
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)." })),
|
|
61
|
+
fallback_models: Type.Optional(Type.Array(Type.String(), { maxItems: 5, description: "Ordered backup models tried automatically on transient failures (provider error, stall, queue timeout)." })),
|
|
62
|
+
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." })),
|
|
63
|
+
context: Type.Optional(
|
|
64
|
+
Type.Union([Type.Literal("fresh"), Type.Literal("fork")], {
|
|
65
|
+
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.",
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
68
|
+
output: Type.Optional(Type.String({ description: "File path to write final output." })),
|
|
69
|
+
output_schema: Type.Optional(
|
|
70
|
+
Type.Unsafe<Record<string, unknown>>(
|
|
71
|
+
Type.Object({}, {
|
|
72
|
+
additionalProperties: true,
|
|
73
|
+
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.",
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
output_mode: Type.Optional({ ...OutputMode, description: "file-only returns a pointer instead of inline text; use for large reports." }),
|
|
78
|
+
resume: Type.Optional(Type.String({ description: "Child session id to continue." })),
|
|
79
|
+
fork_resume: Type.Optional(Type.Boolean({ description: "Fork the resumed session instead of direct resume." })),
|
|
80
|
+
isolation: Type.Optional({ ...Isolation, description: "worktree runs the task in an isolated git worktree; changed work is preserved on a branch." }),
|
|
81
|
+
include_wip: Type.Optional(
|
|
82
|
+
Type.Boolean({ description: "Seed a worktree with the parent checkout's uncommitted changes (staged + unstaged + untracked). Only valid with isolation:'worktree'." }),
|
|
83
|
+
),
|
|
84
|
+
allow_shared_writes: Type.Optional(
|
|
85
|
+
Type.Boolean({ description: "Unsafe opt-in for parallel writers sharing one checkout." }),
|
|
86
|
+
),
|
|
87
|
+
keep_background: Type.Optional(
|
|
88
|
+
Type.Boolean({ description: "Keep processes the child backgrounded (e.g. dev servers) alive after a clean exit." }),
|
|
89
|
+
),
|
|
90
|
+
} as const;
|
|
91
|
+
|
|
92
|
+
export const ParallelTaskItem = Type.Object(
|
|
93
|
+
{
|
|
94
|
+
task: Type.String({ minLength: 1, description: "Task text for one parallel worker." }),
|
|
95
|
+
...TaskFields,
|
|
96
|
+
},
|
|
97
|
+
{ additionalProperties: false },
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Provider-facing tool parameters.
|
|
102
|
+
*
|
|
103
|
+
* IMPORTANT: LLM tool APIs (OpenAI-compatible / OpenRouter / Anthropic via many
|
|
104
|
+
* providers) require the top-level parameters schema to be JSON Schema
|
|
105
|
+
* `type: "object"`. A Type.Union serializes as `anyOf` without `type: "object"`,
|
|
106
|
+
* which surfaces as:
|
|
107
|
+
* Invalid schema for function 'subagent': schema must be a JSON Schema of
|
|
108
|
+
* 'type: "object"', got 'type: "None"'.
|
|
109
|
+
*
|
|
110
|
+
* Mode exclusivity (action vs task vs tasks) is enforced in policy validation,
|
|
111
|
+
* not at the JSON Schema layer.
|
|
112
|
+
*/
|
|
113
|
+
export const SubagentParamsSchema = Type.Object(
|
|
114
|
+
{
|
|
115
|
+
// Management actions
|
|
116
|
+
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." }),
|
|
117
|
+
id: Type.Optional(Type.String({ minLength: 1, description: "Run id (or unique prefix) for management actions." })),
|
|
118
|
+
message: Type.Optional(Type.String({ minLength: 1, description: "Steering message injected into the running child (action: steer)." })),
|
|
119
|
+
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." })),
|
|
120
|
+
|
|
121
|
+
// Single-task mode
|
|
122
|
+
task: Type.Optional(Type.String({ minLength: 1, description: "Task to delegate (single mode)." })),
|
|
123
|
+
...TaskFields,
|
|
124
|
+
async: Type.Optional(Type.Boolean({ description: "Run in the background and return a handle immediately." })),
|
|
125
|
+
|
|
126
|
+
// Parallel mode
|
|
127
|
+
tasks: Type.Optional(
|
|
128
|
+
Type.Array(ParallelTaskItem, {
|
|
129
|
+
minItems: 1,
|
|
130
|
+
maxItems: 8,
|
|
131
|
+
description: "Array of independent tasks for parallel mode. Parallel tasks default to the read-only explore profile; parallel writers need isolation:'worktree'.",
|
|
132
|
+
}),
|
|
133
|
+
),
|
|
134
|
+
synthesis: Type.Optional(
|
|
135
|
+
Type.String({
|
|
136
|
+
minLength: 1,
|
|
137
|
+
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.",
|
|
138
|
+
}),
|
|
139
|
+
),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
additionalProperties: false,
|
|
143
|
+
description: "Subagent request: single, parallel, status, wait, or cancel.",
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
export type SubagentParams = Static<typeof SubagentParamsSchema>;
|
|
148
|
+
export type ParallelTaskInput = Static<typeof ParallelTaskItem>;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Dedicated blocking-wait tool.
|
|
152
|
+
*
|
|
153
|
+
* `subagent` already exposes wait via `action: "wait"`, but collecting a
|
|
154
|
+
* background run is the one management step a model reaches for reflexively
|
|
155
|
+
* mid-flow, and burying it behind an action union costs a discovery step.
|
|
156
|
+
* This mirrors the highest-adoption package in the ecosystem (`pi-subagents`
|
|
157
|
+
* ships `subagent` + `subagent_wait` for the same reason). It is a thin
|
|
158
|
+
* front-end over the identical handler — no second delivery path.
|
|
159
|
+
*/
|
|
160
|
+
export const SubagentWaitParamsSchema = Type.Object(
|
|
161
|
+
{
|
|
162
|
+
id: Type.String({ minLength: 1, description: "Run id (or unique prefix) of the background run to collect." }),
|
|
163
|
+
timeout_ms: Type.Optional(
|
|
164
|
+
Type.Number({
|
|
165
|
+
minimum: 1,
|
|
166
|
+
maximum: 24 * 60 * 60_000,
|
|
167
|
+
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.",
|
|
168
|
+
}),
|
|
169
|
+
),
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
additionalProperties: false,
|
|
173
|
+
description: "Block until a background subagent run settles, then deliver its output.",
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
/** Provider-facing projections without TypeBox's internal `~...` metadata. */
|
|
178
|
+
export const ProviderSubagentParamsSchema = sanitizeProviderSchema(SubagentParamsSchema);
|
|
179
|
+
export const ProviderSubagentWaitParamsSchema = sanitizeProviderSchema(SubagentWaitParamsSchema);
|
|
180
|
+
|
|
181
|
+
export type SubagentWaitParams = Static<typeof SubagentWaitParamsSchema>;
|
|
182
|
+
|
|
183
|
+
/** Runtime guard used by tests/docs to assert provider compatibility. */
|
|
184
|
+
export function assertObjectToolSchema(schema: unknown): asserts schema is { type: "object" } {
|
|
185
|
+
if (!schema || typeof schema !== "object" || (schema as { type?: unknown }).type !== "object") {
|
|
186
|
+
const type = schema && typeof schema === "object" ? (schema as { type?: unknown }).type : typeof schema;
|
|
187
|
+
throw new Error(`Tool parameters must be JSON Schema type "object", got ${JSON.stringify(type ?? "None")}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/context-policy.ts
DELETED
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Remote Context eligibility boundary for the four Pi context-management tools.
|
|
3
|
-
*
|
|
4
|
-
* `pi-subagent` does not own the Remote Context model list: the operator-owned
|
|
5
|
-
* `pi-openai-toolkit` configuration owns it. This module reads that file once
|
|
6
|
-
* per dispatch and fails closed to an empty allowlist whenever the file is
|
|
7
|
-
* missing, unreadable, malformed, or not in `contextManagement: "remote"` mode.
|
|
8
|
-
*
|
|
9
|
-
* Matching is exact and case-sensitive on the complete `provider/model` string:
|
|
10
|
-
* entries are never trimmed, prefix-matched, or inferred from a bare model id.
|
|
11
|
-
* The toolkit's separate native `openai-codex` provider rule is intentionally
|
|
12
|
-
* not mirrored here — only configured `gatewayContextModels` entries are
|
|
13
|
-
* eligible, because the boundary is "models outside the allowlist receive no
|
|
14
|
-
* context-manager tools".
|
|
15
|
-
*
|
|
16
|
-
* Nothing in this module writes to stdout: that channel is the child RPC
|
|
17
|
-
* protocol and the parent TUI.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import * as fs from "node:fs/promises";
|
|
21
|
-
import * as path from "node:path";
|
|
22
|
-
import { piAgentDir } from "./agents.js";
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Pi context-management tools are control-plane capabilities: they may update
|
|
26
|
-
* continuity notes or the remote context window, but they cannot modify the
|
|
27
|
-
* child checkout. Keep them separate from ordinary source-inspection tools so
|
|
28
|
-
* the read-only profile's exception remains explicit.
|
|
29
|
-
*/
|
|
30
|
-
export const CONTEXT_MANAGEMENT_TOOLS: ReadonlySet<string> = new Set([
|
|
31
|
-
"new_context",
|
|
32
|
-
"get_context_remaining",
|
|
33
|
-
"history",
|
|
34
|
-
"notes",
|
|
35
|
-
]);
|
|
36
|
-
|
|
37
|
-
/** Canonical enumeration order when appending context tools to a child allowlist. */
|
|
38
|
-
export const CONTEXT_MANAGEMENT_TOOL_NAMES: readonly string[] = Object.freeze([
|
|
39
|
-
...CONTEXT_MANAGEMENT_TOOLS,
|
|
40
|
-
]);
|
|
41
|
-
|
|
42
|
-
/** Toolkit config path relative to the Pi agent directory. */
|
|
43
|
-
export const TOOLKIT_CONFIG_PATH_PARTS: readonly string[] = Object.freeze([
|
|
44
|
-
"extensions",
|
|
45
|
-
"pi-openai-toolkit",
|
|
46
|
-
"config.json",
|
|
47
|
-
]);
|
|
48
|
-
|
|
49
|
-
/** Immutable per-dispatch snapshot of the operator-owned Remote Context allowlist. */
|
|
50
|
-
export interface ContextManagementPolicy {
|
|
51
|
-
/** Exact `provider/model` strings eligible for Remote Context. */
|
|
52
|
-
readonly gatewayModels: readonly string[];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Fail-closed policy used for missing/unreadable/invalid/disabled configuration. */
|
|
56
|
-
export const EMPTY_CONTEXT_MANAGEMENT_POLICY: ContextManagementPolicy = Object.freeze({
|
|
57
|
-
gatewayModels: Object.freeze([]),
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
61
|
-
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
62
|
-
? (value as Record<string, unknown>)
|
|
63
|
-
: undefined;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Pure parser over the toolkit config JSON.
|
|
68
|
-
*
|
|
69
|
-
* Returns the exact gateway model set only when `compaction.contextManagement`
|
|
70
|
-
* is `"remote"` and every `compaction.gatewayContextModels` entry is a non-empty
|
|
71
|
-
* string. Any other shape — including a partly malformed list — yields the
|
|
72
|
-
* empty policy so a compromised/edited file cannot widen the boundary.
|
|
73
|
-
*/
|
|
74
|
-
export function parseContextManagementPolicy(raw: unknown): ContextManagementPolicy {
|
|
75
|
-
const compaction = asRecord(asRecord(raw)?.compaction);
|
|
76
|
-
if (!compaction || compaction.contextManagement !== "remote") return EMPTY_CONTEXT_MANAGEMENT_POLICY;
|
|
77
|
-
const listed = compaction.gatewayContextModels;
|
|
78
|
-
if (!Array.isArray(listed)) return EMPTY_CONTEXT_MANAGEMENT_POLICY;
|
|
79
|
-
const gatewayModels: string[] = [];
|
|
80
|
-
for (const entry of listed) {
|
|
81
|
-
if (typeof entry !== "string" || entry.trim() === "") return EMPTY_CONTEXT_MANAGEMENT_POLICY;
|
|
82
|
-
if (!gatewayModels.includes(entry)) gatewayModels.push(entry);
|
|
83
|
-
}
|
|
84
|
-
return Object.freeze({ gatewayModels: Object.freeze(gatewayModels) });
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Default toolkit config path, using the shared Pi agent-directory precedence. */
|
|
88
|
-
export function contextManagementConfigPath(agentDir = piAgentDir()): string {
|
|
89
|
-
return path.join(agentDir, ...TOOLKIT_CONFIG_PATH_PARTS);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Read one Remote Context snapshot. The injectable path keeps checks offline.
|
|
94
|
-
* Every failure mode (missing, unreadable, invalid JSON, wrong shape) returns
|
|
95
|
-
* the empty policy, so an ineligible target never inherits context tools.
|
|
96
|
-
*/
|
|
97
|
-
export async function readContextManagementPolicy(
|
|
98
|
-
file = contextManagementConfigPath(),
|
|
99
|
-
): Promise<ContextManagementPolicy> {
|
|
100
|
-
try {
|
|
101
|
-
return parseContextManagementPolicy(JSON.parse(await fs.readFile(file, "utf8")));
|
|
102
|
-
} catch {
|
|
103
|
-
return EMPTY_CONTEXT_MANAGEMENT_POLICY; // fail closed; stdout is the RPC channel
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** True when `model` exactly equals a configured `gatewayContextModels` entry. */
|
|
108
|
-
export function isGatewayContextModel(
|
|
109
|
-
policy: ContextManagementPolicy | undefined,
|
|
110
|
-
model: string | undefined,
|
|
111
|
-
): boolean {
|
|
112
|
-
if (!policy || !model) return false;
|
|
113
|
-
return policy.gatewayModels.includes(model);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Context-tool names a target model may receive: the parent-exposed subset,
|
|
118
|
-
* and only for an exact allowlisted model. Empty for every other target.
|
|
119
|
-
*/
|
|
120
|
-
export function contextToolsForModel(
|
|
121
|
-
policy: ContextManagementPolicy | undefined,
|
|
122
|
-
model: string | undefined,
|
|
123
|
-
parentExposed: readonly string[],
|
|
124
|
-
): string[] {
|
|
125
|
-
if (!isGatewayContextModel(policy, model)) return [];
|
|
126
|
-
const exposed = new Set(parentExposed);
|
|
127
|
-
return CONTEXT_MANAGEMENT_TOOL_NAMES.filter((tool) => exposed.has(tool));
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Tool list for the internally constructed read-only synthesis child. */
|
|
131
|
-
export function synthesisToolsForModel(
|
|
132
|
-
policy: ContextManagementPolicy | undefined,
|
|
133
|
-
model: string | undefined,
|
|
134
|
-
parentExposed: readonly string[],
|
|
135
|
-
): string[] {
|
|
136
|
-
return ["read", ...contextToolsForModel(policy, model, parentExposed)];
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Re-derive a child tool allowlist for one target model (primary request or a
|
|
141
|
-
* fallback attempt). Non-context names keep their existing order/deduplication;
|
|
142
|
-
* all four context names are removed first and re-appended only when the
|
|
143
|
-
* effective backend is Pi and the attempt model is exactly allowlisted.
|
|
144
|
-
*
|
|
145
|
-
* Non-Pi backends are returned unchanged. Absent a dispatch policy, a Pi list
|
|
146
|
-
* loses every context name (fail closed) — low-level `runTasks` callers that
|
|
147
|
-
* provide a validated `TaskSpec` but no snapshot cannot reintroduce them. A Pi
|
|
148
|
-
* task with no explicit list becomes `--no-tools` at the backend boundary
|
|
149
|
-
* rather than silently falling back to Pi's unrestricted default tool set.
|
|
150
|
-
*/
|
|
151
|
-
export function filterContextToolsForModel(
|
|
152
|
-
tools: readonly string[] | undefined,
|
|
153
|
-
options: {
|
|
154
|
-
backend: string;
|
|
155
|
-
model?: string;
|
|
156
|
-
policy?: ContextManagementPolicy;
|
|
157
|
-
parentExposed?: readonly string[];
|
|
158
|
-
},
|
|
159
|
-
): string[] | undefined {
|
|
160
|
-
if (tools === undefined) return options.backend === "pi" ? [] : undefined;
|
|
161
|
-
if (options.backend !== "pi") return [...tools];
|
|
162
|
-
const kept = tools.filter((tool) => !CONTEXT_MANAGEMENT_TOOLS.has(tool));
|
|
163
|
-
return [
|
|
164
|
-
...new Set([
|
|
165
|
-
...kept,
|
|
166
|
-
...contextToolsForModel(options.policy, options.model, options.parentExposed ?? []),
|
|
167
|
-
]),
|
|
168
|
-
];
|
|
169
|
-
}
|