pi-ultracode 0.1.3 → 0.3.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/README.md +47 -42
- package/examples/workflows/loop-until-dry-bugs.workflow.js +16 -6
- package/extensions/ultracode.ts +0 -1
- package/package.json +2 -2
- package/src/commands.ts +19 -23
- package/src/index.ts +1 -1
- package/src/mode.ts +6 -40
- package/src/prompts.ts +18 -28
- package/src/workflow/admission.ts +318 -0
- package/src/workflow/agent-runner.ts +1 -0
- package/src/workflow/display.ts +17 -13
- package/src/workflow/journal.ts +0 -0
- package/src/workflow/json-schema.ts +39 -17
- package/src/workflow/leases.ts +60 -0
- package/src/workflow/parser.ts +809 -5
- package/src/workflow/registry.ts +39 -22
- package/src/workflow/run-artifacts.ts +231 -0
- package/src/workflow/run-details.ts +106 -28
- package/src/workflow/runtime.ts +640 -303
- package/src/workflow/script-executor.ts +317 -0
- package/src/workflow/script-worker.mjs +678 -0
- package/src/workflow/structured-output.ts +2 -0
- package/src/workflow/tool.ts +207 -74
- package/src/workflow/value-limits.ts +363 -0
- package/src/workflow/worktree.ts +5 -3
- package/types/workflow.d.ts +60 -22
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
export const DEFAULT_MAX_AGENTS = 128;
|
|
2
|
+
export const ABSOLUTE_MAX_AGENTS = 1024;
|
|
3
|
+
export const MAX_ACTIVE_WORKFLOWS_PER_SESSION = 4;
|
|
4
|
+
|
|
5
|
+
export const WORKFLOW_POLICY_ERROR_CODE = "WORKFLOW_POLICY_ERROR";
|
|
6
|
+
export const WORKFLOW_ABORT_ERROR_CODE = "WORKFLOW_ABORTED";
|
|
7
|
+
export const WORKFLOW_STALL_ERROR_CODE = "WORKFLOW_STALLED";
|
|
8
|
+
export const WORKFLOW_CLEANUP_TIMEOUT_ERROR_CODE = "WORKFLOW_CLEANUP_TIMEOUT";
|
|
9
|
+
|
|
10
|
+
export class WorkflowPolicyError extends Error {
|
|
11
|
+
readonly code = WORKFLOW_POLICY_ERROR_CODE;
|
|
12
|
+
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "WorkflowPolicyError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class WorkflowAbortError extends Error {
|
|
20
|
+
readonly code = WORKFLOW_ABORT_ERROR_CODE;
|
|
21
|
+
|
|
22
|
+
constructor(message = "workflow aborted") {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "WorkflowAbortError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class WorkflowStallError extends Error {
|
|
29
|
+
readonly code = WORKFLOW_STALL_ERROR_CODE;
|
|
30
|
+
|
|
31
|
+
constructor(message: string) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "WorkflowStallError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class WorkflowCleanupTimeoutError extends Error {
|
|
38
|
+
readonly code = WORKFLOW_CLEANUP_TIMEOUT_ERROR_CODE;
|
|
39
|
+
|
|
40
|
+
constructor(timeoutMs: number) {
|
|
41
|
+
super(`workflow cleanup did not drain within ${timeoutMs}ms`);
|
|
42
|
+
this.name = "WorkflowCleanupTimeoutError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isWorkflowPolicyError(error: unknown): error is WorkflowPolicyError {
|
|
47
|
+
return !!error && typeof error === "object" && (error as any).code === WORKFLOW_POLICY_ERROR_CODE;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isWorkflowAbortError(error: unknown): error is WorkflowAbortError {
|
|
51
|
+
return !!error && typeof error === "object" && (error as any).code === WORKFLOW_ABORT_ERROR_CODE;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isWorkflowStallError(error: unknown): error is WorkflowStallError {
|
|
55
|
+
return !!error && typeof error === "object" && (error as any).code === WORKFLOW_STALL_ERROR_CODE;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isWorkflowCleanupTimeoutError(error: unknown): error is WorkflowCleanupTimeoutError {
|
|
59
|
+
return !!error && typeof error === "object" && (error as any).code === WORKFLOW_CLEANUP_TIMEOUT_ERROR_CODE;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function normalizeMaxAgents(value: unknown, name = "maxAgents"): number {
|
|
63
|
+
if (value === undefined || value === null) return DEFAULT_MAX_AGENTS;
|
|
64
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
65
|
+
throw new WorkflowPolicyError(`workflow ${name} must be an integer between 1 and ${ABSOLUTE_MAX_AGENTS}`);
|
|
66
|
+
}
|
|
67
|
+
if (value < 1 || value > ABSOLUTE_MAX_AGENTS) {
|
|
68
|
+
throw new WorkflowPolicyError(`workflow ${name} must be between 1 and ${ABSOLUTE_MAX_AGENTS} (got ${value})`);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function normalizeReservationSize(value: unknown, fallback: number): number {
|
|
74
|
+
if (value === undefined || value === null) return fallback;
|
|
75
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > ABSOLUTE_MAX_AGENTS) {
|
|
76
|
+
throw new WorkflowPolicyError(`parallel reserveAgents must be an integer between 0 and ${ABSOLUTE_MAX_AGENTS}`);
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface SlotEntry {
|
|
82
|
+
/**
|
|
83
|
+
* Return path for an unused slot. The first live reservation in this stack gets
|
|
84
|
+
* the slot back; if none remain, the slot returns to root capacity. This lets a
|
|
85
|
+
* nested panel transfer a sibling's branch slot without permanently stealing it
|
|
86
|
+
* from its immediate parent, while still preserving the parent's own upstream
|
|
87
|
+
* return path when that parent is released later.
|
|
88
|
+
*/
|
|
89
|
+
returnStack: string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface Reservation {
|
|
93
|
+
id: string;
|
|
94
|
+
slots: SlotEntry[];
|
|
95
|
+
contingentSlots: number;
|
|
96
|
+
contingentParentIds: string[];
|
|
97
|
+
released: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface PanelReservation {
|
|
101
|
+
panelReservationId: string;
|
|
102
|
+
branchReservationIds: string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class AgentAdmission {
|
|
106
|
+
readonly maxAgents: number;
|
|
107
|
+
private rootFree: number;
|
|
108
|
+
private consumed = 0;
|
|
109
|
+
private nextReservation = 0;
|
|
110
|
+
private readonly reservations = new Map<string, Reservation>();
|
|
111
|
+
|
|
112
|
+
constructor(maxAgents: number, usedAgents = 0) {
|
|
113
|
+
this.maxAgents = normalizeMaxAgents(maxAgents);
|
|
114
|
+
if (!Number.isInteger(usedAgents) || usedAgents < 0 || usedAgents > this.maxAgents) {
|
|
115
|
+
throw new WorkflowPolicyError(
|
|
116
|
+
`workflow usedAgents must be an integer between 0 and maxAgents=${this.maxAgents}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
this.consumed = usedAgents;
|
|
120
|
+
this.rootFree = this.maxAgents - usedAgents;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
get usedAgents(): number {
|
|
124
|
+
return this.consumed;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
get availableAgents(): number {
|
|
128
|
+
return this.rootFree;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
reservePanel(requestedSlots: number, parentReservationIds: string[] = []): string {
|
|
132
|
+
const slots = normalizeReservationSize(requestedSlots, requestedSlots);
|
|
133
|
+
const parents = this.uniqueReservations(parentReservationIds);
|
|
134
|
+
const parentFree = parents.reduce((sum, reservation) => sum + reservation.slots.length, 0);
|
|
135
|
+
const totalFree = parentFree + this.rootFree;
|
|
136
|
+
if (totalFree < slots) {
|
|
137
|
+
throw new WorkflowPolicyError(
|
|
138
|
+
`parallel() needs ${slots} agent slot(s), but only ${totalFree} remain under maxAgents=${this.maxAgents}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const entries: SlotEntry[] = [];
|
|
143
|
+
let remaining = slots;
|
|
144
|
+
for (const parent of parents) {
|
|
145
|
+
while (remaining > 0 && parent.slots.length > 0) {
|
|
146
|
+
const slot = parent.slots.shift()!;
|
|
147
|
+
entries.push({ returnStack: [parent.id, ...slot.returnStack] });
|
|
148
|
+
remaining--;
|
|
149
|
+
}
|
|
150
|
+
if (remaining === 0) break;
|
|
151
|
+
}
|
|
152
|
+
if (remaining > 0) {
|
|
153
|
+
this.rootFree -= remaining;
|
|
154
|
+
for (let i = 0; i < remaining; i++) entries.push({ returnStack: [] });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return this.createReservation(entries).id;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
reservePanelWithBranchNeeds(
|
|
161
|
+
requestedSlots: number,
|
|
162
|
+
branchNeedsSlot: boolean[],
|
|
163
|
+
parentReservationIds: string[] = [],
|
|
164
|
+
contingentSlots = 0,
|
|
165
|
+
): PanelReservation {
|
|
166
|
+
const branchCount = branchNeedsSlot.length;
|
|
167
|
+
const requiredBranches = branchNeedsSlot.filter(Boolean).length;
|
|
168
|
+
const slots = normalizeReservationSize(requestedSlots, requestedSlots);
|
|
169
|
+
if (branchCount > ABSOLUTE_MAX_AGENTS) {
|
|
170
|
+
throw new WorkflowPolicyError(`parallel branch count must be at most ${ABSOLUTE_MAX_AGENTS}`);
|
|
171
|
+
}
|
|
172
|
+
if (slots < requiredBranches) {
|
|
173
|
+
throw new WorkflowPolicyError(
|
|
174
|
+
`parallel replay needs at least ${requiredBranches} live branch slot(s); got ${slots}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const contingent = normalizeReservationSize(contingentSlots, contingentSlots);
|
|
178
|
+
const panelReservationId = this.reservePanel(slots, parentReservationIds);
|
|
179
|
+
const panelReservation = this.requireReservation(panelReservationId);
|
|
180
|
+
panelReservation.contingentSlots = contingent;
|
|
181
|
+
panelReservation.contingentParentIds = [...parentReservationIds];
|
|
182
|
+
const branchReservationIds: string[] = [];
|
|
183
|
+
try {
|
|
184
|
+
for (const needsSlot of branchNeedsSlot) {
|
|
185
|
+
branchReservationIds.push(this.reservePanel(needsSlot ? 1 : 0, [panelReservationId]));
|
|
186
|
+
}
|
|
187
|
+
return { panelReservationId, branchReservationIds };
|
|
188
|
+
} catch (error) {
|
|
189
|
+
for (const id of branchReservationIds) this.releasePanel(id);
|
|
190
|
+
this.releasePanel(panelReservationId);
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
reservePanelWithBranches(
|
|
196
|
+
requestedSlots: number,
|
|
197
|
+
branchCount: number,
|
|
198
|
+
parentReservationIds: string[] = [],
|
|
199
|
+
): PanelReservation {
|
|
200
|
+
const slots = normalizeReservationSize(requestedSlots, requestedSlots);
|
|
201
|
+
if (!Number.isInteger(branchCount) || branchCount < 0 || branchCount > ABSOLUTE_MAX_AGENTS) {
|
|
202
|
+
throw new WorkflowPolicyError(`parallel branch count must be an integer between 0 and ${ABSOLUTE_MAX_AGENTS}`);
|
|
203
|
+
}
|
|
204
|
+
if (slots < branchCount) {
|
|
205
|
+
throw new WorkflowPolicyError(
|
|
206
|
+
`parallel reserveAgents must be at least the number of thunks (${branchCount}); got ${slots}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const panelReservationId = this.reservePanel(slots, parentReservationIds);
|
|
211
|
+
const branchReservationIds: string[] = [];
|
|
212
|
+
try {
|
|
213
|
+
for (let i = 0; i < branchCount; i++) {
|
|
214
|
+
branchReservationIds.push(this.reservePanel(1, [panelReservationId]));
|
|
215
|
+
}
|
|
216
|
+
return { panelReservationId, branchReservationIds };
|
|
217
|
+
} catch (error) {
|
|
218
|
+
for (const id of branchReservationIds) this.releasePanel(id);
|
|
219
|
+
this.releasePanel(panelReservationId);
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
releasePanel(id: string): void {
|
|
225
|
+
const reservation = this.reservations.get(id);
|
|
226
|
+
if (!reservation || reservation.released) return;
|
|
227
|
+
reservation.released = true;
|
|
228
|
+
for (const slot of reservation.slots) this.returnSlot(slot);
|
|
229
|
+
reservation.slots = [];
|
|
230
|
+
this.reservations.delete(id);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
consumeAgent(reservationIds: string[] = []): number {
|
|
234
|
+
const reservations = this.uniqueReservations(reservationIds);
|
|
235
|
+
for (const reservation of reservations) {
|
|
236
|
+
if (reservation.slots.length > 0) {
|
|
237
|
+
reservation.slots.shift();
|
|
238
|
+
this.consumed++;
|
|
239
|
+
return this.consumed;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for (const reservation of reservations) {
|
|
243
|
+
if (reservation.contingentSlots > 0 && this.acquireContingentSlot(reservation)) {
|
|
244
|
+
reservation.contingentSlots--;
|
|
245
|
+
this.consumed++;
|
|
246
|
+
return this.consumed;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (reservationIds.length > 0) {
|
|
250
|
+
throw new WorkflowPolicyError(
|
|
251
|
+
"parallel() reservation has no agent slots left; increase reserveAgents for this panel or lower fan-out",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (this.rootFree < 1) {
|
|
255
|
+
throw new WorkflowPolicyError(`workflow exceeded maxAgents=${this.maxAgents} (no agent slots remain)`);
|
|
256
|
+
}
|
|
257
|
+
this.rootFree--;
|
|
258
|
+
this.consumed++;
|
|
259
|
+
return this.consumed;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private createReservation(slots: SlotEntry[]): Reservation {
|
|
263
|
+
const id = `res_${++this.nextReservation}`;
|
|
264
|
+
const reservation: Reservation = {
|
|
265
|
+
id,
|
|
266
|
+
slots,
|
|
267
|
+
contingentSlots: 0,
|
|
268
|
+
contingentParentIds: [],
|
|
269
|
+
released: false,
|
|
270
|
+
};
|
|
271
|
+
this.reservations.set(id, reservation);
|
|
272
|
+
return reservation;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private uniqueReservations(ids: string[]): Reservation[] {
|
|
276
|
+
const out: Reservation[] = [];
|
|
277
|
+
const seen = new Set<string>();
|
|
278
|
+
for (const id of ids) {
|
|
279
|
+
if (!id || seen.has(id)) continue;
|
|
280
|
+
seen.add(id);
|
|
281
|
+
out.push(this.requireReservation(id));
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private requireReservation(id: string): Reservation {
|
|
287
|
+
const reservation = this.reservations.get(id);
|
|
288
|
+
if (!reservation || reservation.released) {
|
|
289
|
+
throw new WorkflowPolicyError(`unknown or expired parallel reservation: ${id}`);
|
|
290
|
+
}
|
|
291
|
+
return reservation;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private acquireContingentSlot(reservation: Reservation): boolean {
|
|
295
|
+
for (const parent of this.uniqueReservations(reservation.contingentParentIds)) {
|
|
296
|
+
if (parent.slots.length === 0) continue;
|
|
297
|
+
parent.slots.shift();
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
if (this.rootFree < 1) return false;
|
|
301
|
+
this.rootFree--;
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private returnSlot(slot: SlotEntry): void {
|
|
306
|
+
const [targetId, ...rest] = slot.returnStack;
|
|
307
|
+
if (!targetId) {
|
|
308
|
+
this.rootFree++;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const target = this.reservations.get(targetId);
|
|
312
|
+
if (target && !target.released) {
|
|
313
|
+
target.slots.push({ returnStack: rest });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
this.returnSlot({ returnStack: rest });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -310,6 +310,7 @@ export class WorkflowAgentRunner {
|
|
|
310
310
|
sessionManager: SessionManager.inMemory(cwd),
|
|
311
311
|
settingsManager: SettingsManager.create(cwd, agentDir),
|
|
312
312
|
customTools,
|
|
313
|
+
excludeTools: ["workflow"],
|
|
313
314
|
...(model ? { model: model as any } : {}),
|
|
314
315
|
...(level ? { thinkingLevel: level as any } : {}),
|
|
315
316
|
...(toolAllowlist ? { tools: toolAllowlist } : {}),
|
package/src/workflow/display.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface WorkflowAgentUsageSnapshot {
|
|
|
31
31
|
|
|
32
32
|
export interface WorkflowAgentSnapshot {
|
|
33
33
|
id: number;
|
|
34
|
+
/** Stable orchestration identity; display order continues to use id. */
|
|
35
|
+
callPath?: string;
|
|
34
36
|
label: string;
|
|
35
37
|
phase?: string;
|
|
36
38
|
workflowPath?: string[];
|
|
@@ -84,16 +86,19 @@ export interface WorkflowSnapshot {
|
|
|
84
86
|
spentTokens: number;
|
|
85
87
|
newTokens: number;
|
|
86
88
|
replayedTokens: number;
|
|
89
|
+
/** Effective logical agent-slot cap for this run; usage-only, not a token budget. */
|
|
90
|
+
maxAgents?: number;
|
|
91
|
+
/** Lifetime live-agent admissions across resume generations. */
|
|
92
|
+
agentsUsed?: number;
|
|
87
93
|
/** Sanitized session-scoped workflow detail manifest for headless consumers. */
|
|
88
94
|
detailsManifestPath?: string;
|
|
89
|
-
budgetTotal: number | null;
|
|
90
95
|
durationMs?: number;
|
|
91
96
|
result?: unknown;
|
|
92
97
|
status: "running" | "completed" | "aborted" | "failed";
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
export interface RenderOptions {
|
|
96
|
-
|
|
101
|
+
maxAgentRows?: number;
|
|
97
102
|
maxLogs?: number;
|
|
98
103
|
showResultPreviews?: boolean;
|
|
99
104
|
/** @deprecated No-op retained for source compatibility. */
|
|
@@ -102,7 +107,7 @@ export interface RenderOptions {
|
|
|
102
107
|
now?: number;
|
|
103
108
|
}
|
|
104
109
|
|
|
105
|
-
export function createSnapshot(meta: WorkflowMeta, runId: string
|
|
110
|
+
export function createSnapshot(meta: WorkflowMeta, runId: string): WorkflowSnapshot {
|
|
106
111
|
return {
|
|
107
112
|
runId,
|
|
108
113
|
name: safeDisplayText(meta.name, 120) || "workflow",
|
|
@@ -119,7 +124,6 @@ export function createSnapshot(meta: WorkflowMeta, runId: string, budgetTotal: n
|
|
|
119
124
|
spentTokens: 0,
|
|
120
125
|
newTokens: 0,
|
|
121
126
|
replayedTokens: 0,
|
|
122
|
-
budgetTotal,
|
|
123
127
|
status: "running",
|
|
124
128
|
};
|
|
125
129
|
}
|
|
@@ -134,7 +138,7 @@ export function recompute(snapshot: WorkflowSnapshot): WorkflowSnapshot {
|
|
|
134
138
|
}
|
|
135
139
|
|
|
136
140
|
export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: RenderOptions = {}): string[] {
|
|
137
|
-
const
|
|
141
|
+
const maxAgentRows = options.maxAgentRows ?? 6;
|
|
138
142
|
const maxLogs = options.maxLogs ?? 2;
|
|
139
143
|
const showResultPreviews = options.showResultPreviews ?? false;
|
|
140
144
|
const now = options.now ?? Date.now();
|
|
@@ -145,9 +149,6 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: RenderO
|
|
|
145
149
|
: snapshot.spentTokens
|
|
146
150
|
? ` · ${formatTokens(snapshot.spentTokens)} output token`
|
|
147
151
|
: "";
|
|
148
|
-
const budget = snapshot.budgetTotal
|
|
149
|
-
? ` · ${formatTokens(snapshot.spentTokens)}/${formatTokens(snapshot.budgetTotal)} out`
|
|
150
|
-
: "";
|
|
151
152
|
const stateParts = [
|
|
152
153
|
snapshot.runningCount > 0 ? `${snapshot.runningCount} running` : undefined,
|
|
153
154
|
snapshot.errorCount > 0 ? `${snapshot.errorCount} errors` : undefined,
|
|
@@ -155,7 +156,10 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: RenderO
|
|
|
155
156
|
].filter(Boolean);
|
|
156
157
|
const state = stateParts.length ? `, ${stateParts.join(", ")}` : "";
|
|
157
158
|
const cached = snapshot.cachedCount ? ` · ${snapshot.cachedCount} cached` : "";
|
|
158
|
-
const
|
|
159
|
+
const agentSlots = snapshot.maxAgents
|
|
160
|
+
? ` · agents ${snapshot.agentsUsed ?? snapshot.agentCount}/${snapshot.maxAgents}`
|
|
161
|
+
: "";
|
|
162
|
+
const header = `◆ ${statusMark(snapshot.status)} ${shorten(snapshot.name, 60)} (${snapshot.doneCount}/${snapshot.agentCount} done${state})${cached}${agentSlots}${tokens}`;
|
|
159
163
|
const lines = [header];
|
|
160
164
|
|
|
161
165
|
const phaseNames = unique([
|
|
@@ -186,19 +190,19 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: RenderO
|
|
|
186
190
|
lines.push(
|
|
187
191
|
` ${marker} ${shorten(phase, 60)} ${done}/${agents.length}${running ? ` · ${running} running` : ""}${errors ? ` · ${errors} errors` : ""}${cancelled ? ` · ${cancelled} cancelled` : ""}`,
|
|
188
192
|
);
|
|
189
|
-
for (const agent of agents.slice(-
|
|
193
|
+
for (const agent of agents.slice(-maxAgentRows)) {
|
|
190
194
|
lines.push(...renderAgentLines(agent, { showResultPreviews, now }));
|
|
191
195
|
}
|
|
192
|
-
if (agents.length >
|
|
196
|
+
if (agents.length > maxAgentRows) lines.push(` … ${agents.length - maxAgentRows} earlier agents`);
|
|
193
197
|
}
|
|
194
198
|
|
|
195
199
|
const unphased = snapshot.agents.filter((a) => !rendered.has(a));
|
|
196
200
|
if (unphased.length) {
|
|
197
201
|
lines.push(" (unphased)");
|
|
198
|
-
for (const agent of unphased.slice(-
|
|
202
|
+
for (const agent of unphased.slice(-maxAgentRows)) {
|
|
199
203
|
lines.push(...renderAgentLines(agent, { showResultPreviews, now }));
|
|
200
204
|
}
|
|
201
|
-
if (unphased.length >
|
|
205
|
+
if (unphased.length > maxAgentRows) lines.push(` … ${unphased.length - maxAgentRows} earlier agents`);
|
|
202
206
|
}
|
|
203
207
|
|
|
204
208
|
for (const log of snapshot.logs.slice(-maxLogs)) lines.push(` log: ${shorten(log, 100)}`);
|
package/src/workflow/journal.ts
CHANGED
|
Binary file
|
|
@@ -3,15 +3,21 @@
|
|
|
3
3
|
* `agent(prompt, { schema })`) into a TypeBox `TSchema` so Pi can both validate
|
|
4
4
|
* the subagent's structured output and serialize a faithful tool schema to the model.
|
|
5
5
|
*
|
|
6
|
-
* Covers the
|
|
7
|
-
*
|
|
6
|
+
* Covers the explicitly validated workflow JSON Schema subset. Unsupported
|
|
7
|
+
* keywords are rejected before conversion rather than silently weakened.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { Type, type TSchema } from "typebox";
|
|
11
|
+
import { assertWorkflowSchemaLimit } from "./value-limits.ts";
|
|
11
12
|
|
|
12
13
|
type Json = Record<string, any>;
|
|
13
14
|
|
|
14
15
|
export function jsonSchemaToTypeBox(schema: unknown): TSchema {
|
|
16
|
+
assertWorkflowSchemaLimit(schema);
|
|
17
|
+
return convertJsonSchema(schema);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function convertJsonSchema(schema: unknown): TSchema {
|
|
15
21
|
if (!schema || typeof schema !== "object") {
|
|
16
22
|
// No constraints -> accept anything.
|
|
17
23
|
return Type.Unsafe<unknown>({});
|
|
@@ -21,27 +27,29 @@ export function jsonSchemaToTypeBox(schema: unknown): TSchema {
|
|
|
21
27
|
|
|
22
28
|
// Composite keywords first.
|
|
23
29
|
if (Array.isArray(node.enum)) {
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
return withAnnotations(Type.Unsafe<unknown>({
|
|
31
|
+
...(node.type !== undefined ? { type: node.type } : {}),
|
|
32
|
+
enum: node.enum,
|
|
33
|
+
}), annotations);
|
|
26
34
|
}
|
|
27
35
|
if ("const" in node) {
|
|
28
|
-
return withAnnotations(Type.
|
|
36
|
+
return withAnnotations(Type.Unsafe<unknown>({
|
|
37
|
+
...(node.type !== undefined ? { type: node.type } : {}),
|
|
38
|
+
const: node.const,
|
|
39
|
+
}), annotations);
|
|
29
40
|
}
|
|
30
41
|
if (Array.isArray(node.anyOf)) {
|
|
31
|
-
return withAnnotations(Type.Union(node.anyOf.map(
|
|
32
|
-
}
|
|
33
|
-
if (Array.isArray(node.oneOf)) {
|
|
34
|
-
return withAnnotations(Type.Union(node.oneOf.map(jsonSchemaToTypeBox)), annotations);
|
|
42
|
+
return withAnnotations(Type.Union(node.anyOf.map(convertJsonSchema)), annotations);
|
|
35
43
|
}
|
|
36
44
|
if (Array.isArray(node.allOf)) {
|
|
37
|
-
return withAnnotations(Type.Intersect(node.allOf.map(
|
|
45
|
+
return withAnnotations(Type.Intersect(node.allOf.map(convertJsonSchema)), annotations);
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
const type = node.type;
|
|
41
49
|
if (Array.isArray(type)) {
|
|
42
50
|
// e.g. ["string", "null"]
|
|
43
51
|
return withAnnotations(
|
|
44
|
-
Type.Union(type.map((t: string) =>
|
|
52
|
+
Type.Union(type.map((t: string) => convertJsonSchema({ ...node, type: t, enum: undefined }))),
|
|
45
53
|
annotations,
|
|
46
54
|
);
|
|
47
55
|
}
|
|
@@ -70,33 +78,47 @@ export function jsonSchemaToTypeBox(schema: unknown): TSchema {
|
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
function objectSchema(node: Json): TSchema {
|
|
73
|
-
const properties: Record<string, TSchema> =
|
|
81
|
+
const properties: Record<string, TSchema> = Object.create(null);
|
|
74
82
|
const required: string[] = Array.isArray(node.required) ? node.required : [];
|
|
75
83
|
const props = (node.properties ?? {}) as Json;
|
|
76
84
|
for (const [key, value] of Object.entries(props)) {
|
|
77
|
-
const child =
|
|
85
|
+
const child = convertJsonSchema(value);
|
|
78
86
|
properties[key] = required.includes(key) ? child : Type.Optional(child);
|
|
79
87
|
}
|
|
80
88
|
const options: Json = {};
|
|
81
89
|
if (node.additionalProperties === false) options.additionalProperties = false;
|
|
82
90
|
else if (node.additionalProperties && typeof node.additionalProperties === "object") {
|
|
83
|
-
options.additionalProperties =
|
|
91
|
+
options.additionalProperties = convertJsonSchema(node.additionalProperties);
|
|
84
92
|
}
|
|
85
93
|
return Type.Object(properties, options);
|
|
86
94
|
}
|
|
87
95
|
|
|
88
96
|
function arraySchema(node: Json): TSchema {
|
|
89
|
-
const items = node.items ? jsonSchemaToTypeBox(Array.isArray(node.items) ? node.items[0] : node.items) : Type.Unknown();
|
|
90
97
|
const options: Json = {};
|
|
91
98
|
if (typeof node.minItems === "number") options.minItems = node.minItems;
|
|
92
99
|
if (typeof node.maxItems === "number") options.maxItems = node.maxItems;
|
|
93
100
|
if (node.uniqueItems === true) options.uniqueItems = true;
|
|
94
|
-
|
|
101
|
+
if (Array.isArray(node.items)) {
|
|
102
|
+
return Type.Tuple(node.items.map(convertJsonSchema), {
|
|
103
|
+
...options,
|
|
104
|
+
additionalItems: true,
|
|
105
|
+
minItems: options.minItems ?? 0,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return Type.Array(node.items ? convertJsonSchema(node.items) : Type.Unknown(), options);
|
|
95
109
|
}
|
|
96
110
|
|
|
97
111
|
function numericAndStringConstraints(node: Json): Json {
|
|
98
112
|
const out: Json = {};
|
|
99
|
-
for (const key of [
|
|
113
|
+
for (const key of [
|
|
114
|
+
"minimum",
|
|
115
|
+
"maximum",
|
|
116
|
+
"exclusiveMinimum",
|
|
117
|
+
"exclusiveMaximum",
|
|
118
|
+
"multipleOf",
|
|
119
|
+
"minLength",
|
|
120
|
+
"maxLength",
|
|
121
|
+
]) {
|
|
100
122
|
if (node[key] !== undefined) out[key] = node[key];
|
|
101
123
|
}
|
|
102
124
|
return out;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { MAX_ACTIVE_WORKFLOWS_PER_SESSION, WorkflowPolicyError } from "./admission.ts";
|
|
3
|
+
|
|
4
|
+
interface ScopeState {
|
|
5
|
+
activeRunIds: Set<string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const scopes = new Map<string, ScopeState>();
|
|
9
|
+
|
|
10
|
+
export interface WorkflowLease {
|
|
11
|
+
readonly runsDir: string;
|
|
12
|
+
readonly runId: string;
|
|
13
|
+
release(): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function acquireWorkflowLease(runsDir: string, runId: string): WorkflowLease {
|
|
17
|
+
const key = normalizeScope(runsDir);
|
|
18
|
+
let state = scopes.get(key);
|
|
19
|
+
if (!state) {
|
|
20
|
+
state = { activeRunIds: new Set() };
|
|
21
|
+
scopes.set(key, state);
|
|
22
|
+
}
|
|
23
|
+
if (state.activeRunIds.has(runId)) {
|
|
24
|
+
throw new WorkflowPolicyError(`workflow runId ${runId} is already active in this session`);
|
|
25
|
+
}
|
|
26
|
+
if (state.activeRunIds.size >= MAX_ACTIVE_WORKFLOWS_PER_SESSION) {
|
|
27
|
+
throw new WorkflowPolicyError(
|
|
28
|
+
`too many active workflows in this session (${state.activeRunIds.size}/${MAX_ACTIVE_WORKFLOWS_PER_SESSION}); wait for one to finish`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
state.activeRunIds.add(runId);
|
|
32
|
+
let released = false;
|
|
33
|
+
return {
|
|
34
|
+
runsDir: key,
|
|
35
|
+
runId,
|
|
36
|
+
release() {
|
|
37
|
+
if (released) return;
|
|
38
|
+
released = true;
|
|
39
|
+
const current = scopes.get(key);
|
|
40
|
+
current?.activeRunIds.delete(runId);
|
|
41
|
+
if (current && current.activeRunIds.size === 0) scopes.delete(key);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function activeWorkflowCount(runsDir: string): number {
|
|
47
|
+
return scopes.get(normalizeScope(runsDir))?.activeRunIds.size ?? 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function clearWorkflowLeasesForTests(): void {
|
|
51
|
+
scopes.clear();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeScope(runsDir: string): string {
|
|
55
|
+
try {
|
|
56
|
+
return path.resolve(runsDir);
|
|
57
|
+
} catch {
|
|
58
|
+
return runsDir;
|
|
59
|
+
}
|
|
60
|
+
}
|