pi-subagents 0.31.1 → 0.33.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 +67 -4
- package/README.md +219 -40
- package/install.mjs +2 -1
- package/package.json +3 -2
- package/skills/pi-subagents/SKILL.md +71 -10
- package/src/agents/agent-management.ts +179 -2
- package/src/agents/agent-memory.ts +254 -0
- package/src/agents/agent-serializer.ts +11 -0
- package/src/agents/agents.ts +193 -19
- package/src/agents/chain-serializer.ts +27 -2
- package/src/extension/config.ts +27 -4
- package/src/extension/doctor.ts +1 -7
- package/src/extension/fanout-child.ts +3 -2
- package/src/extension/index.ts +70 -41
- package/src/extension/rpc.ts +369 -0
- package/src/extension/schemas.ts +54 -10
- package/src/extension/tool-description.ts +200 -0
- package/src/intercom/intercom-bridge.ts +21 -253
- package/src/intercom/native-supervisor-channel.ts +510 -0
- package/src/runs/background/async-execution.ts +187 -38
- package/src/runs/background/async-job-tracker.ts +88 -2
- package/src/runs/background/async-status.ts +67 -10
- package/src/runs/background/chain-root-attachment.ts +34 -4
- package/src/runs/background/completion-batcher.ts +166 -0
- package/src/runs/background/control-channel.ts +156 -1
- package/src/runs/background/fleet-view.ts +515 -0
- package/src/runs/background/notify.ts +161 -44
- package/src/runs/background/result-watcher.ts +1 -2
- package/src/runs/background/run-id-resolver.ts +3 -2
- package/src/runs/background/run-status.ts +167 -6
- package/src/runs/background/scheduled-runs.ts +514 -0
- package/src/runs/background/stale-run-reconciler.ts +28 -1
- package/src/runs/background/subagent-runner.ts +840 -127
- package/src/runs/background/wait.ts +353 -0
- package/src/runs/foreground/chain-execution.ts +123 -27
- package/src/runs/foreground/execution.ts +174 -27
- package/src/runs/foreground/subagent-executor.ts +569 -81
- package/src/runs/shared/acceptance.ts +45 -22
- package/src/runs/shared/dynamic-fanout.ts +2 -2
- package/src/runs/shared/model-fallback.ts +171 -20
- package/src/runs/shared/model-scope.ts +128 -0
- package/src/runs/shared/nested-events.ts +89 -0
- package/src/runs/shared/parallel-utils.ts +50 -1
- package/src/runs/shared/pi-args.ts +35 -4
- package/src/runs/shared/pi-spawn.ts +52 -20
- package/src/runs/shared/single-output.ts +2 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +110 -4
- package/src/runs/shared/tool-budget.ts +74 -0
- package/src/runs/shared/turn-budget.ts +52 -0
- package/src/runs/shared/worktree.ts +28 -5
- package/src/shared/artifacts.ts +16 -1
- package/src/shared/atomic-json.ts +15 -2
- package/src/shared/child-transcript.ts +212 -0
- package/src/shared/fork-context.ts +133 -22
- package/src/shared/settings.ts +3 -1
- package/src/shared/types.ts +197 -4
- package/src/shared/utils.ts +99 -14
- package/src/slash/prompt-workflows.ts +330 -0
- package/src/slash/slash-commands.ts +133 -2
- package/src/tui/render.ts +32 -12
|
@@ -710,13 +710,15 @@ export function aggregateAcceptanceReport(input: {
|
|
|
710
710
|
};
|
|
711
711
|
}
|
|
712
712
|
|
|
713
|
-
function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string): Promise<AcceptanceVerifyResult> {
|
|
713
|
+
function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string, options: { signal?: AbortSignal; abortMessage?: string } = {}): Promise<AcceptanceVerifyResult> {
|
|
714
714
|
return new Promise((resolve) => {
|
|
715
715
|
const startedAt = Date.now();
|
|
716
716
|
const cwd = command.cwd ? path.resolve(defaultCwd, command.cwd) : defaultCwd;
|
|
717
717
|
let stdout = "";
|
|
718
718
|
let stderr = "";
|
|
719
719
|
let timedOut = false;
|
|
720
|
+
let settled = false;
|
|
721
|
+
let hardKill: NodeJS.Timeout | undefined;
|
|
720
722
|
const child = spawn(command.command, {
|
|
721
723
|
cwd,
|
|
722
724
|
env: { ...process.env, ...(command.env ?? {}) },
|
|
@@ -724,12 +726,39 @@ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string):
|
|
|
724
726
|
stdio: ["ignore", "pipe", "pipe"],
|
|
725
727
|
windowsHide: true,
|
|
726
728
|
});
|
|
727
|
-
const
|
|
729
|
+
const finish = (result: Omit<AcceptanceVerifyResult, "id" | "command" | "cwd" | "durationMs">) => {
|
|
730
|
+
if (settled) return;
|
|
731
|
+
settled = true;
|
|
732
|
+
clearTimeout(timeout);
|
|
733
|
+
if (hardKill) clearTimeout(hardKill);
|
|
734
|
+
options.signal?.removeEventListener("abort", abortVerification);
|
|
735
|
+
resolve({
|
|
736
|
+
id: command.id,
|
|
737
|
+
command: command.command,
|
|
738
|
+
cwd,
|
|
739
|
+
durationMs: Date.now() - startedAt,
|
|
740
|
+
...result,
|
|
741
|
+
});
|
|
742
|
+
};
|
|
743
|
+
const abortVerification = () => {
|
|
744
|
+
if (settled || timedOut) return;
|
|
728
745
|
timedOut = true;
|
|
729
746
|
child.kill("SIGTERM");
|
|
730
|
-
setTimeout(() =>
|
|
731
|
-
|
|
747
|
+
hardKill = setTimeout(() => {
|
|
748
|
+
child.kill("SIGKILL");
|
|
749
|
+
finish({
|
|
750
|
+
exitCode: null,
|
|
751
|
+
status: "timed-out",
|
|
752
|
+
stdout: trimOutput(stdout),
|
|
753
|
+
stderr: trimOutput(stderr || options.abortMessage || "Acceptance verification timed out."),
|
|
754
|
+
});
|
|
755
|
+
}, 1000);
|
|
756
|
+
hardKill.unref?.();
|
|
757
|
+
};
|
|
758
|
+
const timeout = setTimeout(abortVerification, command.timeoutMs ?? 120_000);
|
|
732
759
|
timeout.unref?.();
|
|
760
|
+
if (options.signal?.aborted) abortVerification();
|
|
761
|
+
else options.signal?.addEventListener("abort", abortVerification, { once: true });
|
|
733
762
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
734
763
|
stdout += chunk.toString();
|
|
735
764
|
});
|
|
@@ -737,30 +766,19 @@ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string):
|
|
|
737
766
|
stderr += chunk.toString();
|
|
738
767
|
});
|
|
739
768
|
child.on("close", (exitCode) => {
|
|
740
|
-
clearTimeout(timeout);
|
|
741
|
-
const durationMs = Date.now() - startedAt;
|
|
742
769
|
const passed = exitCode === 0 && !timedOut;
|
|
743
|
-
|
|
744
|
-
id: command.id,
|
|
745
|
-
command: command.command,
|
|
746
|
-
cwd,
|
|
770
|
+
finish({
|
|
747
771
|
exitCode,
|
|
748
772
|
status: timedOut ? "timed-out" : passed ? "passed" : command.allowFailure ? "allowed-failure" : "failed",
|
|
749
773
|
stdout: trimOutput(stdout),
|
|
750
|
-
stderr: trimOutput(stderr),
|
|
751
|
-
durationMs,
|
|
774
|
+
stderr: trimOutput(stderr || (timedOut ? options.abortMessage ?? "" : "")),
|
|
752
775
|
});
|
|
753
776
|
});
|
|
754
777
|
child.on("error", (error) => {
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
cwd,
|
|
760
|
-
exitCode: 1,
|
|
761
|
-
status: command.allowFailure ? "allowed-failure" : "failed",
|
|
762
|
-
stderr: error instanceof Error ? error.message : String(error),
|
|
763
|
-
durationMs: Date.now() - startedAt,
|
|
778
|
+
finish({
|
|
779
|
+
exitCode: timedOut ? null : 1,
|
|
780
|
+
status: timedOut ? "timed-out" : command.allowFailure ? "allowed-failure" : "failed",
|
|
781
|
+
stderr: timedOut ? trimOutput(stderr || options.abortMessage || "Acceptance verification timed out.") : error instanceof Error ? error.message : String(error),
|
|
764
782
|
});
|
|
765
783
|
});
|
|
766
784
|
});
|
|
@@ -772,6 +790,8 @@ export async function evaluateAcceptance(input: {
|
|
|
772
790
|
cwd: string;
|
|
773
791
|
report?: AcceptanceReport;
|
|
774
792
|
reviewResult?: AcceptanceReviewResult;
|
|
793
|
+
signal?: AbortSignal;
|
|
794
|
+
abortMessage?: string;
|
|
775
795
|
}): Promise<AcceptanceLedger> {
|
|
776
796
|
const acceptance = input.acceptance;
|
|
777
797
|
const ledger: AcceptanceLedger = {
|
|
@@ -815,7 +835,10 @@ export async function evaluateAcceptance(input: {
|
|
|
815
835
|
return ledger;
|
|
816
836
|
}
|
|
817
837
|
ledger.verifyRuns = [];
|
|
818
|
-
for (const command of acceptance.verify)
|
|
838
|
+
for (const command of acceptance.verify) {
|
|
839
|
+
ledger.verifyRuns.push(await runVerifyCommand(command, input.cwd, { signal: input.signal, abortMessage: input.abortMessage }));
|
|
840
|
+
if (input.signal?.aborted) break;
|
|
841
|
+
}
|
|
819
842
|
if (ledger.verifyRuns.some((run) => run.status === "failed" || run.status === "timed-out")) {
|
|
820
843
|
ledger.status = "rejected";
|
|
821
844
|
return ledger;
|
|
@@ -42,10 +42,10 @@ const ITEM_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
|
42
42
|
const ITEM_REF_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)(?:\.([^{}]+))?\}/g;
|
|
43
43
|
const RESERVED_TEMPLATE_NAMES = new Set(["task", "previous", "chain_dir", "outputs"]);
|
|
44
44
|
const DYNAMIC_STEP_KEYS = new Set(["expand", "parallel", "collect", "concurrency", "failFast", "phase", "label", "acceptance"]);
|
|
45
|
-
const RUNNER_DYNAMIC_STEP_KEYS = new Set([...DYNAMIC_STEP_KEYS, "effectiveAcceptance"]);
|
|
45
|
+
const RUNNER_DYNAMIC_STEP_KEYS = new Set([...DYNAMIC_STEP_KEYS, "effectiveAcceptance", "sessionFiles", "thinkingOverrides"]);
|
|
46
46
|
const DYNAMIC_EXPAND_KEYS = new Set(["from", "item", "key", "maxItems", "onEmpty"]);
|
|
47
47
|
const DYNAMIC_EXPAND_FROM_KEYS = new Set(["output", "path"]);
|
|
48
|
-
const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "acceptance"]);
|
|
48
|
+
const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "toolBudget", "acceptance"]);
|
|
49
49
|
const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
|
|
50
50
|
...DYNAMIC_PARALLEL_KEYS,
|
|
51
51
|
"outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "maxSubagentDepth",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
|
|
2
2
|
import type { Usage } from "../../shared/types.ts";
|
|
3
|
+
import { checkModelScope, type ModelScopeConfig, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
|
|
3
4
|
|
|
4
5
|
export type { AvailableModelInfo };
|
|
5
6
|
|
|
@@ -29,6 +30,144 @@ export interface ParentModel {
|
|
|
29
30
|
id: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Normalize a model id or provider segment for fuzzy comparison: case-fold,
|
|
35
|
+
* treat dots/underscores as dashes (so `4.5` matches `4-5`), and collapse
|
|
36
|
+
* repeated separators. Pure.
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeModelSegment(segment: string): string {
|
|
39
|
+
return segment
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/[._]+/g, "-")
|
|
42
|
+
.replace(/-+/g, "-")
|
|
43
|
+
.replace(/^-|-$/g, "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isPlausibleDateStamp(year: string, month: string, day: string): boolean {
|
|
47
|
+
const yyyy = Number(year);
|
|
48
|
+
const mm = Number(month);
|
|
49
|
+
const dd = Number(day);
|
|
50
|
+
return yyyy >= 1900 && yyyy <= 2099 && mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Drop a trailing date stamp (`-20251001` or `-2025-10-01`) so dated and undated ids match. Pure. */
|
|
54
|
+
function stripTrailingDateStamp(segment: string): string {
|
|
55
|
+
const dashed = /^(.*)-(\d{4})-(\d{2})-(\d{2})$/.exec(segment);
|
|
56
|
+
if (dashed && isPlausibleDateStamp(dashed[2]!, dashed[3]!, dashed[4]!)) return dashed[1]!;
|
|
57
|
+
const compact = /^(.*)-(\d{4})(\d{2})(\d{2})$/.exec(segment);
|
|
58
|
+
if (compact && isPlausibleDateStamp(compact[2]!, compact[3]!, compact[4]!)) return compact[1]!;
|
|
59
|
+
return segment;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveBaseModelCandidate(
|
|
63
|
+
baseModel: string,
|
|
64
|
+
availableModels: AvailableModelInfo[],
|
|
65
|
+
preferredProvider?: string,
|
|
66
|
+
): string | undefined {
|
|
67
|
+
if (baseModel.includes("/")) {
|
|
68
|
+
const exact = availableModels.find((entry) => entry.fullId === baseModel);
|
|
69
|
+
if (exact) return exact.fullId;
|
|
70
|
+
} else {
|
|
71
|
+
const exactMatches = availableModels.filter((entry) => entry.id === baseModel);
|
|
72
|
+
if (preferredProvider) {
|
|
73
|
+
const preferredMatch = exactMatches.find((entry) => entry.provider === preferredProvider);
|
|
74
|
+
if (preferredMatch) return preferredMatch.fullId;
|
|
75
|
+
}
|
|
76
|
+
if (exactMatches.length === 1) return exactMatches[0]!.fullId;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return fuzzyResolveModel(baseModel, availableModels, preferredProvider);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fuzzy-resolve a base model id (thinking suffix already stripped) against the
|
|
84
|
+
* registry, tolerating separator, case, and optional date-stamp differences so
|
|
85
|
+
* users do not have to spell provider/model exactly. A qualified `provider/id`
|
|
86
|
+
* query only matches within the named provider — this never silently switches
|
|
87
|
+
* providers for security/cost-sensitive configs. Returns the matched `fullId`,
|
|
88
|
+
* or `undefined` when there is no match or the match is ambiguous across
|
|
89
|
+
* providers (and no `preferredProvider` disambiguates). Pure.
|
|
90
|
+
*/
|
|
91
|
+
export function fuzzyResolveModel(
|
|
92
|
+
baseModel: string,
|
|
93
|
+
availableModels: AvailableModelInfo[],
|
|
94
|
+
preferredProvider?: string,
|
|
95
|
+
): string | undefined {
|
|
96
|
+
let queryProvider: string | undefined;
|
|
97
|
+
let queryIdRaw = baseModel;
|
|
98
|
+
const slashIdx = baseModel.indexOf("/");
|
|
99
|
+
if (slashIdx !== -1) {
|
|
100
|
+
queryProvider = normalizeModelSegment(baseModel.slice(0, slashIdx));
|
|
101
|
+
queryIdRaw = baseModel.slice(slashIdx + 1);
|
|
102
|
+
} else {
|
|
103
|
+
const providerSeparators = [":", "."];
|
|
104
|
+
for (const separator of providerSeparators) {
|
|
105
|
+
const separatorIdx = baseModel.indexOf(separator);
|
|
106
|
+
if (separatorIdx <= 0) continue;
|
|
107
|
+
const providerPart = normalizeModelSegment(baseModel.slice(0, separatorIdx));
|
|
108
|
+
if (!availableModels.some((entry) => normalizeModelSegment(entry.provider) === providerPart)) continue;
|
|
109
|
+
queryProvider = providerPart;
|
|
110
|
+
queryIdRaw = baseModel.slice(separatorIdx + 1);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const queryId = normalizeModelSegment(queryIdRaw);
|
|
115
|
+
const queryIdNoDate = stripTrailingDateStamp(queryId);
|
|
116
|
+
|
|
117
|
+
const candidates = availableModels.filter((entry) => {
|
|
118
|
+
const entryId = normalizeModelSegment(entry.id);
|
|
119
|
+
if (entryId !== queryId && stripTrailingDateStamp(entryId) !== queryIdNoDate) return false;
|
|
120
|
+
if (queryProvider !== undefined && normalizeModelSegment(entry.provider) !== queryProvider) return false;
|
|
121
|
+
return true;
|
|
122
|
+
});
|
|
123
|
+
if (candidates.length === 0) return undefined;
|
|
124
|
+
if (preferredProvider) {
|
|
125
|
+
const preferredProviderNorm = normalizeModelSegment(preferredProvider);
|
|
126
|
+
const preferred = candidates.find((entry) => normalizeModelSegment(entry.provider) === preferredProviderNorm);
|
|
127
|
+
if (preferred) return preferred.fullId;
|
|
128
|
+
}
|
|
129
|
+
if (candidates.length === 1) return candidates[0]!.fullId;
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve a possibly-loose model id to a canonical `provider/id` (plus any
|
|
135
|
+
* thinking suffix). Exact registry matches win; fuzzy normalization
|
|
136
|
+
* (separator/case/date-stamp via {@link fuzzyResolveModel}) is a fallback so
|
|
137
|
+
* spelling differences still resolve. Never switches providers for a qualified
|
|
138
|
+
* query. Pure.
|
|
139
|
+
*/
|
|
140
|
+
export function resolveModelCandidate(
|
|
141
|
+
model: string | undefined,
|
|
142
|
+
availableModels: AvailableModelInfo[] | undefined,
|
|
143
|
+
preferredProvider?: string,
|
|
144
|
+
): string | undefined {
|
|
145
|
+
if (!model) return undefined;
|
|
146
|
+
if (!availableModels || availableModels.length === 0) return model;
|
|
147
|
+
|
|
148
|
+
const resolvedWhole = resolveBaseModelCandidate(model, availableModels, preferredProvider);
|
|
149
|
+
if (resolvedWhole) return resolvedWhole;
|
|
150
|
+
|
|
151
|
+
const { baseModel, thinkingSuffix } = splitThinkingSuffix(model);
|
|
152
|
+
if (!thinkingSuffix) return model;
|
|
153
|
+
const resolvedBase = resolveBaseModelCandidate(baseModel, availableModels, preferredProvider);
|
|
154
|
+
if (resolvedBase) return `${resolvedBase}${thinkingSuffix}`;
|
|
155
|
+
return model;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface ResolveSubagentModelOverrideOptions {
|
|
159
|
+
/** When set with `enforce: true`, out-of-scope models are rejected. */
|
|
160
|
+
scope?: ModelScopeConfig;
|
|
161
|
+
/** Origin of the requested model: explicit caller-supplied (hard error) vs inherited (warn). Defaults to `"inherited"`. */
|
|
162
|
+
source?: ModelSource;
|
|
163
|
+
/** Called for warn-severity violations instead of `console.warn`. */
|
|
164
|
+
onWarn?: (violation: ModelScopeViolation) => void;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function defaultScopeWarn(violation: ModelScopeViolation): void {
|
|
168
|
+
console.warn(`[pi-subagents] ${violation.message}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
32
171
|
/**
|
|
33
172
|
* Resolve the `--model` override passed to a spawned subagent.
|
|
34
173
|
*
|
|
@@ -43,38 +182,39 @@ export interface ParentModel {
|
|
|
43
182
|
* session's model.
|
|
44
183
|
*
|
|
45
184
|
* An explicitly requested model string is resolved via {@link resolveModelCandidate}.
|
|
185
|
+
* When `options.scope.enforce` is on, an out-of-scope resolved model throws for
|
|
186
|
+
* an explicit (`source: "explicit"`) request and warns for an inherited one.
|
|
46
187
|
*/
|
|
47
188
|
export function resolveSubagentModelOverride(
|
|
48
189
|
requestedModel: string | boolean | undefined,
|
|
49
190
|
parentModel: ParentModel | undefined,
|
|
50
191
|
availableModels: AvailableModelInfo[] | undefined,
|
|
51
192
|
preferredProvider?: string,
|
|
193
|
+
options?: ResolveSubagentModelOverrideOptions,
|
|
52
194
|
): string | undefined {
|
|
53
195
|
const trimmed = typeof requestedModel === "string" ? requestedModel.trim() : "";
|
|
54
196
|
const explicit = trimmed && trimmed !== INHERIT_MODEL ? trimmed : undefined;
|
|
197
|
+
let resolved: string | undefined;
|
|
55
198
|
if (explicit === undefined) {
|
|
56
|
-
|
|
199
|
+
resolved = parentModel ? `${parentModel.provider}/${parentModel.id}` : undefined;
|
|
200
|
+
} else {
|
|
201
|
+
resolved = resolveModelCandidate(explicit, availableModels, preferredProvider);
|
|
202
|
+
}
|
|
203
|
+
if (resolved && options?.scope?.enforce) {
|
|
204
|
+
const source: ModelSource = explicit === undefined ? "inherited" : (options.source ?? "inherited");
|
|
205
|
+
const violation = checkModelScope(resolved, options.scope, source);
|
|
206
|
+
if (violation) {
|
|
207
|
+
if (violation.severity === "error") throw new Error(violation.message);
|
|
208
|
+
(options.onWarn ?? defaultScopeWarn)(violation);
|
|
209
|
+
}
|
|
57
210
|
}
|
|
58
|
-
return
|
|
211
|
+
return resolved;
|
|
59
212
|
}
|
|
60
213
|
|
|
61
|
-
export
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
): string | undefined {
|
|
66
|
-
if (!model) return undefined;
|
|
67
|
-
if (model.includes("/")) return model;
|
|
68
|
-
if (!availableModels || availableModels.length === 0) return model;
|
|
69
|
-
|
|
70
|
-
const { baseModel, thinkingSuffix } = splitThinkingSuffix(model);
|
|
71
|
-
const matches = availableModels.filter((entry) => entry.id === baseModel);
|
|
72
|
-
if (preferredProvider) {
|
|
73
|
-
const preferredMatch = matches.find((entry) => entry.provider === preferredProvider);
|
|
74
|
-
if (preferredMatch) return `${preferredMatch.fullId}${thinkingSuffix}`;
|
|
75
|
-
}
|
|
76
|
-
if (matches.length !== 1) return model;
|
|
77
|
-
return `${matches[0]!.fullId}${thinkingSuffix}`;
|
|
214
|
+
export interface BuildModelCandidatesOptions {
|
|
215
|
+
/** Fallback models are inherited agent config and warn, rather than error, when out of scope. */
|
|
216
|
+
scope?: ModelScopeConfig;
|
|
217
|
+
onWarn?: (violation: ModelScopeViolation) => void;
|
|
78
218
|
}
|
|
79
219
|
|
|
80
220
|
export function buildModelCandidates(
|
|
@@ -82,13 +222,20 @@ export function buildModelCandidates(
|
|
|
82
222
|
fallbackModels: string[] | undefined,
|
|
83
223
|
availableModels: AvailableModelInfo[] | undefined,
|
|
84
224
|
preferredProvider?: string,
|
|
225
|
+
options?: BuildModelCandidatesOptions,
|
|
85
226
|
): string[] {
|
|
86
227
|
const seen = new Set<string>();
|
|
87
228
|
const candidates: string[] = [];
|
|
88
|
-
|
|
229
|
+
const rawCandidates = [primaryModel, ...(fallbackModels ?? [])];
|
|
230
|
+
for (let index = 0; index < rawCandidates.length; index++) {
|
|
231
|
+
const raw = rawCandidates[index];
|
|
89
232
|
if (!raw) continue;
|
|
90
233
|
const normalized = resolveModelCandidate(raw.trim(), availableModels, preferredProvider);
|
|
91
234
|
if (!normalized || seen.has(normalized)) continue;
|
|
235
|
+
if (index > 0 && options?.scope?.enforce) {
|
|
236
|
+
const violation = checkModelScope(normalized, options.scope, "inherited");
|
|
237
|
+
if (violation) (options.onWarn ?? defaultScopeWarn)(violation);
|
|
238
|
+
}
|
|
92
239
|
seen.add(normalized);
|
|
93
240
|
candidates.push(normalized);
|
|
94
241
|
}
|
|
@@ -126,6 +273,10 @@ const RETRYABLE_MODEL_FAILURE_PATTERNS = [
|
|
|
126
273
|
/\b502\b/,
|
|
127
274
|
/\b503\b/,
|
|
128
275
|
/\b504\b/,
|
|
276
|
+
/cold.?start/i,
|
|
277
|
+
/empty response/i,
|
|
278
|
+
/no output/i,
|
|
279
|
+
/model.*(?:load|fail|error)/i,
|
|
129
280
|
];
|
|
130
281
|
|
|
131
282
|
export function isRetryableModelFailure(error: string | undefined): boolean {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional model-scope enforcement for subagent model resolution.
|
|
3
|
+
*
|
|
4
|
+
* When `subagents.modelScope.enforce` is enabled in settings, a resolved model
|
|
5
|
+
* that does not match any `allow` pattern is rejected. The severity depends on
|
|
6
|
+
* where the model came from: an explicit caller-supplied model (`--model`,
|
|
7
|
+
* tool-call `model`, or a TUI clarify pick) is a hard error, while a model
|
|
8
|
+
* inherited from agent frontmatter / `defaultModel` / the parent session only
|
|
9
|
+
* emits a warning so existing configurations keep working.
|
|
10
|
+
*
|
|
11
|
+
* The decision logic ({@link checkModelScope}) is a pure function of its
|
|
12
|
+
* inputs so it can be unit-tested without touching the filesystem or config.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { splitKnownThinkingSuffix } from "../../shared/model-info.ts";
|
|
16
|
+
|
|
17
|
+
export interface ModelScopeConfig {
|
|
18
|
+
enforce?: boolean;
|
|
19
|
+
/** Glob-style allow patterns (only `*` is special), matched against `provider/id`. */
|
|
20
|
+
allow?: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Where a resolved model originated, deciding enforcement severity. */
|
|
24
|
+
export type ModelSource = "explicit" | "inherited";
|
|
25
|
+
|
|
26
|
+
export interface ModelScopeViolation {
|
|
27
|
+
/** Resolved model id (without thinking suffix) that fell outside the scope. */
|
|
28
|
+
model: string;
|
|
29
|
+
severity: "warn" | "error";
|
|
30
|
+
message: string;
|
|
31
|
+
allowedPatterns: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stripThinkingSuffix(model: string): string {
|
|
35
|
+
return splitKnownThinkingSuffix(model).baseModel;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Escape RegExp specials except `*`, then turn `*` into `.*`. */
|
|
39
|
+
function globToRegExp(pattern: string): RegExp {
|
|
40
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
41
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Test whether a resolved model matches a single allow pattern. Both sides are
|
|
46
|
+
* compared case-insensitively against the full `provider/id` (thinking suffix
|
|
47
|
+
* stripped from the model).
|
|
48
|
+
*/
|
|
49
|
+
export function matchesScopePattern(model: string, pattern: string): boolean {
|
|
50
|
+
return globToRegExp(pattern).test(stripThinkingSuffix(model));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Pure scope decision. Returns a {@link ModelScopeViolation} when the model is
|
|
55
|
+
* out of scope and enforcement is on, otherwise `undefined`. Enforcement with
|
|
56
|
+
* no `allow` list is a no-op (the settings parser rejects that combination, but
|
|
57
|
+
* this stays defensive for callers that build configs programmatically).
|
|
58
|
+
*/
|
|
59
|
+
export function checkModelScope(
|
|
60
|
+
model: string | undefined,
|
|
61
|
+
scope: ModelScopeConfig | undefined,
|
|
62
|
+
source: ModelSource,
|
|
63
|
+
): ModelScopeViolation | undefined {
|
|
64
|
+
if (!model || !scope?.enforce) return undefined;
|
|
65
|
+
const allow = scope.allow;
|
|
66
|
+
if (!allow || allow.length === 0) return undefined;
|
|
67
|
+
if (allow.some((pattern) => matchesScopePattern(model, pattern))) return undefined;
|
|
68
|
+
|
|
69
|
+
const baseModel = stripThinkingSuffix(model);
|
|
70
|
+
const severity: ModelScopeViolation["severity"] = source === "explicit" ? "error" : "warn";
|
|
71
|
+
return {
|
|
72
|
+
model: baseModel,
|
|
73
|
+
severity,
|
|
74
|
+
allowedPatterns: allow,
|
|
75
|
+
message:
|
|
76
|
+
`Model '${baseModel}' is outside the configured subagent model scope. ` +
|
|
77
|
+
`Allowed patterns: ${allow.join(", ")}.`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Validate and normalize a raw `subagents.modelScope` value from settings.
|
|
83
|
+
* Throws a descriptive error for malformed configs (matching the surrounding
|
|
84
|
+
* settings-parsing style). Returns `undefined` when the field is absent.
|
|
85
|
+
*/
|
|
86
|
+
export function parseModelScopeConfig(
|
|
87
|
+
value: unknown,
|
|
88
|
+
meta: { filePath: string },
|
|
89
|
+
): ModelScopeConfig | undefined {
|
|
90
|
+
if (value === undefined) return undefined;
|
|
91
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
92
|
+
throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope'; expected an object.`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const input = value as Record<string, unknown>;
|
|
96
|
+
const config: ModelScopeConfig = {};
|
|
97
|
+
|
|
98
|
+
if ("enforce" in input) {
|
|
99
|
+
if (typeof input.enforce !== "boolean") {
|
|
100
|
+
throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.enforce'; expected a boolean.`);
|
|
101
|
+
}
|
|
102
|
+
config.enforce = input.enforce;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if ("allow" in input) {
|
|
106
|
+
if (!Array.isArray(input.allow)) {
|
|
107
|
+
throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected an array of strings.`);
|
|
108
|
+
}
|
|
109
|
+
const allow: string[] = [];
|
|
110
|
+
for (const entry of input.allow) {
|
|
111
|
+
if (typeof entry !== "string") {
|
|
112
|
+
throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected an array of strings.`);
|
|
113
|
+
}
|
|
114
|
+
const trimmed = entry.trim();
|
|
115
|
+
if (trimmed) allow.push(trimmed);
|
|
116
|
+
}
|
|
117
|
+
if (allow.length === 0) {
|
|
118
|
+
throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected a non-empty array of patterns.`);
|
|
119
|
+
}
|
|
120
|
+
config.allow = allow;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (config.enforce === true && (!config.allow || config.allow.length === 0)) {
|
|
124
|
+
throw new Error(`Subagent settings in '${meta.filePath}' set modelScope.enforce without a non-empty 'allow' list; supply allowed model patterns or disable enforcement.`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return Object.keys(config).length > 0 ? config : undefined;
|
|
128
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type AsyncJobState,
|
|
9
9
|
type AsyncStatus,
|
|
10
10
|
type NestedRouteInfo,
|
|
11
|
+
type TurnBudgetState,
|
|
11
12
|
type NestedRunSummary,
|
|
12
13
|
type NestedRunState,
|
|
13
14
|
type NestedStepSummary,
|
|
@@ -184,6 +185,35 @@ function sanitizeTokenUsage(value: unknown): NestedRunSummary["totalTokens"] | u
|
|
|
184
185
|
: undefined;
|
|
185
186
|
}
|
|
186
187
|
|
|
188
|
+
function sanitizeCost(value: unknown): NestedRunSummary["totalCost"] | undefined {
|
|
189
|
+
if (!value || typeof value !== "object") return undefined;
|
|
190
|
+
const raw = value as Record<string, unknown>;
|
|
191
|
+
const inputTokens = clampNumber(raw.inputTokens);
|
|
192
|
+
const outputTokens = clampNumber(raw.outputTokens);
|
|
193
|
+
const costUsd = clampNumber(raw.costUsd);
|
|
194
|
+
return inputTokens !== undefined && outputTokens !== undefined && costUsd !== undefined
|
|
195
|
+
? { inputTokens, outputTokens, costUsd }
|
|
196
|
+
: undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function sanitizeTurnBudget(value: unknown): TurnBudgetState | undefined {
|
|
200
|
+
if (!value || typeof value !== "object") return undefined;
|
|
201
|
+
const raw = value as Record<string, unknown>;
|
|
202
|
+
const maxTurns = clampNumber(raw.maxTurns);
|
|
203
|
+
const graceTurns = clampNumber(raw.graceTurns);
|
|
204
|
+
const turnCount = clampNumber(raw.turnCount);
|
|
205
|
+
const outcome = raw.outcome === "within-budget" || raw.outcome === "wrap-up-requested" || raw.outcome === "exceeded" ? raw.outcome : undefined;
|
|
206
|
+
if (maxTurns === undefined || graceTurns === undefined || turnCount === undefined || !outcome) return undefined;
|
|
207
|
+
return {
|
|
208
|
+
maxTurns,
|
|
209
|
+
graceTurns,
|
|
210
|
+
turnCount,
|
|
211
|
+
outcome,
|
|
212
|
+
...(clampNumber(raw.wrapUpRequestedAtTurn) !== undefined ? { wrapUpRequestedAtTurn: clampNumber(raw.wrapUpRequestedAtTurn) } : {}),
|
|
213
|
+
...(clampNumber(raw.exceededAtTurn) !== undefined ? { exceededAtTurn: clampNumber(raw.exceededAtTurn) } : {}),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
187
217
|
function sanitizeState(value: unknown, fallback: NestedRunState): NestedRunState {
|
|
188
218
|
return value === "queued" || value === "running" || value === "complete" || value === "failed" || value === "paused"
|
|
189
219
|
? value
|
|
@@ -212,6 +242,10 @@ function sanitizeStep(input: unknown, depth: number): NestedStepSummary | undefi
|
|
|
212
242
|
...(clampNumber(raw.startedAt) !== undefined ? { startedAt: clampNumber(raw.startedAt) } : {}),
|
|
213
243
|
...(clampNumber(raw.endedAt) !== undefined ? { endedAt: clampNumber(raw.endedAt) } : {}),
|
|
214
244
|
...(stringValue(raw.error, 1024) ? { error: stringValue(raw.error, 1024) } : {}),
|
|
245
|
+
...(raw.timedOut === true ? { timedOut: true } : {}),
|
|
246
|
+
...(sanitizeTurnBudget(raw.turnBudget) ? { turnBudget: sanitizeTurnBudget(raw.turnBudget) } : {}),
|
|
247
|
+
...(raw.turnBudgetExceeded === true ? { turnBudgetExceeded: true } : {}),
|
|
248
|
+
...(raw.wrapUpRequested === true ? { wrapUpRequested: true } : {}),
|
|
215
249
|
...(depth < MAX_DEPTH && Array.isArray(raw.children) ? { children: raw.children.map((child) => sanitizeSummary(child, depth + 1)).filter((child): child is NestedRunSummary => Boolean(child)).slice(0, MAX_CHILDREN) } : {}),
|
|
216
250
|
};
|
|
217
251
|
}
|
|
@@ -225,6 +259,7 @@ export function sanitizeSummary(input: unknown, depth = 0): NestedRunSummary | u
|
|
|
225
259
|
? raw.steps.map((step) => sanitizeStep(step, depth + 1)).filter((step): step is NestedStepSummary => Boolean(step)).slice(0, MAX_STEPS)
|
|
226
260
|
: undefined;
|
|
227
261
|
const totalTokens = sanitizeTokenUsage(raw.totalTokens);
|
|
262
|
+
const totalCost = sanitizeCost(raw.totalCost);
|
|
228
263
|
return {
|
|
229
264
|
id: raw.id,
|
|
230
265
|
parentRunId: raw.parentRunId,
|
|
@@ -256,9 +291,16 @@ export function sanitizeSummary(input: unknown, depth = 0): NestedRunSummary | u
|
|
|
256
291
|
...(clampNumber(raw.turnCount) !== undefined ? { turnCount: clampNumber(raw.turnCount) } : {}),
|
|
257
292
|
...(clampNumber(raw.toolCount) !== undefined ? { toolCount: clampNumber(raw.toolCount) } : {}),
|
|
258
293
|
...(totalTokens ? { totalTokens } : {}),
|
|
294
|
+
...(totalCost ? { totalCost } : {}),
|
|
259
295
|
...(clampNumber(raw.startedAt) !== undefined ? { startedAt: clampNumber(raw.startedAt) } : {}),
|
|
260
296
|
...(clampNumber(raw.endedAt) !== undefined ? { endedAt: clampNumber(raw.endedAt) } : {}),
|
|
261
297
|
...(clampNumber(raw.lastUpdate) !== undefined ? { lastUpdate: clampNumber(raw.lastUpdate) } : {}),
|
|
298
|
+
...(clampNumber(raw.timeoutMs) !== undefined ? { timeoutMs: clampNumber(raw.timeoutMs) } : {}),
|
|
299
|
+
...(clampNumber(raw.deadlineAt) !== undefined ? { deadlineAt: clampNumber(raw.deadlineAt) } : {}),
|
|
300
|
+
...(raw.timedOut === true ? { timedOut: true } : {}),
|
|
301
|
+
...(sanitizeTurnBudget(raw.turnBudget) ? { turnBudget: sanitizeTurnBudget(raw.turnBudget) } : {}),
|
|
302
|
+
...(raw.turnBudgetExceeded === true ? { turnBudgetExceeded: true } : {}),
|
|
303
|
+
...(raw.wrapUpRequested === true ? { wrapUpRequested: true } : {}),
|
|
262
304
|
...(stringValue(raw.error, 1024) ? { error: stringValue(raw.error, 1024) } : {}),
|
|
263
305
|
...(steps && steps.length > 0 ? { steps } : {}),
|
|
264
306
|
...(depth < MAX_DEPTH && Array.isArray(raw.children) ? { children: raw.children.map((child) => sanitizeSummary(child, depth + 1)).filter((child): child is NestedRunSummary => Boolean(child)).slice(0, MAX_CHILDREN) } : {}),
|
|
@@ -394,6 +436,42 @@ export function findNestedRouteForRootId(rootRunId: string): NestedRoute | undef
|
|
|
394
436
|
return undefined;
|
|
395
437
|
}
|
|
396
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Scan the nested-events directory once and index every route by its root run
|
|
441
|
+
* id. Use this when resolving routes for many runs (e.g. listAsyncRuns) so the
|
|
442
|
+
* cost is O(routes) total instead of O(runs * routes) from calling
|
|
443
|
+
* findNestedRouteForRootId per run.
|
|
444
|
+
*/
|
|
445
|
+
export function buildNestedRouteIndex(): Map<string, NestedRoute> {
|
|
446
|
+
let entries: string[];
|
|
447
|
+
try {
|
|
448
|
+
entries = fs.readdirSync(NESTED_EVENTS_DIR);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Map();
|
|
451
|
+
throw error;
|
|
452
|
+
}
|
|
453
|
+
const index = new Map<string, NestedRoute>();
|
|
454
|
+
for (const entry of entries) {
|
|
455
|
+
const routeRoot = path.join(NESTED_EVENTS_DIR, entry);
|
|
456
|
+
try {
|
|
457
|
+
const metadata = JSON.parse(fs.readFileSync(path.join(routeRoot, ROUTE_FILE), "utf-8")) as { rootRunId?: unknown; capabilityToken?: unknown };
|
|
458
|
+
if (typeof metadata.rootRunId !== "string" || typeof metadata.capabilityToken !== "string") continue;
|
|
459
|
+
if (index.has(metadata.rootRunId)) continue;
|
|
460
|
+
const route: NestedRoute = {
|
|
461
|
+
rootRunId: metadata.rootRunId,
|
|
462
|
+
eventSink: path.join(routeRoot, "events"),
|
|
463
|
+
controlInbox: path.join(routeRoot, "controls"),
|
|
464
|
+
capabilityToken: metadata.capabilityToken,
|
|
465
|
+
};
|
|
466
|
+
validateRouteShape(route);
|
|
467
|
+
index.set(metadata.rootRunId, route);
|
|
468
|
+
} catch {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return index;
|
|
473
|
+
}
|
|
474
|
+
|
|
397
475
|
export function projectNestedRegistryForRoot(rootRunId: string): NestedRegistry | undefined {
|
|
398
476
|
const route = findNestedRouteForRootId(rootRunId);
|
|
399
477
|
return route ? projectNestedEvents(route) : undefined;
|
|
@@ -778,6 +856,13 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
|
|
|
778
856
|
...(status.turnCount !== undefined ? { turnCount: status.turnCount } : {}),
|
|
779
857
|
...(status.toolCount !== undefined ? { toolCount: status.toolCount } : {}),
|
|
780
858
|
...(status.totalTokens ? { totalTokens: status.totalTokens } : {}),
|
|
859
|
+
...(status.timeoutMs !== undefined ? { timeoutMs: status.timeoutMs } : {}),
|
|
860
|
+
...(status.deadlineAt !== undefined ? { deadlineAt: status.deadlineAt } : {}),
|
|
861
|
+
...(status.timedOut !== undefined ? { timedOut: status.timedOut } : {}),
|
|
862
|
+
...(status.turnBudget ? { turnBudget: status.turnBudget } : {}),
|
|
863
|
+
...(status.turnBudgetExceeded !== undefined ? { turnBudgetExceeded: status.turnBudgetExceeded } : {}),
|
|
864
|
+
...(status.wrapUpRequested !== undefined ? { wrapUpRequested: status.wrapUpRequested } : {}),
|
|
865
|
+
...(status.error ? { error: status.error } : {}),
|
|
781
866
|
...(status.startedAt !== undefined ? { startedAt: status.startedAt } : { startedAt: fallback.ts }),
|
|
782
867
|
...(status.endedAt !== undefined ? { endedAt: status.endedAt } : {}),
|
|
783
868
|
lastUpdate: status.lastUpdate ?? fallback.ts,
|
|
@@ -796,6 +881,10 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
|
|
|
796
881
|
...(step.startedAt !== undefined ? { startedAt: step.startedAt } : {}),
|
|
797
882
|
...(step.endedAt !== undefined ? { endedAt: step.endedAt } : {}),
|
|
798
883
|
...(step.error ? { error: step.error } : {}),
|
|
884
|
+
...(step.timedOut !== undefined ? { timedOut: step.timedOut } : {}),
|
|
885
|
+
...(step.turnBudget ? { turnBudget: step.turnBudget } : {}),
|
|
886
|
+
...(step.turnBudgetExceeded !== undefined ? { turnBudgetExceeded: step.turnBudgetExceeded } : {}),
|
|
887
|
+
...(step.wrapUpRequested !== undefined ? { wrapUpRequested: step.wrapUpRequested } : {}),
|
|
799
888
|
})).slice(0, MAX_STEPS) } : {}),
|
|
800
889
|
};
|
|
801
890
|
}
|