@maxiaochao/pi-toolkit 0.9.6 → 0.10.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 +6 -0
- package/README.md +1 -1
- package/package.json +5 -5
- package/packages/herdr-subagents/CHANGELOG.md +41 -0
- package/packages/herdr-subagents/README.md +155 -0
- package/packages/herdr-subagents/TESTING.md +24 -0
- package/packages/herdr-subagents/agents/explorer.md +8 -0
- package/packages/herdr-subagents/agents/reviewer.md +8 -0
- package/packages/{tiny-subagent → herdr-subagents}/agents/worker.md +1 -0
- package/packages/herdr-subagents/docs/demo.gif +0 -0
- package/packages/herdr-subagents/docs/screenshot.png +0 -0
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +258 -0
- package/packages/herdr-subagents/extensions/subagent-report.ts +65 -0
- package/packages/{tiny-subagent → herdr-subagents}/package.json +7 -6
- package/packages/herdr-subagents/src/batch-runner.ts +219 -0
- package/packages/herdr-subagents/src/blocking.ts +175 -0
- package/packages/herdr-subagents/src/config.ts +125 -0
- package/packages/herdr-subagents/src/continue-run.ts +87 -0
- package/packages/herdr-subagents/src/dispatcher.ts +73 -0
- package/packages/herdr-subagents/src/errors.ts +3 -0
- package/packages/herdr-subagents/src/fleet-controller.ts +82 -0
- package/packages/herdr-subagents/src/fleet.ts +56 -0
- package/packages/herdr-subagents/src/herdr.ts +224 -0
- package/packages/herdr-subagents/src/history.ts +210 -0
- package/packages/herdr-subagents/src/live-agent.ts +87 -0
- package/packages/herdr-subagents/src/monitor.ts +18 -0
- package/packages/herdr-subagents/src/ownership.ts +17 -0
- package/packages/herdr-subagents/src/parameters.ts +21 -0
- package/packages/{tiny-subagent → herdr-subagents}/src/personas.ts +13 -2
- package/packages/herdr-subagents/src/protocol.ts +23 -0
- package/packages/herdr-subagents/src/queued-runs.ts +70 -0
- package/packages/herdr-subagents/src/reconcile.ts +167 -0
- package/packages/herdr-subagents/src/records.ts +213 -0
- package/packages/herdr-subagents/src/result-path.ts +7 -0
- package/packages/herdr-subagents/src/run-locator.ts +19 -0
- package/packages/herdr-subagents/src/run-pane-allocator.ts +78 -0
- package/packages/herdr-subagents/src/run-status.ts +23 -0
- package/packages/herdr-subagents/src/runner.ts +250 -0
- package/packages/herdr-subagents/src/state.ts +11 -0
- package/packages/herdr-subagents/src/tab-cleanup.ts +68 -0
- package/packages/herdr-subagents/src/tool.ts +217 -0
- package/packages/herdr-subagents/src/validation.ts +38 -0
- package/packages/herdr-subagents/tests/extension-contract.ts +225 -0
- package/packages/herdr-subagents/tests/run-tests.mjs +889 -0
- package/packages/todo/extensions/todo.ts +1 -1
- package/packages/tiny-subagent/CHANGELOG.md +0 -12
- package/packages/tiny-subagent/README.md +0 -128
- package/packages/tiny-subagent/TESTING.md +0 -20
- package/packages/tiny-subagent/docs/demo.gif +0 -0
- package/packages/tiny-subagent/docs/screenshot.png +0 -0
- package/packages/tiny-subagent/extensions/tiny-subagent.ts +0 -228
- package/packages/tiny-subagent/src/runner.ts +0 -319
- package/packages/tiny-subagent/tests/run-tests.mjs +0 -111
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { resultPathForAttempt } from "../src/result-path.ts";
|
|
7
|
+
|
|
8
|
+
const ReportParams = Type.Object({
|
|
9
|
+
status: StringEnum(["completed", "needs-input", "failed"] as const, { description: "Current outcome for this task." }),
|
|
10
|
+
summary: Type.String({ minLength: 1, description: "One concise paragraph for the parent agent." }),
|
|
11
|
+
result: Type.String({ description: "Complete final result. Stored on disk and excluded from the parent context." }),
|
|
12
|
+
documents: Type.Optional(Type.Array(Type.Object({
|
|
13
|
+
path: Type.String({ minLength: 1, description: "Path to a document produced or consulted by this task." }),
|
|
14
|
+
description: Type.String({ minLength: 1, description: "Short description of the document." }),
|
|
15
|
+
}))),
|
|
16
|
+
error: Type.Optional(Type.String({ minLength: 1, description: "Failure detail when status is failed." })),
|
|
17
|
+
question: Type.Optional(Type.String({ minLength: 1, description: "Exact question when status is needs-input." })),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function writeAtomic(path: string, content: string): void {
|
|
21
|
+
const temporary = `${path}.tmp-${process.pid}`;
|
|
22
|
+
writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600 });
|
|
23
|
+
renameSync(temporary, path);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function (pi: ExtensionAPI) {
|
|
27
|
+
pi.registerTool({
|
|
28
|
+
name: "subagent_report",
|
|
29
|
+
label: "Subagent Report",
|
|
30
|
+
description: "Finish this delegated task by saving its complete result and returning only a compact structured report to the parent.",
|
|
31
|
+
parameters: ReportParams,
|
|
32
|
+
async execute(_toolCallId, params) {
|
|
33
|
+
const taskDirectory = process.env.PI_HERDR_SUBAGENTS_TASK_DIR?.trim();
|
|
34
|
+
if (!taskDirectory) throw new Error("PI_HERDR_SUBAGENTS_TASK_DIR is not set");
|
|
35
|
+
mkdirSync(taskDirectory, { recursive: true, mode: 0o700 });
|
|
36
|
+
let attempt = 1;
|
|
37
|
+
const task = JSON.parse(readFileSync(resolve(taskDirectory, "task.json"), "utf8")) as { attempt?: unknown };
|
|
38
|
+
if (Number.isInteger(task.attempt) && Number(task.attempt) > 1) attempt = Number(task.attempt);
|
|
39
|
+
const resultPath = resultPathForAttempt(taskDirectory, attempt);
|
|
40
|
+
if (attempt > 1) mkdirSync(resolve(taskDirectory, "turns"), { recursive: true, mode: 0o700 });
|
|
41
|
+
writeAtomic(resultPath, params.result);
|
|
42
|
+
const documents = (params.documents ?? []).map((document) => ({
|
|
43
|
+
path: resolve(process.cwd(), document.path),
|
|
44
|
+
description: document.description,
|
|
45
|
+
}));
|
|
46
|
+
if (!documents.some((document) => document.path === resultPath)) {
|
|
47
|
+
documents.unshift({ path: resultPath, description: "Complete task result" });
|
|
48
|
+
}
|
|
49
|
+
const report = {
|
|
50
|
+
status: params.status,
|
|
51
|
+
summary: params.summary,
|
|
52
|
+
documents,
|
|
53
|
+
...(params.error ? { error: params.error } : {}),
|
|
54
|
+
...(params.question ? { question: params.question } : {}),
|
|
55
|
+
reportedAt: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
writeAtomic(resolve(taskDirectory, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: "text" as const, text: `${params.status}: ${params.summary}` }],
|
|
60
|
+
details: report,
|
|
61
|
+
terminate: true,
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "@maxiaochao/pi-
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
2
|
+
"name": "@maxiaochao/pi-herdr-subagents",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Run inspectable interactive Pi subagent batches through Herdr.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"extensions",
|
|
13
13
|
"src",
|
|
14
14
|
"agents",
|
|
15
|
+
"tests",
|
|
15
16
|
"docs",
|
|
16
17
|
"README.md",
|
|
17
18
|
"TESTING.md",
|
|
@@ -21,11 +22,11 @@
|
|
|
21
22
|
"repository": {
|
|
22
23
|
"type": "git",
|
|
23
24
|
"url": "git+https://github.com/chaochaoxiaochao/pi-toolkit.git",
|
|
24
|
-
"directory": "packages/
|
|
25
|
+
"directory": "packages/herdr-subagents"
|
|
25
26
|
},
|
|
26
27
|
"pi": {
|
|
27
28
|
"extensions": [
|
|
28
|
-
"./extensions/
|
|
29
|
+
"./extensions/herdr-subagents.ts"
|
|
29
30
|
]
|
|
30
31
|
},
|
|
31
32
|
"peerDependencies": {
|
|
@@ -36,7 +37,7 @@
|
|
|
36
37
|
},
|
|
37
38
|
"scripts": {
|
|
38
39
|
"test": "node --experimental-strip-types tests/run-tests.mjs",
|
|
39
|
-
"load-test": "pi -ne -ns --no-session -e ./extensions/
|
|
40
|
+
"load-test": "env -u PI_HERDR_SUBAGENTS_CHILD pi -ne -ns --no-session -e ./tests/extension-contract.ts && env -u PI_HERDR_SUBAGENTS_CHILD pi -ne -ns --no-session -e ./extensions/herdr-subagents.ts"
|
|
40
41
|
},
|
|
41
42
|
"publishConfig": {
|
|
42
43
|
"access": "public"
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
|
|
5
|
+
import type { PersonaAccess } from "./config.ts";
|
|
6
|
+
import type { HerdrSubagentsDocument } from "./runner.ts";
|
|
7
|
+
import { ensureRuntimeIgnored } from "./history.ts";
|
|
8
|
+
import { liveAgentName, promptLiveAgent } from "./live-agent.ts";
|
|
9
|
+
import { writeJsonAtomic } from "./state.ts";
|
|
10
|
+
import type { OwnerIdentity } from "./ownership.ts";
|
|
11
|
+
import { ownerRecord } from "./ownership.ts";
|
|
12
|
+
import { RunPaneAllocator } from "./run-pane-allocator.ts";
|
|
13
|
+
import { applyReport, applyTaskStatus, settleAttemptFailure, type PersistedReport, type RunRecord, type TaskRecord, type TaskStatus } from "./records.ts";
|
|
14
|
+
import { reportProtocolPrompt } from "./protocol.ts";
|
|
15
|
+
import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
16
|
+
import { applyRunStatus } from "./run-status.ts";
|
|
17
|
+
import { isAbortError } from "./errors.ts";
|
|
18
|
+
|
|
19
|
+
export interface BatchTask {
|
|
20
|
+
name: string;
|
|
21
|
+
prompt: string;
|
|
22
|
+
agent: string;
|
|
23
|
+
access: PersonaAccess;
|
|
24
|
+
model?: string;
|
|
25
|
+
thinking?: string;
|
|
26
|
+
tools?: string[];
|
|
27
|
+
skills: string[];
|
|
28
|
+
systemPrompt: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface BatchOptions {
|
|
32
|
+
runId?: string;
|
|
33
|
+
label: string;
|
|
34
|
+
concurrency: number;
|
|
35
|
+
maxConcurrency: number;
|
|
36
|
+
stalledWarningSeconds?: number;
|
|
37
|
+
cwd: string;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
herdr?: HerdrAutomation;
|
|
40
|
+
owner?: OwnerIdentity;
|
|
41
|
+
onUpdate?: (result: BatchResult) => void;
|
|
42
|
+
onCleanupError?: (message: string) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type BatchTaskStatus = Exclude<TaskStatus, "starting">;
|
|
46
|
+
export interface BatchTaskResult {
|
|
47
|
+
index: number;
|
|
48
|
+
name: string;
|
|
49
|
+
status: Exclude<BatchTaskStatus, "queued" | "running">;
|
|
50
|
+
summary: string;
|
|
51
|
+
documents: HerdrSubagentsDocument[];
|
|
52
|
+
error?: string;
|
|
53
|
+
question?: string;
|
|
54
|
+
paneId: string;
|
|
55
|
+
recordDirectory: string;
|
|
56
|
+
sessionFile: string;
|
|
57
|
+
}
|
|
58
|
+
export interface BatchResult {
|
|
59
|
+
ok: boolean;
|
|
60
|
+
status: "starting" | "running" | "blocked" | "completed" | "partial" | "failed" | "cancelled";
|
|
61
|
+
runId: string;
|
|
62
|
+
label: string;
|
|
63
|
+
requestedConcurrency: number;
|
|
64
|
+
effectiveConcurrency: number;
|
|
65
|
+
tasks: BatchTaskResult[];
|
|
66
|
+
activity: Array<{ index: number; name: string; status: BatchTaskStatus; paneId?: string }>;
|
|
67
|
+
summary: string;
|
|
68
|
+
documents: HerdrSubagentsDocument[];
|
|
69
|
+
recordDirectory: string;
|
|
70
|
+
tabId?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type TaskContext = {
|
|
74
|
+
taskId: string;
|
|
75
|
+
taskDirectory: string;
|
|
76
|
+
sessionFile: string;
|
|
77
|
+
reportFile: string;
|
|
78
|
+
taskFile: string;
|
|
79
|
+
systemPromptFile: string;
|
|
80
|
+
taskRecord: TaskRecord;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function timestamp(): string { return new Date().toISOString(); }
|
|
84
|
+
export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchOptions): Promise<BatchResult> {
|
|
85
|
+
if (!tasks.length) throw new Error("At least one task is required.");
|
|
86
|
+
if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_WORKSPACE_ID) throw new Error("Herdr Subagents must run from a Pi session inside Herdr.");
|
|
87
|
+
const runId = options.runId ?? randomUUID();
|
|
88
|
+
const requestedConcurrency = Math.max(1, Math.floor(options.concurrency));
|
|
89
|
+
const effectiveConcurrency = tasks.some((task) => task.access === "write") ? 1 : Math.min(requestedConcurrency, options.maxConcurrency, tasks.length);
|
|
90
|
+
const runDirectory = join(options.cwd, ".pi", "herdr-subagents", "runs", `${timestamp().replace(/[-:.]/g, "").replace("Z", "Z-")}${runId.slice(0, 8)}`);
|
|
91
|
+
const runFile = join(runDirectory, "run.json");
|
|
92
|
+
await ensureRuntimeIgnored(options.cwd);
|
|
93
|
+
await mkdir(join(runDirectory, "tasks"), { recursive: true, mode: 0o700 });
|
|
94
|
+
const contexts: TaskContext[] = await Promise.all(tasks.map(async (task, index) => {
|
|
95
|
+
const taskId = randomUUID();
|
|
96
|
+
const taskDirectory = join(runDirectory, "tasks", `${String(index + 1).padStart(2, "0")}-${taskId.slice(0, 8)}`);
|
|
97
|
+
const sessionFile = join(taskDirectory, "session.jsonl");
|
|
98
|
+
const systemPromptFile = join(taskDirectory, "system-prompt.md");
|
|
99
|
+
const taskRecord: TaskRecord = {
|
|
100
|
+
id: taskId, runId, order: index + 1, name: task.name, status: "queued", prompt: task.prompt,
|
|
101
|
+
agent: task.agent, access: task.access, model: task.model, thinking: task.thinking,
|
|
102
|
+
tools: task.tools, skills: task.skills, systemPrompt: reportProtocolPrompt(task.systemPrompt), sessionFile,
|
|
103
|
+
};
|
|
104
|
+
await mkdir(taskDirectory, { recursive: true, mode: 0o700 });
|
|
105
|
+
await writeFile(systemPromptFile, String(taskRecord.systemPrompt), { encoding: "utf8", mode: 0o600 });
|
|
106
|
+
const taskFile = join(taskDirectory, "task.json");
|
|
107
|
+
await writeJsonAtomic(taskFile, taskRecord);
|
|
108
|
+
return { taskId, taskDirectory, sessionFile, reportFile: join(taskDirectory, "report.json"), taskFile, systemPromptFile, taskRecord };
|
|
109
|
+
}));
|
|
110
|
+
const runRecord: RunRecord = {
|
|
111
|
+
id: runId, label: options.label, status: "starting", cwd: options.cwd, ...ownerRecord(options.owner), requestedConcurrency,
|
|
112
|
+
effectiveConcurrency, stalledWarningSeconds: options.stalledWarningSeconds, startedAt: timestamp(), taskIds: contexts.map((context) => context.taskId), paneIds: [],
|
|
113
|
+
};
|
|
114
|
+
await writeJsonAtomic(runFile, runRecord);
|
|
115
|
+
const herdr = options.herdr ?? new CliHerdrAutomation();
|
|
116
|
+
const results = new Array<BatchTaskResult>(tasks.length);
|
|
117
|
+
const activity: BatchResult["activity"] = tasks.map((task, index) => ({ index, name: task.name, status: "queued" }));
|
|
118
|
+
let tabId: string | undefined;
|
|
119
|
+
const paneAllocator = new RunPaneAllocator({ herdr, run: runRecord, runFile, signal: options.signal, onCleanupError: options.onCleanupError });
|
|
120
|
+
const emit = (status: BatchResult["status"], summary: string) => options.onUpdate?.({
|
|
121
|
+
ok: false, status, runId, label: options.label, requestedConcurrency, effectiveConcurrency,
|
|
122
|
+
tasks: results.filter(Boolean), activity: activity.map((entry) => ({ ...entry })), summary,
|
|
123
|
+
documents: results.filter(Boolean).flatMap((result) => result.documents), recordDirectory: runDirectory, tabId,
|
|
124
|
+
});
|
|
125
|
+
const allocatePane = async (index: number): Promise<string> => {
|
|
126
|
+
const paneId = await paneAllocator.allocate(contexts[index].taskDirectory);
|
|
127
|
+
tabId = paneAllocator.tabId;
|
|
128
|
+
return paneId;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
emit("starting", `Starting up to ${effectiveConcurrency} interactive Pi Agent${effectiveConcurrency === 1 ? "" : "s"}...`);
|
|
132
|
+
let nextIndex = 0;
|
|
133
|
+
let settled = 0;
|
|
134
|
+
try {
|
|
135
|
+
const runOne = async (index: number): Promise<void> => {
|
|
136
|
+
const task = tasks[index];
|
|
137
|
+
const context = contexts[index];
|
|
138
|
+
let paneId = "";
|
|
139
|
+
let agentName = liveAgentName(context.taskId);
|
|
140
|
+
let report: PersistedReport;
|
|
141
|
+
let status: BatchTaskResult["status"];
|
|
142
|
+
try {
|
|
143
|
+
paneId = await allocatePane(index);
|
|
144
|
+
const paneLabel = `${String(index + 1).padStart(2, "0")} · ${task.name}`;
|
|
145
|
+
await herdr.renamePane(paneId, paneLabel, options.signal);
|
|
146
|
+
applyTaskStatus(context.taskRecord, "running");
|
|
147
|
+
Object.assign(context.taskRecord, { tabId, paneId, paneLabel, agentName, startedAt: timestamp() });
|
|
148
|
+
await writeJsonAtomic(context.taskFile, context.taskRecord);
|
|
149
|
+
activity[index] = { index, name: task.name, status: "running", paneId };
|
|
150
|
+
emit("running", `${settled}/${tasks.length} tasks settled.`);
|
|
151
|
+
const prompted = await promptLiveAgent({ herdr, task: { ...task, id: context.taskId, sessionFile: context.sessionFile }, paneId, prompt: task.prompt, systemPromptFile: context.systemPromptFile, agentName, signal: options.signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => emit("running", `${task.name} appears stalled; it is still running.`) });
|
|
152
|
+
agentName = prompted.agentName;
|
|
153
|
+
report = prompted.report;
|
|
154
|
+
status = applyReport(context.taskRecord, report);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const settled = await settleAttemptFailure(context.taskRecord, context.reportFile, {
|
|
157
|
+
cancelled: isAbortError(error, options.signal),
|
|
158
|
+
error: error instanceof Error ? error.message : String(error),
|
|
159
|
+
name: task.name,
|
|
160
|
+
});
|
|
161
|
+
report = settled.report;
|
|
162
|
+
status = settled.status;
|
|
163
|
+
}
|
|
164
|
+
Object.assign(context.taskRecord, { paneId, agentName });
|
|
165
|
+
await writeJsonAtomic(context.taskFile, context.taskRecord);
|
|
166
|
+
results[index] = { index, name: task.name, status, summary: report.summary, documents: report.documents, ...(report.error ? { error: report.error } : {}), ...(report.question ? { question: report.question } : {}), paneId, recordDirectory: context.taskDirectory, sessionFile: context.sessionFile };
|
|
167
|
+
activity[index] = { index, name: task.name, status, ...(paneId ? { paneId } : {}) };
|
|
168
|
+
settled += 1;
|
|
169
|
+
emit("running", `${settled}/${tasks.length} tasks settled.`);
|
|
170
|
+
};
|
|
171
|
+
const worker = async () => {
|
|
172
|
+
while (!options.signal?.aborted) {
|
|
173
|
+
const index = nextIndex++;
|
|
174
|
+
if (index >= tasks.length) return;
|
|
175
|
+
await runOne(index);
|
|
176
|
+
if (results[index]?.status === "blocked") return;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
await Promise.all(Array.from({ length: effectiveConcurrency }, worker));
|
|
180
|
+
if (options.signal?.aborted) {
|
|
181
|
+
for (let index = 0; index < tasks.length; index += 1) {
|
|
182
|
+
if (activity[index].status !== "queued") continue;
|
|
183
|
+
activity[index].status = "cancelled";
|
|
184
|
+
Object.assign(contexts[index].taskRecord, { status: "cancelled", completedAt: timestamp(), error: "Parent Pi session closed." });
|
|
185
|
+
await writeJsonAtomic(contexts[index].taskFile, contexts[index].taskRecord);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const settledResults = results.filter(Boolean);
|
|
189
|
+
const blocked = settledResults.find((result) => result.status === "blocked");
|
|
190
|
+
const failed = settledResults.filter((result) => result.status === "failed").length;
|
|
191
|
+
const cancelled = options.signal?.aborted || activity.some((entry) => entry.status === "cancelled");
|
|
192
|
+
const status: BatchResult["status"] = cancelled ? "cancelled" : blocked || activity.some((entry) => entry.status === "queued") ? "blocked" : failed === 0 ? "completed" : failed === tasks.length ? "failed" : "partial";
|
|
193
|
+
const summary = status === "cancelled" ? "Subagent batch cancelled because the parent Pi session closed." : blocked ? `${blocked.name} needs input: ${blocked.question}` : failed ? `${tasks.length - failed}/${tasks.length} tasks completed; ${failed} failed.` : `${tasks.length}/${tasks.length} tasks completed.`;
|
|
194
|
+
applyRunStatus(runRecord, status, { question: blocked?.question });
|
|
195
|
+
await writeJsonAtomic(runFile, runRecord);
|
|
196
|
+
return { ok: status === "completed", status, runId, label: options.label, requestedConcurrency, effectiveConcurrency, tasks: settledResults, activity, summary, documents: settledResults.flatMap((result) => result.documents), recordDirectory: runDirectory, tabId };
|
|
197
|
+
} catch (error) {
|
|
198
|
+
applyRunStatus(runRecord, options.signal?.aborted ? "cancelled" : "failed", { error: error instanceof Error ? error.message : String(error) });
|
|
199
|
+
await writeJsonAtomic(runFile, runRecord);
|
|
200
|
+
throw error;
|
|
201
|
+
} finally {
|
|
202
|
+
const keepTab = !options.signal?.aborted && activity.some((entry) => entry.status === "blocked" || entry.status === "queued" || entry.status === "running");
|
|
203
|
+
if (tabId && !keepTab) {
|
|
204
|
+
try {
|
|
205
|
+
await cleanupRunTab({
|
|
206
|
+
herdr, tabId, runFile, runRecord, signal: options.signal, failureStatus: "failed",
|
|
207
|
+
errorPrefix: "Task results settled, but Herdr tab cleanup failed",
|
|
208
|
+
onError: (message) => { emit("failed", message); options.onCleanupError?.(message); },
|
|
209
|
+
});
|
|
210
|
+
} catch (error) {
|
|
211
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
212
|
+
const cleanupError = new Error(`Task results settled, but Herdr tab cleanup failed: ${message}`) as Error & { recordDirectory: string; runId: string };
|
|
213
|
+
cleanupError.recordDirectory = runDirectory;
|
|
214
|
+
cleanupError.runId = runId;
|
|
215
|
+
throw cleanupError;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { mkdir, rename } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
|
|
5
|
+
import { continueQueuedRun } from "./continue-run.ts";
|
|
6
|
+
import { liveAgentName, promptLiveAgent } from "./live-agent.ts";
|
|
7
|
+
import { writeJsonAtomic, writeTextAtomic } from "./state.ts";
|
|
8
|
+
import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
9
|
+
import type { OwnerIdentity } from "./ownership.ts";
|
|
10
|
+
import { ownerRecord } from "./ownership.ts";
|
|
11
|
+
import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
|
|
12
|
+
import { applyReport, applyTaskStatus, isActiveStatus, persistFailureReport, readReport, readRunRecord, readTaskRecord, readTurnRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type TaskRecord, type TaskStatus, type TurnRecord } from "./records.ts";
|
|
13
|
+
import { isAbortError } from "./errors.ts";
|
|
14
|
+
import { continuationSystemPrompt } from "./protocol.ts";
|
|
15
|
+
import { RunPaneAllocator } from "./run-pane-allocator.ts";
|
|
16
|
+
import { findRunDirectory } from "./run-locator.ts";
|
|
17
|
+
|
|
18
|
+
export interface RespondToBlockedTaskOptions { herdr?: HerdrAutomation; taskNumber?: number; signal?: AbortSignal; owner?: OwnerIdentity; onCleanupError?: (message: string) => void; onWarning?: (message: string) => void; onStalled?: (message: string) => void; onUpdate?: (result: ResumeBlockedResult) => void; }
|
|
19
|
+
export interface ResumeBlockedResult {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
runId: string;
|
|
22
|
+
label: string;
|
|
23
|
+
status: "blocked" | "completed" | "partial" | "failed" | "cancelled";
|
|
24
|
+
summary: string;
|
|
25
|
+
question?: string;
|
|
26
|
+
documents: PersistedDocument[];
|
|
27
|
+
recordDirectory: string;
|
|
28
|
+
requestedConcurrency: number;
|
|
29
|
+
effectiveConcurrency: number;
|
|
30
|
+
tasks?: Array<{ index: number; name: string; status: TaskStatus; summary: string; documents: PersistedDocument[]; error?: string; question?: string; paneId: string; recordDirectory: string; sessionFile: string }>;
|
|
31
|
+
activity?: Array<{ index: number; name: string; status: TaskStatus; paneId?: string }>;
|
|
32
|
+
tabId?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function emitProgress(options: RespondToBlockedTaskOptions, runDirectory: string, run: RunRecord, taskDirectories: string[], summary: string): Promise<void> {
|
|
36
|
+
if (!options.onUpdate) return;
|
|
37
|
+
const tasks = await Promise.all(taskDirectories.map((directory) => readTaskRecord(join(directory, "task.json"))));
|
|
38
|
+
options.onUpdate({
|
|
39
|
+
ok: false,
|
|
40
|
+
runId: run.id,
|
|
41
|
+
label: run.label,
|
|
42
|
+
status: "blocked",
|
|
43
|
+
summary,
|
|
44
|
+
documents: [],
|
|
45
|
+
recordDirectory: runDirectory,
|
|
46
|
+
requestedConcurrency: Number(run.requestedConcurrency ?? 1),
|
|
47
|
+
effectiveConcurrency: Number(run.effectiveConcurrency ?? 1),
|
|
48
|
+
activity: tasks.map((entry, index) => ({ index, name: String(entry.name ?? entry.agent ?? `Task ${index + 1}`), status: entry.status, ...(entry.paneId ? { paneId: entry.paneId } : {}) })),
|
|
49
|
+
...(run.tabId ? { tabId: run.tabId } : {}),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function projectSettledRun(runDirectory: string, runFile: string, run: RunRecord, taskDirectories: string[], herdr: HerdrAutomation, onCleanupError?: (message: string) => void): Promise<ResumeBlockedResult> {
|
|
54
|
+
const finalTasks = await Promise.all(taskDirectories.map((directory) => readTaskRecord(join(directory, "task.json"))));
|
|
55
|
+
const finalReports = await Promise.all(taskDirectories.map(async (directory, index) => {
|
|
56
|
+
const reportFile = join(directory, "report.json");
|
|
57
|
+
if (!existsSync(reportFile) && (finalTasks[index].status === "queued" || finalTasks[index].status === "cancelled")) return { status: "failed" as const, summary: finalTasks[index].error ?? (finalTasks[index].status === "queued" ? "Task is still queued." : "Task was cancelled."), documents: [] };
|
|
58
|
+
try { return await readReport(reportFile); }
|
|
59
|
+
catch (error) {
|
|
60
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
61
|
+
const failedReport = { status: "failed" as const, summary: `Could not read task report: ${detail}`, documents: [], error: detail };
|
|
62
|
+
applyReport(finalTasks[index], failedReport);
|
|
63
|
+
await Promise.all([writeJsonAtomic(join(directory, "task.json"), finalTasks[index]), persistFailureReport(reportFile, failedReport)]);
|
|
64
|
+
return failedReport;
|
|
65
|
+
}
|
|
66
|
+
}));
|
|
67
|
+
const aggregateStatus = aggregateTaskStatus(finalTasks.map((entry) => entry.status));
|
|
68
|
+
const runStatus = aggregateStatus === "running" ? "blocked" : aggregateStatus;
|
|
69
|
+
const blockedIndex = finalTasks.findIndex((entry) => entry.status === "blocked");
|
|
70
|
+
const blockedReport = blockedIndex >= 0 ? finalReports[blockedIndex] : undefined;
|
|
71
|
+
run = await readRunRecord(runFile);
|
|
72
|
+
applyRunStatus(run, runStatus, { question: blockedReport?.question });
|
|
73
|
+
await writeJsonAtomic(runFile, run);
|
|
74
|
+
if (runStatus !== "blocked" && run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, onError: onCleanupError, failureStatus: "failed" });
|
|
75
|
+
const summary = runStatus === "completed" ? "All tasks completed after the answer." : runStatus === "blocked" ? `${finalTasks[blockedIndex]?.name ?? "Task"} needs input: ${blockedReport?.question ?? "Input required."}` : finalReports.map((entry, index) => `${finalTasks[index].name}: ${entry.summary}`).join("\n");
|
|
76
|
+
const activity = finalTasks.map((entry, index) => ({ index, name: String(entry.name ?? entry.agent ?? `Task ${index + 1}`), status: entry.status, ...(entry.paneId ? { paneId: String(entry.paneId) } : {}) }));
|
|
77
|
+
const tasks = finalTasks.map((entry, index) => ({ index, name: activity[index].name, status: entry.status, summary: String(finalReports[index].summary ?? "Task has no report."), documents: finalReports[index].documents ?? [], ...(entry.error ? { error: String(entry.error) } : {}), ...(entry.question ? { question: String(entry.question) } : {}), paneId: String(entry.paneId ?? ""), recordDirectory: taskDirectories[index], sessionFile: String(entry.sessionFile ?? "") }));
|
|
78
|
+
return { ok: runStatus === "completed", runId: run.id, label: String(run.label ?? "batch"), status: runStatus, summary, ...(blockedReport?.question ? { question: blockedReport.question } : {}), documents: finalReports.flatMap((entry) => entry.documents ?? []), recordDirectory: runDirectory, requestedConcurrency: Number(run.requestedConcurrency ?? run.effectiveConcurrency ?? 1), effectiveConcurrency: Number(run.effectiveConcurrency ?? 1), tasks, activity, ...(run.tabId ? { tabId: String(run.tabId) } : {}) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function respondToBlockedTask(cwd: string, runId: string, answer: string, options: RespondToBlockedTaskOptions = {}): Promise<ResumeBlockedResult> {
|
|
82
|
+
const herdr = options.herdr ?? new CliHerdrAutomation();
|
|
83
|
+
const { taskNumber, signal, onCleanupError } = options;
|
|
84
|
+
const runsDirectory = join(cwd, ".pi", "herdr-subagents", "runs");
|
|
85
|
+
const runDirectory = await findRunDirectory(runsDirectory, runId);
|
|
86
|
+
if (!runDirectory) throw new Error(`Unknown Subagent run '${runId}'.`);
|
|
87
|
+
const runFile = join(runDirectory, "run.json");
|
|
88
|
+
const run = await readRunRecord(runFile);
|
|
89
|
+
const taskDirectories = readdirSync(join(runDirectory, "tasks")).sort().map((entry) => join(runDirectory, "tasks", entry));
|
|
90
|
+
let taskDirectory: string | undefined;
|
|
91
|
+
let task: TaskRecord | undefined;
|
|
92
|
+
for (const directory of taskDirectories) {
|
|
93
|
+
const candidate = await readTaskRecord(join(directory, "task.json"));
|
|
94
|
+
if (candidate.status === "blocked" && (!taskNumber || candidate.order === taskNumber)) { taskDirectory = directory; task = candidate; break; }
|
|
95
|
+
}
|
|
96
|
+
if (!taskDirectory || !task) throw new Error(`Run '${runId}' has no blocked task.`);
|
|
97
|
+
Object.assign(run, ownerRecord(options.owner));
|
|
98
|
+
const previousReport = await readReport(join(taskDirectory, "report.json"));
|
|
99
|
+
const turnsDirectory = join(taskDirectory, "turns");
|
|
100
|
+
await mkdir(turnsDirectory, { recursive: true, mode: 0o700 });
|
|
101
|
+
const resumePrompt = join(taskDirectory, "resume-system-prompt.md");
|
|
102
|
+
await writeTextAtomic(resumePrompt, `${continuationSystemPrompt(task.systemPrompt, "Continue from the supplied answer.", "resumed")}\n`);
|
|
103
|
+
let paneId = String(task.paneId ?? "");
|
|
104
|
+
let agentName = String(task.agentName ?? liveAgentName(task.id));
|
|
105
|
+
let live = Boolean(paneId) && await herdr.paneExists(paneId, signal) && await herdr.isTaskRunning(paneId, signal);
|
|
106
|
+
if (!live) {
|
|
107
|
+
const paneAllocator = new RunPaneAllocator({ herdr, run, runFile, signal, validateExistingPane: true, onCleanupError });
|
|
108
|
+
paneId = await paneAllocator.allocate(taskDirectory);
|
|
109
|
+
agentName = liveAgentName(task.id, Number(task.attempt ?? 1) + 1);
|
|
110
|
+
task.attempt = Number(task.attempt ?? 1) + 1;
|
|
111
|
+
await herdr.renamePane(paneId, `${String(task.order).padStart(2, "0")} · ${task.name ?? task.agent}`, signal);
|
|
112
|
+
}
|
|
113
|
+
const attempt = Number(task.attempt ?? 1);
|
|
114
|
+
const turnFile = live && task.currentTurnFile ? String(task.currentTurnFile) : join(turnsDirectory, `${String(attempt).padStart(2, "0")}.json`);
|
|
115
|
+
let turn: TurnRecord = { attempt, prompt: task.prompt, status: "blocked", summary: previousReport.summary, question: previousReport.question, documents: previousReport.documents ?? [], sessionFile: task.sessionFile, startedAt: task.startedAt };
|
|
116
|
+
if (existsSync(turnFile)) {
|
|
117
|
+
try { turn = await readTurnRecord(turnFile); }
|
|
118
|
+
catch (error) {
|
|
119
|
+
const archivedPath = `${turnFile}.invalid-${Date.now()}`;
|
|
120
|
+
await rename(turnFile, archivedPath);
|
|
121
|
+
options.onWarning?.(`Archived invalid durable turn ${turnFile} at ${archivedPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
turn.events = [...(Array.isArray(turn.events) ? turn.events : []), { type: "answer", text: answer, at: new Date().toISOString() }];
|
|
125
|
+
task.currentTurnFile = turnFile;
|
|
126
|
+
applyTaskStatus(task, "running");
|
|
127
|
+
Object.assign(task, { tabId: run.tabId, paneId, agentName });
|
|
128
|
+
await Promise.all([writeJsonAtomic(join(taskDirectory, "task.json"), task), writeJsonAtomic(runFile, run), writeJsonAtomic(turnFile, turn)]);
|
|
129
|
+
await emitProgress(options, runDirectory, run, taskDirectories, `${task.name ?? task.agent} is continuing after input.`);
|
|
130
|
+
let report: PersistedReport;
|
|
131
|
+
let status: "blocked" | "completed" | "failed" | "cancelled";
|
|
132
|
+
let attemptError: unknown;
|
|
133
|
+
try {
|
|
134
|
+
report = (await promptLiveAgent({ herdr, task: { ...task, id: task.id, sessionFile: task.sessionFile }, paneId, prompt: answer, systemPromptFile: resumePrompt, agentName, start: !live, signal, stalledWarningMs: run.stalledWarningSeconds ? Number(run.stalledWarningSeconds) * 1000 : undefined, onStalled: () => options.onStalled?.(`${task.name ?? task.agent} appears stalled; it is still running.`) })).report;
|
|
135
|
+
status = applyReport(task, report);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
attemptError = error;
|
|
138
|
+
const settled = await settleAttemptFailure(task, join(taskDirectory, "report.json"), {
|
|
139
|
+
cancelled: isAbortError(error, signal),
|
|
140
|
+
error: error instanceof Error ? error.message : String(error),
|
|
141
|
+
name: `${task.name ?? task.agent} response`,
|
|
142
|
+
replaceReport: true,
|
|
143
|
+
});
|
|
144
|
+
report = settled.report;
|
|
145
|
+
status = settled.status;
|
|
146
|
+
}
|
|
147
|
+
if (attemptError && isAbortError(attemptError, signal)) {
|
|
148
|
+
const message = attemptError instanceof Error ? attemptError.message : String(attemptError);
|
|
149
|
+
applyTaskStatus(task, "cancelled", { error: message });
|
|
150
|
+
applyRunStatus(run, "cancelled", { error: message });
|
|
151
|
+
applyTaskStatus(turn, "cancelled", { error: message });
|
|
152
|
+
for (const siblingDirectory of taskDirectories) {
|
|
153
|
+
if (siblingDirectory === taskDirectory) continue;
|
|
154
|
+
const siblingFile = join(siblingDirectory, "task.json");
|
|
155
|
+
const sibling = await readTaskRecord(siblingFile);
|
|
156
|
+
if (!isActiveStatus(sibling.status)) continue;
|
|
157
|
+
applyTaskStatus(sibling, "cancelled", { error: "Parent Pi session closed." });
|
|
158
|
+
await writeJsonAtomic(siblingFile, sibling);
|
|
159
|
+
}
|
|
160
|
+
await Promise.all([writeJsonAtomic(join(taskDirectory, "task.json"), task), writeJsonAtomic(runFile, run), writeJsonAtomic(turnFile, turn)]);
|
|
161
|
+
if (run.tabId) await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal, onError: onCleanupError, failureStatus: "failed" });
|
|
162
|
+
return { ok: false, runId, label: String(run.label ?? "batch"), status: "cancelled", summary: "Subagent response cancelled.", documents: [], recordDirectory: runDirectory, requestedConcurrency: Number(run.requestedConcurrency ?? 1), effectiveConcurrency: Number(run.effectiveConcurrency ?? 1), ...(run.tabId ? { tabId: String(run.tabId) } : {}) };
|
|
163
|
+
}
|
|
164
|
+
turn.events.push({ type: "report", status, summary: report.summary, question: report.question, error: report.error, at: new Date().toISOString() });
|
|
165
|
+
Object.assign(turn, { summary: report.summary, documents: report.documents ?? [] });
|
|
166
|
+
applyReport(turn, report);
|
|
167
|
+
await Promise.all([writeJsonAtomic(join(taskDirectory, "task.json"), task), writeJsonAtomic(turnFile, turn)]);
|
|
168
|
+
await emitProgress(options, runDirectory, run, taskDirectories, `${task.name ?? task.agent} ${status}.`);
|
|
169
|
+
if (status !== "blocked") {
|
|
170
|
+
applyRunStatus(run, "queued");
|
|
171
|
+
await writeJsonAtomic(runFile, run);
|
|
172
|
+
await continueQueuedRun(runDirectory, herdr, { cleanup: false, signal, onStalled: options.onStalled, onCleanupError, onUpdate: () => emitProgress(options, runDirectory, run, taskDirectories, "Queued tasks are continuing.") });
|
|
173
|
+
}
|
|
174
|
+
return projectSettledRun(runDirectory, runFile, run, taskDirectories, herdr, onCleanupError);
|
|
175
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { discoverPackageAgents, type AgentDefinition } from "./personas.ts";
|
|
5
|
+
|
|
6
|
+
export type PersonaAccess = "read" | "write";
|
|
7
|
+
export interface PersonaConfig { model?: string; thinking?: string; skills?: string[]; }
|
|
8
|
+
export interface SubagentConfig {
|
|
9
|
+
defaultConcurrency: number;
|
|
10
|
+
maxConcurrency: number;
|
|
11
|
+
stalledWarningSeconds: number;
|
|
12
|
+
defaultModel?: string;
|
|
13
|
+
personas: Record<string, PersonaConfig>;
|
|
14
|
+
}
|
|
15
|
+
export interface EffectivePersona extends AgentDefinition {
|
|
16
|
+
source: "project" | "global" | "package";
|
|
17
|
+
access: PersonaAccess;
|
|
18
|
+
model?: string;
|
|
19
|
+
modelSource: string;
|
|
20
|
+
thinking?: string;
|
|
21
|
+
thinkingSource: string;
|
|
22
|
+
skills: string[];
|
|
23
|
+
skillsSource: string;
|
|
24
|
+
}
|
|
25
|
+
export interface SubagentConfiguration {
|
|
26
|
+
settings: SubagentConfig;
|
|
27
|
+
settingSources: Record<string, string>;
|
|
28
|
+
personas: EffectivePersona[];
|
|
29
|
+
diagnostics: string[];
|
|
30
|
+
}
|
|
31
|
+
interface ConfigFile {
|
|
32
|
+
defaultConcurrency?: number;
|
|
33
|
+
maxConcurrency?: number;
|
|
34
|
+
stalledWarningSeconds?: number;
|
|
35
|
+
defaultModel?: string;
|
|
36
|
+
personas?: Record<string, PersonaConfig>;
|
|
37
|
+
}
|
|
38
|
+
export interface ConfigurationPaths {
|
|
39
|
+
globalConfig?: string;
|
|
40
|
+
projectConfig?: string;
|
|
41
|
+
globalAgents?: string;
|
|
42
|
+
projectAgents?: string;
|
|
43
|
+
}
|
|
44
|
+
const DEFAULTS: SubagentConfig = { defaultConcurrency: 1, maxConcurrency: 4, stalledWarningSeconds: 300, personas: {} };
|
|
45
|
+
|
|
46
|
+
function readConfig(path: string, source: string, diagnostics: string[]): ConfigFile {
|
|
47
|
+
if (!existsSync(path)) return {};
|
|
48
|
+
try {
|
|
49
|
+
const value = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
50
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected a JSON object");
|
|
51
|
+
return value as ConfigFile;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
diagnostics.push(`${source} configuration ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mergeConfig(base: SubagentConfig, input: ConfigFile, source: string, sources: Record<string, string>, diagnostics: string[]): SubagentConfig {
|
|
59
|
+
const result: SubagentConfig = { ...base, personas: { ...base.personas } };
|
|
60
|
+
for (const key of ["defaultConcurrency", "maxConcurrency", "stalledWarningSeconds"] as const) {
|
|
61
|
+
const value = input[key];
|
|
62
|
+
if (value === undefined) continue;
|
|
63
|
+
if (!Number.isInteger(value) || Number(value) <= 0) { diagnostics.push(`${source} ${key} must be a positive integer`); continue; }
|
|
64
|
+
result[key] = value as number;
|
|
65
|
+
sources[key] = source;
|
|
66
|
+
}
|
|
67
|
+
if (input.defaultModel !== undefined) {
|
|
68
|
+
if (typeof input.defaultModel === "string" && input.defaultModel.trim()) { result.defaultModel = input.defaultModel.trim(); sources.defaultModel = source; }
|
|
69
|
+
else diagnostics.push(`${source} defaultModel must be a non-empty string`);
|
|
70
|
+
}
|
|
71
|
+
if (input.personas !== undefined) {
|
|
72
|
+
if (!input.personas || typeof input.personas !== "object" || Array.isArray(input.personas)) diagnostics.push(`${source} personas must be an object`);
|
|
73
|
+
else for (const [name, raw] of Object.entries(input.personas)) {
|
|
74
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) { diagnostics.push(`${source} persona '${name}' must be an object`); continue; }
|
|
75
|
+
const valid: PersonaConfig = {};
|
|
76
|
+
for (const field of ["model", "thinking"] as const) {
|
|
77
|
+
const value = raw[field];
|
|
78
|
+
if (value === undefined) continue;
|
|
79
|
+
if (typeof value === "string" && value.trim()) valid[field] = value.trim();
|
|
80
|
+
else diagnostics.push(`${source} persona '${name}' ${field} must be a non-empty string`);
|
|
81
|
+
}
|
|
82
|
+
if (raw.skills !== undefined) {
|
|
83
|
+
if (Array.isArray(raw.skills) && raw.skills.every((value) => typeof value === "string" && value.trim())) valid.skills = raw.skills.map((value) => value.trim());
|
|
84
|
+
else diagnostics.push(`${source} persona '${name}' skills must be an array of non-empty strings`);
|
|
85
|
+
}
|
|
86
|
+
input.personas[name] = valid;
|
|
87
|
+
result.personas[name] = { ...(result.personas[name] ?? {}), ...valid };
|
|
88
|
+
for (const field of Object.keys(valid)) sources[`personas.${name}.${field}`] = source;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function loadSubagentConfiguration(cwd: string, packageAgentsDirectory: string, parentModel?: string, paths: ConfigurationPaths = {}): SubagentConfiguration {
|
|
95
|
+
const diagnostics: string[] = [];
|
|
96
|
+
const globalConfigPath = paths.globalConfig ?? join(homedir(), ".pi", "agent", "herdr-subagents.json");
|
|
97
|
+
const projectConfigPath = paths.projectConfig ?? join(cwd, ".pi", "herdr-subagents.json");
|
|
98
|
+
const globalAgentsDirectory = paths.globalAgents ?? join(dirname(globalConfigPath), "herdr-subagents", "agents");
|
|
99
|
+
const projectAgentsDirectory = paths.projectAgents ?? join(cwd, ".pi", "herdr-subagents", "agents");
|
|
100
|
+
const settingSources: Record<string, string> = { defaultConcurrency: "built-in", maxConcurrency: "built-in", stalledWarningSeconds: "built-in" };
|
|
101
|
+
const globalInput = readConfig(globalConfigPath, "global", diagnostics);
|
|
102
|
+
const projectInput = readConfig(projectConfigPath, "project", diagnostics);
|
|
103
|
+
const globalSettings = mergeConfig(DEFAULTS, globalInput, "global", settingSources, diagnostics);
|
|
104
|
+
const settings = mergeConfig(globalSettings, projectInput, "project", settingSources, diagnostics);
|
|
105
|
+
if (settings.defaultConcurrency > settings.maxConcurrency) { diagnostics.push("defaultConcurrency exceeds maxConcurrency; maxConcurrency is used"); settings.defaultConcurrency = settings.maxConcurrency; }
|
|
106
|
+
const layers = [
|
|
107
|
+
{ source: "package" as const, discovery: discoverPackageAgents(packageAgentsDirectory) },
|
|
108
|
+
{ source: "global" as const, discovery: discoverPackageAgents(globalAgentsDirectory, true) },
|
|
109
|
+
{ source: "project" as const, discovery: discoverPackageAgents(projectAgentsDirectory, true) },
|
|
110
|
+
];
|
|
111
|
+
const definitions = new Map<string, { definition: AgentDefinition; source: "project" | "global" | "package" }>();
|
|
112
|
+
for (const layer of layers) { diagnostics.push(...layer.discovery.diagnostics); for (const definition of layer.discovery.agents) definitions.set(definition.name, { definition, source: layer.source }); }
|
|
113
|
+
const personas = [...definitions.values()].map(({ definition, source }): EffectivePersona => {
|
|
114
|
+
const globalPersona = globalInput.personas?.[definition.name] ?? {};
|
|
115
|
+
const projectPersona = projectInput.personas?.[definition.name] ?? {};
|
|
116
|
+
const model = projectPersona.model ?? globalPersona.model ?? definition.model ?? settings.defaultModel ?? parentModel;
|
|
117
|
+
const modelSource = projectPersona.model ? "project persona" : globalPersona.model ? "global persona" : definition.model ? `${source} persona` : settings.defaultModel ? settingSources.defaultModel : parentModel ? "parent session" : "unresolved";
|
|
118
|
+
const thinking = projectPersona.thinking ?? globalPersona.thinking ?? definition.thinking;
|
|
119
|
+
const thinkingSource = projectPersona.thinking ? "project persona" : globalPersona.thinking ? "global persona" : definition.thinking ? `${source} persona` : "default";
|
|
120
|
+
const skills = projectPersona.skills ?? globalPersona.skills ?? definition.skills ?? [];
|
|
121
|
+
const skillsSource = projectPersona.skills ? "project persona" : globalPersona.skills ? "global persona" : definition.skills ? `${source} persona` : "none";
|
|
122
|
+
return { ...definition, source, access: definition.access ?? "write", model, modelSource, thinking, thinkingSource, skills, skillsSource };
|
|
123
|
+
}).sort((left, right) => left.name.localeCompare(right.name));
|
|
124
|
+
return { settings, settingSources, personas, diagnostics };
|
|
125
|
+
}
|