@rosetears/aili-pi 0.1.8 → 0.1.10

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.
Files changed (63) hide show
  1. package/README.md +7 -7
  2. package/THIRD_PARTY_NOTICES.md +12 -12
  3. package/docs/persistent-agents.md +114 -0
  4. package/extensions/matrix/index.ts +24 -8
  5. package/extensions/zentui/config.ts +13 -13
  6. package/extensions/zentui/format.ts +15 -1
  7. package/extensions/zentui/gradient.ts +22 -13
  8. package/extensions/zentui/tool-execution.ts +7 -4
  9. package/manifests/adapter-evidence.json +53 -20
  10. package/manifests/capabilities.json +3 -3
  11. package/manifests/live-verification.json +18 -19
  12. package/manifests/provenance.json +12 -12
  13. package/manifests/roles.json +270 -57
  14. package/manifests/sbom.json +1 -128
  15. package/manifests/skill-compatibility.json +57 -61
  16. package/manifests/subagent-provenance.json +8 -15
  17. package/package.json +3 -3
  18. package/roles/agent-evaluator.md +8 -3
  19. package/roles/ai-regression-scout.md +8 -3
  20. package/roles/browser-qa-runner.md +8 -3
  21. package/roles/code-reviewer.md +8 -3
  22. package/roles/code-scout.md +8 -3
  23. package/roles/convergence-reviewer.md +8 -3
  24. package/roles/doc-researcher.md +8 -3
  25. package/roles/e2e-artifact-runner.md +8 -3
  26. package/roles/general.md +49 -0
  27. package/roles/implementer.md +8 -3
  28. package/roles/opensource-sanitizer.md +8 -3
  29. package/roles/plan-auditor.md +8 -3
  30. package/roles/pr-test-analyzer.md +8 -3
  31. package/roles/security-auditor.md +8 -3
  32. package/roles/silent-failure-reviewer.md +8 -3
  33. package/roles/spec-miner.md +8 -3
  34. package/roles/test-coverage-reviewer.md +8 -3
  35. package/roles/test-engineer.md +8 -3
  36. package/roles/web-performance-auditor.md +8 -3
  37. package/roles/web-researcher.md +8 -3
  38. package/scripts/sync-roles.ts +186 -24
  39. package/src/runtime/doctor.ts +38 -10
  40. package/src/runtime/global-resources.ts +5 -1
  41. package/src/runtime/index.ts +2 -2
  42. package/src/runtime/persistent-agents/hub.ts +429 -0
  43. package/src/runtime/persistent-agents/model-selection.ts +363 -0
  44. package/src/runtime/persistent-agents/output-delivery.ts +356 -0
  45. package/src/runtime/persistent-agents/permission.ts +223 -0
  46. package/src/runtime/persistent-agents/policy.ts +311 -0
  47. package/src/runtime/persistent-agents/production.ts +569 -0
  48. package/src/runtime/persistent-agents/runtime.ts +164 -0
  49. package/src/runtime/persistent-agents/sandbox.ts +68 -0
  50. package/src/runtime/persistent-agents/scheduler.ts +190 -0
  51. package/src/runtime/persistent-agents/session-factory.ts +184 -0
  52. package/src/runtime/persistent-agents/storage.ts +775 -0
  53. package/src/runtime/persistent-agents/task-coordinator.ts +460 -0
  54. package/src/runtime/persistent-agents/task-schema.ts +141 -0
  55. package/src/runtime/persistent-agents/types.ts +134 -0
  56. package/src/runtime/persistent-agents/workspace.ts +335 -0
  57. package/src/runtime/registry.ts +28 -32
  58. package/src/runtime/roles.ts +211 -18
  59. package/src/runtime/rose-context.ts +1 -1
  60. package/templates/APPEND_SYSTEM.md +1 -1
  61. package/themes/rose-cyberdeck.json +5 -9
  62. package/upstream/opencode-global-agents.lock.json +1 -1
  63. package/src/runtime/subagents.ts +0 -249
