@abelxiaoxing/cadence 1.0.0 → 1.0.2

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,
@@ -16,6 +17,20 @@ import {
16
17
  type SubmitClassification,
17
18
  } from "./submit-tool.ts";
18
19
 
20
+ function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
21
+ signal.throwIfAborted();
22
+ return new Promise<T>((resolve, reject) => {
23
+ const onAbort = () => {
24
+ signal.removeEventListener("abort", onAbort);
25
+ reject(signal.reason);
26
+ };
27
+ signal.addEventListener("abort", onAbort, { once: true });
28
+ promise
29
+ .then(resolve, reject)
30
+ .finally(() => signal.removeEventListener("abort", onAbort));
31
+ });
32
+ }
33
+
19
34
  const ZERO_USAGE: Usage = {
20
35
  input: 0,
21
36
  output: 0,
@@ -52,9 +67,22 @@ export class UsageAggregator {
52
67
  }
53
68
  }
54
69
 
55
- function wrapScopedTools(roots: string[]) {
70
+ function wrapScopedTools(
71
+ roots: string[],
72
+ cwd: string,
73
+ allowedPaths?: string[],
74
+ ) {
56
75
  const order = ["read", "grep", "find", "ls"];
57
- return createScopedTools({ roots })
76
+ return createScopedTools({
77
+ roots,
78
+ ...(allowedPaths
79
+ ? {
80
+ allowedPaths: allowedPaths.map((relative) =>
81
+ path.resolve(cwd, relative),
82
+ ),
83
+ }
84
+ : {}),
85
+ })
58
86
  .sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name))
