@abelxiaoxing/cadence 1.0.1 → 1.0.3

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,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import type { Model, Usage } from "@earendil-works/pi-ai";
2
3
  import {
3
4
  createAgentSession,
@@ -7,7 +8,7 @@ import {
7
8
  SettingsManager,
8
9
  } from "@earendil-works/pi-coding-agent";
9
10
  import { Type } from "typebox";
10
- import type { DiffResult, EvidenceResult } from "./contracts.ts";
11
+ import type { ChildFailure, DiffResult, EvidenceResult } from "./contracts.ts";
11
12
  import { EmptyResourceLoader } from "./empty-resource-loader.ts";
12
13
  import { createScopedTools } from "./scoped-tools.ts";
13
14
  import {
@@ -30,6 +31,22 @@ function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
30
31
  });
31
32
  }
32
33
 
34
+ const ABORT_SETTLE_GRACE_MS = 250;
35
+
36
+ async function settleAbort(promise: Promise<void>): Promise<void> {
37
+ let timer: ReturnType<typeof setTimeout> | undefined;
38
+ try {
39
+ await Promise.race([
40
+ promise.catch(() => undefined),
41
+ new Promise<void>((resolve) => {
42
+ timer = setTimeout(resolve, ABORT_SETTLE_GRACE_MS);
43
+ }),
44
+ ]);
45
+ } finally {
46
+ if (timer !== undefined) clearTimeout(timer);
47
+ }
48
+ }
49
+
33
50
  const ZERO_USAGE: Usage = {
34
51
  input: 0,
35
52
  output: 0,
@@ -45,11 +62,21 @@ export class UsageAggregator {
45
62
  add(id: string, value: Usage): boolean {
46
63
  if (this.ids.has(id)) return false;
47
64
  this.ids.add(id);
65
+ const cacheWrite1h =
66
+ this.usage.cacheWrite1h === undefined && value.cacheWrite1h === undefined
67
+ ? undefined
68
+ : (this.usage.cacheWrite1h ?? 0) + (value.cacheWrite1h ?? 0);
69
+ const reasoning =
70
+ this.usage.reasoning === undefined && value.reasoning === undefined
71
+ ? undefined
72
+ : (this.usage.reasoning ?? 0) + (value.reasoning ?? 0);
48
73
  this.usage = {
49
74
  input: this.usage.input + value.input,
50
75
  output: this.usage.output + value.output,
51
76
  cacheRead: this.usage.cacheRead + value.cacheRead,
52
77
  cacheWrite: this.usage.cacheWrite + value.cacheWrite,
78
+ ...(cacheWrite1h === undefined ? {} : { cacheWrite1h }),
79
+ ...(reasoning === undefined ? {} : { reasoning }),
53
80
  totalTokens: this.usage.totalTokens + value.totalTokens,
54
81
  cost: {
55
82
  input: this.usage.cost.input + value.cost.input,
@@ -61,14 +88,30 @@ export class UsageAggregator {
61
88
  };
62
89
  return true;
63
90
  }
91
+ hasUsage(): boolean {
92
+ return this.ids.size > 0;
93
+ }
64
94
  total(): Usage {
65
95
  return structuredClone(this.usage);
66
96
  }
67
97
  }
68
98
 
69
- function wrapScopedTools(roots: string[]) {
99
+ function wrapScopedTools(
100
+ roots: string[],
101
+ cwd: string,
102
+ allowedPaths?: string[],
103
+ ) {
70
104
  const order = ["read", "grep", "find", "ls"];
71
- return createScopedTools({ roots })
105
+ return createScopedTools({
106
+ roots,
107
+ ...(allowedPaths
108
+ ? {
109
+ allowedPaths: allowedPaths.map((relative) =>
110
+ path.resolve(cwd, relative),
111
+ ),
112
+ }
113
+ : {}),
114
+ })
72
115
  .sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name))
73
116
  .map((scoped) =>
74
117
  defineTool({
@@ -94,19 +137,7 @@ function wrapScopedTools(roots: string[]) {
94
137
 
95
138
  export type ChildFailureKind = "failed" | "cancelled" | "timed-out";
96
139
 
97
- export async function runChildSession(input: {
98
- cwd: string;
99
- modelRuntime: ModelRuntime;
100
- model: Model<string>;
101
- systemPrompt: string;
102
- requestId: string;
103
- role: string;
104
- phase?: string;
105
- output: "evidence" | "diff";
106
- roots: string[];
107
- timeoutMs: number;
108
- signal?: AbortSignal;
109
- }): Promise<
140
+ export type ChildSessionResult =
110
141
  | {
111
142
  ok: true;
112
143
  result: EvidenceResult | DiffResult;
@@ -119,18 +150,37 @@ export async function runChildSession(input: {
119
150
  | {
120
151
  ok: false;
121
152
  error: string;
153
+ failure: ChildFailure;
122
154
  failureKind: ChildFailureKind;
155
+ transportFailure: boolean;
123
156
  disposeCount: number;
157
+ usage: Usage;
124
158
  classification: SubmitClassification;
125
- }
126
- > {
159
+ };
160
+
161
+ export async function runChildSession(input: {
162
+ cwd: string;
163
+ modelRuntime: ModelRuntime;
164
+ model: Model<string>;
165
+ systemPrompt: string;
166
+ requestId: string;
167
+ taskId?: string;
168
+ role: string;
169
+ phase?: string;
170
+ output: "evidence" | "diff";
171
+ roots: string[];
172
+ allowedPaths?: string[];
173
+ timeoutMs: number;
174
+ signal?: AbortSignal;
175
+ }): Promise<ChildSessionResult> {
127
176
  const submit = createSubmitTool({
128
177
  requestId: input.requestId,
178
+ taskId: input.taskId,
129
179
  role: input.role,
130
180
  phase: input.phase ?? "red",
131
181
  output: input.output,
132
182
  });
133
- const readTools = wrapScopedTools(input.roots);
183
+ const readTools = wrapScopedTools(input.roots, input.cwd, input.allowedPaths);
134
184
  const customTools = [...readTools, submit.tool];
135
185
  const toolNames = customTools.map((tool) => tool.name);
136
186
  let disposeCount = 0;
@@ -152,6 +202,8 @@ export async function runChildSession(input: {
152
202
  | Awaited<ReturnType<typeof createAgentSession>>["session"]
153
203
  | undefined;
154
204
  let unsubscribe: (() => void) | undefined;
205
+ let abortSettlement: Promise<void> | undefined;
206
+ let assistantSequence = 0;
155
207
  const disposeOnce = () => {
156
208
  if (session) {
157
209
  session.dispose();
@@ -185,6 +237,23 @@ export async function runChildSession(input: {
185
237
  identity: submit.getIdentity(),
186
238
  };
187
239
  };
240
+ const transportFailed = (): boolean => {
241
+ const final = session?.messages
242
+ .filter((message) => message.role === "assistant")
243
+ .at(-1);
244
+ if (final === undefined) return true;
245
+ if (final.stopReason === "error") {
246
+ return (
247
+ submit.getAttempts() === 0 &&
248
+ !final.content.some(
249
+ (content) =>
250
+ content.type === "toolCall" &&
251
+ content.name === "abel_submit_result",
252
+ )
253
+ );
254
+ }
255
+ return final.stopReason === "aborted";
256
+ };
188
257
  try {
189
258
  abort.signal.throwIfAborted();
190
259
  const creation = createAgentSession({
@@ -209,19 +278,24 @@ export async function runChildSession(input: {
209
278
  .catch(() => undefined);
210
279
  throw error;
211
280
  }
212
- abort.signal.throwIfAborted();
213
281
  unsubscribe = session.subscribe((event) => {
214
282
  if (event.type === "message_end" && event.message.role === "assistant") {
215
- usage.add(
216
- `${event.message.provider}:${event.message.model}:${event.message.timestamp}`,
217
- event.message.usage,
218
- );
283
+ usage.add(`assistant:${assistantSequence++}`, event.message.usage);
219
284
  }
220
285
  });
221
- const onAbort = () => void session?.abort();
286
+ const startAbortSettlement = () => {
287
+ if (session && !abortSettlement) abortSettlement = session.abort();
288
+ return abortSettlement;
289
+ };
290
+ const onAbort = () => {
291
+ void startAbortSettlement()?.catch(() => undefined);
292
+ };
222
293
  abort.signal.addEventListener("abort", onAbort, { once: true });
223
294
  try {
224
- if (abort.signal.aborted) throw abort.signal.reason;
295
+ if (abort.signal.aborted) {
296
+ onAbort();
297
+ throw abort.signal.reason;
298
+ }
225
299
  await Promise.race([
226
300
  session.prompt(input.systemPrompt, { expandPromptTemplates: false }),
227
301
  new Promise<never>((_, reject) =>
@@ -239,20 +313,31 @@ export async function runChildSession(input: {
239
313
  const attempts = submit.getAttempts();
240
314
  const classification = classifySession();
241
315
  if (!result || attempts !== 1) {
316
+ const transportFailure = transportFailed();
317
+ const failure =
318
+ submit.getFailure() ??
319
+ (transportFailure
320
+ ? ({ kind: "transport", code: "transport-failure" } as const)
321
+ : ({
322
+ kind: "artifact",
323
+ code: "invalid-structural-result",
324
+ } as const));
242
325
  disposeOnce();
243
326
  return {
244
327
  ok: false,
245
328
  error: "child did not retain exactly one structural submission",
329
+ failure,
246
330
  failureKind: "failed",
331
+ transportFailure,
247
332
  disposeCount,
333
+ usage: usage.total(),
248
334
  classification,
249
335
  };
250
336
  }
251
337
  const assistants = session.messages.filter((m) => m.role === "assistant");
252
338
  const final = assistants.at(-1);
253
339
  if (
254
- !final ||
255
- final.role !== "assistant" ||
340
+ final?.role !== "assistant" ||
256
341
  final.content.length !== 1 ||
257
342
  final.content[0]?.type !== "toolCall" ||
258
343
  final.content[0]?.name !== "abel_submit_result"
@@ -261,8 +346,11 @@ export async function runChildSession(input: {
261
346
  return {
262
347
  ok: false,
263
348
  error: "final assistant message is not one structural submit",
349
+ failure: { kind: "artifact", code: "invalid-structural-result" },
264
350
  failureKind: "failed",
351
+ transportFailure: false,
265
352
  disposeCount,
353
+ usage: usage.total(),
266
354
  classification,
267
355
  };
268
356
  }
@@ -277,19 +365,37 @@ export async function runChildSession(input: {
277
365
  classification,
278
366
  };
279
367
  } catch (error) {
280
- disposeOnce();
368
+ if (abort.signal.aborted && session) {
369
+ const settlement = abortSettlement ?? session.abort();
370
+ abortSettlement = settlement;
371
+ await settleAbort(settlement);
372
+ }
281
373
  const failureKind: ChildFailureKind = timedOut
282
374
  ? "timed-out"
283
375
  : input.signal !== undefined &&
284
376
  (input.signal.aborted || abort.signal.reason === input.signal.reason)
285
377
  ? "cancelled"
286
378
  : "failed";
379
+ const classification = classifySession();
380
+ const transportFailure = transportFailed();
381
+ const failure: ChildFailure = timedOut
382
+ ? { kind: "transport", code: "timeout" }
383
+ : failureKind === "cancelled"
384
+ ? { kind: "cancelled", code: "cancelled" }
385
+ : (submit.getFailure() ??
386
+ (transportFailure
387
+ ? { kind: "transport", code: "transport-failure" }
388
+ : { kind: "artifact", code: "invalid-structural-result" }));
389
+ disposeOnce();
287
390
  return {
288
391
  ok: false,
289
392
  error: (error as Error).message,
393
+ failure,
290
394
  failureKind,
395
+ transportFailure,
291
396
  disposeCount,
292
- classification: classifySession(),
397
+ usage: usage.total(),
398
+ classification,
293
399
  };
294
400
  } finally {
295
401
  clearTimeout(timer);