@@ -0,0 +1,460 @@
1
+ import type { RoleProfile } from "../roles.js";
2
+ import { loadRoleProfiles } from "../roles.js";
3
+ import { allocateAgentId, type CoordinatorJournal } from "./storage.js";
4
+ import type { AgentRecord, JobRecord, TurnRecord } from "./types.js";
5
+ import { evaluateSpawn } from "./policy.js";
6
+ import { assertNoCredentialMaterial } from "./permission.js";
7
+ import {
8
+ DEFAULT_AGENT_MAX_RUNTIME_MS,
9
+ DEFAULT_AGENT_SOFT_REQUEST_BUDGET,
10
+ FifoTurnScheduler,
11
+ ScheduledTaskCancelledError,
12
+ type ScheduledExecutionContext,
13
+ type ScheduledHandle,
14
+ type SchedulerPermit,
15
+ } from "./scheduler.js";
16
+ import { validateTaskRequest, type NormalizedTaskItem } from "./task-schema.js";
17
+
18
+ export interface TaskExecutionOutput {
19
+ status?: "completed" | "failed";
20
+ output: string;
21
+ error?: string;
22
+ evidence?: unknown;
23
+ model?: { provider?: string; model?: string; thinking?: string; layer?: string };
24
+ profile?: { profileHash?: string; sourceHash?: string; version?: number };
25
+ workspace?: Record<string, unknown>;
26
+ }
27
+
28
+ export interface TaskExecutorInput {
29
+ agentId: string;
30
+ jobId: string;
31
+ turnId: string;
32
+ item: NormalizedTaskItem;
33
+ role: RoleProfile;
34
+ depth: number;
35
+ context: ScheduledExecutionContext;
36
+ }
37
+
38
+ export interface OutputTruncation {
39
+ truncated: boolean;
40
+ originalBytes: number;
41
+ returnedBytes: number;
42
+ originalLines: number;
43
+ returnedLines: number;
44
+ limits: { bytes: 500_000; lines: 5_000 };
45
+ }
46
+
47
+ export interface NormalizedTaskSettlement {
48
+ status: "completed" | "failed" | "aborted";
49
+ agentId: string;
50
+ jobId: string;
51
+ turnId: string;
52
+ selector: string;
53
+ async: boolean;
54
+ effectiveMode: "sync" | "async";
55
+ effectiveModeReason: "default-async" | "requested-async" | "requested-sync" | "role-blocking" | "nested-sync";
56
+ output: string;
57
+ error?: string;
58
+ evidence?: unknown;
59
+ outputRef: string;
60
+ historyRef: string;
61
+ truncation: OutputTruncation;
62
+ lifecycle: { agent: "idle" | "aborted"; job: "completed" | "failed" | "aborted"; turn: "completed" | "failed" | "aborted" };
63
+ model: { requested?: string; provider?: string; model?: string; thinking?: string; layer?: string };
64
+ profile: { profileHash: string; sourceHash: string; version: number };
65
+ workspace: { requested: NormalizedTaskItem["workspace"]; writeScope: NormalizedTaskItem["writeScope"] } & Record<string, unknown>;
66
+ deliveryRequired: boolean;
67
+ limits: { maxRuntimeMs: 0; softRequestBudget: 0 };
68
+ }
69
+
70
+ export interface TaskAcceptedResult {
71
+ status: "accepted";
72
+ agentId: string;
73
+ jobId: string;
74
+ turnId: string;
75
+ selector: string;
76
+ async: true;
77
+ effectiveMode: "async";
78
+ effectiveModeReason: "default-async" | "requested-async";
79
+ lifecycle: { agent: string; job: string; turn: string };
80
+ outputRef: string;
81
+ historyRef: string;
82
+ deliveryRequired: true;
83
+ limits: { maxRuntimeMs: 0; softRequestBudget: 0 };
84
+ }
85
+
86
+ export type TaskItemResult = TaskAcceptedResult | NormalizedTaskSettlement;
87
+
88
+ export interface TaskResponse {
89
+ batch: boolean;
90
+ results: TaskItemResult[];
91
+ }
92
+
93
+ export interface TaskAncestry {
94
+ parentAgentId: string;
95
+ parentSelector: string;
96
+ parentDepth: number;
97
+ inheritedPermit: SchedulerPermit;
98
+ configuredMaxDepth?: number;
99
+ }
100
+
101
+ export interface TaskCoordinatorOptions {
102
+ journal: CoordinatorJournal;
103
+ scheduler?: FifoTurnScheduler;
104
+ loadProfiles?: () => Promise<RoleProfile[]>;
105
+ execute: (input: TaskExecutorInput) => Promise<TaskExecutionOutput>;
106
+ onSettled?: (settlement: NormalizedTaskSettlement, fullOutput: string) => void | Promise<void>;
107
+ onAsyncSettled?: (settlement: NormalizedTaskSettlement, fullOutput: string) => void | Promise<void>;
108
+ clock?: () => Date;
109
+ }
110
+
111
+ interface CreatedTask {
112
+ item: NormalizedTaskItem;
113
+ role: RoleProfile;
114
+ agentId: string;
115
+ jobId: string;
116
+ turnId: string;
117
+ depth: number;
118
+ effectiveAsync: boolean;
119
+ reason: TaskAcceptedResult["effectiveModeReason"] | "requested-sync" | "role-blocking" | "nested-sync";
120
+ handle: ScheduledHandle<NormalizedTaskSettlement>;
121
+ }
122
+
123
+ function nextNumericId(prefix: string, existing: Iterable<string>): string {
124
+ const pattern = new RegExp(`^${prefix}-(\\d+)$`);
125
+ let max = 0;
126
+ for (const id of existing) {
127
+ const match = id.match(pattern);
128
+ if (match) max = Math.max(max, Number(match[1]));
129
+ }
130
+ return `${prefix}-${max + 1}`;
131
+ }
132
+
133
+ function tailByUtf8Bytes(value: string, maxBytes: number): string {
134
+ const buffer = Buffer.from(value);
135
+ if (buffer.byteLength <= maxBytes) return value;
136
+ let start = buffer.byteLength - maxBytes;
137
+ while (start < buffer.byteLength && (buffer[start]! & 0b1100_0000) === 0b1000_0000) start += 1;
138
+ return buffer.subarray(start).toString("utf8");
139
+ }
140
+
141
+ export function truncateTaskOutput(output: string): { output: string; truncation: OutputTruncation } {
142
+ const originalBytes = Buffer.byteLength(output);
143
+ const originalLines = output.length === 0 ? 0 : output.split("\n").length;
144
+ const lines = output.split("\n");
145
+ let returned = lines.length > 5_000 ? lines.slice(-5_000).join("\n") : output;
146
+ returned = tailByUtf8Bytes(returned, 500_000);
147
+ const returnedBytes = Buffer.byteLength(returned);
148
+ const returnedLines = returned.length === 0 ? 0 : returned.split("\n").length;
149
+ return {
150
+ output: returned,
151
+ truncation: {
152
+ truncated: returnedBytes !== originalBytes || returnedLines !== originalLines,
153
+ originalBytes,
154
+ returnedBytes,
155
+ originalLines,
156
+ returnedLines,
157
+ limits: { bytes: 500_000, lines: 5_000 },
158
+ },
159
+ };
160
+ }
161
+
162
+ export class TaskCoordinator {
163
+ readonly scheduler: FifoTurnScheduler;
164
+ private readonly loadProfiles: () => Promise<RoleProfile[]>;
165
+ private readonly clock: () => Date;
166
+ private readonly handles = new Map<string, ScheduledHandle<NormalizedTaskSettlement>>();
167
+ private readonly settlements = new Map<string, Promise<NormalizedTaskSettlement>>();
168
+ private readonly fullOutputs = new Map<string, string>();
169
+ private submissionTail: Promise<void> = Promise.resolve();
170
+
171
+ constructor(private readonly options: TaskCoordinatorOptions) {
172
+ this.scheduler = options.scheduler ?? new FifoTurnScheduler();
173
+ this.loadProfiles = options.loadProfiles ?? loadRoleProfiles;
174
+ this.clock = options.clock ?? (() => new Date());
175
+ }
176
+
177
+ async submit(raw: unknown, ancestry?: TaskAncestry, parentSignal?: AbortSignal): Promise<TaskResponse> {
178
+ await assertNoCredentialMaterial(raw, "task input");
179
+ const prepared = await this.serializeSubmission(async () => {
180
+ const profiles = await this.loadProfiles();
181
+ const request = validateTaskRequest(raw, profiles);
182
+ const bySelector = new Map(profiles.map((profile) => [profile.selector, profile]));
183
+ if (ancestry && !this.scheduler.isPermitActive(ancestry.inheritedPermit)) {
184
+ throw new Error("nested task requires an active inherited ancestor permit");
185
+ }
186
+
187
+ // Resolve every role/spawn decision before the first durable allocation.
188
+ for (const item of request.items) {
189
+ const role = bySelector.get(item.agent)!;
190
+ if (ancestry) {
191
+ const parentRole = bySelector.get(ancestry.parentSelector);
192
+ if (!parentRole) throw new Error(`${ancestry.parentSelector}: parent selector is unavailable`);
193
+ const spawn = evaluateSpawn(parentRole, role.selector, ancestry.parentDepth, ancestry.configuredMaxDepth);
194
+ if (!spawn.allowed) throw new Error(`${role.selector}: nested spawn denied (${spawn.reason})`);
195
+ }
196
+ }
197
+
198
+ const created: CreatedTask[] = [];
199
+ for (const item of request.items) created.push(await this.createAndSchedule(item, bySelector.get(item.agent)!, ancestry));
200
+ return { request, created };
201
+ });
202
+
203
+ let abortListener: (() => void) | undefined;
204
+ if (parentSignal) {
205
+ abortListener = () => {
206
+ for (const task of prepared.created) void this.cancel(task.jobId);
207
+ };
208
+ parentSignal.addEventListener("abort", abortListener, { once: true });
209
+ if (parentSignal.aborted) abortListener();
210
+ void Promise.allSettled(prepared.created.map((task) => task.handle.result)).then(() => {
211
+ if (abortListener) parentSignal.removeEventListener("abort", abortListener);
212
+ });
213
+ }
214
+
215
+ const results = await Promise.all(prepared.created.map(async (task): Promise<TaskItemResult> => {
216
+ if (task.effectiveAsync) {
217
+ const state = this.options.journal.getState();
218
+ return {
219
+ status: "accepted",
220
+ agentId: task.agentId,
221
+ jobId: task.jobId,
222
+ turnId: task.turnId,
223
+ selector: task.role.selector,
224
+ async: true,
225
+ effectiveMode: "async",
226
+ effectiveModeReason: task.reason as "default-async" | "requested-async",
227
+ lifecycle: {
228
+ agent: state.agents[task.agentId]?.state ?? "queued",
229
+ job: state.jobs[task.jobId]?.state ?? "queued",
230
+ turn: state.turns[task.turnId]?.state ?? "queued",
231
+ },
232
+ outputRef: `agent://${task.agentId}`,
233
+ historyRef: `history://${task.agentId}`,
234
+ deliveryRequired: true,
235
+ limits: { maxRuntimeMs: 0, softRequestBudget: 0 },
236
+ };
237
+ }
238
+ return await task.handle.result;
239
+ }));
240
+ return { batch: prepared.request.batch, results };
241
+ }
242
+
243
+ private async serializeSubmission<T>(operation: () => Promise<T>): Promise<T> {
244
+ const current = this.submissionTail.then(operation);
245
+ this.submissionTail = current.then(() => undefined, () => undefined);
246
+ return await current;
247
+ }
248
+
249
+ getSettlement(jobId: string): Promise<NormalizedTaskSettlement> | undefined {
250
+ return this.settlements.get(jobId);
251
+ }
252
+
253
+ getHandle(jobId: string): ScheduledHandle<NormalizedTaskSettlement> | undefined {
254
+ return this.handles.get(jobId);
255
+ }
256
+
257
+ async cancel(jobId: string): Promise<"queued" | "running" | "not-found"> {
258
+ return await this.scheduler.cancel(jobId);
259
+ }
260
+
261
+ private async createAndSchedule(item: NormalizedTaskItem, role: RoleProfile, ancestry?: TaskAncestry): Promise<CreatedTask> {
262
+ const before = this.options.journal.getState();
263
+ const agentId = allocateAgentId(item.name ?? role.name, [...Object.keys(before.agents), ...Object.keys(before.releasedAgents)], ancestry?.parentAgentId);
264
+ const jobId = nextNumericId("job", Object.keys(before.jobs));
265
+ const turnId = nextNumericId("turn", Object.keys(before.turns));
266
+ const depth = ancestry ? ancestry.parentDepth + 1 : 0;
267
+ const now = this.clock().toISOString();
268
+ const agent: AgentRecord = {
269
+ id: agentId,
270
+ name: item.name ?? role.name,
271
+ selector: role.selector,
272
+ state: "queued",
273
+ parentAgentId: ancestry?.parentAgentId,
274
+ currentJobId: jobId,
275
+ currentTurnId: turnId,
276
+ createdAt: now,
277
+ updatedAt: now,
278
+ metadata: {
279
+ depth,
280
+ profileHash: role.profileHash,
281
+ sourceHash: role.sourceHash,
282
+ profileVersion: role.profileVersion,
283
+ },
284
+ };
285
+ const job: JobRecord = {
286
+ id: jobId,
287
+ agentId,
288
+ state: "queued",
289
+ createdAt: now,
290
+ updatedAt: now,
291
+ metadata: { requestedModel: item.model, requestedAsync: item.async, workspace: item.workspace, writeScope: item.writeScope },
292
+ };
293
+ const turn: TurnRecord = {
294
+ id: turnId,
295
+ agentId,
296
+ jobId,
297
+ state: "queued",
298
+ createdAt: now,
299
+ updatedAt: now,
300
+ metadata: {
301
+ task: item.task,
302
+ profileHash: role.profileHash,
303
+ sourceHash: role.sourceHash,
304
+ maxRuntimeMs: DEFAULT_AGENT_MAX_RUNTIME_MS,
305
+ softRequestBudget: DEFAULT_AGENT_SOFT_REQUEST_BUDGET,
306
+ },
307
+ };
308
+ await this.options.journal.append({ kind: "agent.created", agentId, payload: { record: agent } });
309
+ await this.options.journal.append({ kind: "job.created", agentId, jobId, payload: { record: job } });
310
+ await this.options.journal.append({ kind: "turn.created", agentId, jobId, turnId, payload: { record: turn } });
311
+
312
+ const effectiveAsync = ancestry ? false : role.blocking ? false : item.async ?? true;
313
+ const reason: CreatedTask["reason"] = ancestry
314
+ ? "nested-sync"
315
+ : role.blocking
316
+ ? "role-blocking"
317
+ : item.async === false
318
+ ? "requested-sync"
319
+ : item.async === true
320
+ ? "requested-async"
321
+ : "default-async";
322
+ const run = (context: ScheduledExecutionContext) => this.runLifecycle({ item, role, agentId, jobId, turnId, depth, effectiveAsync, reason, context });
323
+ const onCancelBeforeStart = () => this.cancelBeforeStart(agentId, jobId, turnId, role, item, effectiveAsync, reason);
324
+ const handle = ancestry
325
+ ? this.scheduler.runNested(jobId, ancestry.inheritedPermit, run)
326
+ : this.scheduler.enqueue(jobId, run, onCancelBeforeStart);
327
+ this.handles.set(jobId, handle);
328
+ const baseSettlement = handle.result.catch(async (error) => {
329
+ if (error instanceof ScheduledTaskCancelledError && error.beforeStart) {
330
+ return this.cancelledSettlement(agentId, jobId, turnId, role, item, effectiveAsync, reason, error.message);
331
+ }
332
+ throw error;
333
+ });
334
+ const settlement = baseSettlement.then(async (result) => {
335
+ const fullOutput = this.fullOutputs.get(jobId) ?? "";
336
+ if (effectiveAsync) await this.options.onAsyncSettled?.(result, fullOutput);
337
+ return result;
338
+ });
339
+ this.settlements.set(jobId, settlement);
340
+ void settlement.catch(() => undefined);
341
+ const normalizedHandle: ScheduledHandle<NormalizedTaskSettlement> = { ...handle, result: settlement };
342
+ this.handles.set(jobId, normalizedHandle);
343
+ return { item, role, agentId, jobId, turnId, depth, effectiveAsync, reason, handle: normalizedHandle };
344
+ }
345
+
346
+ private async begin(agentId: string, jobId: string, turnId: string): Promise<void> {
347
+ await this.options.journal.append({ kind: "agent.state", agentId, payload: { from: "queued", to: "running" } });
348
+ await this.options.journal.append({ kind: "job.state", agentId, jobId, payload: { from: "queued", to: "running" } });
349
+ await this.options.journal.append({ kind: "turn.state", agentId, jobId, turnId, payload: { from: "queued", to: "running" } });
350
+ }
351
+
352
+ private async runLifecycle(args: Omit<CreatedTask, "handle"> & { context: ScheduledExecutionContext }): Promise<NormalizedTaskSettlement> {
353
+ const { agentId, jobId, turnId, role, item, effectiveAsync, reason, context, depth } = args;
354
+ await this.begin(agentId, jobId, turnId);
355
+ let status: NormalizedTaskSettlement["status"] = "completed";
356
+ let output: TaskExecutionOutput;
357
+ try {
358
+ output = await this.options.execute({ agentId, jobId, turnId, item, role, depth, context });
359
+ if (context.signal.aborted) throw context.signal.reason ?? new ScheduledTaskCancelledError(jobId, false);
360
+ if (output.status === "failed") status = "failed";
361
+ } catch (error) {
362
+ const message = error instanceof Error ? error.message : String(error);
363
+ status = context.signal.aborted || error instanceof ScheduledTaskCancelledError ? "aborted" : "failed";
364
+ output = { output: "", error: message };
365
+ }
366
+
367
+ let result = this.settlement(status, agentId, jobId, turnId, role, item, effectiveAsync, reason, output);
368
+ try {
369
+ await this.options.onSettled?.(result, output.output);
370
+ } catch (error) {
371
+ const message = `output persistence failed: ${error instanceof Error ? error.message : String(error)}`;
372
+ status = status === "aborted" ? "aborted" : "failed";
373
+ output = { output: "", error: message };
374
+ result = this.settlement(status, agentId, jobId, turnId, role, item, effectiveAsync, reason, output);
375
+ }
376
+
377
+ if (status === "completed") await this.finishCompleted(agentId, jobId, turnId);
378
+ else if (status === "aborted") await this.finishAborted(agentId, jobId, turnId, output.error ?? "aborted");
379
+ else await this.finishFailed(agentId, jobId, turnId, output.error ?? "Agent execution reported failure");
380
+ return result;
381
+ }
382
+
383
+ private async finishCompleted(agentId: string, jobId: string, turnId: string): Promise<void> {
384
+ await this.options.journal.append({ kind: "turn.state", agentId, jobId, turnId, payload: { from: "running", to: "completed", outcome: "completed" } });
385
+ await this.options.journal.append({ kind: "job.state", agentId, jobId, payload: { from: "running", to: "completed" } });
386
+ await this.options.journal.append({ kind: "agent.state", agentId, payload: { from: "running", to: "idle" } });
387
+ }
388
+
389
+ private async finishFailed(agentId: string, jobId: string, turnId: string, error: string): Promise<void> {
390
+ await this.options.journal.append({ kind: "turn.state", agentId, jobId, turnId, payload: { from: "running", to: "failed", outcome: error } });
391
+ await this.options.journal.append({ kind: "job.state", agentId, jobId, payload: { from: "running", to: "failed", error } });
392
+ await this.options.journal.append({ kind: "agent.state", agentId, payload: { from: "running", to: "idle" } });
393
+ }
394
+
395
+ private async finishAborted(agentId: string, jobId: string, turnId: string, error: string): Promise<void> {
396
+ await this.options.journal.append({ kind: "turn.state", agentId, jobId, turnId, payload: { from: "running", to: "aborted", outcome: error } });
397
+ await this.options.journal.append({ kind: "job.state", agentId, jobId, payload: { from: "running", to: "aborted", error } });
398
+ await this.options.journal.append({ kind: "agent.state", agentId, payload: { from: "running", to: "aborted" } });
399
+ }
400
+
401
+ private async cancelBeforeStart(agentId: string, jobId: string, turnId: string, role: RoleProfile, item: NormalizedTaskItem, effectiveAsync: boolean, reason: CreatedTask["reason"]): Promise<void> {
402
+ await this.options.journal.append({ kind: "turn.state", agentId, jobId, turnId, payload: { from: "queued", to: "aborted", outcome: "cancelled-before-start" } });
403
+ await this.options.journal.append({ kind: "job.state", agentId, jobId, payload: { from: "queued", to: "aborted", error: "cancelled-before-start" } });
404
+ await this.options.journal.append({ kind: "agent.state", agentId, payload: { from: "queued", to: "aborted" } });
405
+ void role;
406
+ void item;
407
+ void effectiveAsync;
408
+ void reason;
409
+ }
410
+
411
+ private cancelledSettlement(agentId: string, jobId: string, turnId: string, role: RoleProfile, item: NormalizedTaskItem, effectiveAsync: boolean, reason: CreatedTask["reason"], error: string): NormalizedTaskSettlement {
412
+ return this.settlement("aborted", agentId, jobId, turnId, role, item, effectiveAsync, reason, { output: "", error });
413
+ }
414
+
415
+ private settlement(
416
+ status: NormalizedTaskSettlement["status"],
417
+ agentId: string,
418
+ jobId: string,
419
+ turnId: string,
420
+ role: RoleProfile,
421
+ item: NormalizedTaskItem,
422
+ effectiveAsync: boolean,
423
+ reason: CreatedTask["reason"],
424
+ execution: TaskExecutionOutput,
425
+ ): NormalizedTaskSettlement {
426
+ this.fullOutputs.set(jobId, execution.output);
427
+ const truncated = truncateTaskOutput(execution.output);
428
+ const lifecycle = status === "completed"
429
+ ? { agent: "idle" as const, job: "completed" as const, turn: "completed" as const }
430
+ : status === "failed"
431
+ ? { agent: "idle" as const, job: "failed" as const, turn: "failed" as const }
432
+ : { agent: "aborted" as const, job: "aborted" as const, turn: "aborted" as const };
433
+ return {
434
+ status,
435
+ agentId,
436
+ jobId,
437
+ turnId,
438
+ selector: role.selector,
439
+ async: effectiveAsync,
440
+ effectiveMode: effectiveAsync ? "async" : "sync",
441
+ effectiveModeReason: reason,
442
+ output: truncated.output,
443
+ error: execution.error,
444
+ evidence: execution.evidence,
445
+ outputRef: `agent://${agentId}`,
446
+ historyRef: `history://${agentId}`,
447
+ truncation: truncated.truncation,
448
+ lifecycle,
449
+ model: { requested: item.model, ...execution.model },
450
+ profile: {
451
+ profileHash: execution.profile?.profileHash ?? role.profileHash,
452
+ sourceHash: execution.profile?.sourceHash ?? role.sourceHash,
453
+ version: execution.profile?.version ?? role.profileVersion,
454
+ },
455
+ workspace: { requested: item.workspace, writeScope: item.writeScope, ...(execution.workspace ?? {}) },
456
+ deliveryRequired: effectiveAsync,
457
+ limits: { maxRuntimeMs: 0, softRequestBudget: 0 },
458
+ };
459
+ }
460
+ }
@@ -0,0 +1,141 @@
1
+ import { Type } from "typebox";
2
+ import { BUNDLED_ROLE_SELECTORS, type RoleProfile } from "../roles.js";
3
+
4
+ export type TaskWorkspaceMode = "auto" | "shared" | "isolated";
5
+
6
+ export interface TaskWriteScope {
7
+ paths: string[];
8
+ resources: string[];
9
+ }
10
+
11
+ export interface NormalizedTaskItem {
12
+ task: string;
13
+ context?: string;
14
+ agent: string;
15
+ name?: string;
16
+ model?: string;
17
+ async?: boolean;
18
+ tools?: string[];
19
+ workspace: TaskWorkspaceMode;
20
+ writeScope: TaskWriteScope;
21
+ cwd?: string;
22
+ }
23
+
24
+ export interface NormalizedTaskRequest {
25
+ batch: boolean;
26
+ items: NormalizedTaskItem[];
27
+ }
28
+
29
+ const WriteScopeSchema = Type.Object({
30
+ paths: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
31
+ resources: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
32
+ }, { additionalProperties: false });
33
+
34
+ const ItemFields = {
35
+ task: Type.String({ minLength: 1 }),
36
+ context: Type.Optional(Type.String()),
37
+ agent: Type.Optional(Type.String({ minLength: 1 })),
38
+ name: Type.Optional(Type.String({ minLength: 1 })),
39
+ model: Type.Optional(Type.String({ minLength: 1 })),
40
+ async: Type.Optional(Type.Boolean({ description: "Set false to wait synchronously or true for background execution. Do not send blocking; blocking is profile-only internal metadata." })),
41
+ tools: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
42
+ workspace: Type.Optional(Type.Union([Type.Literal("auto"), Type.Literal("shared"), Type.Literal("isolated")])),
43
+ writeScope: Type.Optional(WriteScopeSchema),
44
+ cwd: Type.Optional(Type.String({ minLength: 1 })),
45
+ };
46
+
47
+ export const TASK_TOOL_SCHEMA = Type.Union([
48
+ Type.Object(ItemFields, { additionalProperties: false }),
49
+ Type.Object({
50
+ context: Type.Optional(Type.String()),
51
+ tasks: Type.Array(Type.Object(ItemFields, { additionalProperties: false }), { minItems: 1 }),
52
+ }, { additionalProperties: false }),
53
+ ]);
54
+
55
+ const ITEM_KEYS = new Set(Object.keys(ItemFields));
56
+ const BATCH_KEYS = new Set(["context", "tasks"]);
57
+
58
+ function record(value: unknown, label: string): Record<string, unknown> {
59
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
60
+ return value as Record<string, unknown>;
61
+ }
62
+
63
+ function rejectUnknownKeys(value: Record<string, unknown>, allowed: Set<string>, label: string): void {
64
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
65
+ if (unknown.length > 0) throw new Error(`${label} contains unknown fields: ${unknown.join(", ")}`);
66
+ }
67
+
68
+ function optionalString(value: unknown, label: string): string | undefined {
69
+ if (value === undefined) return undefined;
70
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${label} must be a non-empty string`);
71
+ return value.trim();
72
+ }
73
+
74
+ function stringArray(value: unknown, label: string): string[] | undefined {
75
+ if (value === undefined) return undefined;
76
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item.trim())) throw new Error(`${label} must be an array of non-empty strings`);
77
+ const normalized = value.map((item) => (item as string).trim());
78
+ if (new Set(normalized).size !== normalized.length) throw new Error(`${label} must not contain duplicates`);
79
+ return normalized;
80
+ }
81
+
82
+ function normalizeWriteScope(value: unknown, label: string): TaskWriteScope {
83
+ if (value === undefined) return { paths: [], resources: [] };
84
+ const scope = record(value, label);
85
+ rejectUnknownKeys(scope, new Set(["paths", "resources"]), label);
86
+ return {
87
+ paths: stringArray(scope.paths, `${label}.paths`) ?? [],
88
+ resources: stringArray(scope.resources, `${label}.resources`) ?? [],
89
+ };
90
+ }
91
+
92
+ function normalizeItem(
93
+ raw: unknown,
94
+ index: number,
95
+ roleBySelector: Map<string, RoleProfile>,
96
+ sharedContext?: string,
97
+ ): NormalizedTaskItem {
98
+ const label = `task item ${index + 1}`;
99
+ const item = record(raw, label);
100
+ rejectUnknownKeys(item, ITEM_KEYS, label);
101
+ const task = optionalString(item.task, `${label}.task`);
102
+ if (!task) throw new Error(`${label}.task is required`);
103
+ const selector = optionalString(item.agent, `${label}.agent`) ?? "general";
104
+ if (!roleBySelector.has(selector)) {
105
+ throw new Error(`${label}.agent '${selector}' is not canonical; available selectors: ${(BUNDLED_ROLE_SELECTORS as readonly string[]).join(", ")}`);
106
+ }
107
+ if (item.async !== undefined && typeof item.async !== "boolean") throw new Error(`${label}.async must be boolean`);
108
+ const workspace = item.workspace ?? "auto";
109
+ if (workspace !== "auto" && workspace !== "shared" && workspace !== "isolated") throw new Error(`${label}.workspace must be auto, shared, or isolated`);
110
+ const itemContext = item.context;
111
+ if (itemContext !== undefined && typeof itemContext !== "string") throw new Error(`${label}.context must be a string`);
112
+ const contextParts = [sharedContext, itemContext as string | undefined].filter((part): part is string => Boolean(part?.trim()));
113
+ return {
114
+ task,
115
+ context: contextParts.length > 0 ? contextParts.join("\n\n") : undefined,
116
+ agent: selector,
117
+ name: optionalString(item.name, `${label}.name`),
118
+ model: optionalString(item.model, `${label}.model`),
119
+ async: item.async as boolean | undefined,
120
+ tools: stringArray(item.tools, `${label}.tools`),
121
+ workspace,
122
+ writeScope: normalizeWriteScope(item.writeScope, `${label}.writeScope`),
123
+ cwd: optionalString(item.cwd, `${label}.cwd`),
124
+ };
125
+ }
126
+
127
+ export function validateTaskRequest(raw: unknown, profiles: RoleProfile[]): NormalizedTaskRequest {
128
+ const input = record(raw, "task input");
129
+ const roleBySelector = new Map(profiles.map((profile) => [profile.selector, profile]));
130
+ if (roleBySelector.size !== profiles.length) throw new Error("role catalog contains duplicate selectors");
131
+ const hasBatch = Object.prototype.hasOwnProperty.call(input, "tasks");
132
+ if (hasBatch) {
133
+ rejectUnknownKeys(input, BATCH_KEYS, "batch task input");
134
+ if (Object.prototype.hasOwnProperty.call(input, "task")) throw new Error("task input cannot mix flat task and tasks[]");
135
+ if (input.context !== undefined && typeof input.context !== "string") throw new Error("batch context must be a string");
136
+ if (!Array.isArray(input.tasks) || input.tasks.length === 0) throw new Error("batch tasks must be a non-empty array");
137
+ const sharedContext = typeof input.context === "string" ? input.context : undefined;
138
+ return { batch: true, items: input.tasks.map((item, index) => normalizeItem(item, index, roleBySelector, sharedContext)) };
139
+ }
140
+ return { batch: false, items: [normalizeItem(input, 0, roleBySelector)] };
141
+ }