59
87
  .map((scoped) =>
60
88
  defineTool({
@@ -78,17 +106,22 @@ function wrapScopedTools(roots: string[]) {
78
106
  );
79
107
  }
80
108
 
109
+ export type ChildFailureKind = "failed" | "cancelled" | "timed-out";
110
+
81
111
  export async function runChildSession(input: {
82
112
  cwd: string;
83
113
  modelRuntime: ModelRuntime;
84
114
  model: Model<string>;
85
115
  systemPrompt: string;
86
116
  requestId: string;
117
+ taskId?: string;
87
118
  role: string;
88
119
  phase?: string;
89
120
  output: "evidence" | "diff";
90
121
  roots: string[];
122
+ allowedPaths?: string[];
91
123
  timeoutMs: number;
124
+ signal?: AbortSignal;
92
125
  }): Promise<
93
126
  | {
94
127
  ok: true;
@@ -102,26 +135,37 @@ export async function runChildSession(input: {
102
135
  | {
103
136
  ok: false;
104
137
  error: string;
138
+ failureKind: ChildFailureKind;
139
+ transportFailure: boolean;
105
140
  disposeCount: number;
106
141
  classification: SubmitClassification;
107
142
  }
108
143
  > {
109
144
  const submit = createSubmitTool({
110
145
  requestId: input.requestId,
146
+ taskId: input.taskId,
111
147
  role: input.role,
112
148
  phase: input.phase ?? "red",
113
149
  output: input.output,
114
150
  });
115
- const readTools = wrapScopedTools(input.roots);
151
+ const readTools = wrapScopedTools(input.roots, input.cwd, input.allowedPaths);
116
152
  const customTools = [...readTools, submit.tool];
117
153
  const toolNames = customTools.map((tool) => tool.name);
118
154
  let disposeCount = 0;
119
155
  const usage = new UsageAggregator();
120
156
  const abort = new AbortController();
121
- const timer = setTimeout(
122
- () => abort.abort(new Error("child phase timeout")),
123
- input.timeoutMs,
124
- );
157
+ const forwardCancellation = () =>
158
+ abort.abort(input.signal?.reason ?? new Error("child phase cancelled"));
159
+ if (input.signal?.aborted) forwardCancellation();
160
+ else
161
+ input.signal?.addEventListener("abort", forwardCancellation, {
162
+ once: true,
163
+ });
164
+ let timedOut = false;
165
+ const timer = setTimeout(() => {
166
+ timedOut = true;
167
+ abort.abort(new Error("child phase timeout"));
168
+ }, input.timeoutMs);
125
169
  let session:
126
170
  | Awaited<ReturnType<typeof createAgentSession>>["session"]
127
171
  | undefined;
@@ -159,8 +203,26 @@ export async function runChildSession(input: {
159
203
  identity: submit.getIdentity(),
160
204
  };
161
205
  };
206
+ const transportFailed = (): boolean => {
207
+ const final = session?.messages
208
+ .filter((message) => message.role === "assistant")
209
+ .at(-1);
210
+ if (final === undefined) return true;
211
+ if (final.stopReason === "error") {
212
+ return (
213
+ submit.getAttempts() === 0 &&
214
+ !final.content.some(
215
+ (content) =>
216
+ content.type === "toolCall" &&
217
+ content.name === "abel_submit_result",
218
+ )
219
+ );
220
+ }
221
+ return final.stopReason === "aborted";
222
+ };
162
223
  try {
163
- ({ session } = await createAgentSession({
224
+ abort.signal.throwIfAborted();
225
+ const creation = createAgentSession({
164
226
  cwd: input.cwd,
165
227
  modelRuntime: input.modelRuntime,
166
228
  model: input.model,
@@ -173,7 +235,16 @@ export async function runChildSession(input: {
173
235
  compaction: { enabled: false },
174
236
  retry: { enabled: false, maxRetries: 0, provider: { maxRetries: 0 } },
175
237
  }),
176
- }));
238
+ });
239
+ try {
240
+ ({ session } = await abortable(creation, abort.signal));
241
+ } catch (error) {
242
+ void creation
243
+ .then(({ session: lateSession }) => lateSession.dispose())
244
+ .catch(() => undefined);
245
+ throw error;
246
+ }
247
+ abort.signal.throwIfAborted();
177
248
  unsubscribe = session.subscribe((event) => {
178
249
  if (event.type === "message_end" && event.message.role === "assistant") {
179
250
  usage.add(
@@ -185,6 +256,7 @@ export async function runChildSession(input: {
185
256
  const onAbort = () => void session?.abort();
186
257
  abort.signal.addEventListener("abort", onAbort, { once: true });
187
258
  try {
259
+ if (abort.signal.aborted) throw abort.signal.reason;
188
260
  await Promise.race([
189
261
  session.prompt(input.systemPrompt, { expandPromptTemplates: false }),
190
262
  new Promise<never>((_, reject) =>
@@ -202,10 +274,13 @@ export async function runChildSession(input: {
202
274
  const attempts = submit.getAttempts();
203
275
  const classification = classifySession();
204
276
  if (!result || attempts !== 1) {
277
+ const transportFailure = transportFailed();
205
278
  disposeOnce();
206
279
  return {
207
280
  ok: false,
208
281
  error: "child did not retain exactly one structural submission",
282
+ failureKind: "failed",
283
+ transportFailure,
209
284
  disposeCount,
210
285
  classification,
211
286
  };
@@ -223,6 +298,8 @@ export async function runChildSession(input: {
223
298
  return {
224
299
  ok: false,
225
300
  error: "final assistant message is not one structural submit",
301
+ failureKind: "failed",
302
+ transportFailure: false,
226
303
  disposeCount,
227
304
  classification,
228
305
  };
@@ -238,15 +315,26 @@ export async function runChildSession(input: {
238
315
  classification,
239
316
  };
240
317
  } catch (error) {
318
+ const failureKind: ChildFailureKind = timedOut
319
+ ? "timed-out"
320
+ : input.signal !== undefined &&
321
+ (input.signal.aborted || abort.signal.reason === input.signal.reason)
322
+ ? "cancelled"
323
+ : "failed";
324
+ const classification = classifySession();
325
+ const transportFailure = transportFailed();
241
326
  disposeOnce();
242
327
  return {
243
328
  ok: false,
244
329
  error: (error as Error).message,
330
+ failureKind,
331
+ transportFailure,
245
332
  disposeCount,
246
- classification: classifySession(),
333
+ classification,
247
334
  };
248
335
  } finally {
249
336
  clearTimeout(timer);
337
+ input.signal?.removeEventListener("abort", forwardCancellation);
250
338
  unsubscribe?.();
251
339
  disposeOnce();
252
340
  }