@bacnh85/pi-subagent 0.15.2 → 0.16.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.
- package/CHANGELOG.md +31 -0
- package/README.md +53 -14
- 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 +214 -125
- package/extensions/model.ts +94 -8
- package/extensions/roles-panel.ts +154 -0
- package/extensions/roles.ts +263 -0
- package/extensions/runner.ts +9 -3
- package/extensions/security.ts +87 -3
- package/extensions/service.ts +56 -71
- package/package.json +13 -2
package/extensions/model.ts
CHANGED
|
@@ -13,12 +13,15 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Model } from "@earendil-works/pi-ai";
|
|
15
15
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { splitThinkingSuffix, type SubagentThinkingLevel } from "./roles.ts";
|
|
16
17
|
|
|
17
18
|
export interface ResolvedModel {
|
|
18
19
|
model: Model<any> | null;
|
|
19
20
|
attempted: string[];
|
|
20
21
|
/** The raw candidate name that matched, if a candidate resolved. Undefined for parent fallback. */
|
|
21
22
|
matchedCandidate?: string;
|
|
23
|
+
/** Thinking level carried by the matched candidate's `:level` suffix, if any. */
|
|
24
|
+
matchedThinking?: SubagentThinkingLevel;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
/** Known provider prefixes for unqualified model names. */
|
|
@@ -46,19 +49,20 @@ export async function resolveModel(
|
|
|
46
49
|
};
|
|
47
50
|
|
|
48
51
|
for (const modelName of [...new Set(modelNames.map((name) => name.trim()).filter(Boolean))]) {
|
|
49
|
-
const
|
|
52
|
+
const { name: bareName, thinking } = splitThinkingSuffix(modelName);
|
|
53
|
+
const idx = bareName.indexOf("/");
|
|
50
54
|
if (idx > 0) {
|
|
51
|
-
const found = tryAvailable(
|
|
52
|
-
if (found) return { model: found, attempted, matchedCandidate: modelName };
|
|
55
|
+
const found = tryAvailable(bareName);
|
|
56
|
+
if (found) return { model: found, attempted, matchedCandidate: modelName, matchedThinking: thinking };
|
|
53
57
|
continue;
|
|
54
58
|
}
|
|
55
59
|
for (const [provider, pattern] of KNOWN_PROVIDERS) {
|
|
56
|
-
if (!pattern.test(
|
|
57
|
-
const found = tryAvailable(`${provider}/${
|
|
58
|
-
if (found) return { model: found, attempted, matchedCandidate: modelName };
|
|
60
|
+
if (!pattern.test(bareName)) continue;
|
|
61
|
+
const found = tryAvailable(`${provider}/${bareName}`);
|
|
62
|
+
if (found) return { model: found, attempted, matchedCandidate: modelName, matchedThinking: thinking };
|
|
59
63
|
}
|
|
60
|
-
const found = tryAvailable(`anthropic/${
|
|
61
|
-
if (found) return { model: found, attempted, matchedCandidate: modelName };
|
|
64
|
+
const found = tryAvailable(`anthropic/${bareName}`);
|
|
65
|
+
if (found) return { model: found, attempted, matchedCandidate: modelName, matchedThinking: thinking };
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
if (parentModel) {
|
|
@@ -67,3 +71,85 @@ export async function resolveModel(
|
|
|
67
71
|
}
|
|
68
72
|
return { model: null, attempted };
|
|
69
73
|
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Rate-limit model fallback (shared by tool path and service path)
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export type ModelFallbackExhaustReason = "no-model" | "already-tried" | "parent-rate-limited";
|
|
80
|
+
|
|
81
|
+
export interface ModelFallbackOptions<T> {
|
|
82
|
+
candidates: readonly string[];
|
|
83
|
+
parentModel: Model<any> | undefined;
|
|
84
|
+
modelRegistry?: ModelRegistry;
|
|
85
|
+
/** thinking suffix per stripped candidate name (from resolveAgentModelChain). */
|
|
86
|
+
thinkingByCandidate: ReadonlyMap<string, SubagentThinkingLevel>;
|
|
87
|
+
/** Fallback thinking when no candidate carries a `:level` suffix. */
|
|
88
|
+
defaultThinking?: SubagentThinkingLevel;
|
|
89
|
+
runAttempt: (model: Model<any>, thinkingLevel: SubagentThinkingLevel | undefined) => Promise<T>;
|
|
90
|
+
isRateLimited: (result: T) => boolean;
|
|
91
|
+
/** Build the terminal value when all models are exhausted (path-specific error mapping). */
|
|
92
|
+
onExhausted: (reason: ModelFallbackExhaustReason, triedModels: string[], remaining: string[]) => T;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Retry loop shared by the tool handler (index.ts) and the event-driven
|
|
97
|
+
* service path (service.ts): try candidates in priority order, falling back to
|
|
98
|
+
* the parent model, advancing on rate-limit errors. Single source of truth for
|
|
99
|
+
* `triedModels` bookkeeping and per-candidate `:thinking` resolution.
|
|
100
|
+
*/
|
|
101
|
+
export async function runWithModelFallback<T>(options: ModelFallbackOptions<T>): Promise<T> {
|
|
102
|
+
const {
|
|
103
|
+
candidates,
|
|
104
|
+
parentModel,
|
|
105
|
+
modelRegistry,
|
|
106
|
+
thinkingByCandidate,
|
|
107
|
+
defaultThinking,
|
|
108
|
+
runAttempt,
|
|
109
|
+
isRateLimited,
|
|
110
|
+
onExhausted,
|
|
111
|
+
} = options;
|
|
112
|
+
const triedModels: string[] = [];
|
|
113
|
+
|
|
114
|
+
const attempt = async (): Promise<T> => {
|
|
115
|
+
const remaining = candidates.filter((m) => !triedModels.includes(m));
|
|
116
|
+
const isParentFallback = remaining.length === 0;
|
|
117
|
+
const fallbackResolved = await resolveModel(remaining, parentModel, modelRegistry);
|
|
118
|
+
if (!fallbackResolved.model) {
|
|
119
|
+
return onExhausted("no-model", triedModels, remaining);
|
|
120
|
+
}
|
|
121
|
+
const triedName = `${fallbackResolved.model!.provider}/${fallbackResolved.model!.id}`;
|
|
122
|
+
if (triedModels.includes(triedName)) {
|
|
123
|
+
// Already tried this model (e.g., all candidates unavailable
|
|
124
|
+
// and parent fallback) — no further options.
|
|
125
|
+
return onExhausted("already-tried", triedModels, remaining);
|
|
126
|
+
}
|
|
127
|
+
triedModels.push(triedName);
|
|
128
|
+
// Also track the raw candidate name so candidates.filter() can
|
|
129
|
+
// exclude it even when the agent uses unqualified names.
|
|
130
|
+
// Avoid duplicating when candidate name is already qualified (matchedCandidate === triedName).
|
|
131
|
+
if (fallbackResolved.matchedCandidate && fallbackResolved.matchedCandidate !== triedName) {
|
|
132
|
+
triedModels.push(fallbackResolved.matchedCandidate);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The `:thinking` suffix lives on the candidate as written; resolve it from
|
|
136
|
+
// the stripped-name map (matchedCandidate is the raw candidate string).
|
|
137
|
+
const thinkingLevel =
|
|
138
|
+
thinkingByCandidate.get(fallbackResolved.matchedCandidate ?? triedName) ??
|
|
139
|
+
fallbackResolved.matchedThinking ??
|
|
140
|
+
defaultThinking;
|
|
141
|
+
|
|
142
|
+
const result = await runAttempt(fallbackResolved.model, thinkingLevel);
|
|
143
|
+
if (result && isRateLimited(result)) {
|
|
144
|
+
// If the model that just rate-limited was the parent fallback
|
|
145
|
+
// (no remaining candidates), stop — no further options.
|
|
146
|
+
if (isParentFallback) {
|
|
147
|
+
return onExhausted("parent-rate-limited", triedModels, remaining);
|
|
148
|
+
}
|
|
149
|
+
return attempt();
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return attempt();
|
|
155
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
import type { AgentConfig } from "./agents.ts";
|
|
18
|
+
import { DEFAULT_ROLES, readSubagentRoles, type RolesConfig } from "./roles.ts";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Settings persistence (global only — repo .pi/settings.json is read-only)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
function settingsPath(): string {
|
|
25
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
26
|
+
return join(agentDir, "settings.json");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Read the GLOBAL settings.json. Returns null only when the file is missing;
|
|
30
|
+
* throws when the file exists but is not valid JSON (so a corrupt file can
|
|
31
|
+
* never be silently overwritten by a panel save). */
|
|
32
|
+
function readSettingsJson(): Record<string, unknown> | null {
|
|
33
|
+
const path = settingsPath();
|
|
34
|
+
if (!existsSync(path)) return null;
|
|
35
|
+
const raw = readFileSync(path, "utf8");
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
38
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
39
|
+
throw new Error(`settings.json root is not a JSON object (${path})`);
|
|
40
|
+
}
|
|
41
|
+
return parsed as Record<string, unknown>;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err instanceof Error && err.message.includes("not a JSON object")) throw err;
|
|
44
|
+
throw new Error(`settings.json is not valid JSON (${path})`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Read-modify-write the `subagent` section of the GLOBAL settings.json.
|
|
49
|
+
* Writes a temp file then renames (atomic); never touches other keys.
|
|
50
|
+
* Throws if the existing settings.json is corrupt (the file is left intact).
|
|
51
|
+
* Returns false when nothing changes. */
|
|
52
|
+
export function writeSubagentSection(patch: { roles?: RolesConfig["roles"]; agentModels?: RolesConfig["agentModels"] }): boolean {
|
|
53
|
+
const settings = readSettingsJson() ?? {};
|
|
54
|
+
const existing = (settings.subagent ?? {}) as Record<string, unknown>;
|
|
55
|
+
const subagent = { ...existing };
|
|
56
|
+
if (patch.roles !== undefined) subagent.roles = patch.roles;
|
|
57
|
+
if (patch.agentModels !== undefined) subagent.agentModels = patch.agentModels;
|
|
58
|
+
if (JSON.stringify(subagent) === JSON.stringify(existing)) return false;
|
|
59
|
+
const rolesEmpty = subagent.roles === undefined || (typeof subagent.roles === "object" && Object.keys(subagent.roles as object).length === 0);
|
|
60
|
+
const modelsEmpty = subagent.agentModels === undefined || (typeof subagent.agentModels === "object" && Object.keys(subagent.agentModels as object).length === 0);
|
|
61
|
+
if (rolesEmpty) delete subagent.roles;
|
|
62
|
+
if (modelsEmpty) delete subagent.agentModels;
|
|
63
|
+
// Preserve any unrelated subagent.* keys (forward compat); drop the section
|
|
64
|
+
// only when roles/agentModels were the only content.
|
|
65
|
+
if (Object.keys(subagent).length === 0) delete settings.subagent;
|
|
66
|
+
else settings.subagent = subagent;
|
|
67
|
+
const target = settingsPath();
|
|
68
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
69
|
+
const tmp = `${target}.tmp-${Date.now()}`;
|
|
70
|
+
writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
|
|
71
|
+
renameSync(tmp, target);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Row model — unit-testable without a TUI
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export interface RolesPanelCfg {
|
|
80
|
+
/** Working copy: role name → comma chain ("" = use default). */
|
|
81
|
+
roles: Record<string, string>;
|
|
82
|
+
/** Working copy: agent name → override selector ("" = inherit). */
|
|
83
|
+
agentModels: Record<string, string>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Seed a working config from current effective settings + bundled agents. */
|
|
87
|
+
export function buildRolesPanelCfg(agents: AgentConfig[], current: RolesConfig): RolesPanelCfg {
|
|
88
|
+
const roleNames = new Set([...Object.keys(DEFAULT_ROLES), ...Object.keys(current.roles)]);
|
|
89
|
+
const cfg: RolesPanelCfg = { roles: {}, agentModels: {} };
|
|
90
|
+
for (const name of roleNames) {
|
|
91
|
+
// Only show explicitly-configured values; defaults render blank (= default).
|
|
92
|
+
const explicit = (current.roles[name] !== undefined && JSON.stringify(current.roles[name]) !== JSON.stringify(DEFAULT_ROLES[name]))
|
|
93
|
+
? (Array.isArray(current.roles[name]) ? current.roles[name].join(", ") : String(current.roles[name]))
|
|
94
|
+
: "";
|
|
95
|
+
cfg.roles[name] = explicit;
|
|
96
|
+
}
|
|
97
|
+
for (const agent of agents) cfg.agentModels[agent.name] = current.agentModels[agent.name] ?? "";
|
|
98
|
+
return cfg;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Build panel groups. Role rows first, then one override row per agent. */
|
|
102
|
+
export function buildRows(cfg: RolesPanelCfg, agents: AgentConfig[]): PanelGroup[] {
|
|
103
|
+
const defaultChain = (name: string) => Array.isArray(DEFAULT_ROLES[name]) ? (DEFAULT_ROLES[name] as string[]).join(", ") : String(DEFAULT_ROLES[name] ?? "");
|
|
104
|
+
const roleRows = Object.keys(cfg.roles).sort().map((name) => {
|
|
105
|
+
// Label shows the default chain so blank is meaningful.
|
|
106
|
+
return row(`role.${name}`, `@${name} (default: ${defaultChain(name) || "none"})`, "string", cfg.roles[name], (v) => {
|
|
107
|
+
cfg.roles[name] = String(v ?? "").trim();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
const agentRows = agents.map((agent) =>
|
|
111
|
+
row(`agent.${agent.name}`, agent.name, "string", cfg.agentModels[agent.name] ?? "", (v) => {
|
|
112
|
+
const value = String(v ?? "").trim();
|
|
113
|
+
if (value) cfg.agentModels[agent.name] = value;
|
|
114
|
+
else delete cfg.agentModels[agent.name];
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
return [
|
|
118
|
+
{ key: "roles", label: "Model roles (chain, blank = default)", rows: roleRows },
|
|
119
|
+
{ key: "agents", label: "Per-agent overrides (blank = inherit)", rows: agentRows },
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Convert a working config back to a settings patch. Blank values drop keys
|
|
124
|
+
* (role falls back to default; agent override is removed). */
|
|
125
|
+
export function cfgToPatch(cfg: RolesPanelCfg): { roles: RolesConfig["roles"]; agentModels: RolesConfig["agentModels"] } {
|
|
126
|
+
const roles: RolesConfig["roles"] = {};
|
|
127
|
+
for (const [name, chain] of Object.entries(cfg.roles)) {
|
|
128
|
+
const trimmed = chain.trim();
|
|
129
|
+
if (!trimmed) continue;
|
|
130
|
+
roles[name] = trimmed.includes(",") ? trimmed.split(",").map((s) => s.trim()).filter(Boolean) : trimmed;
|
|
131
|
+
}
|
|
132
|
+
const agentModels: RolesConfig["agentModels"] = {};
|
|
133
|
+
for (const [name, value] of Object.entries(cfg.agentModels)) {
|
|
134
|
+
const trimmed = value.trim();
|
|
135
|
+
if (trimmed) agentModels[name] = trimmed;
|
|
136
|
+
}
|
|
137
|
+
return { roles, agentModels };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Keep overrides for agents NOT shown in the panel (e.g. overrides for
|
|
141
|
+
* project-local agents saved globally from another project) so a panel save
|
|
142
|
+
* doesn't wipe them. Discovered-agent entries always follow the panel. */
|
|
143
|
+
export function preserveUnknownAgentModels(
|
|
144
|
+
patch: RolesConfig["agentModels"],
|
|
145
|
+
discoveredNames: readonly string[],
|
|
146
|
+
existing: RolesConfig["agentModels"] | undefined,
|
|
147
|
+
): RolesConfig["agentModels"] {
|
|
148
|
+
if (!existing) return patch;
|
|
149
|
+
const out = { ...patch };
|
|
150
|
+
for (const [name, value] of Object.entries(existing)) {
|
|
151
|
+
if (!discoveredNames.includes(name) && out[name] === undefined) out[name] = value;
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
@@ -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
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
classifyStopReason,
|
|
31
31
|
createCombinedAbortSignal,
|
|
32
32
|
type SubagentStatus,
|
|
33
|
+
DEFAULT_TIMEOUT_MS,
|
|
34
|
+
HARD_TIMEOUT_MS,
|
|
33
35
|
} from "./security.ts";
|
|
34
36
|
|
|
35
37
|
// ---------------------------------------------------------------------------
|
|
@@ -46,8 +48,10 @@ export interface UsageStats {
|
|
|
46
48
|
turns: number;
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
export
|
|
50
|
-
export const
|
|
51
|
+
/** Re-export from security.ts — single source of truth for both timeouts. */
|
|
52
|
+
export const DEFAULT_INACTIVITY_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
|
53
|
+
export { HARD_TIMEOUT_MS };
|
|
54
|
+
|
|
51
55
|
|
|
52
56
|
// ---------------------------------------------------------------------------
|
|
53
57
|
// Extension resource loader
|
|
@@ -409,7 +413,9 @@ export function getFinalOutput(messages: Message[]): string {
|
|
|
409
413
|
if (part.type === "text" && part.text.trim()) texts.push(part.text);
|
|
410
414
|
}
|
|
411
415
|
if (texts.length === 0) continue;
|
|
412
|
-
|
|
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");
|
|
413
419
|
}
|
|
414
420
|
return "";
|
|
415
421
|
}
|