@g3un/pi-orchestra 0.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/LICENSE +21 -0
- package/README.md +46 -0
- package/docs/orchestration-model.md +69 -0
- package/package.json +56 -0
- package/src/adapters/in-memory-store.ts +85 -0
- package/src/adapters/index.ts +2 -0
- package/src/adapters/pi-runtime.ts +348 -0
- package/src/core/bus-format.ts +14 -0
- package/src/core/bus.ts +11 -0
- package/src/core/index.ts +8 -0
- package/src/core/orchestra.ts +322 -0
- package/src/core/runtime.ts +14 -0
- package/src/core/store.ts +21 -0
- package/src/core/subagent.ts +27 -0
- package/src/core/workflow.ts +49 -0
- package/src/core/workgroup.ts +12 -0
- package/src/extension/index.ts +58 -0
- package/src/index.ts +4 -0
- package/src/profiles/index.ts +1 -0
- package/src/profiles/stage-leader.ts +20 -0
- package/src/tools/bus.ts +275 -0
- package/src/tools/index.ts +4 -0
- package/src/tools/subagent.ts +243 -0
- package/src/tools/workflow.ts +712 -0
- package/src/tools/workgroup.ts +422 -0
- package/src/utils.ts +101 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { AgentProfile, AgentRun } from "../core/subagent.ts";
|
|
4
|
+
import type { Bus } from "../core/bus.ts";
|
|
5
|
+
import type { AgentResultStatus } from "../core/subagent.ts";
|
|
6
|
+
import type { OrchestraApi, WaitRunResult } from "../core/orchestra.ts";
|
|
7
|
+
import { WORKGROUP_STRATEGY_VALUES, type WorkgroupMember, type WorkgroupStrategy } from "../core/workgroup.ts";
|
|
8
|
+
import { closeAgentRuns, formatError, formatNamedEntityLabel, normalizeEntityName, slugify } from "../utils.ts";
|
|
9
|
+
import {
|
|
10
|
+
AgentProfileParams,
|
|
11
|
+
spawnSubagent,
|
|
12
|
+
SubagentRunNameParam,
|
|
13
|
+
type SubagentSpawnInput,
|
|
14
|
+
withDefaultProfileModel,
|
|
15
|
+
} from "./subagent.ts";
|
|
16
|
+
|
|
17
|
+
export type { WorkgroupMember, WorkgroupStrategy } from "../core/workgroup.ts";
|
|
18
|
+
|
|
19
|
+
export interface WorkgroupInput {
|
|
20
|
+
busId: string;
|
|
21
|
+
goal: string;
|
|
22
|
+
strategy: WorkgroupStrategy;
|
|
23
|
+
members: WorkgroupMember[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WorkgroupOutput {
|
|
27
|
+
bus: Bus;
|
|
28
|
+
runs: AgentRun[];
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface WorkgroupSettlement {
|
|
33
|
+
strategy: WorkgroupStrategy;
|
|
34
|
+
status: AgentResultStatus;
|
|
35
|
+
/** Results that should be consumed by downstream orchestration. For compete, this is the winning result when present. */
|
|
36
|
+
workerResults: WaitRunResult[];
|
|
37
|
+
/** Every terminal result observed while settling this workgroup. */
|
|
38
|
+
completedResults: WaitRunResult[];
|
|
39
|
+
winner?: WaitRunResult;
|
|
40
|
+
pendingRunIds: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface WorkgroupTool {
|
|
44
|
+
name: "workgroup";
|
|
45
|
+
execute(input: WorkgroupInput): Promise<WorkgroupOutput>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface WorkgroupToolDeps {
|
|
49
|
+
orchestra: OrchestraApi;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const WorkgroupMemberParams = Type.Object(
|
|
53
|
+
{
|
|
54
|
+
profile: AgentProfileParams,
|
|
55
|
+
name: Type.Optional(SubagentRunNameParam),
|
|
56
|
+
assignment: Type.Optional(
|
|
57
|
+
Type.String({
|
|
58
|
+
description: "Optional member-specific assignment or focus within the shared goal.",
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
},
|
|
62
|
+
{ additionalProperties: false },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const WorkgroupToolParams = Type.Object(
|
|
66
|
+
{
|
|
67
|
+
busId: Type.String({
|
|
68
|
+
description: "Existing bus id/name; create with bus action=create first.",
|
|
69
|
+
}),
|
|
70
|
+
goal: Type.String({
|
|
71
|
+
description: "Shared workgroup goal.",
|
|
72
|
+
}),
|
|
73
|
+
strategy: Type.String({
|
|
74
|
+
enum: [...WORKGROUP_STRATEGY_VALUES],
|
|
75
|
+
description: "compete = one success is enough; synthesize = combine complementary findings.",
|
|
76
|
+
}),
|
|
77
|
+
members: Type.Array(WorkgroupMemberParams, {
|
|
78
|
+
description: "Subagents to spawn.",
|
|
79
|
+
minItems: 1,
|
|
80
|
+
}),
|
|
81
|
+
},
|
|
82
|
+
{ additionalProperties: false },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
interface PreparedWorkgroupMember extends WorkgroupMember {
|
|
86
|
+
name: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface PreparedWorkgroupInput extends Omit<WorkgroupInput, "members"> {
|
|
90
|
+
members: PreparedWorkgroupMember[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface SpawnSuccess {
|
|
94
|
+
member: PreparedWorkgroupMember;
|
|
95
|
+
run: AgentRun;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface SpawnFailure {
|
|
99
|
+
member: PreparedWorkgroupMember;
|
|
100
|
+
error: unknown;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function createWorkgroupTool({ orchestra }: WorkgroupToolDeps): WorkgroupTool {
|
|
104
|
+
return {
|
|
105
|
+
name: "workgroup",
|
|
106
|
+
|
|
107
|
+
async execute(input) {
|
|
108
|
+
if (input.members.length === 0) throw new Error("workgroup requires at least one member.");
|
|
109
|
+
|
|
110
|
+
const bus = orchestra.getBus(input.busId);
|
|
111
|
+
if (!bus) throw new Error(`Bus ${input.busId} not found.`);
|
|
112
|
+
|
|
113
|
+
const preparedInput: PreparedWorkgroupInput = {
|
|
114
|
+
...input,
|
|
115
|
+
members: prepareMembers(input.members, orchestra.listRuns()),
|
|
116
|
+
};
|
|
117
|
+
const spawnResults = await Promise.allSettled(
|
|
118
|
+
preparedInput.members.map(async (member): Promise<SpawnSuccess> => {
|
|
119
|
+
const run = await spawnSubagent(orchestra, toSubagentSpawnInput(preparedInput, member, bus.id));
|
|
120
|
+
return { member, run };
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const successes = collectSpawnSuccesses(spawnResults);
|
|
125
|
+
const failures = collectSpawnFailures(preparedInput.members, spawnResults);
|
|
126
|
+
if (failures.length > 0) {
|
|
127
|
+
const cleanupResults = await Promise.allSettled(
|
|
128
|
+
successes.map((success) => orchestra.closeAgent(success.run.id)),
|
|
129
|
+
);
|
|
130
|
+
throw new Error(formatLaunchFailure(failures, successes, cleanupResults));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const runs = successes.map((success) => success.run);
|
|
134
|
+
return {
|
|
135
|
+
bus,
|
|
136
|
+
runs,
|
|
137
|
+
message: formatWorkgroupMessage(bus, preparedInput, runs),
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function settleWorkgroupRuns(
|
|
144
|
+
orchestra: OrchestraApi,
|
|
145
|
+
busId: string,
|
|
146
|
+
strategy: WorkgroupStrategy,
|
|
147
|
+
): Promise<WorkgroupSettlement> {
|
|
148
|
+
if (strategy === "compete") return await settleCompeteWorkgroupRuns(orchestra, busId);
|
|
149
|
+
|
|
150
|
+
const settled = await orchestra.waitBusSettled(busId, { timeoutMs: null });
|
|
151
|
+
return {
|
|
152
|
+
strategy,
|
|
153
|
+
status: resolveWorkgroupStatus(settled.runResults),
|
|
154
|
+
workerResults: settled.runResults,
|
|
155
|
+
completedResults: settled.runResults,
|
|
156
|
+
pendingRunIds: settled.pendingRunIds,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function defineWorkgroupPiTool(resolveTool: (ctx: ExtensionContext) => WorkgroupTool) {
|
|
161
|
+
return defineTool({
|
|
162
|
+
name: "workgroup",
|
|
163
|
+
label: "Workgroup",
|
|
164
|
+
description: "Spawn multiple subagents onto an existing bus; you lead and collect results.",
|
|
165
|
+
promptSnippet: "Spawn a main-led workgroup on an existing bus, then collect results with bus wait actions.",
|
|
166
|
+
promptGuidelines: [
|
|
167
|
+
"Create a bus first; workgroup only spawns members.",
|
|
168
|
+
"Use compete when one successful member is enough; use wait_next, then close losers and summarize.",
|
|
169
|
+
"Use synthesize when members provide complementary findings to combine; wait_settled usually fits.",
|
|
170
|
+
"publish_bus is peer-reference context, not a leader-request channel.",
|
|
171
|
+
],
|
|
172
|
+
parameters: WorkgroupToolParams,
|
|
173
|
+
executionMode: "sequential",
|
|
174
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
175
|
+
const input = withDefaultModelsForWorkgroup(toWorkgroupInput(params as RawWorkgroupParams), ctx);
|
|
176
|
+
const output = await resolveTool(ctx).execute(input);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
content: [{ type: "text", text: output.message }],
|
|
180
|
+
details: output,
|
|
181
|
+
};
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function settleCompeteWorkgroupRuns(orchestra: OrchestraApi, busId: string): Promise<WorkgroupSettlement> {
|
|
187
|
+
const completedResults: WaitRunResult[] = [];
|
|
188
|
+
const excludeRunIds: string[] = [];
|
|
189
|
+
|
|
190
|
+
for (;;) {
|
|
191
|
+
const nextRun = await orchestra.waitNextRun(busId, { excludeRunIds, timeoutMs: null });
|
|
192
|
+
if (!nextRun.runResult) {
|
|
193
|
+
return {
|
|
194
|
+
strategy: "compete",
|
|
195
|
+
status: resolveWorkgroupStatus(completedResults),
|
|
196
|
+
workerResults: completedResults,
|
|
197
|
+
completedResults,
|
|
198
|
+
pendingRunIds: nextRun.pendingRunIds,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
completedResults.push(nextRun.runResult);
|
|
203
|
+
excludeRunIds.push(nextRun.runResult.runId);
|
|
204
|
+
if (nextRun.runResult.result?.status === "success") {
|
|
205
|
+
await closeAgentRuns(orchestra, nextRun.pendingRunIds);
|
|
206
|
+
return {
|
|
207
|
+
strategy: "compete",
|
|
208
|
+
status: "success",
|
|
209
|
+
workerResults: [nextRun.runResult],
|
|
210
|
+
completedResults,
|
|
211
|
+
winner: nextRun.runResult,
|
|
212
|
+
pendingRunIds: [],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function resolveWorkgroupStatus(results: WaitRunResult[]): AgentResultStatus {
|
|
219
|
+
const statuses = results.map((result) => result.result?.status);
|
|
220
|
+
if (statuses.includes("success")) return "success";
|
|
221
|
+
if (statuses.includes("blocked")) return "blocked";
|
|
222
|
+
return "failed";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function toWorkgroupInput(params: RawWorkgroupParams): WorkgroupInput {
|
|
226
|
+
if (!params.busId) throw new Error("workgroup requires busId.");
|
|
227
|
+
if (!params.goal) throw new Error("workgroup requires goal.");
|
|
228
|
+
if (!params.strategy) throw new Error("workgroup requires strategy.");
|
|
229
|
+
if (!params.members || params.members.length === 0) throw new Error("workgroup requires members.");
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
busId: params.busId,
|
|
233
|
+
goal: params.goal,
|
|
234
|
+
strategy: params.strategy,
|
|
235
|
+
members: params.members.map((member, index) => toWorkgroupMember(member, `workgroup member ${index + 1}`)),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function toWorkgroupMember(member: RawWorkgroupMemberParams, label: string): WorkgroupMember {
|
|
240
|
+
if (!member.profile) throw new Error(`${label} requires profile.`);
|
|
241
|
+
return { profile: member.profile, name: member.name, assignment: member.assignment };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function withDefaultModelsForWorkgroup(input: WorkgroupInput, ctx: ExtensionContext): WorkgroupInput {
|
|
245
|
+
return {
|
|
246
|
+
...input,
|
|
247
|
+
members: withDefaultModelsForWorkgroupMembers(input.members, ctx),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function withDefaultModelsForWorkgroupMembers(
|
|
252
|
+
members: WorkgroupMember[],
|
|
253
|
+
ctx: ExtensionContext,
|
|
254
|
+
): WorkgroupMember[] {
|
|
255
|
+
return members.map((member) => withDefaultModelForWorkgroupMember(member, ctx));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function withDefaultModelForWorkgroupMember(member: WorkgroupMember, ctx: ExtensionContext): WorkgroupMember {
|
|
259
|
+
return {
|
|
260
|
+
...member,
|
|
261
|
+
profile: withDefaultProfileModel(member.profile, ctx),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function prepareMembers(members: WorkgroupMember[], existingRuns: AgentRun[]): PreparedWorkgroupMember[] {
|
|
266
|
+
const reservedNames = new Set<string>();
|
|
267
|
+
for (const run of existingRuns) {
|
|
268
|
+
reservedNames.add(run.id);
|
|
269
|
+
reservedNames.add(run.name);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return members.map((member, index) => {
|
|
273
|
+
const name =
|
|
274
|
+
member.name !== undefined
|
|
275
|
+
? normalizeEntityName(member.name, "Workgroup member")
|
|
276
|
+
: nextGeneratedMemberName(member.profile.name, index, reservedNames);
|
|
277
|
+
const id = slugify(name);
|
|
278
|
+
if (!id) throw new Error(`Workgroup member name "${name}" must contain letters or numbers.`);
|
|
279
|
+
if (reservedNames.has(name) || reservedNames.has(id)) {
|
|
280
|
+
throw new Error(`Workgroup member name "${name}" is already in use.`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
reservedNames.add(name);
|
|
284
|
+
reservedNames.add(id);
|
|
285
|
+
return { ...member, name };
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function nextGeneratedMemberName(profileName: string, index: number, reservedNames: Set<string>): string {
|
|
290
|
+
const base = slugify(profileName) || `member-${index + 1}`;
|
|
291
|
+
for (let suffix = 1; ; suffix++) {
|
|
292
|
+
const name = suffix === 1 ? base : `${base}-${suffix}`;
|
|
293
|
+
if (!reservedNames.has(name)) return name;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function collectSpawnSuccesses(results: Array<PromiseSettledResult<SpawnSuccess>>): SpawnSuccess[] {
|
|
298
|
+
return results
|
|
299
|
+
.filter((result): result is PromiseFulfilledResult<SpawnSuccess> => result.status === "fulfilled")
|
|
300
|
+
.map((result) => result.value);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function collectSpawnFailures(
|
|
304
|
+
members: PreparedWorkgroupMember[],
|
|
305
|
+
results: Array<PromiseSettledResult<SpawnSuccess>>,
|
|
306
|
+
): SpawnFailure[] {
|
|
307
|
+
return results.flatMap((result, index) =>
|
|
308
|
+
result.status === "rejected" ? [{ member: members[index], error: result.reason }] : [],
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function formatLaunchFailure(
|
|
313
|
+
failures: SpawnFailure[],
|
|
314
|
+
successes: SpawnSuccess[],
|
|
315
|
+
cleanupResults: Array<PromiseSettledResult<AgentRun | undefined>>,
|
|
316
|
+
): string {
|
|
317
|
+
const parts = ["Failed to launch every workgroup member.", "", "Failures:"];
|
|
318
|
+
for (const failure of failures) {
|
|
319
|
+
parts.push(`- ${failure.member.name}: ${formatError(failure.error)}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (successes.length > 0) {
|
|
323
|
+
parts.push("", "Cleanup:");
|
|
324
|
+
for (let index = 0; index < successes.length; index++) {
|
|
325
|
+
const success = successes[index];
|
|
326
|
+
const cleanupResult = cleanupResults[index];
|
|
327
|
+
if (cleanupResult?.status === "rejected") {
|
|
328
|
+
parts.push(`- ${formatNamedEntityLabel(success.run)}: failed to close (${formatError(cleanupResult.reason)})`);
|
|
329
|
+
} else {
|
|
330
|
+
parts.push(`- ${formatNamedEntityLabel(success.run)}: closed`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return parts.join("\n");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function toSubagentSpawnInput(
|
|
339
|
+
input: PreparedWorkgroupInput,
|
|
340
|
+
member: PreparedWorkgroupMember,
|
|
341
|
+
busId: string,
|
|
342
|
+
): SubagentSpawnInput {
|
|
343
|
+
return {
|
|
344
|
+
action: "spawn",
|
|
345
|
+
profile: member.profile,
|
|
346
|
+
task: buildMemberTask(input, member),
|
|
347
|
+
busId,
|
|
348
|
+
name: member.name,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function buildMemberTask(input: PreparedWorkgroupInput, member: PreparedWorkgroupMember): string {
|
|
353
|
+
return [
|
|
354
|
+
"You are a workgroup member on a shared peer-reference bus.",
|
|
355
|
+
"Use publish_bus for sibling context; use finish(status=blocked) for leader action or decisions.",
|
|
356
|
+
"",
|
|
357
|
+
"## Workgroup context",
|
|
358
|
+
`Workgroup strategy: ${input.strategy}`,
|
|
359
|
+
"Shared goal:",
|
|
360
|
+
"<shared_goal>",
|
|
361
|
+
input.goal,
|
|
362
|
+
"</shared_goal>",
|
|
363
|
+
"",
|
|
364
|
+
"Your assignment:",
|
|
365
|
+
"<assignment>",
|
|
366
|
+
member.assignment ?? `Apply your profile "${member.profile.name}" to the shared goal.`,
|
|
367
|
+
"</assignment>",
|
|
368
|
+
"",
|
|
369
|
+
"Workgroup members:",
|
|
370
|
+
"<workgroup_members>",
|
|
371
|
+
...input.members.map(formatRosterMember),
|
|
372
|
+
"</workgroup_members>",
|
|
373
|
+
"",
|
|
374
|
+
...buildStrategyGuidelines(input.strategy),
|
|
375
|
+
].join("\n");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function buildStrategyGuidelines(strategy: WorkgroupStrategy): string[] {
|
|
379
|
+
if (strategy === "compete") {
|
|
380
|
+
return [
|
|
381
|
+
"Compete guidelines:",
|
|
382
|
+
"- Work independently; keep conclusions/recommendations until finish.",
|
|
383
|
+
"- publish_bus only facts, evidence, blockers, or useful constraints.",
|
|
384
|
+
"- finish with approach, evidence, risks, and recommendation.",
|
|
385
|
+
];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return [
|
|
389
|
+
"Synthesize guidelines:",
|
|
390
|
+
"- Contribute your expert angle and engage peer findings.",
|
|
391
|
+
"- publish_bus important findings, questions, blockers, or rebuttals.",
|
|
392
|
+
"- finish with findings, gaps/risks, and next actions.",
|
|
393
|
+
];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function formatRosterMember(member: PreparedWorkgroupMember): string {
|
|
397
|
+
return `- ${member.name} (${member.profile.name})${member.assignment ? `: ${member.assignment}` : ""}`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function formatWorkgroupMessage(bus: Bus, input: PreparedWorkgroupInput, runs: AgentRun[]): string {
|
|
401
|
+
return [
|
|
402
|
+
`Launched ${input.strategy} workgroup on bus ${formatNamedEntityLabel(bus)} with ${runs.length} run(s).`,
|
|
403
|
+
"",
|
|
404
|
+
"Runs:",
|
|
405
|
+
...runs.map((run) => `- ${formatNamedEntityLabel(run)}: ${run.state}`),
|
|
406
|
+
"",
|
|
407
|
+
"Use bus action=wait_next to handle member results as they finish, or bus action=wait_settled for full fan-in.",
|
|
408
|
+
].join("\n");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
type RawWorkgroupParams = {
|
|
412
|
+
busId?: string;
|
|
413
|
+
goal?: string;
|
|
414
|
+
strategy?: WorkgroupStrategy;
|
|
415
|
+
members?: RawWorkgroupMemberParams[];
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
export type RawWorkgroupMemberParams = {
|
|
419
|
+
profile?: AgentProfile;
|
|
420
|
+
name?: string;
|
|
421
|
+
assignment?: string;
|
|
422
|
+
};
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { OrchestraApi, WaitRunResult } from "./core/orchestra.ts";
|
|
2
|
+
import type { AgentRun, AgentState } from "./core/subagent.ts";
|
|
3
|
+
import type { AgentStore } from "./core/store.ts";
|
|
4
|
+
import type { WorkflowRun } from "./core/workflow.ts";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
export interface NamedEntity {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function slugify(value: string): string {
|
|
14
|
+
return value
|
|
15
|
+
.trim()
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
18
|
+
.replace(/^-+|-+$/g, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeEntityName(name: string, entityLabel: string, maxLength = 64): string {
|
|
22
|
+
const trimmed = name.trim();
|
|
23
|
+
if (!trimmed) throw new Error(`${entityLabel} name must not be empty.`);
|
|
24
|
+
if (trimmed.length > maxLength) throw new Error(`${entityLabel} name must be ${maxLength} characters or fewer.`);
|
|
25
|
+
return trimmed;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createEntityIdentity(
|
|
29
|
+
requestedName: string | undefined,
|
|
30
|
+
autoSeed: string,
|
|
31
|
+
existingEntities: NamedEntity[],
|
|
32
|
+
entityLabel: string,
|
|
33
|
+
): NamedEntity {
|
|
34
|
+
if (requestedName !== undefined) {
|
|
35
|
+
const name = normalizeEntityName(requestedName, entityLabel);
|
|
36
|
+
const id = slugify(name);
|
|
37
|
+
if (!id) throw new Error(`${entityLabel} name "${name}" must contain letters or numbers.`);
|
|
38
|
+
if (existingEntities.some((entity) => entity.id === id || entity.name === name)) {
|
|
39
|
+
throw new Error(`${entityLabel} name "${name}" is already in use.`);
|
|
40
|
+
}
|
|
41
|
+
return { id, name };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const base = slugify(autoSeed) || entityLabel.toLowerCase();
|
|
45
|
+
for (let index = 1; ; index++) {
|
|
46
|
+
const id = index === 1 ? base : `${base}-${index}`;
|
|
47
|
+
if (!existingEntities.some((entity) => entity.id === id || entity.name === id)) return { id, name: id };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatNamedEntityLabel(entity: NamedEntity): string {
|
|
52
|
+
return entity.name === entity.id ? entity.id : `${entity.name} (${entity.id})`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveWaitTimeoutMs(label: string, timeoutMs: number | null | undefined): number | null {
|
|
56
|
+
const resolvedTimeoutMs = timeoutMs === undefined ? DEFAULT_WAIT_TIMEOUT_MS : timeoutMs;
|
|
57
|
+
if (resolvedTimeoutMs !== null && (!Number.isFinite(resolvedTimeoutMs) || resolvedTimeoutMs <= 0)) {
|
|
58
|
+
throw new Error(`${label} timeoutMs must be positive, or null to wait indefinitely.`);
|
|
59
|
+
}
|
|
60
|
+
return resolvedTimeoutMs;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function findWorkflow(store: AgentStore, id: string): WorkflowRun | undefined {
|
|
64
|
+
return store.getWorkflow(id) ?? store.listWorkflows().find((workflow) => workflow.name === id);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function requireWorkflow(store: AgentStore, id: string): WorkflowRun {
|
|
68
|
+
const workflow = store.getWorkflow(id);
|
|
69
|
+
if (!workflow) throw new Error(`Workflow ${id} not found.`);
|
|
70
|
+
return workflow;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isTerminalAgentState(state: AgentState): boolean {
|
|
74
|
+
return state !== "idle";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function indent(text: string, prefix = " "): string {
|
|
78
|
+
return text
|
|
79
|
+
.split(/\r?\n/)
|
|
80
|
+
.map((line) => `${prefix}${line}`)
|
|
81
|
+
.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function formatError(error: unknown): string {
|
|
85
|
+
return error instanceof Error ? error.message : String(error);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function toWaitRunResult(run: AgentRun): WaitRunResult {
|
|
89
|
+
const runResult: WaitRunResult = {
|
|
90
|
+
runId: run.id,
|
|
91
|
+
name: run.name,
|
|
92
|
+
profile: run.profile,
|
|
93
|
+
state: run.state,
|
|
94
|
+
};
|
|
95
|
+
if (run.result !== undefined) runResult.result = run.result;
|
|
96
|
+
return runResult;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function closeAgentRuns(orchestra: OrchestraApi, runIds: string[]): Promise<void> {
|
|
100
|
+
await Promise.allSettled([...new Set(runIds)].map(async (runId) => await orchestra.closeAgent(runId)));
|
|
101
|
+
}
|