@abelxiaoxing/cadence 1.8.2 → 1.8.4

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.
@@ -1,640 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import path from "node:path";
3
- import {
4
- type AssistantMessage,
5
- type Context,
6
- type Model,
7
- type Usage,
8
- validateToolArguments,
9
- } from "@earendil-works/pi-ai";
10
- import { defineTool } from "@earendil-works/pi-coding-agent";
11
- import { Type } from "typebox";
12
- import { childContextBudget, classifyProviderFailure } from "./child-budget.ts";
13
- import {
14
- abortable,
15
- type ChildModelClient,
16
- disposeChildTransport,
17
- requestChildTurn,
18
- } from "./child-model.ts";
19
- import type {
20
- ChildFailure,
21
- DiffResult,
22
- EvidenceResult,
23
- IdentityDimension,
24
- SafeFailureDetails,
25
- } from "./contracts.ts";
26
- import {
27
- createScopedTools,
28
- type Observation,
29
- ScopedObservationCollector,
30
- TOOL_LIMITS,
31
- } from "./scoped-tools.ts";
32
- import {
33
- type CandidateArtifactSubmission,
34
- type CandidateArtifactSubmissionResult,
35
- createCandidateArtifactTool,
36
- createStructuredPatchTool,
37
- createSubmitTool,
38
- type SubmitClassification,
39
- } from "./submit-tool.ts";
40
- import { serializeTaskLedgerProjection } from "./task-ledger.ts";
41
- import { TransportTimeout } from "./transport-budget.ts";
42
-
43
- export const CHILD_EXECUTION_LIMITS = Object.freeze({
44
- maxTurns: 64,
45
- maxContextBytes: 4 * 1024 * 1024,
46
- });
47
-
48
- const ZERO_USAGE: Usage = {
49
- input: 0,
50
- output: 0,
51
- cacheRead: 0,
52
- cacheWrite: 0,
53
- totalTokens: 0,
54
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
55
- };
56
-
57
- export class UsageAggregator {
58
- private readonly ids = new Set<string>();
59
- private usage: Usage = structuredClone(ZERO_USAGE);
60
- add(id: string, value: Usage): boolean {
61
- if (this.ids.has(id)) return false;
62
- this.ids.add(id);
63
- const cacheWrite1h =
64
- this.usage.cacheWrite1h === undefined && value.cacheWrite1h === undefined
65
- ? undefined
66
- : (this.usage.cacheWrite1h ?? 0) + (value.cacheWrite1h ?? 0);
67
- const reasoning =
68
- this.usage.reasoning === undefined && value.reasoning === undefined
69
- ? undefined
70
- : (this.usage.reasoning ?? 0) + (value.reasoning ?? 0);
71
- this.usage = {
72
- input: this.usage.input + value.input,
73
- output: this.usage.output + value.output,
74
- cacheRead: this.usage.cacheRead + value.cacheRead,
75
- cacheWrite: this.usage.cacheWrite + value.cacheWrite,
76
- ...(cacheWrite1h === undefined ? {} : { cacheWrite1h }),
77
- ...(reasoning === undefined ? {} : { reasoning }),
78
- totalTokens: this.usage.totalTokens + value.totalTokens,
79
- cost: {
80
- input: this.usage.cost.input + value.cost.input,
81
- output: this.usage.cost.output + value.cost.output,
82
- cacheRead: this.usage.cost.cacheRead + value.cost.cacheRead,
83
- cacheWrite: this.usage.cost.cacheWrite + value.cost.cacheWrite,
84
- total: this.usage.cost.total + value.cost.total,
85
- },
86
- };
87
- return true;
88
- }
89
- hasUsage(): boolean {
90
- return this.ids.size > 0;
91
- }
92
- total(): Usage {
93
- return structuredClone(this.usage);
94
- }
95
- }
96
-
97
- function wrapScopedTools(
98
- roots: string[],
99
- cwd: string,
100
- allowedPaths?: string[],
101
- observer?: (observation: Observation) => void,
102
- ) {
103
- const order = ["read", "grep", "find", "ls"];
104
- return createScopedTools({
105
- cwd,
106
- roots,
107
- ...(allowedPaths
108
- ? {
109
- allowedPaths: allowedPaths.map((relative) =>
110
- path.resolve(cwd, relative),
111
- ),
112
- }
113
- : {}),
114
- ...(observer ? { observer } : {}),
115
- })
116
- .sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name))
117
- .map((scoped) =>
118
- defineTool({
119
- name: scoped.name,
120
- label: scoped.name,
121
- description: scoped.description,
122
- parameters:
123
- scoped.name === "grep"
124
- ? Type.Object({
125
- path: Type.Optional(Type.String()),
126
- pattern: Type.String(),
127
- })
128
- : scoped.name === "find"
129
- ? Type.Object({
130
- path: Type.Optional(Type.String()),
131
- pattern: Type.String(),
132
- limit: Type.Optional(
133
- Type.Integer({
134
- minimum: 1,
135
- maximum: TOOL_LIMITS.maxEntries,
136
- }),
137
- ),
138
- })
139
- : scoped.name === "read"
140
- ? Type.Object({
141
- path: Type.String(),
142
- offset: Type.Optional(Type.Integer({ minimum: 1 })),
143
- limit: Type.Optional(Type.Integer({ minimum: 1 })),
144
- })
145
- : Type.Object({
146
- path: Type.Optional(Type.String()),
147
- }),
148
- async execute(_id, params, signal) {
149
- const result = await scoped.execute(
150
- params as Record<string, unknown>,
151
- signal,
152
- );
153
- return {
154
- content: [{ type: "text" as const, text: JSON.stringify(result) }],
155
- details: result,
156
- };
157
- },
158
- }),
159
- );
160
- }
161
-
162
- export type ChildFailureKind = "failed" | "cancelled" | "timed-out";
163
-
164
- function finalDeliveryContent(message: AssistantMessage | undefined) {
165
- return (
166
- message?.content.filter(
167
- (content) =>
168
- content.type !== "thinking" &&
169
- !(content.type === "text" && content.text.trim().length === 0),
170
- ) ?? []
171
- );
172
- }
173
-
174
- function safeChildError(failure: ChildFailure): string {
175
- switch (failure.kind) {
176
- case "environment":
177
- return "child environment unavailable";
178
- case "transport":
179
- switch (failure.code) {
180
- case "child-timeout":
181
- case "timeout":
182
- return "child phase timeout";
183
- case "child-no-final-assistant":
184
- return "child produced no final assistant";
185
- case "child-provider-stream-aborted":
186
- return "child provider stream aborted";
187
- case "child-provider-stream-error":
188
- case "transport-failure":
189
- return "child provider stream failed";
190
- }
191
- return "child provider stream failed";
192
- case "artifact":
193
- return failure.code === "child-no-structural-submit"
194
- ? "child produced no structural submission"
195
- : "child structural submission is invalid";
196
- case "cancelled":
197
- return "child phase cancelled";
198
- case "stale":
199
- case "approval-boundary":
200
- case "verification-adapter":
201
- case "execution-limit":
202
- case "result-limit":
203
- return "child result rejected";
204
- }
205
- }
206
-
207
- function submitDetails(
208
- classification: SubmitClassification,
209
- ): SafeFailureDetails {
210
- const identityMismatch = (
211
- ["request", "role", "task", "phase"] as IdentityDimension[]
212
- ).filter((dimension) => !classification.identity[dimension]);
213
- return {
214
- finalCategory: classification.finalCategory,
215
- submitAttempts: Math.min(classification.attempts, 2),
216
- schema: classification.schema,
217
- ...(identityMismatch.length > 0 ? { identityMismatch } : {}),
218
- };
219
- }
220
-
221
- function withSubmitDetails(
222
- failure: ChildFailure,
223
- classification: SubmitClassification,
224
- ): ChildFailure {
225
- return failure.kind === "artifact"
226
- ? {
227
- ...failure,
228
- details: {
229
- ...failure.details,
230
- ...submitDetails(classification),
231
- },
232
- }
233
- : failure;
234
- }
235
-
236
- export type ChildSessionResult =
237
- | {
238
- ok: true;
239
- result: EvidenceResult | DiffResult | CandidateArtifactSubmissionResult;
240
- toolNames: string[];
241
- submitCount: number;
242
- disposeCount: number;
243
- usage: Usage;
244
- classification: SubmitClassification;
245
- observations?: { observations: Observation[]; truncated: boolean };
246
- }
247
- | {
248
- ok: false;
249
- error: string;
250
- failure: ChildFailure;
251
- failureKind: ChildFailureKind;
252
- transportFailure: boolean;
253
- disposeCount: number;
254
- usage: Usage;
255
- classification: SubmitClassification;
256
- observations?: { observations: Observation[]; truncated: boolean };
257
- };
258
-
259
- export async function runChildSession(input: {
260
- cwd: string;
261
- modelRuntime: ChildModelClient;
262
- model: Model<string>;
263
- systemPrompt: string;
264
- requestId: string;
265
- taskId?: string;
266
- role: string;
267
- phase?: string;
268
- output: "evidence" | "diff";
269
- roots: string[];
270
- allowedPaths?: string[];
271
- timeoutMs: number;
272
- signal?: AbortSignal;
273
- ledgerProjection?: unknown;
274
- captureObservations?: boolean;
275
- candidateArtifact?: CandidateArtifactSubmission;
276
- structuredPatch?: {
277
- workspaceRoot: string;
278
- writePaths: string[];
279
- deletePaths: string[];
280
- };
281
- onStreamStart?: () => void;
282
- onStreamHeaders?: () => void;
283
- onStreamProgress?: () => void;
284
- }): Promise<ChildSessionResult> {
285
- const submit = input.candidateArtifact
286
- ? createCandidateArtifactTool(input.candidateArtifact)
287
- : input.structuredPatch
288
- ? createStructuredPatchTool({
289
- requestId: input.requestId,
290
- taskId: input.taskId,
291
- role: input.role,
292
- phase: input.phase ?? "red",
293
- workspaceRoot: input.structuredPatch.workspaceRoot,
294
- writePaths: input.structuredPatch.writePaths,
295
- deletePaths: input.structuredPatch.deletePaths,
296
- })
297
- : createSubmitTool({
298
- requestId: input.requestId,
299
- taskId: input.taskId,
300
- role: input.role,
301
- phase: input.phase ?? "red",
302
- output: input.output,
303
- });
304
- const observationCollector = input.captureObservations
305
- ? new ScopedObservationCollector()
306
- : undefined;
307
- const readTools = wrapScopedTools(
308
- input.roots,
309
- input.cwd,
310
- input.allowedPaths,
311
- observationCollector?.observe,
312
- );
313
- const customTools = [...readTools, submit.tool];
314
- const toolNames = customTools.map((tool) => tool.name);
315
- let disposeCount = 0;
316
- const usage = new UsageAggregator();
317
- const effectivePrompt =
318
- input.ledgerProjection === undefined
319
- ? input.systemPrompt
320
- : [
321
- input.systemPrompt,
322
- "",
323
- "<abel-task-ledger-projection>",
324
- serializeTaskLedgerProjection(input.ledgerProjection),
325
- "</abel-task-ledger-projection>",
326
- ].join("\n");
327
- const observationMetadata = () =>
328
- observationCollector
329
- ? { observations: observationCollector.projection() }
330
- : {};
331
- const abort = new AbortController();
332
- const forwardCancellation = () =>
333
- abort.abort(input.signal?.reason ?? new Error("child phase cancelled"));
334
- if (input.signal?.aborted) forwardCancellation();
335
- else
336
- input.signal?.addEventListener("abort", forwardCancellation, {
337
- once: true,
338
- });
339
- let timedOut = false;
340
- const timer = setTimeout(() => {
341
- timedOut = true;
342
- abort.abort(new Error("child phase timeout"));
343
- }, input.timeoutMs);
344
- const context: Context = {
345
- systemPrompt: effectivePrompt,
346
- messages: [
347
- {
348
- role: "user",
349
- content: "Complete the bounded task and submit its result.",
350
- timestamp: Date.now(),
351
- },
352
- ],
353
- tools: customTools.map(({ name, description, parameters }) => ({
354
- name,
355
- description,
356
- parameters,
357
- })),
358
- };
359
- const sessionId = randomUUID();
360
- let assistantSequence = 0;
361
- let structuralAttempts = 0;
362
- let last: AssistantMessage | undefined;
363
- let preflightRejected = false;
364
- let turns = 0;
365
- const contextBudget = childContextBudget(input.model);
366
- let reminderUsed = false;
367
- const observeStart = () => {
368
- try {
369
- input.onStreamStart?.();
370
- } catch {
371
- /* observation only */
372
- }
373
- };
374
- const observeProgress = () => {
375
- try {
376
- input.onStreamProgress?.();
377
- } catch {
378
- /* observation only */
379
- }
380
- };
381
- const classification = (): SubmitClassification => ({
382
- finalCategory:
383
- submit.getResult() !== undefined
384
- ? structuralAttempts > 1
385
- ? "multiple-submit"
386
- : "single-submit-only"
387
- : !last
388
- ? "no-final-assistant"
389
- : finalDeliveryContent(last).length === 1 &&
390
- finalDeliveryContent(last)[0]?.type === "text"
391
- ? "text-only"
392
- : "mixed",
393
- attempts: structuralAttempts,
394
- schema: preflightRejected ? "invalid" : submit.getSchema(),
395
- identity: submit.getIdentity(),
396
- });
397
- let failure: ChildFailure | undefined;
398
- const missingResult = (): ChildFailure => ({
399
- kind: "artifact",
400
- code:
401
- structuralAttempts === 0
402
- ? "child-no-structural-submit"
403
- : "invalid-structural-result",
404
- stage:
405
- structuralAttempts === 0 ? "child-finalization" : "structural-submit",
406
- });
407
- try {
408
- for (;;) {
409
- abort.signal.throwIfAborted();
410
- if (turns >= CHILD_EXECUTION_LIMITS.maxTurns) {
411
- failure = { kind: "execution-limit", code: "child-turn-limit" };
412
- break;
413
- }
414
- if (Buffer.byteLength(JSON.stringify(context), "utf8") > contextBudget) {
415
- failure = { kind: "execution-limit", code: "child-context-limit" };
416
- break;
417
- }
418
- turns++;
419
- const message = await requestChildTurn({
420
- client: input.modelRuntime,
421
- model: input.model,
422
- context,
423
- signal: abort.signal,
424
- sessionId,
425
- onStart: observeStart,
426
- onHeaders: input.onStreamHeaders,
427
- onProgress: observeProgress,
428
- onMessage: (message) => {
429
- usage.add(`assistant:${assistantSequence++}`, message.usage);
430
- },
431
- });
432
- last = message;
433
- if (!message) {
434
- failure = {
435
- kind: "transport",
436
- code: "child-no-final-assistant",
437
- stage: "child-finalization",
438
- };
439
- break;
440
- }
441
- context.messages.push(message);
442
- if (Buffer.byteLength(JSON.stringify(context), "utf8") > contextBudget) {
443
- failure = { kind: "execution-limit", code: "child-context-limit" };
444
- break;
445
- }
446
- // Only complete, normal turns may execute tools, including terminal submit.
447
- if (message.stopReason === "error" || message.stopReason === "aborted") {
448
- failure =
449
- message.stopReason === "error"
450
- ? classifyProviderFailure(message.errorMessage)
451
- : {
452
- kind: "transport",
453
- code: "child-provider-stream-aborted",
454
- stage: "child-provider-stream",
455
- };
456
- break;
457
- }
458
- if (message.stopReason !== "toolUse" && message.stopReason !== "stop") {
459
- failure = missingResult();
460
- break;
461
- }
462
- const calls = message.content.filter((part) => part.type === "toolCall");
463
- if (calls.length === 0) {
464
- if (
465
- !reminderUsed &&
466
- structuralAttempts === 0 &&
467
- message.stopReason === "stop" &&
468
- finalDeliveryContent(message).length > 0 &&
469
- finalDeliveryContent(message).every((part) => part.type === "text")
470
- ) {
471
- reminderUsed = true;
472
- context.messages.push({
473
- role: "user",
474
- timestamp: Date.now(),
475
- content:
476
- "No result has been accepted. Submit existing findings through abel_submit_result, not prose. Do not repeat investigation or expand scope. Keep evidence gaps explicit; use context-request if available and needed. This is the only missing-submit reminder; the original deadline still applies.",
477
- });
478
- continue;
479
- }
480
- failure = missingResult();
481
- break;
482
- }
483
- // Source-order execution is a Cadence contract, not Pi's batch scheduling.
484
- // Process the batch even after acceptance to reject a duplicate terminal
485
- // submission while still allowing harmless scoped reads in that batch.
486
- for (const call of calls) {
487
- abort.signal.throwIfAborted();
488
- const isSubmit = call.name === "abel_submit_result";
489
- if (isSubmit) {
490
- structuralAttempts++;
491
- if (structuralAttempts > 2) {
492
- failure = {
493
- kind: "artifact",
494
- code: "invalid-structural-result",
495
- stage: "structural-submit",
496
- };
497
- break;
498
- }
499
- if (submit.getResult() !== undefined) {
500
- preflightRejected = true;
501
- failure = {
502
- kind: "artifact",
503
- code: "invalid-structural-result",
504
- stage: "structural-submit",
505
- };
506
- break;
507
- }
508
- }
509
- const tool = customTools.find((tool) => tool.name === call.name);
510
- let content: Array<{ type: "text"; text: string }>;
511
- let isError = false;
512
- let validated = false;
513
- try {
514
- if (!tool) throw new Error("unknown scoped tool");
515
- const params = validateToolArguments(tool, call);
516
- validated = true;
517
- if (isSubmit) preflightRejected = false;
518
- const result = await abortable(
519
- tool.execute(
520
- call.id,
521
- params as never,
522
- abort.signal,
523
- undefined,
524
- {} as never,
525
- ),
526
- abort.signal,
527
- );
528
- content = result.content as typeof content;
529
- } catch (error) {
530
- abort.signal.throwIfAborted();
531
- isError = true;
532
- if (isSubmit && !validated) preflightRejected = true;
533
- content = [
534
- {
535
- type: "text",
536
- text:
537
- error instanceof Error
538
- ? error.message
539
- : "tool execution failed",
540
- },
541
- ];
542
- }
543
- context.messages.push({
544
- role: "toolResult",
545
- toolCallId: call.id,
546
- toolName: call.name,
547
- content,
548
- isError,
549
- timestamp: Date.now(),
550
- });
551
- }
552
- if (failure) break;
553
- if (submit.getResult() !== undefined) break;
554
- if (
555
- structuralAttempts >= 2 ||
556
- submit.getFailure()?.kind === "result-limit"
557
- ) {
558
- failure = missingResult();
559
- break;
560
- }
561
- }
562
- } catch (error) {
563
- failure =
564
- error instanceof TransportTimeout
565
- ? {
566
- kind: "transport",
567
- code: error.code,
568
- stage: "child-provider-stream",
569
- }
570
- : classifyProviderFailure(error);
571
- } finally {
572
- clearTimeout(timer);
573
- input.signal?.removeEventListener("abort", forwardCancellation);
574
- // Dispose our conversation and only this child's provider session resources.
575
- context.messages.length = 0;
576
- try {
577
- disposeChildTransport(sessionId);
578
- } catch {
579
- failure ??= {
580
- kind: "environment",
581
- code: "sandbox-runtime-unavailable",
582
- stage: "child-finalization",
583
- };
584
- }
585
- disposeCount++;
586
- }
587
- const result = submit.getResult();
588
- // All exit paths share the same priority, including thrown/empty streams.
589
- // A later transport failure cannot erase a rejected submission. Preflight
590
- // rejection never reaches execute, so retain it independently of the tool.
591
- // A valid correction clears both the tool failure and preflightRejected.
592
- const terminalFailure: ChildFailure | undefined = timedOut
593
- ? { kind: "transport", code: "child-timeout", stage: "child-timeout" }
594
- : input.signal?.aborted
595
- ? { kind: "cancelled", code: "cancelled" }
596
- : (submit.getFailure() ??
597
- (preflightRejected ? missingResult() : undefined) ??
598
- failure ??
599
- (!result ? missingResult() : undefined));
600
- const projection = classification();
601
- if (terminalFailure) {
602
- const typed = withSubmitDetails(terminalFailure, projection);
603
- return {
604
- ok: false,
605
- error:
606
- typed.kind === "cancelled" && input.signal?.reason instanceof Error
607
- ? input.signal.reason.message
608
- : safeChildError(typed),
609
- failure: typed,
610
- failureKind:
611
- timedOut ||
612
- (typed.kind === "transport" &&
613
- [
614
- "first-progress-timeout",
615
- "stream-idle-timeout",
616
- "attempt-timeout",
617
- ].includes(typed.code))
618
- ? "timed-out"
619
- : typed.kind === "cancelled"
620
- ? "cancelled"
621
- : "failed",
622
- transportFailure: typed.kind === "transport",
623
- disposeCount,
624
- usage: usage.total(),
625
- classification: projection,
626
- ...observationMetadata(),
627
- };
628
- }
629
- if (!result) throw new Error("child-result-invariant");
630
- return {
631
- ok: true,
632
- result,
633
- toolNames,
634
- submitCount: structuralAttempts,
635
- disposeCount,
636
- usage: usage.total(),
637
- classification: projection,
638
- ...observationMetadata(),
639
- };
640
- }