@bacnh85/pi-subagent 0.15.3 → 0.16.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 +36 -0
- package/README.md +51 -12
- package/agent-format.md +22 -6
- package/agents/general-purpose.md +1 -5
- package/agents/planner.md +1 -4
- package/agents/reviewer.md +1 -4
- package/agents/scout.md +1 -4
- package/agents/tester.md +1 -4
- package/agents/worker.md +1 -5
- package/extensions/agents.ts +2 -0
- package/extensions/background.ts +13 -0
- package/extensions/history.ts +5 -2
- package/extensions/index.ts +225 -121
- package/extensions/model.ts +94 -8
- package/extensions/roles-panel.ts +182 -0
- package/extensions/roles.ts +263 -0
- package/extensions/runner.ts +3 -1
- package/extensions/security.ts +8 -0
- package/extensions/service.ts +56 -71
- package/package.json +12 -7
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/subagent roles` panel (kernel lives in @bacnh85/pi-config-panel).
|
|
3
|
+
*
|
|
4
|
+
* Row model for role-based model routing: one string row per role (comma
|
|
5
|
+
* chain, blank = reset to default) and one string row per discovered agent
|
|
6
|
+
* (`subagent.agentModels` override, blank = inherit agent file settings).
|
|
7
|
+
* Saving writes both maps to the GLOBAL `~/.pi/agent/settings.json` under
|
|
8
|
+
* `subagent.roles` / `subagent.agentModels` (merge + atomic rename), then
|
|
9
|
+
* invalidates the agent cache.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { row } from "@bacnh85/pi-config-panel";
|
|
16
|
+
import type { PanelGroup } from "@bacnh85/pi-config-panel";
|
|
17
|
+
|
|
18
|
+
// ponytail: local structural type — the kernel only reads value/label/
|
|
19
|
+
// description, so this stays compatible with the published 0.1.0 range while
|
|
20
|
+
// completion support ships in 0.1.1 (no static import of new kernel symbols).
|
|
21
|
+
interface CompletionItem {
|
|
22
|
+
value: string;
|
|
23
|
+
label?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
}
|
|
26
|
+
import type { AgentConfig } from "./agents.ts";
|
|
27
|
+
import { DEFAULT_ROLES, readSubagentRoles, type RolesConfig } from "./roles.ts";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Settings persistence (global only — repo .pi/settings.json is read-only)
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
function settingsPath(): string {
|
|
34
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
35
|
+
return join(agentDir, "settings.json");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Read the GLOBAL settings.json. Returns null only when the file is missing;
|
|
39
|
+
* throws when the file exists but is not valid JSON (so a corrupt file can
|
|
40
|
+
* never be silently overwritten by a panel save). */
|
|
41
|
+
function readSettingsJson(): Record<string, unknown> | null {
|
|
42
|
+
const path = settingsPath();
|
|
43
|
+
if (!existsSync(path)) return null;
|
|
44
|
+
const raw = readFileSync(path, "utf8");
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
47
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
48
|
+
throw new Error(`settings.json root is not a JSON object (${path})`);
|
|
49
|
+
}
|
|
50
|
+
return parsed as Record<string, unknown>;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err instanceof Error && err.message.includes("not a JSON object")) throw err;
|
|
53
|
+
throw new Error(`settings.json is not valid JSON (${path})`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read-modify-write the `subagent` section of the GLOBAL settings.json.
|
|
58
|
+
* Writes a temp file then renames (atomic); never touches other keys.
|
|
59
|
+
* Throws if the existing settings.json is corrupt (the file is left intact).
|
|
60
|
+
* Returns false when nothing changes. */
|
|
61
|
+
export function writeSubagentSection(patch: { roles?: RolesConfig["roles"]; agentModels?: RolesConfig["agentModels"] }): boolean {
|
|
62
|
+
const settings = readSettingsJson() ?? {};
|
|
63
|
+
const existing = (settings.subagent ?? {}) as Record<string, unknown>;
|
|
64
|
+
const subagent = { ...existing };
|
|
65
|
+
if (patch.roles !== undefined) subagent.roles = patch.roles;
|
|
66
|
+
if (patch.agentModels !== undefined) subagent.agentModels = patch.agentModels;
|
|
67
|
+
if (JSON.stringify(subagent) === JSON.stringify(existing)) return false;
|
|
68
|
+
const rolesEmpty = subagent.roles === undefined || (typeof subagent.roles === "object" && Object.keys(subagent.roles as object).length === 0);
|
|
69
|
+
const modelsEmpty = subagent.agentModels === undefined || (typeof subagent.agentModels === "object" && Object.keys(subagent.agentModels as object).length === 0);
|
|
70
|
+
if (rolesEmpty) delete subagent.roles;
|
|
71
|
+
if (modelsEmpty) delete subagent.agentModels;
|
|
72
|
+
// Preserve any unrelated subagent.* keys (forward compat); drop the section
|
|
73
|
+
// only when roles/agentModels were the only content.
|
|
74
|
+
if (Object.keys(subagent).length === 0) delete settings.subagent;
|
|
75
|
+
else settings.subagent = subagent;
|
|
76
|
+
const target = settingsPath();
|
|
77
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
78
|
+
const tmp = `${target}.tmp-${Date.now()}`;
|
|
79
|
+
writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
|
|
80
|
+
renameSync(tmp, target);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Row model — unit-testable without a TUI
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export interface RolesPanelCfg {
|
|
89
|
+
/** Working copy: role name → comma chain ("" = use default). */
|
|
90
|
+
roles: Record<string, string>;
|
|
91
|
+
/** Working copy: agent name → override selector ("" = inherit). */
|
|
92
|
+
agentModels: Record<string, string>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Completion sources for the panel's model rows (lazy — resolved per keypress). */
|
|
96
|
+
export interface RolesPanelOptions {
|
|
97
|
+
/** Available model refs (`provider/id`), sorted; may be empty before registry sync. */
|
|
98
|
+
models: () => string[];
|
|
99
|
+
/** Known role names (defaults + configured), offered as `@role` on agent rows. */
|
|
100
|
+
roles: () => string[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Seed a working config from current effective settings + bundled agents. */
|
|
104
|
+
export function buildRolesPanelCfg(agents: AgentConfig[], current: RolesConfig): RolesPanelCfg {
|
|
105
|
+
const roleNames = new Set([...Object.keys(DEFAULT_ROLES), ...Object.keys(current.roles)]);
|
|
106
|
+
const cfg: RolesPanelCfg = { roles: {}, agentModels: {} };
|
|
107
|
+
for (const name of roleNames) {
|
|
108
|
+
// Only show explicitly-configured values; defaults render blank (= default).
|
|
109
|
+
const explicit = (current.roles[name] !== undefined && JSON.stringify(current.roles[name]) !== JSON.stringify(DEFAULT_ROLES[name]))
|
|
110
|
+
? (Array.isArray(current.roles[name]) ? current.roles[name].join(", ") : String(current.roles[name]))
|
|
111
|
+
: "";
|
|
112
|
+
cfg.roles[name] = explicit;
|
|
113
|
+
}
|
|
114
|
+
for (const agent of agents) cfg.agentModels[agent.name] = current.agentModels[agent.name] ?? "";
|
|
115
|
+
return cfg;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Build panel groups. Role rows first, then one override row per agent.
|
|
119
|
+
* `options` adds inline model/@role completions when provided (optional so
|
|
120
|
+
* existing unit tests and non-TUI callers stay unchanged). */
|
|
121
|
+
export function buildRows(cfg: RolesPanelCfg, agents: AgentConfig[], options?: RolesPanelOptions): PanelGroup[] {
|
|
122
|
+
const defaultChain = (name: string) => Array.isArray(DEFAULT_ROLES[name]) ? (DEFAULT_ROLES[name] as string[]).join(", ") : String(DEFAULT_ROLES[name] ?? "");
|
|
123
|
+
const modelItems = (): CompletionItem[] =>
|
|
124
|
+
(options?.models() ?? []).sort().map((ref) => ({ value: ref }));
|
|
125
|
+
const roleItems = (): CompletionItem[] =>
|
|
126
|
+
(options?.roles() ?? []).map((name) => ({ value: `@${name}`, description: "role chain" }));
|
|
127
|
+
// ponytail: opts spread keeps this compilable against kernel 0.1.0 (whose
|
|
128
|
+
// row() opts type lacks `completions`); the runtime contract is additive and
|
|
129
|
+
// 0.1.1+ consumes the field. Drop the cast when the dep floor moves to 0.1.1.
|
|
130
|
+
const withCompletions = (completions: () => CompletionItem[]) =>
|
|
131
|
+
({ completions }) as unknown as { mask?: boolean };
|
|
132
|
+
const roleRows = Object.keys(cfg.roles).sort().map((name) => {
|
|
133
|
+
// Label shows the default chain so blank is meaningful.
|
|
134
|
+
return row(`role.${name}`, `@${name} (default: ${defaultChain(name) || "none"})`, "string", cfg.roles[name], (v) => {
|
|
135
|
+
cfg.roles[name] = String(v ?? "").trim();
|
|
136
|
+
}, withCompletions(modelItems));
|
|
137
|
+
});
|
|
138
|
+
const agentRows = agents.map((agent) =>
|
|
139
|
+
row(`agent.${agent.name}`, agent.name, "string", cfg.agentModels[agent.name] ?? "", (v) => {
|
|
140
|
+
const value = String(v ?? "").trim();
|
|
141
|
+
if (value) cfg.agentModels[agent.name] = value;
|
|
142
|
+
else delete cfg.agentModels[agent.name];
|
|
143
|
+
}, withCompletions(() => [...modelItems(), ...roleItems()])),
|
|
144
|
+
);
|
|
145
|
+
return [
|
|
146
|
+
{ key: "roles", label: "Model roles (chain, blank = default)", rows: roleRows },
|
|
147
|
+
{ key: "agents", label: "Per-agent overrides (blank = inherit)", rows: agentRows },
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Convert a working config back to a settings patch. Blank values drop keys
|
|
152
|
+
* (role falls back to default; agent override is removed). */
|
|
153
|
+
export function cfgToPatch(cfg: RolesPanelCfg): { roles: RolesConfig["roles"]; agentModels: RolesConfig["agentModels"] } {
|
|
154
|
+
const roles: RolesConfig["roles"] = {};
|
|
155
|
+
for (const [name, chain] of Object.entries(cfg.roles)) {
|
|
156
|
+
const trimmed = chain.trim();
|
|
157
|
+
if (!trimmed) continue;
|
|
158
|
+
roles[name] = trimmed.includes(",") ? trimmed.split(",").map((s) => s.trim()).filter(Boolean) : trimmed;
|
|
159
|
+
}
|
|
160
|
+
const agentModels: RolesConfig["agentModels"] = {};
|
|
161
|
+
for (const [name, value] of Object.entries(cfg.agentModels)) {
|
|
162
|
+
const trimmed = value.trim();
|
|
163
|
+
if (trimmed) agentModels[name] = trimmed;
|
|
164
|
+
}
|
|
165
|
+
return { roles, agentModels };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Keep overrides for agents NOT shown in the panel (e.g. overrides for
|
|
169
|
+
* project-local agents saved globally from another project) so a panel save
|
|
170
|
+
* doesn't wipe them. Discovered-agent entries always follow the panel. */
|
|
171
|
+
export function preserveUnknownAgentModels(
|
|
172
|
+
patch: RolesConfig["agentModels"],
|
|
173
|
+
discoveredNames: readonly string[],
|
|
174
|
+
existing: RolesConfig["agentModels"] | undefined,
|
|
175
|
+
): RolesConfig["agentModels"] {
|
|
176
|
+
if (!existing) return patch;
|
|
177
|
+
const out = { ...patch };
|
|
178
|
+
for (const [name, value] of Object.entries(existing)) {
|
|
179
|
+
if (!discoveredNames.includes(name) && out[name] === undefined) out[name] = value;
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role-based model routing for pi-subagent.
|
|
3
|
+
*
|
|
4
|
+
* A role maps a *function* (fast / coder / smart / custom) to an ordered model
|
|
5
|
+
* chain in settings.json under `subagent.roles`. Agent frontmatter references
|
|
6
|
+
* roles via `@role` aliases (`model: @fast`), so remapping a function is one
|
|
7
|
+
* settings edit instead of N agent-file edits.
|
|
8
|
+
*
|
|
9
|
+
* Settings shape (`~/.pi/agent/settings.json`; repo `.pi/settings.json` is a
|
|
10
|
+
* read-only overlay honored when the project is trusted):
|
|
11
|
+
*
|
|
12
|
+
* {
|
|
13
|
+
* "subagent": {
|
|
14
|
+
* "roles": {
|
|
15
|
+
* "fast": "nvidia/openai/gpt-oss-20b, opencode-go/deepseek-v4-flash",
|
|
16
|
+
* "coder": ["zai-coding-cn/glm-5.1", "opencode-go/deepseek-v4-flash"],
|
|
17
|
+
* "smart": "*"
|
|
18
|
+
* },
|
|
19
|
+
* "agentModels": { "reviewer": "@smart:high" }
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* - Role value: string (comma chain) or array. `*` / `@default` = parent model.
|
|
24
|
+
* - `@role` entries may reference other roles (visited-set cycle guard).
|
|
25
|
+
* - Unknown roles are skipped and recorded in `unresolved` for diagnostics.
|
|
26
|
+
* - A trailing `:thinking` suffix (off|minimal|low|medium|high|xhigh|max) on
|
|
27
|
+
* any entry overrides the agent's frontmatter thinking for that match.
|
|
28
|
+
* Strict trailing match only — openrouter ids like `model:free` keep intact.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
35
|
+
import type { AgentConfig } from "./agents.ts";
|
|
36
|
+
|
|
37
|
+
export type SubagentThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
38
|
+
|
|
39
|
+
const THINKING_LEVELS: readonly string[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
40
|
+
|
|
41
|
+
/** Role → raw chain value (string/array, may contain `@role` and `*` refs). */
|
|
42
|
+
export type RoleMap = Record<string, string | string[]>;
|
|
43
|
+
|
|
44
|
+
export interface RolesConfig {
|
|
45
|
+
roles: RoleMap;
|
|
46
|
+
/** Per-agent model override (agent name → selector / role alias / `*`). */
|
|
47
|
+
agentModels: Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ExpandedCandidates {
|
|
51
|
+
/** Concrete candidate names in priority order (may be empty = parent fallback). */
|
|
52
|
+
candidates: string[];
|
|
53
|
+
/** thinking suffix per matched candidate name (name as written, without suffix). */
|
|
54
|
+
thinkingByCandidate: Map<string, SubagentThinkingLevel>;
|
|
55
|
+
/** Unknown role aliases encountered (for diagnostics). */
|
|
56
|
+
unresolved: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Today's bundled chains, as role defaults — behavior-preserving baseline. */
|
|
60
|
+
export const DEFAULT_ROLES: RoleMap = {
|
|
61
|
+
fast: ["zai-coding-cn/glm-5-turbo", "nvidia/openai/gpt-oss-20b", "opencode-go/deepseek-v4-flash"],
|
|
62
|
+
coder: [
|
|
63
|
+
"zai-coding-cn/glm-5.1",
|
|
64
|
+
"nvidia/mistralai/mistral-small-4-119b-2603",
|
|
65
|
+
"openrouter/nvidia/nemotron-3-super-120b-a12b:free",
|
|
66
|
+
"opencode-go/deepseek-v4-flash",
|
|
67
|
+
],
|
|
68
|
+
smart: ["zai-coding-cn/glm-5.3", "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free", "opencode-go/deepseek-v4-pro"],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Settings reading
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function agentDir(): string {
|
|
76
|
+
return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readJson(path: string): Record<string, unknown> | null {
|
|
80
|
+
try {
|
|
81
|
+
if (!existsSync(path)) return null;
|
|
82
|
+
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function subagentSection(json: Record<string, unknown> | null): Record<string, unknown> {
|
|
89
|
+
const section = json?.subagent;
|
|
90
|
+
return section && typeof section === "object" && !Array.isArray(section)
|
|
91
|
+
? (section as Record<string, unknown>)
|
|
92
|
+
: {};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Normalize a role value: comma string → array of trimmed non-empty entries. */
|
|
96
|
+
function normalizeChain(value: string | string[]): string[] {
|
|
97
|
+
const entries = typeof value === "string" ? value.split(",") : value;
|
|
98
|
+
return entries
|
|
99
|
+
.map((entry) => String(entry).trim())
|
|
100
|
+
.filter(Boolean);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Merge `subagent.roles` / `subagent.agentModels` from a settings JSON into cfg. */
|
|
104
|
+
function mergeSection(cfg: RolesConfig, json: Record<string, unknown> | null): void {
|
|
105
|
+
const section = subagentSection(json);
|
|
106
|
+
const roles = section.roles;
|
|
107
|
+
if (roles && typeof roles === "object" && !Array.isArray(roles)) {
|
|
108
|
+
for (const [name, value] of Object.entries(roles as RoleMap)) {
|
|
109
|
+
if (value === null || value === undefined) continue;
|
|
110
|
+
const chain = normalizeChain(value as string | string[]);
|
|
111
|
+
if (chain.length > 0) cfg.roles[name] = chain;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const agentModels = section.agentModels;
|
|
115
|
+
if (agentModels && typeof agentModels === "object" && !Array.isArray(agentModels)) {
|
|
116
|
+
for (const [name, value] of Object.entries(agentModels as Record<string, unknown>)) {
|
|
117
|
+
if (typeof value === "string" && value.trim()) cfg.agentModels[name] = value.trim();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Read effective roles config. Precedence: global settings.json → repo
|
|
124
|
+
* `.pi/settings.json` (project-trusted only) → `(ctx as any).settings` (the
|
|
125
|
+
* SDK-layered view, when present). Later layers win per role/agent key.
|
|
126
|
+
*/
|
|
127
|
+
export function readSubagentRoles(ctx?: ExtensionContext): RolesConfig {
|
|
128
|
+
const cfg: RolesConfig = {
|
|
129
|
+
roles: structuredClone(DEFAULT_ROLES),
|
|
130
|
+
agentModels: {},
|
|
131
|
+
};
|
|
132
|
+
mergeSection(cfg, readJson(join(agentDir(), "settings.json")));
|
|
133
|
+
try {
|
|
134
|
+
if (ctx?.isProjectTrusted?.()) {
|
|
135
|
+
mergeSection(cfg, readJson(join(ctx.cwd, ".pi", "settings.json")));
|
|
136
|
+
}
|
|
137
|
+
} catch { /* untrusted or ctx without cwd — global only */ }
|
|
138
|
+
const layered = (ctx as unknown as { settings?: Record<string, unknown> } | undefined)?.settings;
|
|
139
|
+
if (layered) mergeSection(cfg, layered);
|
|
140
|
+
return cfg;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Global-only variant used by the panel so a save never persists repo
|
|
144
|
+
* `.pi/settings.json` overlay values into the user's global settings. */
|
|
145
|
+
export function readSubagentRolesGlobal(): RolesConfig {
|
|
146
|
+
const cfg: RolesConfig = {
|
|
147
|
+
roles: structuredClone(DEFAULT_ROLES),
|
|
148
|
+
agentModels: {},
|
|
149
|
+
};
|
|
150
|
+
mergeSection(cfg, readJson(join(agentDir(), "settings.json")));
|
|
151
|
+
return cfg;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Expansion
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/** Split a trailing `:thinking` suffix. Returns null when the trailing segment
|
|
159
|
+
* is not a known level (e.g. openrouter `:free`), leaving the name intact. */
|
|
160
|
+
export function splitThinkingSuffix(name: string): { name: string; thinking?: SubagentThinkingLevel } {
|
|
161
|
+
const idx = name.lastIndexOf(":");
|
|
162
|
+
if (idx <= 0 || idx >= name.length - 1) return { name };
|
|
163
|
+
const suffix = name.slice(idx + 1);
|
|
164
|
+
if (!THINKING_LEVELS.includes(suffix)) return { name };
|
|
165
|
+
return { name: name.slice(0, idx), thinking: suffix as SubagentThinkingLevel };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isParentRef(entry: string): boolean {
|
|
169
|
+
return entry === "*" || entry === "@default";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Expand a raw candidate list into concrete model names.
|
|
174
|
+
* `@role` entries expand in place (recursively, cycle-safe); `*` / `@default`
|
|
175
|
+
* resolve to an empty list (= parent fallback in resolveModel).
|
|
176
|
+
*/
|
|
177
|
+
export function expandModelCandidates(candidates: readonly string[], roles: RoleMap): ExpandedCandidates {
|
|
178
|
+
const out: string[] = [];
|
|
179
|
+
const thinkingByCandidate = new Map<string, SubagentThinkingLevel>();
|
|
180
|
+
const unresolved: string[] = [];
|
|
181
|
+
const seen = new Set<string>();
|
|
182
|
+
|
|
183
|
+
const pushEntry = (entry: string, thinking?: SubagentThinkingLevel): void => {
|
|
184
|
+
const { name, thinking: own } = splitThinkingSuffix(entry);
|
|
185
|
+
if (!name) return;
|
|
186
|
+
if (isParentRef(name)) return; // parent fallback: resolveModel handles the tail
|
|
187
|
+
if (seen.has(name)) {
|
|
188
|
+
// Earlier occurrence wins for thinking too (priority order).
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
seen.add(name);
|
|
192
|
+
out.push(name);
|
|
193
|
+
const level = own ?? thinking;
|
|
194
|
+
if (level) thinkingByCandidate.set(name, level);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const walk = (entry: string, visited: Set<string>, inheritedThinking?: SubagentThinkingLevel): void => {
|
|
198
|
+
const raw = entry.trim();
|
|
199
|
+
if (!raw) return;
|
|
200
|
+
const { name: bare, thinking: own } = splitThinkingSuffix(raw);
|
|
201
|
+
if (isParentRef(bare)) return;
|
|
202
|
+
if (bare.startsWith("@")) {
|
|
203
|
+
const roleName = bare.slice(1);
|
|
204
|
+
const value = roles[roleName];
|
|
205
|
+
if (value === undefined || normalizeChain(value).length === 0) {
|
|
206
|
+
if (!unresolved.includes(bare)) unresolved.push(bare);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (visited.has(roleName)) return; // cycle guard
|
|
210
|
+
const next = new Set(visited).add(roleName);
|
|
211
|
+
// A suffix on the role reference ("@smart:high") applies to every model
|
|
212
|
+
// the role expands to; per-entry suffixes win over the inherited one.
|
|
213
|
+
const effective = own ?? inheritedThinking;
|
|
214
|
+
for (const child of normalizeChain(value)) walk(child, next, effective);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
pushEntry(bare, own ?? inheritedThinking);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
for (const entry of candidates) walk(entry, new Set());
|
|
221
|
+
return { candidates: out, thinkingByCandidate, unresolved };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface AgentModelChain {
|
|
225
|
+
candidates: string[];
|
|
226
|
+
thinkingByCandidate: Map<string, SubagentThinkingLevel>;
|
|
227
|
+
unresolved: string[];
|
|
228
|
+
/** True when `subagent.agentModels` replaced the agent's own list. */
|
|
229
|
+
overridden: boolean;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Effective model chain for an agent: `subagent.agentModels[name]` (if set)
|
|
234
|
+
* replaces the agent's own candidates entirely, then `@role` / `:thinking`
|
|
235
|
+
* entries expand. No settings → bundled defaults → today's exact chains.
|
|
236
|
+
*/
|
|
237
|
+
export function resolveAgentModelChain(
|
|
238
|
+
agent: Pick<AgentConfig, "name" | "model" | "models">,
|
|
239
|
+
roles: RolesConfig,
|
|
240
|
+
): AgentModelChain {
|
|
241
|
+
const override = roles.agentModels[agent.name];
|
|
242
|
+
const raw =
|
|
243
|
+
override !== undefined
|
|
244
|
+
? [override]
|
|
245
|
+
: [...new Set([agent.model, ...(agent.models ?? [])].filter((model): model is string => Boolean(model)))];
|
|
246
|
+
const expanded = expandModelCandidates(raw, roles.roles);
|
|
247
|
+
return { ...expanded, overridden: override !== undefined };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Human-readable effective chain for prompt catalog + `/subagent` details. */
|
|
251
|
+
export function describeAgentModels(
|
|
252
|
+
agent: Pick<AgentConfig, "name" | "model" | "models">,
|
|
253
|
+
roles: RolesConfig,
|
|
254
|
+
): string {
|
|
255
|
+
const raw =
|
|
256
|
+
roles.agentModels[agent.name] ??
|
|
257
|
+
[...new Set([agent.model, ...(agent.models ?? [])].filter((model): model is string => Boolean(model)))].join(", ");
|
|
258
|
+
const { candidates, unresolved } = resolveAgentModelChain(agent, roles);
|
|
259
|
+
const chain = candidates.length > 0 ? `${candidates.join(" → ")} → parent fallback` : "parent fallback";
|
|
260
|
+
const source = roles.agentModels[agent.name] ? "override" : "frontmatter";
|
|
261
|
+
const warn = unresolved.length > 0 ? ` (unresolved: ${unresolved.join(", ")})` : "";
|
|
262
|
+
return `[${source}: ${raw}] ${chain}${warn}`;
|
|
263
|
+
}
|
package/extensions/runner.ts
CHANGED
|
@@ -413,7 +413,9 @@ export function getFinalOutput(messages: Message[]): string {
|
|
|
413
413
|
if (part.type === "text" && part.text.trim()) texts.push(part.text);
|
|
414
414
|
}
|
|
415
415
|
if (texts.length === 0) continue;
|
|
416
|
-
|
|
416
|
+
// Join with a newline so interleaved text segments (text around toolCall
|
|
417
|
+
// parts in one message) stay separated instead of gluing "…done.Next step…".
|
|
418
|
+
return texts.join("\n");
|
|
417
419
|
}
|
|
418
420
|
return "";
|
|
419
421
|
}
|
package/extensions/security.ts
CHANGED
|
@@ -565,6 +565,14 @@ export function validateExecutionRequest(
|
|
|
565
565
|
): ValidationError[] {
|
|
566
566
|
const errors: ValidationError[] = [];
|
|
567
567
|
|
|
568
|
+
// Timeout (single/parallel/chain share one requested timeout)
|
|
569
|
+
if (options.timeout !== undefined) {
|
|
570
|
+
const t = normalizeTimeout({ requested: options.timeout });
|
|
571
|
+
if (t.error) {
|
|
572
|
+
errors.push({ field: "timeout", message: t.error });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
568
576
|
// Agent name
|
|
569
577
|
if (options.agentName !== undefined) {
|
|
570
578
|
if (typeof options.agentName !== "string" || options.agentName.trim().length === 0) {
|
package/extensions/service.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { type AgentConfig
|
|
2
|
+
import { type AgentConfig } from "./agents.ts";
|
|
3
3
|
import { runSubAgent, type SubAgentProgress, type SubAgentResult } from "./runner.ts";
|
|
4
|
-
import { resolveModel } from "./model.ts";
|
|
4
|
+
import { resolveModel, runWithModelFallback } from "./model.ts";
|
|
5
|
+
import { readSubagentRoles, resolveAgentModelChain } from "./roles.ts";
|
|
5
6
|
import {
|
|
6
7
|
isRateLimitError,
|
|
7
8
|
validateAgentTools,
|
|
@@ -42,10 +43,15 @@ export async function runNamedAgent(options: {
|
|
|
42
43
|
signal?: AbortSignal;
|
|
43
44
|
/** When true, only read-only tools are permitted regardless of agent.sandbox. */
|
|
44
45
|
readOnly?: boolean;
|
|
46
|
+
/** Trusted opt-out for child cwd outside the workspace (from getTrustedConfig). */
|
|
47
|
+
allowExternalCwd?: boolean;
|
|
45
48
|
onMessage?: (result: SubAgentResult) => void;
|
|
46
49
|
onProgress?: (progress: SubAgentProgress) => void;
|
|
47
50
|
}): Promise<SubAgentResult> {
|
|
48
|
-
const
|
|
51
|
+
const rolesCfg = readSubagentRoles(options.ctx);
|
|
52
|
+
const agentChain = resolveAgentModelChain(options.agent, rolesCfg);
|
|
53
|
+
const resolvedModel = await resolveModel(agentChain.candidates, options.ctx.model, options.ctx.modelRegistry);
|
|
54
|
+
const { model, attempted } = resolvedModel;
|
|
49
55
|
if (!model) throw new Error(`No model resolved for agent "${options.agent.name}" (tried: ${attempted.join(", ") || "none"})`);
|
|
50
56
|
|
|
51
57
|
const modelRegistry = options.ctx.modelRegistry;
|
|
@@ -53,7 +59,11 @@ export async function runNamedAgent(options: {
|
|
|
53
59
|
const authStorage = (modelRegistry as any).authStorage;
|
|
54
60
|
|
|
55
61
|
// Security: validate and normalise timeout.
|
|
56
|
-
const
|
|
62
|
+
const timeoutResult = normalizeTimeout({ requested: options.timeout });
|
|
63
|
+
if (timeoutResult.error) {
|
|
64
|
+
throw new Error(timeoutResult.error);
|
|
65
|
+
}
|
|
66
|
+
const effectiveTimeoutMs = timeoutResult.timeoutMs;
|
|
57
67
|
|
|
58
68
|
// Parent tool names — agents without an explicit `tools` line inherit them.
|
|
59
69
|
const parentToolNames = (options.ctx as any).getAllTools?.()?.map((t: { name: string }) => t.name) as string[] | undefined;
|
|
@@ -77,80 +87,55 @@ export async function runNamedAgent(options: {
|
|
|
77
87
|
|
|
78
88
|
// Security: validate cwd (service caller must provide valid cwd).
|
|
79
89
|
// The service path uses the same policy as the tool path.
|
|
80
|
-
const safeCwd = resolveSafeCwd({ workspaceRoot: options.ctx.cwd, childCwd: options.cwd });
|
|
90
|
+
const safeCwd = resolveSafeCwd({ workspaceRoot: options.ctx.cwd, childCwd: options.cwd, allowExternalCwd: options.allowExternalCwd });
|
|
81
91
|
if (safeCwd.error) {
|
|
82
92
|
throw new Error(safeCwd.error);
|
|
83
93
|
}
|
|
84
94
|
|
|
85
95
|
const contract = options.instructions?.slice(0, MAX_INSTRUCTIONS_LENGTH);
|
|
86
96
|
|
|
87
|
-
// Retry loop: rate-limit model fallback
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
task: options.task,
|
|
123
|
-
tools: toolValidation.tools,
|
|
124
|
-
model: fallbackResolved.model,
|
|
125
|
-
modelRuntime,
|
|
126
|
-
authStorage,
|
|
127
|
-
modelRegistry,
|
|
128
|
-
signal: options.signal,
|
|
129
|
-
timeoutMs: effectiveTimeoutMs,
|
|
130
|
-
agentName: options.agent.name,
|
|
131
|
-
thinkingLevel: options.agent.thinking,
|
|
132
|
-
onMessage: options.onMessage,
|
|
133
|
-
onProgress: options.onProgress,
|
|
134
|
-
loadExtensions,
|
|
135
|
-
projectTrusted,
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
if (result.errorMessage && isRateLimitError(result.errorMessage)) {
|
|
139
|
-
// If the model that just rate-limited was the parent fallback
|
|
140
|
-
// (no remaining candidates), stop — no further options.
|
|
141
|
-
if (isParentFallback) {
|
|
97
|
+
// Retry loop: rate-limit model fallback — shared with the tool path so the
|
|
98
|
+
// triedModels bookkeeping and per-candidate `:thinking` resolution stay in
|
|
99
|
+
// one place (see runWithModelFallback in model.ts). Errors are thrown here;
|
|
100
|
+
// the caller maps them to its own response shape.
|
|
101
|
+
return runWithModelFallback<SubAgentResult>({
|
|
102
|
+
candidates: agentChain.candidates,
|
|
103
|
+
parentModel: options.ctx.model,
|
|
104
|
+
modelRegistry: options.ctx.modelRegistry,
|
|
105
|
+
thinkingByCandidate: agentChain.thinkingByCandidate,
|
|
106
|
+
defaultThinking: options.agent.thinking,
|
|
107
|
+
runAttempt: (model, thinkingLevel) =>
|
|
108
|
+
runSubAgent({
|
|
109
|
+
cwd: safeCwd.path,
|
|
110
|
+
sandbox: options.agent.sandbox === "worktree" ? "worktree" : undefined,
|
|
111
|
+
systemPrompt: contract ? `${options.agent.systemPrompt}\n\n## Task Contract\n${contract}` : options.agent.systemPrompt,
|
|
112
|
+
task: options.task,
|
|
113
|
+
tools: toolValidation.tools,
|
|
114
|
+
model,
|
|
115
|
+
modelRuntime,
|
|
116
|
+
authStorage,
|
|
117
|
+
modelRegistry,
|
|
118
|
+
signal: options.signal,
|
|
119
|
+
timeoutMs: effectiveTimeoutMs,
|
|
120
|
+
agentName: options.agent.name,
|
|
121
|
+
thinkingLevel,
|
|
122
|
+
onMessage: options.onMessage,
|
|
123
|
+
onProgress: options.onProgress,
|
|
124
|
+
loadExtensions,
|
|
125
|
+
projectTrusted,
|
|
126
|
+
}),
|
|
127
|
+
isRateLimited: (result) => Boolean(result.errorMessage && isRateLimitError(result.errorMessage)),
|
|
128
|
+
onExhausted: (reason, triedModels, remaining) => {
|
|
129
|
+
const tried = triedModels.join(" → ") || "(none)";
|
|
130
|
+
const parent = options.ctx.model ? `${options.ctx.model.provider}/${options.ctx.model.id}` : "none";
|
|
131
|
+
if (reason === "no-model") {
|
|
142
132
|
throw new Error(
|
|
143
|
-
`All
|
|
133
|
+
`All models rate-limited or unavailable. Tried: ${tried}. ` +
|
|
134
|
+
`Remaining candidates: ${remaining.join(", ") || "none"}. ` +
|
|
135
|
+
`Parent: ${parent}.`,
|
|
144
136
|
);
|
|
145
137
|
}
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
try {
|
|
152
|
-
return await tryWithFallback();
|
|
153
|
-
} finally {
|
|
154
|
-
// No manual timeout handling needed — runSubAgent handles timeouts internally.
|
|
155
|
-
}
|
|
138
|
+
throw new Error(`All available models exhausted. Tried: ${tried}.`);
|
|
139
|
+
},
|
|
140
|
+
});
|
|
156
141
|
}
|