agenticros 0.4.0 → 0.5.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.
Files changed (41) hide show
  1. package/dist/commands/publish-skill.d.ts.map +1 -1
  2. package/dist/commands/publish-skill.js +19 -0
  3. package/dist/commands/publish-skill.js.map +1 -1
  4. package/dist/commands/skills.d.ts +2 -0
  5. package/dist/commands/skills.d.ts.map +1 -1
  6. package/dist/commands/skills.js +150 -42
  7. package/dist/commands/skills.js.map +1 -1
  8. package/dist/index.js +2 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/util/marketplace.d.ts +2 -0
  11. package/dist/util/marketplace.d.ts.map +1 -1
  12. package/dist/util/marketplace.js.map +1 -1
  13. package/package.json +2 -2
  14. package/runtime/BUNDLE.json +1 -1
  15. package/runtime/docs/cli.md +2 -2
  16. package/runtime/packages/agenticros/src/tools/ros2-mission.ts +3 -2
  17. package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.d.ts +2 -0
  18. package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.d.ts.map +1 -1
  19. package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.js +14 -0
  20. package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.js.map +1 -1
  21. package/runtime/packages/agenticros-claude-code/dist/tools.d.ts +3 -1
  22. package/runtime/packages/agenticros-claude-code/dist/tools.d.ts.map +1 -1
  23. package/runtime/packages/agenticros-claude-code/dist/tools.js +6 -4
  24. package/runtime/packages/agenticros-claude-code/dist/tools.js.map +1 -1
  25. package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +16 -0
  26. package/runtime/packages/agenticros-claude-code/src/tools.ts +6 -3
  27. package/runtime/packages/agenticros-gemini/src/find-object/find-object.ts +16 -0
  28. package/runtime/packages/agenticros-gemini/src/tools.ts +5 -2
  29. package/runtime/packages/core/package.json +1 -1
  30. package/runtime/packages/core/src/__tests__/mission-retry-cancel.test.ts +165 -0
  31. package/runtime/packages/core/src/__tests__/skill-refs.test.ts +14 -0
  32. package/runtime/packages/core/src/external-capability.ts +67 -25
  33. package/runtime/packages/core/src/index.ts +3 -1
  34. package/runtime/packages/core/src/mission.ts +188 -31
  35. package/runtime/packages/core/src/skill-refs.ts +257 -30
  36. package/runtime/pnpm-lock.yaml +23 -2
  37. package/runtime/scripts/check-cli-tarball-install.mjs +41 -8
  38. package/templates/skills/camera/package.json +17 -6
  39. package/templates/skills/depth/package.json +18 -6
  40. package/templates/skills/hello/package.json +16 -5
  41. package/templates/skills/robot/package.json +18 -6
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Mission runner v2 — retries / backoff + mid-step cancel for interruptible caps.
3
+ */
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+
7
+ import {
8
+ runMission,
9
+ MissionStepAbortedError,
10
+ type CapabilityToolBindings,
11
+ type Mission,
12
+ type MissionToolDispatcher,
13
+ } from "../mission.js";
14
+ import type { Capability } from "../capabilities.js";
15
+
16
+ const CAPS: Capability[] = [
17
+ { id: "drive_base", verb: "drive", description: "drive", source: { kind: "builtin" } },
18
+ {
19
+ id: "find_object",
20
+ verb: "find",
21
+ description: "find",
22
+ interruptible: true,
23
+ source: { kind: "builtin" },
24
+ },
25
+ {
26
+ id: "take_snapshot",
27
+ verb: "see",
28
+ description: "see",
29
+ interruptible: false,
30
+ source: { kind: "builtin" },
31
+ },
32
+ ];
33
+
34
+ const BINDINGS: CapabilityToolBindings = {
35
+ drive_base: {
36
+ tool: "ros2_publish",
37
+ buildArgs: (inputs) => ({ topic: "cmd_vel", msg: inputs }),
38
+ },
39
+ find_object: {
40
+ tool: "ros2_find_object",
41
+ buildArgs: (inputs) => inputs,
42
+ },
43
+ take_snapshot: {
44
+ tool: "ros2_camera_snapshot",
45
+ buildArgs: () => ({}),
46
+ },
47
+ };
48
+
49
+ test("retry: retries failed step then succeeds", async () => {
50
+ let calls = 0;
51
+ const dispatch: MissionToolDispatcher = async () => {
52
+ calls += 1;
53
+ if (calls < 3) return { text: "transient", isError: true };
54
+ return { text: "ok", outputs: { done: true } };
55
+ };
56
+ const mission: Mission = {
57
+ name: "retry-drive",
58
+ steps: [
59
+ {
60
+ id: "a",
61
+ capability: "drive_base",
62
+ inputs: { linear_x: 0.1 },
63
+ retry: { max_attempts: 3, backoff_ms: 1 },
64
+ },
65
+ ],
66
+ };
67
+ const result = await runMission(mission, CAPS, BINDINGS, dispatch);
68
+ assert.equal(result.status, "ok");
69
+ assert.equal(calls, 3);
70
+ assert.equal(result.steps[0]!.attempts, 3);
71
+ assert.equal(result.steps[0]!.status, "ok");
72
+ });
73
+
74
+ test("retry: exhausts attempts then on_fail=stop", async () => {
75
+ let calls = 0;
76
+ const dispatch: MissionToolDispatcher = async () => {
77
+ calls += 1;
78
+ return { text: "always fail", isError: true };
79
+ };
80
+ const mission: Mission = {
81
+ name: "retry-fail",
82
+ steps: [
83
+ {
84
+ id: "a",
85
+ capability: "drive_base",
86
+ retry: { max_attempts: 2, backoff_ms: 1 },
87
+ },
88
+ { id: "b", capability: "take_snapshot" },
89
+ ],
90
+ };
91
+ const result = await runMission(mission, CAPS, BINDINGS, dispatch);
92
+ assert.equal(result.status, "error");
93
+ assert.equal(calls, 2);
94
+ assert.equal(result.steps[0]!.status, "error");
95
+ assert.equal(result.steps[0]!.attempts, 2);
96
+ assert.equal(result.steps[1]!.status, "skipped");
97
+ });
98
+
99
+ test("mid-step cancel: interruptible step aborts via signal", async () => {
100
+ const cancellation = { cancelled: false, reason: "stop find" };
101
+ const dispatch: MissionToolDispatcher = async (_tool, _args, ctx) => {
102
+ // Flip cancel shortly after start; runner polls and aborts.
103
+ setTimeout(() => {
104
+ cancellation.cancelled = true;
105
+ }, 30);
106
+ await new Promise<void>((resolve, reject) => {
107
+ const t = setInterval(() => {
108
+ if (ctx?.signal?.aborted) {
109
+ clearInterval(t);
110
+ reject(new MissionStepAbortedError("find aborted"));
111
+ }
112
+ }, 10);
113
+ setTimeout(() => {
114
+ clearInterval(t);
115
+ resolve();
116
+ }, 5000);
117
+ });
118
+ return { text: "should not finish" };
119
+ };
120
+ const mission: Mission = {
121
+ name: "find-cancel",
122
+ steps: [
123
+ { id: "find", capability: "find_object", inputs: { target: "chair" } },
124
+ { id: "snap", capability: "take_snapshot" },
125
+ ],
126
+ };
127
+ const result = await runMission(mission, CAPS, BINDINGS, dispatch, {
128
+ mission_id: "mn_mid",
129
+ cancellation,
130
+ });
131
+ assert.equal(result.status, "cancelled");
132
+ assert.equal(result.steps[0]!.status, "cancelled");
133
+ assert.equal(result.steps[1]!.status, "cancelled");
134
+ });
135
+
136
+ test("mid-step cancel: non-interruptible finishes current step", async () => {
137
+ const cancellation = { cancelled: false, reason: "stop later" };
138
+ let snapStarted = false;
139
+ const dispatch: MissionToolDispatcher = async (tool) => {
140
+ if (tool === "ros2_camera_snapshot") {
141
+ snapStarted = true;
142
+ setTimeout(() => {
143
+ cancellation.cancelled = true;
144
+ }, 20);
145
+ await new Promise((r) => setTimeout(r, 80));
146
+ return { text: "snap ok", outputs: { ok: true } };
147
+ }
148
+ return { text: "ok" };
149
+ };
150
+ const mission: Mission = {
151
+ name: "snap-then-cancel",
152
+ steps: [
153
+ { id: "snap", capability: "take_snapshot" },
154
+ { id: "drive", capability: "drive_base", inputs: { linear_x: 0.1 } },
155
+ ],
156
+ };
157
+ const result = await runMission(mission, CAPS, BINDINGS, dispatch, {
158
+ mission_id: "mn_nonint",
159
+ cancellation,
160
+ });
161
+ assert.ok(snapStarted);
162
+ assert.equal(result.steps[0]!.status, "ok");
163
+ assert.equal(result.status, "cancelled");
164
+ assert.equal(result.steps[1]!.status, "cancelled");
165
+ });
@@ -12,6 +12,7 @@ import {
12
12
 
13
13
  test("parseSkillRef: owner/skill and @pin", () => {
14
14
  assert.deepEqual(parseSkillRef("agenticros/navigate-to"), {
15
+ kind: "marketplace",
15
16
  marketplaceRef: "agenticros/navigate-to",
16
17
  owner: "agenticros",
17
18
  skill: "navigate-to",
@@ -22,6 +23,19 @@ test("parseSkillRef: owner/skill and @pin", () => {
22
23
  assert.equal(parseSkillRef(""), null);
23
24
  });
24
25
 
26
+ test("parseSkillRef: npm scoped package", () => {
27
+ assert.deepEqual(parseSkillRef("@agenticros-skills/navigate-to"), {
28
+ kind: "npm",
29
+ npmPackage: "@agenticros-skills/navigate-to",
30
+ npmVersion: undefined,
31
+ });
32
+ assert.deepEqual(parseSkillRef("@agenticros-skills/find@^0.2.0"), {
33
+ kind: "npm",
34
+ npmPackage: "@agenticros-skills/find",
35
+ npmVersion: "^0.2.0",
36
+ });
37
+ });
38
+
25
39
  test("parseConfig: skillRefs defaults to []", () => {
26
40
  const cfg = parseConfig({});
27
41
  assert.deepEqual(cfg.skillRefs, []);
@@ -16,6 +16,11 @@ export interface ExecuteExternalOptions {
16
16
  namespace?: string;
17
17
  /** Optional timeout for subscribe_once (ms). */
18
18
  timeoutMs?: number;
19
+ /**
20
+ * When aborted mid-action, call `transport.cancelActionGoal` (best-effort).
21
+ * Subscribe waits also reject early on abort.
22
+ */
23
+ signal?: AbortSignal;
19
24
  }
20
25
 
21
26
  export interface ExecuteExternalResult {
@@ -98,6 +103,7 @@ export async function executeExternalCapability(
98
103
  }
99
104
 
100
105
  const ns = options.namespace;
106
+ const signal = options.signal;
101
107
 
102
108
  try {
103
109
  if (impl.action) {
@@ -110,22 +116,46 @@ export async function executeExternalCapability(
110
116
  };
111
117
  }
112
118
  const args = buildExternalGoal(impl, inputs);
113
- const result = await transport.sendActionGoal({
119
+ const goalPromise = transport.sendActionGoal({
114
120
  action,
115
121
  actionType,
116
122
  args,
117
123
  });
118
- const outputs = {
119
- success: result.result,
120
- ...(result.values ?? {}),
121
- launch_hint: impl.launch ?? null,
122
- package_hint: impl.package ?? null,
123
- };
124
- return {
125
- text: JSON.stringify(outputs),
126
- outputs,
127
- isError: !result.result,
128
- };
124
+ let onAbort: (() => void) | undefined;
125
+ const abortPromise = signal
126
+ ? new Promise<never>((_, reject) => {
127
+ const fail = () => {
128
+ void transport.cancelActionGoal(action).catch(() => {});
129
+ reject(new Error("Action cancelled"));
130
+ };
131
+ if (signal.aborted) {
132
+ fail();
133
+ return;
134
+ }
135
+ onAbort = fail;
136
+ signal.addEventListener("abort", fail, { once: true });
137
+ })
138
+ : null;
139
+ try {
140
+ const result = abortPromise
141
+ ? await Promise.race([goalPromise, abortPromise])
142
+ : await goalPromise;
143
+ const outputs = {
144
+ success: result.result,
145
+ ...(result.values ?? {}),
146
+ launch_hint: impl.launch ?? null,
147
+ package_hint: impl.package ?? null,
148
+ };
149
+ return {
150
+ text: JSON.stringify(outputs),
151
+ outputs,
152
+ isError: !result.result,
153
+ };
154
+ } finally {
155
+ if (signal && onAbort) {
156
+ signal.removeEventListener("abort", onAbort);
157
+ }
158
+ }
129
159
  }
130
160
 
131
161
  if (impl.service) {
@@ -177,28 +207,40 @@ export async function executeExternalCapability(
177
207
  : 5000);
178
208
  const msg = await new Promise<Record<string, unknown>>((resolve, reject) => {
179
209
  let settled = false;
180
- const timer = setTimeout(() => {
210
+ let sub: { unsubscribe: () => void } | undefined;
211
+ let onAbort: (() => void) | undefined;
212
+ let timer: ReturnType<typeof setTimeout> | undefined;
213
+ const finish = (fn: () => void) => {
181
214
  if (settled) return;
182
215
  settled = true;
216
+ if (timer) clearTimeout(timer);
217
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
183
218
  try {
184
- sub.unsubscribe();
219
+ sub?.unsubscribe();
185
220
  } catch {
186
221
  /* ignore */
187
222
  }
188
- reject(new Error(`Timed out waiting for message on ${topic} after ${timeout}ms`));
223
+ fn();
224
+ };
225
+ timer = setTimeout(() => {
226
+ finish(() =>
227
+ reject(new Error(`Timed out waiting for message on ${topic} after ${timeout}ms`)),
228
+ );
189
229
  }, timeout);
190
- const sub = transport.subscribe(
230
+ onAbort = () => {
231
+ finish(() => reject(new Error("Subscribe cancelled")));
232
+ };
233
+ if (signal) {
234
+ if (signal.aborted) {
235
+ onAbort();
236
+ return;
237
+ }
238
+ signal.addEventListener("abort", onAbort, { once: true });
239
+ }
240
+ sub = transport.subscribe(
191
241
  { topic, type: msgType || "std_msgs/msg/String" },
192
242
  (message) => {
193
- if (settled) return;
194
- settled = true;
195
- clearTimeout(timer);
196
- try {
197
- sub.unsubscribe();
198
- } catch {
199
- /* ignore */
200
- }
201
- resolve(message as Record<string, unknown>);
243
+ finish(() => resolve(message as Record<string, unknown>));
202
244
  },
203
245
  );
204
246
  });
@@ -84,13 +84,14 @@ export type {
84
84
  ExecuteExternalResult,
85
85
  } from "./external-capability.js";
86
86
 
87
- export { runMission } from "./mission.js";
87
+ export { runMission, MissionStepAbortedError } from "./mission.js";
88
88
  export type {
89
89
  Mission,
90
90
  MissionStep,
91
91
  MissionResult,
92
92
  MissionStepResult,
93
93
  MissionToolDispatcher,
94
+ MissionDispatchContext,
94
95
  CapabilityToolBinding,
95
96
  CapabilityToolBindings,
96
97
  MissionCancellationToken,
@@ -175,6 +176,7 @@ export {
175
176
  DEFAULT_SKILLS_API,
176
177
  DEFAULT_SKILLS_CACHE_DIR,
177
178
  ensureSkillRefCached,
179
+ ensureNpmPackageCached,
178
180
  fetchInstallDescriptor,
179
181
  githubRepoBasename,
180
182
  parseSkillRef,
@@ -45,13 +45,13 @@
45
45
  *
46
46
  * What's still deferred:
47
47
  * - Parallel step execution (today: sequential only).
48
- * - Per-tool cancellation (cancel TIES to step boundaries today;
49
- * pause/resume also ties to step boundaries).
50
- * - Retry / backoff policies.
51
48
  *
52
49
  * Shipped since the original Phase 1.f header:
53
50
  * - Natural-language plan compilation (`compileGoalToMission` + goal arg).
54
51
  * - Pause / resume via the same control token (`paused` flag).
52
+ * - Step-level retry / backoff (`MissionStep.retry`).
53
+ * - Mid-step cancel for interruptible capabilities via AbortSignal
54
+ * on the dispatcher (non-interruptible steps still finish naturally).
55
55
  *
56
56
  * See: docs/strategy-ai-agents-plus-ros.md §4 (Phase 1.c / 1.f).
57
57
  */
@@ -131,8 +131,22 @@ export interface MissionStep {
131
131
  * Optional behaviour when this step fails. Defaults to "stop".
132
132
  * - "stop": halt the mission, mark it failed
133
133
  * - "continue": record the failure and run the next step anyway
134
+ * Applied only after retry attempts (if any) are exhausted.
134
135
  */
135
136
  on_fail?: "stop" | "continue";
137
+ /**
138
+ * Optional retry / backoff when the step errors. Default is a single
139
+ * attempt (`max_attempts: 1`). Retries re-invoke the same tool with
140
+ * the same resolved args; `on_fail` applies only after the last attempt.
141
+ */
142
+ retry?: {
143
+ /** Total attempts including the first (minimum 1). */
144
+ max_attempts: number;
145
+ /** Delay before the second attempt (ms). Default 0. */
146
+ backoff_ms?: number;
147
+ /** Multiply backoff after each failed attempt. Default 1. */
148
+ backoff_multiplier?: number;
149
+ };
136
150
  }
137
151
 
138
152
  /** A complete mission plan. */
@@ -180,6 +194,8 @@ export interface MissionStepResult {
180
194
  error?: string;
181
195
  /** Wall time in ms. */
182
196
  duration_ms: number;
197
+ /** Number of dispatcher attempts used (including the successful one). */
198
+ attempts?: number;
183
199
  }
184
200
 
185
201
  /** Result of a mission run. */
@@ -222,6 +238,16 @@ export interface MissionResult {
222
238
  cancellation_reason?: string;
223
239
  }
224
240
 
241
+ /** Optional context passed to each dispatcher invocation. */
242
+ export interface MissionDispatchContext {
243
+ /**
244
+ * AbortSignal aborted when the mission is cancelled mid-step and the
245
+ * capability is `interruptible`. Long-running tools should poll
246
+ * `signal.aborted` (or listen for `abort`) and stop cleanly.
247
+ */
248
+ signal?: AbortSignal;
249
+ }
250
+
225
251
  /**
226
252
  * A dispatcher invokes a single MCP tool by name and returns:
227
253
  * - `text`: the tool's text response (free-form, for human display)
@@ -230,12 +256,29 @@ export interface MissionResult {
230
256
  * `{{...}}` template resolution by later steps.
231
257
  *
232
258
  * Adapters wrap their existing tool entry points to satisfy this contract.
259
+ * The optional third argument carries an AbortSignal for mid-step cancel.
233
260
  */
234
261
  export type MissionToolDispatcher = (
235
262
  toolName: string,
236
263
  args: Record<string, unknown>,
264
+ ctx?: MissionDispatchContext,
237
265
  ) => Promise<{ text: string; outputs?: Record<string, unknown>; isError?: boolean }>;
238
266
 
267
+ /** Thrown (or returned via isError) when a tool stops due to AbortSignal. */
268
+ export class MissionStepAbortedError extends Error {
269
+ constructor(message = "Step aborted") {
270
+ super(message);
271
+ this.name = "MissionStepAbortedError";
272
+ }
273
+ }
274
+
275
+ function isAbortError(err: unknown): boolean {
276
+ if (err instanceof MissionStepAbortedError) return true;
277
+ if (err instanceof Error && err.name === "AbortError") return true;
278
+ if (err instanceof Error && /aborted|cancelled/i.test(err.message)) return true;
279
+ return false;
280
+ }
281
+
239
282
  /**
240
283
  * Capability → MCP tool mapping. Each entry says "to satisfy capability X,
241
284
  * call tool Y with these arguments". The transform receives the step's
@@ -397,6 +440,7 @@ export async function runMission(
397
440
  options?: RunMissionOptions,
398
441
  ): Promise<MissionResult> {
399
442
  const capIds = new Set(capabilities.map((c) => c.id));
443
+ const capById = new Map(capabilities.map((c) => [c.id, c]));
400
444
  const stepOutputs: Record<string, Record<string, unknown> | undefined> = {};
401
445
  const results: MissionStepResult[] = [];
402
446
  const t0Mission = Date.now();
@@ -551,40 +595,153 @@ export async function runMission(
551
595
  if (effective) toolArgs.robot_id = effective;
552
596
  }
553
597
 
554
- try {
555
- const dispatched = await dispatcher(binding.tool, toolArgs);
556
- const outputs =
557
- dispatched.outputs ??
558
- binding.parseOutputs?.(dispatched.text) ??
559
- tryParseJsonOutputs(dispatched.text);
560
- stepOutputs[step.id] = outputs;
561
- const status: MissionStepResult["status"] = dispatched.isError ? "error" : "ok";
562
- const result: MissionStepResult = {
563
- id: step.id,
564
- capability: step.capability,
565
- status,
566
- inputs: resolvedInputs,
567
- outputs,
568
- message: dispatched.text,
569
- ...(dispatched.isError ? { error: dispatched.text } : {}),
570
- duration_ms: Date.now() - t0,
571
- };
572
- results.push(result);
573
- emitTranscript(idx, t0, result);
574
- if (status === "error" && (step.on_fail ?? "stop") === "stop") aborted = true;
575
- } catch (err) {
576
- const msg = err instanceof Error ? err.message : String(err);
577
- const result: MissionStepResult = {
598
+ const maxAttempts = Math.max(1, Math.floor(step.retry?.max_attempts ?? 1));
599
+ let backoffMs = Math.max(0, step.retry?.backoff_ms ?? 0);
600
+ const backoffMult = step.retry?.backoff_multiplier ?? 1;
601
+ const interruptible = capById.get(step.capability)?.interruptible === true;
602
+
603
+ let attempts = 0;
604
+ let lastError: string | undefined;
605
+ let stepCancelledMid = false;
606
+ let finalResult: MissionStepResult | undefined;
607
+
608
+ while (attempts < maxAttempts) {
609
+ attempts += 1;
610
+ if (attempts > 1 && backoffMs > 0) {
611
+ await sleep(backoffMs);
612
+ backoffMs = Math.max(0, Math.round(backoffMs * backoffMult));
613
+ }
614
+
615
+ // Mid-step cancel: abort in-flight work when interruptible.
616
+ const controller = new AbortController();
617
+ let cancelPoll: ReturnType<typeof setInterval> | undefined;
618
+ if (interruptible && options?.cancellation) {
619
+ const token = options.cancellation;
620
+ const maybeAbort = () => {
621
+ if (token.cancelled && !controller.signal.aborted) {
622
+ controller.abort();
623
+ }
624
+ };
625
+ maybeAbort();
626
+ cancelPoll = setInterval(maybeAbort, PAUSE_POLL_MS);
627
+ }
628
+
629
+ try {
630
+ const dispatched = await dispatcher(binding.tool, toolArgs, {
631
+ signal: controller.signal,
632
+ });
633
+ if (cancelPoll) clearInterval(cancelPoll);
634
+
635
+ // Cooperative cancel: tool returned after abort without throwing.
636
+ if (controller.signal.aborted || options?.cancellation?.cancelled) {
637
+ if (interruptible) {
638
+ stepCancelledMid = true;
639
+ cancelled = true;
640
+ finalResult = {
641
+ id: step.id,
642
+ capability: step.capability,
643
+ status: "cancelled",
644
+ inputs: resolvedInputs,
645
+ message: `Cancelled mid-step: ${options?.cancellation?.reason ?? "cancelled"}`,
646
+ duration_ms: Date.now() - t0,
647
+ attempts,
648
+ };
649
+ break;
650
+ }
651
+ }
652
+
653
+ const outputs =
654
+ dispatched.outputs ??
655
+ binding.parseOutputs?.(dispatched.text) ??
656
+ tryParseJsonOutputs(dispatched.text);
657
+ stepOutputs[step.id] = outputs;
658
+ const status: MissionStepResult["status"] = dispatched.isError ? "error" : "ok";
659
+ if (status === "ok") {
660
+ finalResult = {
661
+ id: step.id,
662
+ capability: step.capability,
663
+ status: "ok",
664
+ inputs: resolvedInputs,
665
+ outputs,
666
+ message: dispatched.text,
667
+ duration_ms: Date.now() - t0,
668
+ attempts,
669
+ };
670
+ break;
671
+ }
672
+ lastError = dispatched.text;
673
+ if (attempts >= maxAttempts) {
674
+ finalResult = {
675
+ id: step.id,
676
+ capability: step.capability,
677
+ status: "error",
678
+ inputs: resolvedInputs,
679
+ outputs,
680
+ message: dispatched.text,
681
+ error: dispatched.text,
682
+ duration_ms: Date.now() - t0,
683
+ attempts,
684
+ };
685
+ }
686
+ } catch (err) {
687
+ if (cancelPoll) clearInterval(cancelPoll);
688
+ if (isAbortError(err) || controller.signal.aborted) {
689
+ stepCancelledMid = true;
690
+ cancelled = true;
691
+ finalResult = {
692
+ id: step.id,
693
+ capability: step.capability,
694
+ status: "cancelled",
695
+ inputs: resolvedInputs,
696
+ message: `Cancelled mid-step: ${options?.cancellation?.reason ?? (err instanceof Error ? err.message : "cancelled")}`,
697
+ duration_ms: Date.now() - t0,
698
+ attempts,
699
+ };
700
+ break;
701
+ }
702
+ lastError = err instanceof Error ? err.message : String(err);
703
+ if (attempts >= maxAttempts) {
704
+ finalResult = {
705
+ id: step.id,
706
+ capability: step.capability,
707
+ status: "error",
708
+ inputs: resolvedInputs,
709
+ error: lastError,
710
+ duration_ms: Date.now() - t0,
711
+ attempts,
712
+ };
713
+ }
714
+ } finally {
715
+ if (cancelPoll) clearInterval(cancelPoll);
716
+ }
717
+
718
+ // If cancel flipped during a non-interruptible attempt, finish
719
+ // this step's result then stop subsequent steps at the boundary.
720
+ if (!interruptible && options?.cancellation?.cancelled && !stepCancelledMid) {
721
+ cancelled = true;
722
+ }
723
+ if (finalResult) break;
724
+ }
725
+
726
+ if (!finalResult) {
727
+ finalResult = {
578
728
  id: step.id,
579
729
  capability: step.capability,
580
730
  status: "error",
581
731
  inputs: resolvedInputs,
582
- error: msg,
732
+ error: lastError ?? "Unknown step failure",
583
733
  duration_ms: Date.now() - t0,
734
+ attempts,
584
735
  };
585
- results.push(result);
586
- emitTranscript(idx, t0, result);
587
- if ((step.on_fail ?? "stop") === "stop") aborted = true;
736
+ }
737
+
738
+ results.push(finalResult);
739
+ emitTranscript(idx, t0, finalResult);
740
+ if (finalResult.status === "error" && (step.on_fail ?? "stop") === "stop") {
741
+ aborted = true;
742
+ }
743
+ if (options?.cancellation?.cancelled) {
744
+ cancelled = true;
588
745
  }
589
746
  }
590
747