pi-herdr-agents 2.0.4 → 3.0.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/AGENTS.md +20 -14
- package/CHANGELOG.md +63 -1
- package/CONTEXT.md +86 -32
- package/README.md +262 -141
- package/RELEASING.md +4 -2
- package/config.json.example +0 -3
- package/docs/README.md +38 -15
- package/docs/adr/0001-btw-ephemeral-side-questions.md +1 -1
- package/docs/adr/0002-agent-workflow-skill-runtime-taxonomy.md +1 -1
- package/docs/adr/0003-installable-role-packs.md +21 -13
- package/docs/adr/0009-remove-workflow-subsystem.md +1 -1
- package/docs/adr/0011-explicit-worktree-cleanup.md +1 -1
- package/docs/adr/0012-adopt-maestro-seams-in-repo.md +45 -0
- package/docs/adr/0013-pack-neutral-execution-host.md +63 -0
- package/docs/adr/0014-operator-cancel-terminal-intent.md +134 -0
- package/docs/worktree-subagents.md +10 -6
- package/maestro/adapters/fake/fake-harness-adapter.ts +263 -0
- package/{pi-extension/subagents/activity.ts → maestro/adapters/pi/activity-file.ts} +15 -72
- package/{pi-extension/subagents → maestro/adapters/pi/child}/subagent-done.ts +30 -3
- package/{pi-extension/subagents → maestro/adapters/pi}/completion.ts +4 -4
- package/{pi-extension/subagents → maestro/adapters/pi}/launch.ts +388 -285
- package/maestro/adapters/pi/model-registry.ts +46 -0
- package/maestro/adapters/pi/pi-harness-adapter.ts +701 -0
- package/maestro/adapters/pi/process-identity.ts +303 -0
- package/{pi-extension/subagents → maestro/adapters/pi}/session.ts +1 -13
- package/maestro/adapters/pi/task-model-init.ts +55 -0
- package/maestro/core/activity.ts +29 -0
- package/maestro/core/config/model-config.ts +473 -0
- package/{pi-extension/subagents → maestro/core/config}/pane-config.ts +4 -6
- package/{pi-extension/subagents → maestro/core/config}/persistent-config.ts +4 -6
- package/{pi-extension/subagents → maestro/core/config}/role-config.ts +15 -10
- package/{pi-extension/subagents → maestro/core/config}/supervision-config.ts +4 -6
- package/{pi-extension/subagents → maestro/core/config}/task-model-init.ts +45 -58
- package/maestro/core/config/task-model-types.ts +22 -0
- package/maestro/core/harness-adapter.ts +117 -0
- package/maestro/core/index.ts +3 -0
- package/{pi-extension/subagents → maestro/core}/lifecycle.ts +27 -126
- package/maestro/core/roles/discovery.ts +555 -0
- package/{pi-extension/subagents/runtime-routing.ts → maestro/core/routing.ts} +36 -43
- package/maestro/core/shell.ts +3 -0
- package/{pi-extension/subagents → maestro/core}/status.ts +3 -7
- package/{pi-extension/subagents → maestro/core}/supervision.ts +1 -1
- package/maestro/core/surface-provider.ts +185 -0
- package/maestro/core/types.ts +449 -0
- package/maestro/core/worktree-cleanup.ts +374 -0
- package/maestro/core/worktree.ts +93 -0
- package/maestro/runtime/index.ts +44 -0
- package/maestro/runtime/pi-run-session.ts +1210 -0
- package/maestro/runtime/run-session.ts +801 -0
- package/maestro/runtime/surface-retention.ts +6 -0
- package/maestro/runtime/task-model-init.ts +22 -0
- package/maestro/runtime/worktree-operations.ts +627 -0
- package/maestro/surfaces/fake/fake-surface-provider.ts +311 -0
- package/maestro/surfaces/herdr/herdr-surface-provider.ts +200 -0
- package/{pi-extension/subagents → maestro/surfaces/herdr}/herdr.ts +65 -14
- package/{pi-extension/subagents → maestro/surfaces/herdr}/terminal.ts +7 -5
- package/package.json +10 -9
- package/pi-extension/subagents/config-path.ts +5 -1
- package/pi-extension/subagents/index.ts +848 -1805
- package/pi-extension/subagents/model-registry.ts +46 -0
- package/skills/pi-herdr-agents/SKILL.md +276 -0
- package/agents/adversarial-reviewer.md +0 -153
- package/agents/planner.md +0 -87
- package/agents/poteto.md +0 -32
- package/agents/reviewer.md +0 -132
- package/agents/scout.md +0 -105
- package/agents/visual-tester.md +0 -234
- package/agents/worker.md +0 -121
- package/docs/review-evaluation.md +0 -183
- package/pi-extension/subagents/model-config.ts +0 -305
- package/pi-extension/subagents/plan-skill.md +0 -280
- package/pi-extension/subagents/worktree-cleanup.ts +0 -789
- package/skills/orchestrate/SKILL.md +0 -91
- package/skills/orchestrate/adversarial-review-example.js +0 -400
- package/skills/orchestrate/adversarial-review.md +0 -87
- /package/{pi-extension/subagents → maestro/core/config}/type-guards.ts +0 -0
- /package/{pi-extension/subagents → maestro/core}/wake.ts +0 -0
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
# Evaluate adversarial code review
|
|
2
|
-
|
|
3
|
-
This small corpus compares review strategies against known changes. It does not
|
|
4
|
-
measure the workflow runtime or establish that any strategy is optimal.
|
|
5
|
-
|
|
6
|
-
## Use the corpus
|
|
7
|
-
|
|
8
|
-
[`../test/evals/cases.public.json`](../test/evals/cases.public.json) contains
|
|
9
|
-
eight before/after source pairs. Case IDs (`case-NN`) and titles (`Scenario NN`)
|
|
10
|
-
are intentionally neutral. They are evaluator metadata, not reviewer input.
|
|
11
|
-
|
|
12
|
-
When you call a reviewer, send **only** these public fields:
|
|
13
|
-
|
|
14
|
-
```json
|
|
15
|
-
{
|
|
16
|
-
"reviewTask": "...",
|
|
17
|
-
"spec": "...",
|
|
18
|
-
"source": { "path": "target.mjs", "before": "...", "after": "..." }
|
|
19
|
-
}
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
Do not send [`../test/evals/oracle.json`](../test/evals/oracle.json). It holds
|
|
23
|
-
private expected finding IDs, descriptions, rationales, and ground-truth
|
|
24
|
-
programs. The public validator rejects unknown public or source keys, including
|
|
25
|
-
`expectedFindingIds` and `label`, so labels cannot be added to reviewer input by
|
|
26
|
-
accident.
|
|
27
|
-
|
|
28
|
-
The corpus covers correctness, trust boundaries, lifecycle/concurrency, two
|
|
29
|
-
clean counterexamples, hostile instructions embedded in source, and missing
|
|
30
|
-
validation evidence. The limit contract explicitly clamps finite values outside
|
|
31
|
-
`1..100`; only non-finite values default to `20`.
|
|
32
|
-
|
|
33
|
-
The legacy prompt fixture,
|
|
34
|
-
[`../test/evals/legacy-adversarial-reviewer.md`](../test/evals/legacy-adversarial-reviewer.md),
|
|
35
|
-
is pinned text copied from `agents/adversarial-reviewer.md` when the corpus was
|
|
36
|
-
added. Keep it unchanged for historical comparisons.
|
|
37
|
-
|
|
38
|
-
Validate the corpus and run dependency-free self-tests. These commands require
|
|
39
|
-
Node.js 22.6.0 or later, which supports `--experimental-strip-types`:
|
|
40
|
-
|
|
41
|
-
```bash
|
|
42
|
-
node --experimental-strip-types test/evals/validate.mjs
|
|
43
|
-
node --experimental-strip-types --test test/evals/*.test.mjs
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
The validator imports each source version before it runs the ground-truth
|
|
47
|
-
program. A real-finding case must pass before and fail after with a Node
|
|
48
|
-
`AssertionError [ERR_ASSERTION]`. A clean case must pass in both versions.
|
|
49
|
-
Syntax errors, unintended throws, signals, timeouts, spawn errors, and other
|
|
50
|
-
non-assertion failures reject the corpus and retain child stdout/stderr in the
|
|
51
|
-
diagnostic. Each child has a three-second bound. These tests prove only the
|
|
52
|
-
included trigger, not the absence of every other defect.
|
|
53
|
-
|
|
54
|
-
`.npmignore` excludes `test/`, so fixtures do not enter the package preview.
|
|
55
|
-
`docs/review-evaluation.md` is intentional package documentation. If the
|
|
56
|
-
`test/` exclusion changes, add an explicit `test/evals/` exclusion before
|
|
57
|
-
publishing; this deliverable does not change packaging configuration.
|
|
58
|
-
|
|
59
|
-
## Score captured results
|
|
60
|
-
|
|
61
|
-
A human adjudicator maps each concrete reported issue to one private oracle ID
|
|
62
|
-
(`F01` through `F06`) only when it matches that finding's private description.
|
|
63
|
-
For each false positive, use a stable ID scoped to the case, such as
|
|
64
|
-
`case-06-fp-01`; retain a pointer to the raw report in the capture system. This
|
|
65
|
-
makes adjudication unambiguous without exact-prose matching or an LLM judge.
|
|
66
|
-
|
|
67
|
-
A result file declares the cases and trial IDs it intends to cover. Every
|
|
68
|
-
strategy uses the same plan. Trial IDs allow repeated trials while rejecting a
|
|
69
|
-
duplicate result for the same case/trial pair.
|
|
70
|
-
|
|
71
|
-
```json
|
|
72
|
-
{
|
|
73
|
-
"version": 1,
|
|
74
|
-
"plan": {
|
|
75
|
-
"caseIds": ["case-01", "case-02", "case-06"],
|
|
76
|
-
"trialIds": ["trial-01", "trial-02"]
|
|
77
|
-
},
|
|
78
|
-
"strategies": [
|
|
79
|
-
{
|
|
80
|
-
"name": "two-reviewers-plus-synthesis",
|
|
81
|
-
"runs": [
|
|
82
|
-
{
|
|
83
|
-
"caseId": "case-01",
|
|
84
|
-
"trialId": "trial-01",
|
|
85
|
-
"complete": true,
|
|
86
|
-
"findings": [{ "id": "F01", "disposition": "verified" }],
|
|
87
|
-
"metrics": {
|
|
88
|
-
"latencyMs": 12400,
|
|
89
|
-
"inputTokens": 950,
|
|
90
|
-
"outputTokens": 260,
|
|
91
|
-
"totalTokens": 1210
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
]
|
|
95
|
-
}
|
|
96
|
-
]
|
|
97
|
-
}
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
`plan.caseIds` must include at least one clean and one finding case.
|
|
101
|
-
`complete` means the captured reviewer output met its reporting/evidence
|
|
102
|
-
contract. It is not a candidate verdict. Findings use `verified`,
|
|
103
|
-
`false_positive`, or `unresolved` dispositions. Omit `metrics` when the
|
|
104
|
-
provider did not supply a value; do not infer or manufacture telemetry.
|
|
105
|
-
|
|
106
|
-
Run the scorer:
|
|
107
|
-
|
|
108
|
-
```bash
|
|
109
|
-
node --experimental-strip-types test/evals/score.mjs path/to/captured-results.json
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
For each strategy, the score includes:
|
|
113
|
-
|
|
114
|
-
- precision: verified claims divided by verified plus false-positive claims;
|
|
115
|
-
- recall over all planned finding opportunities, including omitted trials;
|
|
116
|
-
- `findings.verified`: reproducible, human-adjudicated findings;
|
|
117
|
-
- deduplicated unique verified and expected findings;
|
|
118
|
-
- clean-case false-positive rate over submitted clean runs (`null` when none are submitted);
|
|
119
|
-
- planned completeness and observed completeness; and
|
|
120
|
-
- coverage with omitted case/trial pairs, omitted cases, and submitted/omitted
|
|
121
|
-
counts for clean and finding classes.
|
|
122
|
-
|
|
123
|
-
Metric summaries give observations, total, and mean only for supplied latency
|
|
124
|
-
or token values. Missing data remains missing. The scorer rejects malformed
|
|
125
|
-
metric arrays, invalid metrics, unknown verified IDs, duplicated oracle IDs,
|
|
126
|
-
and duplicated case/trial records.
|
|
127
|
-
|
|
128
|
-
## Compare strategies
|
|
129
|
-
|
|
130
|
-
Use the same public reviewer payload, exact models, tools, system prompts, and
|
|
131
|
-
adjudication contract for each strategy:
|
|
132
|
-
|
|
133
|
-
1. One fresh reviewer.
|
|
134
|
-
2. The textual legacy `3 Optimizer + 3 Skeptic + 1 synthesis` baseline.
|
|
135
|
-
3. The routine proposal: two fresh reviewers and one fresh synthesis pass.
|
|
136
|
-
4. The high-risk proposal: three lens reviewers, targeted cross-family
|
|
137
|
-
verifier passes for high-risk claims, and one fresh synthesis pass.
|
|
138
|
-
|
|
139
|
-
The legacy fixture describes a theoretical offline `3+3+1` topology. It is not
|
|
140
|
-
a runnable baseline in the current spawned-coordinator lifecycle: child starts
|
|
141
|
-
are asynchronous, the coordinator lacks an adapter that awaits and gathers all
|
|
142
|
-
six reports before later stages, and `auto-exit` can end its turn. Do not claim
|
|
143
|
-
a runnable legacy result without that adapter. Label any measurement of the
|
|
144
|
-
fixture as an offline simulation.
|
|
145
|
-
|
|
146
|
-
Before tuning prompts, create a stratified split. This corpus uses
|
|
147
|
-
`case-01`, `case-02`, `case-04`, and `case-06` for tuning, and `case-03`,
|
|
148
|
-
`case-05`, `case-07`, and `case-08` for holdout. Each split has clean and
|
|
149
|
-
finding cases. Keep the holdout sealed until the prompt is selected.
|
|
150
|
-
|
|
151
|
-
For each split:
|
|
152
|
-
|
|
153
|
-
1. Pin exact model IDs and versions where available, reviewer/synthesizer
|
|
154
|
-
prompts, tools, and public payloads.
|
|
155
|
-
2. Use the same trial IDs for every strategy and rotate strategy order inside
|
|
156
|
-
each case/trial block.
|
|
157
|
-
3. Match **total** token budgets across strategies: sum all reviewer, verifier,
|
|
158
|
-
and synthesis budgets. Per-reviewer budgets alone favor strategies that
|
|
159
|
-
launch more calls. Report both the total budget and its allocation.
|
|
160
|
-
4. Preserve raw reviewer and synthesis outputs, timestamps, run IDs, and only
|
|
161
|
-
provider-reported latency/token fields. Redact secrets before storage.
|
|
162
|
-
5. Blind the adjudicator to strategy name, score the captures, and report raw
|
|
163
|
-
counts, coverage, and absent telemetry beside rates.
|
|
164
|
-
6. Evaluate the sealed holdout only after prompt selection. Treat it as an
|
|
165
|
-
estimate for this corpus, not a general performance claim.
|
|
166
|
-
|
|
167
|
-
Actual model measurements require ordinary exact workflow preparation and user
|
|
168
|
-
approval. Prepare the workflow, show its approval packet, and wait for the
|
|
169
|
-
exact approval before starting it. This corpus does not authorize live models
|
|
170
|
-
or approved workflow execution.
|
|
171
|
-
|
|
172
|
-
## Evidence and limits
|
|
173
|
-
|
|
174
|
-
This procedure follows vendor guidance to define task-specific ground truth,
|
|
175
|
-
retain traces, and evaluate agent behavior end to end. See Anthropic's
|
|
176
|
-
[Demystifying evals for AI agents](https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents)
|
|
177
|
-
and OpenAI's [agent evals guide](https://developers.openai.com/api/docs/guides/agent-evals).
|
|
178
|
-
|
|
179
|
-
Diverse passes are a hypothesis, not an independence guarantee. Kim et al.,
|
|
180
|
-
[Correlated Errors in Large Language Models](https://proceedings.mlr.press/v267/kim25e.html)
|
|
181
|
-
(ICML 2025) provides direct evidence that LLM errors can be correlated. It does
|
|
182
|
-
not measure this package or code review, so this corpus measures misses and
|
|
183
|
-
false positives instead of assuming provider or lens diversity solves them.
|
|
@@ -1,305 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
import { getSubagentsConfigPath } from "./config-path.ts";
|
|
4
|
-
import { isPlainObject, isString } from "./type-guards.ts";
|
|
5
|
-
|
|
6
|
-
export const TASK_CATEGORIES = [
|
|
7
|
-
"coding",
|
|
8
|
-
"review",
|
|
9
|
-
"recon",
|
|
10
|
-
"qa",
|
|
11
|
-
"architecture",
|
|
12
|
-
"docs",
|
|
13
|
-
] as const;
|
|
14
|
-
export type TaskCategory = (typeof TASK_CATEGORIES)[number];
|
|
15
|
-
export const TASK_CATEGORY_DESCRIPTIONS = {
|
|
16
|
-
coding: "Implementation workers",
|
|
17
|
-
review: "Code reviewers",
|
|
18
|
-
recon: "Reconnaissance scouts",
|
|
19
|
-
qa: "Software and test runners",
|
|
20
|
-
architecture: "Planning and diagnosis",
|
|
21
|
-
docs: "Documentation workers",
|
|
22
|
-
} satisfies Record<TaskCategory, string>;
|
|
23
|
-
export type TaskPreferences = Partial<Record<TaskCategory, string[]>>;
|
|
24
|
-
export interface TaskPreferencesMeta {
|
|
25
|
-
generatedAt: string;
|
|
26
|
-
method: "research" | "registry-only";
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface ModelConfig {
|
|
30
|
-
default?: string;
|
|
31
|
-
agents: Record<string, string>;
|
|
32
|
-
tasks?: TaskPreferences;
|
|
33
|
-
tasksMeta?: TaskPreferencesMeta;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function invalidModelConfig(source: string, message: string): never {
|
|
37
|
-
throw new Error(`Invalid subagent model config in ${source}: ${message}`);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function rejectTaskReference(
|
|
41
|
-
value: string,
|
|
42
|
-
field: string,
|
|
43
|
-
source: string,
|
|
44
|
-
): void {
|
|
45
|
-
if (value.trim().toLowerCase().startsWith("task:")) {
|
|
46
|
-
invalidModelConfig(
|
|
47
|
-
source,
|
|
48
|
-
`${field} cannot use task: references; task: references are only valid in the subagent tool's model parameter`,
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function parseTasks(value: any, source: string): TaskPreferences | undefined {
|
|
54
|
-
if (value == null) return undefined;
|
|
55
|
-
if (!isPlainObject(value))
|
|
56
|
-
invalidModelConfig(source, "models.tasks must be an object");
|
|
57
|
-
const keys = Object.keys(value);
|
|
58
|
-
if (keys.length === 0) return undefined;
|
|
59
|
-
const unsupported = keys.filter(
|
|
60
|
-
// SAFETY: this check only compares strings against the fixed category set.
|
|
61
|
-
(key) => !TASK_CATEGORIES.includes(key as TaskCategory),
|
|
62
|
-
);
|
|
63
|
-
if (unsupported.length > 0) {
|
|
64
|
-
invalidModelConfig(
|
|
65
|
-
source,
|
|
66
|
-
`models.tasks.${unsupported[0]} is unsupported; supported categories: ${TASK_CATEGORIES.join(", ")}`,
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
const tasks: TaskPreferences = {};
|
|
70
|
-
for (const category of TASK_CATEGORIES) {
|
|
71
|
-
if (!Object.hasOwn(value, category)) continue;
|
|
72
|
-
const candidates = value[category];
|
|
73
|
-
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
74
|
-
invalidModelConfig(
|
|
75
|
-
source,
|
|
76
|
-
`models.tasks.${category} must be a non-empty list`,
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
const seen = new Set<string>();
|
|
80
|
-
tasks[category] = candidates.map((candidate, index) => {
|
|
81
|
-
if (!isString(candidate) || candidate.trim() === "") {
|
|
82
|
-
invalidModelConfig(
|
|
83
|
-
source,
|
|
84
|
-
`models.tasks.${category}[${index}] must be a non-empty string`,
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
const reference = candidate.trim();
|
|
88
|
-
if (seen.has(reference)) {
|
|
89
|
-
invalidModelConfig(
|
|
90
|
-
source,
|
|
91
|
-
`models.tasks.${category} has duplicate candidate ${JSON.stringify(reference)}`,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
seen.add(reference);
|
|
95
|
-
return reference;
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
return tasks;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function parseTasksMeta(
|
|
102
|
-
value: any,
|
|
103
|
-
source: string,
|
|
104
|
-
): TaskPreferencesMeta | undefined {
|
|
105
|
-
if (value == null) return undefined;
|
|
106
|
-
if (!isPlainObject(value))
|
|
107
|
-
invalidModelConfig(source, "models.tasksMeta must be an object");
|
|
108
|
-
const unsupported = Object.keys(value).filter(
|
|
109
|
-
(key) => key !== "generatedAt" && key !== "method",
|
|
110
|
-
);
|
|
111
|
-
if (unsupported.length > 0) {
|
|
112
|
-
invalidModelConfig(
|
|
113
|
-
source,
|
|
114
|
-
`models.tasksMeta has unsupported key(s): ${unsupported.join(", ")}`,
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
if (
|
|
118
|
-
!isString(value.generatedAt) ||
|
|
119
|
-
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(
|
|
120
|
-
value.generatedAt,
|
|
121
|
-
) ||
|
|
122
|
-
Number.isNaN(Date.parse(value.generatedAt))
|
|
123
|
-
) {
|
|
124
|
-
invalidModelConfig(
|
|
125
|
-
source,
|
|
126
|
-
"models.tasksMeta.generatedAt must be an ISO-8601 string",
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
if (value.method !== "research" && value.method !== "registry-only") {
|
|
130
|
-
invalidModelConfig(
|
|
131
|
-
source,
|
|
132
|
-
'models.tasksMeta.method must be "research" or "registry-only"',
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
return { generatedAt: value.generatedAt, method: value.method };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export function parseModelConfig(
|
|
139
|
-
rawConfig: any,
|
|
140
|
-
source = "config.json",
|
|
141
|
-
): ModelConfig {
|
|
142
|
-
if (!isPlainObject(rawConfig))
|
|
143
|
-
invalidModelConfig(source, "root must be an object");
|
|
144
|
-
const models = rawConfig.models;
|
|
145
|
-
if (models == null) return { agents: {} };
|
|
146
|
-
if (!isPlainObject(models))
|
|
147
|
-
invalidModelConfig(source, "models must be an object");
|
|
148
|
-
const allowedKeys = new Set(["default", "agents", "tasks", "tasksMeta"]);
|
|
149
|
-
const unsupportedKeys = Object.keys(models).filter(
|
|
150
|
-
(key) => !allowedKeys.has(key),
|
|
151
|
-
);
|
|
152
|
-
if (unsupportedKeys.length > 0)
|
|
153
|
-
invalidModelConfig(
|
|
154
|
-
source,
|
|
155
|
-
`models has unsupported key(s): ${unsupportedKeys.join(", ")}`,
|
|
156
|
-
);
|
|
157
|
-
|
|
158
|
-
let defaultModel: string | undefined;
|
|
159
|
-
if (models.default != null) {
|
|
160
|
-
if (!isString(models.default) || models.default.trim() === "")
|
|
161
|
-
invalidModelConfig(source, "models.default must be a non-empty string");
|
|
162
|
-
const trimmedDefault = models.default.trim();
|
|
163
|
-
defaultModel = trimmedDefault;
|
|
164
|
-
rejectTaskReference(trimmedDefault, "models.default", source);
|
|
165
|
-
}
|
|
166
|
-
const agents: Record<string, string> = {};
|
|
167
|
-
if (models.agents != null) {
|
|
168
|
-
if (!isPlainObject(models.agents))
|
|
169
|
-
invalidModelConfig(source, "models.agents must be an object");
|
|
170
|
-
for (const [agent, model] of Object.entries(models.agents)) {
|
|
171
|
-
if (!isString(model) || model.trim() === "")
|
|
172
|
-
invalidModelConfig(
|
|
173
|
-
source,
|
|
174
|
-
`models.agents.${agent} must be a non-empty string`,
|
|
175
|
-
);
|
|
176
|
-
const trimmed = model.trim();
|
|
177
|
-
rejectTaskReference(trimmed, `models.agents.${agent}`, source);
|
|
178
|
-
Object.defineProperty(agents, agent, {
|
|
179
|
-
value: trimmed,
|
|
180
|
-
enumerable: true,
|
|
181
|
-
writable: true,
|
|
182
|
-
configurable: true,
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
const tasks = parseTasks(models.tasks, source);
|
|
187
|
-
const tasksMeta = parseTasksMeta(models.tasksMeta, source);
|
|
188
|
-
const config: ModelConfig = { agents };
|
|
189
|
-
if (defaultModel) config.default = defaultModel;
|
|
190
|
-
if (tasks) config.tasks = tasks;
|
|
191
|
-
if (tasksMeta) config.tasksMeta = tasksMeta;
|
|
192
|
-
return config;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export function resolveModelDefault(
|
|
196
|
-
agentName: string | undefined,
|
|
197
|
-
agentModel: string | undefined,
|
|
198
|
-
config: ModelConfig,
|
|
199
|
-
): string | undefined {
|
|
200
|
-
if (agentModel) return agentModel;
|
|
201
|
-
if (agentName && Object.hasOwn(config.agents, agentName))
|
|
202
|
-
return config.agents[agentName];
|
|
203
|
-
return config.default;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
export function loadModelConfig(
|
|
207
|
-
configPath = getSubagentsConfigPath(),
|
|
208
|
-
): ModelConfig {
|
|
209
|
-
let raw: string;
|
|
210
|
-
try {
|
|
211
|
-
raw = readFileSync(configPath, "utf8");
|
|
212
|
-
} catch (error) {
|
|
213
|
-
// SAFETY: readFileSync errors expose the Node errno code.
|
|
214
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
215
|
-
return { agents: {} };
|
|
216
|
-
throw error;
|
|
217
|
-
}
|
|
218
|
-
try {
|
|
219
|
-
return parseModelConfig(JSON.parse(raw), configPath);
|
|
220
|
-
} catch (error) {
|
|
221
|
-
if (error instanceof SyntaxError)
|
|
222
|
-
throw new Error(
|
|
223
|
-
`Invalid JSON in subagent model config ${configPath}: ${error.message}`,
|
|
224
|
-
);
|
|
225
|
-
throw error;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
export interface SavedTaskModelConfig {
|
|
230
|
-
configPath: string;
|
|
231
|
-
tasks: TaskPreferences;
|
|
232
|
-
tasksMeta: TaskPreferencesMeta | undefined;
|
|
233
|
-
missingCategories: TaskCategory[];
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** Atomically replace only models.tasks and models.tasksMeta in the durable user config. */
|
|
237
|
-
export function writeTaskModelConfig(
|
|
238
|
-
configPath: string,
|
|
239
|
-
examplePath: string,
|
|
240
|
-
tasks: TaskPreferences,
|
|
241
|
-
tasksMeta: TaskPreferencesMeta,
|
|
242
|
-
isAuthenticatedCandidate: (candidate: string) => boolean,
|
|
243
|
-
fileOperations: Pick<
|
|
244
|
-
typeof import("node:fs"),
|
|
245
|
-
"renameSync" | "writeFileSync"
|
|
246
|
-
> = { renameSync, writeFileSync },
|
|
247
|
-
): SavedTaskModelConfig {
|
|
248
|
-
const candidateConfig = parseModelConfig(
|
|
249
|
-
{ models: { tasks, tasksMeta } },
|
|
250
|
-
configPath,
|
|
251
|
-
);
|
|
252
|
-
for (const candidates of Object.values(candidateConfig.tasks ?? {})) {
|
|
253
|
-
for (const candidate of candidates) {
|
|
254
|
-
if (!isAuthenticatedCandidate(candidate)) {
|
|
255
|
-
throw new Error(
|
|
256
|
-
`Task model candidate ${JSON.stringify(candidate)} is not an authenticated exact registry model`,
|
|
257
|
-
);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
mkdirSync(dirname(configPath), { recursive: true });
|
|
262
|
-
const current = readFileIfExists(configPath);
|
|
263
|
-
const source = current ?? readFileSync(examplePath, "utf8");
|
|
264
|
-
let parsed: any;
|
|
265
|
-
try {
|
|
266
|
-
parsed = JSON.parse(source);
|
|
267
|
-
} catch (error) {
|
|
268
|
-
const path = current == null ? examplePath : configPath;
|
|
269
|
-
throw new Error(
|
|
270
|
-
`Invalid JSON in subagent config ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
if (!isPlainObject(parsed))
|
|
274
|
-
throw new Error(
|
|
275
|
-
`Invalid JSON in subagent config ${configPath}: root must be an object`,
|
|
276
|
-
);
|
|
277
|
-
const models = isPlainObject(parsed.models) ? { ...parsed.models } : {};
|
|
278
|
-
models.tasks = candidateConfig.tasks;
|
|
279
|
-
models.tasksMeta = candidateConfig.tasksMeta;
|
|
280
|
-
const output = JSON.stringify({ ...parsed, models }, null, 2) + "\n";
|
|
281
|
-
const temporary = join(
|
|
282
|
-
dirname(configPath),
|
|
283
|
-
`.${Date.now()}-${process.pid}-config.tmp`,
|
|
284
|
-
);
|
|
285
|
-
fileOperations.writeFileSync(temporary, output, { flag: "wx" });
|
|
286
|
-
fileOperations.renameSync(temporary, configPath);
|
|
287
|
-
return {
|
|
288
|
-
configPath,
|
|
289
|
-
tasks: candidateConfig.tasks ?? {},
|
|
290
|
-
tasksMeta: candidateConfig.tasksMeta,
|
|
291
|
-
missingCategories: TASK_CATEGORIES.filter(
|
|
292
|
-
(category) => !candidateConfig.tasks?.[category],
|
|
293
|
-
),
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function readFileIfExists(path: string): string | undefined {
|
|
298
|
-
try {
|
|
299
|
-
return readFileSync(path, "utf8");
|
|
300
|
-
} catch (error) {
|
|
301
|
-
// SAFETY: readFileSync errors expose the Node errno code.
|
|
302
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
303
|
-
throw error;
|
|
304
|
-
}
|
|
305
|
-
}
|