@henryqw/pi-subagent 3.0.2 → 3.1.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/CONTEXT.md +8 -4
- package/README.md +54 -62
- package/dist/ephemeral.d.ts +50 -0
- package/dist/ephemeral.js +651 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +11 -3
- package/docs/adr/001-composable-ephemeral-execution.md +19 -0
- package/docs/orchestration.md +342 -0
- package/examples/roles/implementer.md +14 -0
- package/examples/roles/reviewer.md +11 -0
- package/examples/roles/scout.md +17 -0
- package/examples/roles/synthesizer.md +18 -0
- package/extensions/result-transport.ts +213 -0
- package/extensions/role-tools.ts +1 -1
- package/extensions/subagent.ts +366 -573
- package/extensions/workflow.ts +202 -0
- package/package.json +4 -2
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { PROFILE_NAMES, THINKING_LEVELS, type ProfileName, type ThinkingLevel } from "@henryqw/pi-task-models";
|
|
3
|
+
import { Type, type Static } from "typebox";
|
|
4
|
+
|
|
5
|
+
export const MAX_WORKFLOW_ENTRIES = 8;
|
|
6
|
+
|
|
7
|
+
const RoleSchema = Type.String({ minLength: 1, description: "Configured Subagent role name" });
|
|
8
|
+
const TaskSchema = Type.String({ minLength: 1, description: "Bounded task packet" });
|
|
9
|
+
const ModelSchema = Type.String({ minLength: 1, description: "Designated model as provider/modelId; overrides modelClass" });
|
|
10
|
+
const ModelClassSchema = StringEnum(PROFILE_NAMES, { description: "Task model profile" });
|
|
11
|
+
const ThinkingSchema = StringEnum(THINKING_LEVELS, { description: "Task thinking-level override" });
|
|
12
|
+
|
|
13
|
+
export const DelegationSchema = Type.Object({
|
|
14
|
+
role: RoleSchema,
|
|
15
|
+
task: TaskSchema,
|
|
16
|
+
model: Type.Optional(ModelSchema),
|
|
17
|
+
modelClass: Type.Optional(ModelClassSchema),
|
|
18
|
+
thinking: Type.Optional(ThinkingSchema),
|
|
19
|
+
}, { additionalProperties: false });
|
|
20
|
+
|
|
21
|
+
export const WorkflowSchema = Type.Object({
|
|
22
|
+
role: Type.Optional(RoleSchema),
|
|
23
|
+
task: Type.Optional(TaskSchema),
|
|
24
|
+
model: Type.Optional(ModelSchema),
|
|
25
|
+
modelClass: Type.Optional(ModelClassSchema),
|
|
26
|
+
thinking: Type.Optional(ThinkingSchema),
|
|
27
|
+
tasks: Type.Optional(Type.Array(DelegationSchema, {
|
|
28
|
+
minItems: 1,
|
|
29
|
+
maxItems: MAX_WORKFLOW_ENTRIES,
|
|
30
|
+
description: "Independent delegations to run concurrently",
|
|
31
|
+
})),
|
|
32
|
+
chain: Type.Optional(Type.Array(DelegationSchema, {
|
|
33
|
+
minItems: 1,
|
|
34
|
+
maxItems: MAX_WORKFLOW_ENTRIES,
|
|
35
|
+
description: "Dependent delegations to run sequentially",
|
|
36
|
+
})),
|
|
37
|
+
background: Type.Optional(Type.Boolean({ description: "Run the selected workflow without blocking" })),
|
|
38
|
+
}, {
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
description: "Exactly one mode: role and task, tasks, or chain",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export type Delegation = Static<typeof DelegationSchema>;
|
|
44
|
+
export type WorkflowMode = "single" | "parallel" | "chain";
|
|
45
|
+
export type ParsedWorkflow =
|
|
46
|
+
| { mode: "single"; background: boolean; delegations: [Delegation] }
|
|
47
|
+
| { mode: "parallel"; background: boolean; delegations: Delegation[] }
|
|
48
|
+
| { mode: "chain"; background: boolean; delegations: Delegation[] };
|
|
49
|
+
|
|
50
|
+
const DELEGATION_KEYS = ["role", "task", "model", "modelClass", "thinking"] as const;
|
|
51
|
+
const TOP_LEVEL_KEYS = [...DELEGATION_KEYS, "tasks", "chain", "background"] as const;
|
|
52
|
+
|
|
53
|
+
function record(value: unknown, path: string): Record<string, unknown> {
|
|
54
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object.`);
|
|
55
|
+
return value as Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function rejectUnknown(value: Record<string, unknown>, allowed: readonly string[], path: string): void {
|
|
59
|
+
const unknown = Object.keys(value).find((key) => !allowed.includes(key));
|
|
60
|
+
if (unknown) throw new Error(`${path} contains unknown property ${JSON.stringify(unknown)}.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function text(value: unknown, path: string): string {
|
|
64
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
|
|
65
|
+
throw new Error(`${path} must be non-empty text without NUL.`);
|
|
66
|
+
}
|
|
67
|
+
return value.trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function enumValue<T extends string>(value: unknown, values: readonly T[], path: string): T {
|
|
71
|
+
if (typeof value !== "string" || !values.includes(value as T)) {
|
|
72
|
+
throw new Error(`${path} must be one of: ${values.join(", ")}.`);
|
|
73
|
+
}
|
|
74
|
+
return value as T;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseDelegation(value: unknown, path: string, extraKeys: readonly string[] = []): Delegation {
|
|
78
|
+
const input = record(value, path);
|
|
79
|
+
rejectUnknown(input, [...DELEGATION_KEYS, ...extraKeys], path);
|
|
80
|
+
if (!Object.hasOwn(input, "role") || !Object.hasOwn(input, "task")) {
|
|
81
|
+
throw new Error(`${path} requires both role and task.`);
|
|
82
|
+
}
|
|
83
|
+
const delegation: Delegation = {
|
|
84
|
+
role: text(input.role, `${path}.role`),
|
|
85
|
+
task: text(input.task, `${path}.task`),
|
|
86
|
+
};
|
|
87
|
+
if (Object.hasOwn(input, "model")) delegation.model = text(input.model, `${path}.model`);
|
|
88
|
+
if (Object.hasOwn(input, "modelClass")) {
|
|
89
|
+
delegation.modelClass = enumValue(input.modelClass, PROFILE_NAMES, `${path}.modelClass`) as ProfileName;
|
|
90
|
+
}
|
|
91
|
+
if (Object.hasOwn(input, "thinking")) {
|
|
92
|
+
delegation.thinking = enumValue(input.thinking, THINKING_LEVELS, `${path}.thinking`) as ThinkingLevel;
|
|
93
|
+
}
|
|
94
|
+
return delegation;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseDelegations(value: unknown, path: "tasks" | "chain"): Delegation[] {
|
|
98
|
+
if (!Array.isArray(value)) throw new Error(`${path} must be an array.`);
|
|
99
|
+
if (value.length < 1 || value.length > MAX_WORKFLOW_ENTRIES) {
|
|
100
|
+
throw new Error(`${path} must contain 1 to ${MAX_WORKFLOW_ENTRIES} delegations.`);
|
|
101
|
+
}
|
|
102
|
+
return value.map((delegation, index) => parseDelegation(delegation, `${path}[${index}]`));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parseWorkflow(value: unknown): ParsedWorkflow {
|
|
106
|
+
const input = record(value, "workflow");
|
|
107
|
+
rejectUnknown(input, TOP_LEVEL_KEYS, "workflow");
|
|
108
|
+
const background = Object.hasOwn(input, "background") ? input.background : false;
|
|
109
|
+
if (typeof background !== "boolean") throw new Error("workflow.background must be a boolean.");
|
|
110
|
+
|
|
111
|
+
const single = DELEGATION_KEYS.some((key) => Object.hasOwn(input, key));
|
|
112
|
+
const parallel = Object.hasOwn(input, "tasks");
|
|
113
|
+
const chain = Object.hasOwn(input, "chain");
|
|
114
|
+
if (Number(single) + Number(parallel) + Number(chain) !== 1) {
|
|
115
|
+
throw new Error("workflow must select exactly one mode: role and task, tasks, or chain.");
|
|
116
|
+
}
|
|
117
|
+
if (single) {
|
|
118
|
+
return { mode: "single", background, delegations: [parseDelegation(input, "workflow", ["background"])] };
|
|
119
|
+
}
|
|
120
|
+
if (parallel) return { mode: "parallel", background, delegations: parseDelegations(input.tasks, "tasks") };
|
|
121
|
+
return { mode: "chain", background, delegations: parseDelegations(input.chain, "chain") };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type WorkflowEntry = {
|
|
125
|
+
id: string;
|
|
126
|
+
mode: WorkflowMode;
|
|
127
|
+
index: number;
|
|
128
|
+
delegation: Delegation;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export function identifyWorkflowEntries(toolCallId: string, workflow: ParsedWorkflow): WorkflowEntry[] {
|
|
132
|
+
return workflow.delegations.map((delegation, index) => ({
|
|
133
|
+
id: `${toolCallId}:${workflow.mode}:${index}`,
|
|
134
|
+
mode: workflow.mode,
|
|
135
|
+
index,
|
|
136
|
+
delegation,
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type DelegationExecution<T> =
|
|
141
|
+
| { ok: true; assistantOutput: string; result: T }
|
|
142
|
+
| { ok: false; result: T };
|
|
143
|
+
|
|
144
|
+
export type DelegationRunner<T> = (
|
|
145
|
+
entry: WorkflowEntry,
|
|
146
|
+
) => DelegationExecution<T> | Promise<DelegationExecution<T>>;
|
|
147
|
+
|
|
148
|
+
export type WorkflowEntryOutcome<T> =
|
|
149
|
+
| { status: "succeeded"; entry: WorkflowEntry; assistantOutput: string; result: T }
|
|
150
|
+
| { status: "failed"; entry: WorkflowEntry; result: T }
|
|
151
|
+
| { status: "rejected"; entry: WorkflowEntry; reason: unknown };
|
|
152
|
+
|
|
153
|
+
async function runEntry<T>(entry: WorkflowEntry, run: DelegationRunner<T>): Promise<WorkflowEntryOutcome<T>> {
|
|
154
|
+
try {
|
|
155
|
+
const execution = await run(entry);
|
|
156
|
+
return execution.ok
|
|
157
|
+
? { status: "succeeded", entry, assistantOutput: execution.assistantOutput, result: execution.result }
|
|
158
|
+
: { status: "failed", entry, result: execution.result };
|
|
159
|
+
} catch (reason) {
|
|
160
|
+
return { status: "rejected", entry, reason };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Run only foreground policy. Callback failures are `rejected`; parent aborts are rethrown after started work settles. */
|
|
165
|
+
export async function runForegroundWorkflow<T>(
|
|
166
|
+
toolCallId: string,
|
|
167
|
+
workflow: ParsedWorkflow,
|
|
168
|
+
run: DelegationRunner<T>,
|
|
169
|
+
signal?: AbortSignal,
|
|
170
|
+
): Promise<WorkflowEntryOutcome<T>[]> {
|
|
171
|
+
if (workflow.background) throw new Error("Background workflows cannot use foreground orchestration.");
|
|
172
|
+
signal?.throwIfAborted();
|
|
173
|
+
const entries = identifyWorkflowEntries(toolCallId, workflow);
|
|
174
|
+
if (workflow.mode === "single") {
|
|
175
|
+
const outcome = await runEntry(entries[0]!, run);
|
|
176
|
+
signal?.throwIfAborted();
|
|
177
|
+
return [outcome];
|
|
178
|
+
}
|
|
179
|
+
if (workflow.mode === "parallel") {
|
|
180
|
+
const outcomes = await Promise.all(entries.map((entry) => runEntry(entry, run)));
|
|
181
|
+
signal?.throwIfAborted();
|
|
182
|
+
return outcomes;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const outcomes: WorkflowEntryOutcome<T>[] = [];
|
|
186
|
+
let previous = "";
|
|
187
|
+
for (const entry of entries) {
|
|
188
|
+
const chained = {
|
|
189
|
+
...entry,
|
|
190
|
+
delegation: {
|
|
191
|
+
...entry.delegation,
|
|
192
|
+
task: entry.delegation.task.replaceAll("{previous}", () => previous),
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
const outcome = await runEntry(chained, run);
|
|
196
|
+
signal?.throwIfAborted();
|
|
197
|
+
outcomes.push(outcome);
|
|
198
|
+
if (outcome.status !== "succeeded") break;
|
|
199
|
+
previous = outcome.assistantOutput;
|
|
200
|
+
}
|
|
201
|
+
return outcomes;
|
|
202
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-subagent",
|
|
3
|
-
"version": "3.0
|
|
4
|
-
"description": "Delegate
|
|
3
|
+
"version": "3.1.0",
|
|
4
|
+
"description": "Delegate bounded single, parallel, or chained tasks to isolated Pi roles.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi",
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"dist",
|
|
18
18
|
"extensions",
|
|
19
|
+
"docs",
|
|
20
|
+
"examples",
|
|
19
21
|
"README.md",
|
|
20
22
|
"CONTEXT.md",
|
|
21
23
|
"LICENSE"
|