@cr1ms0n/pi-subagent 0.8.8 → 0.9.0

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.
@@ -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
- }
@@ -1,169 +0,0 @@
1
- import * as fs from "node:fs/promises";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
- import { isThinkingLevel, type ThinkingLevel } from "./thinking.js";
5
-
6
- export const MODEL_POLICY_CONFIG_FILE = path.join(os.homedir(), ".pi", "subagent.json");
7
-
8
- export interface ModelRoute {
9
- model: string;
10
- /** Immutable order owned by the user configuration. */
11
- fallbackModels: readonly string[];
12
- /** Optional route default; callers may override it with task/agent/profile settings. */
13
- thinking?: ThinkingLevel;
14
- }
15
-
16
- export interface ModelPolicySnapshot {
17
- default: ModelRoute;
18
- agents: ReadonlyMap<string, ModelRoute>;
19
- source: string;
20
- }
21
-
22
- export interface ModelPolicyValidation {
23
- route?: ModelRoute;
24
- error?: string;
25
- }
26
-
27
- const AGENT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
28
-
29
- function invalid(source: string, message: string): never {
30
- throw new Error(`Invalid modelPolicy in ${source}: ${message}`);
31
- }
32
-
33
- function modelId(value: unknown, pathName: string, source: string): string {
34
- if (typeof value !== "string") invalid(source, `${pathName}.model must be a string in provider/model-id form`);
35
- const model = value.trim();
36
- const slash = model.indexOf("/");
37
- if (!model || slash <= 0 || slash === model.length - 1 || /[\x00-\x1f\s]/.test(model)) {
38
- invalid(source, `${pathName}.model must be a non-empty provider/model-id without whitespace`);
39
- }
40
- return model;
41
- }
42
-
43
- function route(value: unknown, pathName: string, source: string): ModelRoute {
44
- if (!value || typeof value !== "object" || Array.isArray(value)) {
45
- invalid(source, `${pathName} must be an object`);
46
- }
47
- const record = value as Record<string, unknown>;
48
- const unknown = Object.keys(record).filter((key) => key !== "model" && key !== "fallbackModels" && key !== "thinking");
49
- if (unknown.length) invalid(source, `${pathName} has unknown field(s): ${unknown.join(", ")}`);
50
- const model = modelId(record.model, pathName, source);
51
- const rawFallbacks = record.fallbackModels === undefined ? [] : record.fallbackModels;
52
- if (!Array.isArray(rawFallbacks)) invalid(source, `${pathName}.fallbackModels must be an array`);
53
- const fallbackModels = rawFallbacks.map((value, index) => modelId(value, `${pathName}.fallbackModels[${index}]`, source));
54
- if (new Set(fallbackModels).size !== fallbackModels.length) invalid(source, `${pathName}.fallbackModels must not contain duplicates`);
55
- if (fallbackModels.includes(model)) invalid(source, `${pathName}.fallbackModels must not repeat the primary model`);
56
- const thinking = record.thinking;
57
- if (thinking !== undefined && !isThinkingLevel(thinking)) {
58
- invalid(source, `${pathName}.thinking must be a non-empty Pi thinking level string without whitespace or control characters`);
59
- }
60
- return Object.freeze({
61
- model,
62
- fallbackModels: Object.freeze([...fallbackModels]),
63
- ...(thinking === undefined ? {} : { thinking }),
64
- });
65
- }
66
-
67
- /** Parse only the modelPolicy subtree; no provider catalog or credentials are read. */
68
- export function parseModelPolicy(raw: unknown, source = MODEL_POLICY_CONFIG_FILE): ModelPolicySnapshot {
69
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
70
- invalid(source, "modelPolicy must be an object");
71
- }
72
- const record = raw as Record<string, unknown>;
73
- const unknown = Object.keys(record).filter((key) => key !== "default" && key !== "agents");
74
- if (unknown.length) invalid(source, `modelPolicy has unknown field(s): ${unknown.join(", ")}`);
75
- if (!("default" in record)) invalid(source, "modelPolicy.default is required");
76
- const defaultRoute = route(record.default, "modelPolicy.default", source);
77
- const agents = new Map<string, ModelRoute>();
78
- const rawAgents = record.agents === undefined ? {} : record.agents;
79
- if (!rawAgents || typeof rawAgents !== "object" || Array.isArray(rawAgents)) {
80
- invalid(source, "modelPolicy.agents must be an object");
81
- }
82
- for (const [name, value] of Object.entries(rawAgents as Record<string, unknown>)) {
83
- const normalized = name.trim().toLowerCase();
84
- if (!AGENT_NAME.test(normalized)) invalid(source, `modelPolicy.agents key ${JSON.stringify(name)} is not a valid agent name`);
85
- if (agents.has(normalized)) invalid(source, `modelPolicy.agents contains duplicate agent ${normalized}`);
86
- agents.set(normalized, route(value, `modelPolicy.agents.${normalized}`, source));
87
- }
88
- return Object.freeze({ default: defaultRoute, agents, source });
89
- }
90
-
91
- /** Read the raw config only far enough to validate modelPolicy. */
92
- export async function readModelPolicyFile(file = MODEL_POLICY_CONFIG_FILE): Promise<ModelPolicySnapshot> {
93
- let raw: unknown;
94
- try {
95
- raw = JSON.parse(await fs.readFile(file, "utf8"));
96
- } catch (error: any) {
97
- if (error?.code === "ENOENT") throw new Error(`Model policy is not configured. Create ${file} using the model policy template.`);
98
- throw new Error(`Could not read ${file} as JSON; model policy was not loaded.`);
99
- }
100
- if (!raw || typeof raw !== "object" || Array.isArray(raw) || !("modelPolicy" in (raw as Record<string, unknown>))) {
101
- throw new Error(`Model policy is missing from ${file}. Add modelPolicy.default using the model policy template.`);
102
- }
103
- return parseModelPolicy((raw as Record<string, unknown>).modelPolicy, file);
104
- }
105
-
106
- export function resolveModelRoute(policy: ModelPolicySnapshot, agent?: string): ModelRoute {
107
- const name = agent?.trim().toLowerCase();
108
- return (name && policy.agents.get(name)) || policy.default;
109
- }
110
-
111
- /** Validate the caller-owned fields against the immutable configured route. */
112
- export function validateModelRequest(
113
- policy: ModelPolicySnapshot,
114
- options: { agent?: string; model?: string; fallbackModels?: string[]; fallbackModelsProvided?: boolean },
115
- ): ModelPolicyValidation {
116
- const route = resolveModelRoute(policy, options.agent);
117
- const actualModel = options.model?.trim();
118
- if (!actualModel) return { error: `model is required; expected configured model ${route.model}` };
119
- if (actualModel !== route.model) {
120
- return { error: `model ${JSON.stringify(actualModel)} does not match the configured model ${JSON.stringify(route.model)}${options.agent ? ` for agent ${options.agent}` : ""}` };
121
- }
122
- if (options.fallbackModelsProvided) {
123
- const requested = (options.fallbackModels ?? []).map((model) => model.trim());
124
- if (requested.length !== route.fallbackModels.length || requested.some((model, index) => model !== route.fallbackModels[index])) {
125
- return {
126
- error: `fallback_models must exactly match the configured order for ${actualModel}: [${route.fallbackModels.join(", ")}]`,
127
- };
128
- }
129
- }
130
- return { route };
131
- }
132
-
133
- export function formatModelPolicyPrompt(policy: ModelPolicySnapshot | undefined, error?: string): string {
134
- if (!policy) {
135
- return [
136
- "## Subagent model policy",
137
- error || `No valid model policy is configured at ${MODEL_POLICY_CONFIG_FILE}.`,
138
- "Management actions (status/wait/cancel/steer/diff/apply/discard) remain available, but every new task/tasks[] spawn and plan request is rejected until modelPolicy is configured.",
139
- "Use the package model-policy template; do not invent model IDs or fallback models.",
140
- ].join("\n");
141
- }
142
- const lines = [
143
- "## Subagent model policy",
144
- "Every new task/tasks[] spawn must pass model explicitly and it must exactly match this mapping. Management actions do not need model.",
145
- `default (agentless and unmapped agents): model=${policy.default.model}; fallback_models=[${policy.default.fallbackModels.join(", ")}]; thinking=${policy.default.thinking ?? "(unset)"}`,
146
- ];
147
- for (const [agent, route] of [...policy.agents.entries()].sort(([a], [b]) => a.localeCompare(b))) {
148
- lines.push(`agent:${agent}: model=${route.model}; fallback_models=[${route.fallbackModels.join(", ")}]; thinking=${route.thinking ?? "(unset)"}`);
149
- }
150
- lines.push(
151
- "If fallback_models is omitted, the configured list is used. If it is supplied, it must match the configured list exactly, including order.",
152
- "thinking is a route default: explicit task thinking, agent thinking, and profile taskDefaults.thinking override it; parent thinking is used only when the route is unset.",
153
- "Do not use model/fallback_models from agent frontmatter, taskDefaults, or the parent session; those legacy model fields are ignored.",
154
- );
155
- return lines.join("\n");
156
- }
157
-
158
- export function modelPolicyTemplate(): string {
159
- return JSON.stringify({
160
- modelPolicy: {
161
- default: { model: "<provider/model-id>", fallbackModels: [], thinking: "medium" },
162
- agents: { "<agent-name>": { model: "<provider/model-id>", fallbackModels: [], thinking: "high" } },
163
- },
164
- }, null, 2);
165
- }
166
-
167
- export function modelPolicySource(file = MODEL_POLICY_CONFIG_FILE): string {
168
- return path.normalize(file);
169
- }