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.
- package/dist/commands/publish-skill.d.ts.map +1 -1
- package/dist/commands/publish-skill.js +19 -0
- package/dist/commands/publish-skill.js.map +1 -1
- package/dist/commands/skills.d.ts +2 -0
- package/dist/commands/skills.d.ts.map +1 -1
- package/dist/commands/skills.js +150 -42
- package/dist/commands/skills.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/util/marketplace.d.ts +2 -0
- package/dist/util/marketplace.d.ts.map +1 -1
- package/dist/util/marketplace.js.map +1 -1
- package/package.json +2 -2
- package/runtime/BUNDLE.json +1 -1
- package/runtime/docs/cli.md +2 -2
- package/runtime/packages/agenticros/src/tools/ros2-mission.ts +3 -2
- package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.d.ts +2 -0
- package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.d.ts.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.js +14 -0
- package/runtime/packages/agenticros-claude-code/dist/find-object/find-object.js.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/tools.d.ts +3 -1
- package/runtime/packages/agenticros-claude-code/dist/tools.d.ts.map +1 -1
- package/runtime/packages/agenticros-claude-code/dist/tools.js +6 -4
- package/runtime/packages/agenticros-claude-code/dist/tools.js.map +1 -1
- package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +16 -0
- package/runtime/packages/agenticros-claude-code/src/tools.ts +6 -3
- package/runtime/packages/agenticros-gemini/src/find-object/find-object.ts +16 -0
- package/runtime/packages/agenticros-gemini/src/tools.ts +5 -2
- package/runtime/packages/core/package.json +1 -1
- package/runtime/packages/core/src/__tests__/mission-retry-cancel.test.ts +165 -0
- package/runtime/packages/core/src/__tests__/skill-refs.test.ts +14 -0
- package/runtime/packages/core/src/external-capability.ts +67 -25
- package/runtime/packages/core/src/index.ts +3 -1
- package/runtime/packages/core/src/mission.ts +188 -31
- package/runtime/packages/core/src/skill-refs.ts +257 -30
- package/runtime/pnpm-lock.yaml +23 -2
- package/runtime/scripts/check-cli-tarball-install.mjs +41 -8
- package/templates/skills/camera/package.json +17 -6
- package/templates/skills/depth/package.json +18 -6
- package/templates/skills/hello/package.json +16 -5
- 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
|
|
119
|
+
const goalPromise = transport.sendActionGoal({
|
|
114
120
|
action,
|
|
115
121
|
actionType,
|
|
116
122
|
args,
|
|
117
123
|
});
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
219
|
+
sub?.unsubscribe();
|
|
185
220
|
} catch {
|
|
186
221
|
/* ignore */
|
|
187
222
|
}
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
if (
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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:
|
|
732
|
+
error: lastError ?? "Unknown step failure",
|
|
583
733
|
duration_ms: Date.now() - t0,
|
|
734
|
+
attempts,
|
|
584
735
|
};
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
|