agenticros 0.1.17 → 0.1.19
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/__tests__/robot-config.test.d.ts +2 -0
- package/dist/__tests__/robot-config.test.d.ts.map +1 -0
- package/dist/__tests__/robot-config.test.js +374 -0
- package/dist/__tests__/robot-config.test.js.map +1 -0
- package/dist/__tests__/transport-shorthand.test.d.ts +2 -0
- package/dist/__tests__/transport-shorthand.test.d.ts.map +1 -0
- package/dist/__tests__/transport-shorthand.test.js +88 -0
- package/dist/__tests__/transport-shorthand.test.js.map +1 -0
- package/dist/commands/down.d.ts.map +1 -1
- package/dist/commands/down.js +55 -3
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/robots.d.ts +57 -0
- package/dist/commands/robots.d.ts.map +1 -0
- package/dist/commands/robots.js +678 -0
- package/dist/commands/robots.js.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/util/mcp-discovery.d.ts +57 -0
- package/dist/util/mcp-discovery.d.ts.map +1 -0
- package/dist/util/mcp-discovery.js +153 -0
- package/dist/util/mcp-discovery.js.map +1 -0
- package/dist/util/robot-config.d.ts +167 -0
- package/dist/util/robot-config.d.ts.map +1 -0
- package/dist/util/robot-config.js +375 -0
- package/dist/util/robot-config.js.map +1 -0
- package/dist/util/transport-shorthand.d.ts +66 -0
- package/dist/util/transport-shorthand.d.ts.map +1 -0
- package/dist/util/transport-shorthand.js +102 -0
- package/dist/util/transport-shorthand.js.map +1 -0
- package/package.json +2 -1
- package/runtime/BUNDLE.json +1 -1
- package/runtime/README.md +4 -0
- package/runtime/package.json +6 -1
- package/runtime/packages/agenticros/openclaw.plugin.json +6 -0
- package/runtime/packages/agenticros/package.json +2 -1
- package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +568 -0
- package/runtime/packages/agenticros/src/mission-registry.ts +22 -0
- package/runtime/packages/agenticros/src/service.ts +120 -2
- package/runtime/packages/agenticros/src/skill-loader.ts +9 -1
- package/runtime/packages/agenticros/src/tools/_robot-helpers.ts +68 -0
- package/runtime/packages/agenticros/src/tools/index.ts +49 -9
- package/runtime/packages/agenticros/src/tools/mission-cancel.ts +70 -0
- package/runtime/packages/agenticros/src/tools/ros2-action.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-camera.ts +10 -4
- package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +63 -0
- package/runtime/packages/agenticros/src/tools/ros2-depth-distance.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-discover.ts +65 -0
- package/runtime/packages/agenticros/src/tools/ros2-find-robots-for.ts +128 -0
- package/runtime/packages/agenticros/src/tools/ros2-mission.ts +311 -0
- package/runtime/packages/agenticros/src/tools/ros2-param.ts +20 -7
- package/runtime/packages/agenticros/src/tools/ros2-publish.ts +15 -8
- package/runtime/packages/agenticros/src/tools/ros2-robots.ts +52 -0
- package/runtime/packages/agenticros/src/tools/ros2-service.ts +11 -4
- package/runtime/packages/agenticros/src/tools/ros2-subscribe.ts +11 -4
- package/runtime/packages/agenticros-claude-code/package.json +2 -1
- package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +964 -0
- package/runtime/packages/agenticros-claude-code/src/__tests__/follow-me-registry.test.ts +119 -0
- package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +7 -6
- package/runtime/packages/agenticros-claude-code/src/follow-me/depth-loop.ts +41 -9
- package/runtime/packages/agenticros-claude-code/src/follow-me/loop.ts +40 -11
- package/runtime/packages/agenticros-claude-code/src/index.ts +5 -5
- package/runtime/packages/agenticros-claude-code/src/mission-registry.ts +21 -0
- package/runtime/packages/agenticros-claude-code/src/tools.ts +668 -56
- package/runtime/packages/agenticros-claude-code/src/transport.ts +43 -43
- package/runtime/packages/agenticros-gemini/package.json +1 -0
- package/runtime/packages/agenticros-gemini/src/__tests__/mission-cancel.test.ts +197 -0
- package/runtime/packages/agenticros-gemini/src/__tests__/mission-transcripts.test.ts +178 -0
- package/runtime/packages/agenticros-gemini/src/tools.ts +504 -21
- package/runtime/packages/agenticros-gemini/src/transport.ts +40 -43
- package/runtime/packages/core/package.json +1 -1
- package/runtime/packages/core/src/__tests__/capabilities.test.ts +261 -0
- package/runtime/packages/core/src/__tests__/discovery.test.ts +206 -0
- package/runtime/packages/core/src/__tests__/find-robots-for.test.ts +258 -0
- package/runtime/packages/core/src/__tests__/mission-cancel-transcript.test.ts +371 -0
- package/runtime/packages/core/src/__tests__/mission-transcript-sink.test.ts +189 -0
- package/runtime/packages/core/src/__tests__/mission.test.ts +501 -0
- package/runtime/packages/core/src/__tests__/planner.test.ts +279 -0
- package/runtime/packages/core/src/__tests__/robots.test.ts +406 -0
- package/runtime/packages/core/src/__tests__/topic-utils.test.ts +105 -0
- package/runtime/packages/core/src/__tests__/transport-pool.test.ts +295 -0
- package/runtime/packages/core/src/capabilities.ts +340 -0
- package/runtime/packages/core/src/config.ts +220 -38
- package/runtime/packages/core/src/discovery.ts +193 -0
- package/runtime/packages/core/src/find-robots-for.ts +164 -0
- package/runtime/packages/core/src/index.ts +67 -1
- package/runtime/packages/core/src/mission-registry.ts +142 -0
- package/runtime/packages/core/src/mission-transcript-sink.ts +81 -0
- package/runtime/packages/core/src/mission.ts +574 -0
- package/runtime/packages/core/src/planner/index.ts +564 -0
- package/runtime/packages/core/src/robots.ts +282 -0
- package/runtime/packages/core/src/topic-utils.ts +47 -9
- package/runtime/packages/core/src/transport-pool.ts +171 -0
- package/runtime/ros2_ws/src/agenticros_discovery/agenticros_discovery/discovery_node.py +108 -13
- package/runtime/ros2_ws/src/agenticros_msgs/CMakeLists.txt +1 -0
- package/runtime/ros2_ws/src/agenticros_msgs/msg/RobotInfo.msg +44 -0
- package/runtime/scripts/__tests__/refresh-skill-deps.test.mjs +222 -0
- package/runtime/scripts/refresh-skill-deps.mjs +381 -0
- package/runtime/scripts/setup_gateway_plugin.sh +32 -5
- package/runtime/scripts/start_demo.sh +26 -3
- package/runtime/scripts/sync-skill-tools.mjs +67 -10
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mission runner — Phase 1.c of the AgenticROS strategy.
|
|
3
|
+
*
|
|
4
|
+
* A mission is a declarative sequence of capability invocations. Each step
|
|
5
|
+
* names a capability (from the registry exposed by `ros2_list_capabilities`)
|
|
6
|
+
* with literal or templated inputs, where templates can reference outputs
|
|
7
|
+
* from earlier steps via `{{stepId.outputs.fieldName}}`.
|
|
8
|
+
*
|
|
9
|
+
* The runner here is transport-agnostic — it takes a `dispatcher` callback
|
|
10
|
+
* that knows how to invoke a single tool for the host adapter. Adapters
|
|
11
|
+
* pass their existing tool dispatch path (handleToolCall in claude-code,
|
|
12
|
+
* tool.execute() in OpenClaw, executeTool() in Gemini), and the runner
|
|
13
|
+
* stays the same across all three.
|
|
14
|
+
*
|
|
15
|
+
* Phase 1.d adds multi-robot routing: `mission.robot_id` (optional) and
|
|
16
|
+
* per-step `inputs.robot_id` are auto-injected into every dispatched
|
|
17
|
+
* tool call when the binding's tool args don't already specify one. The
|
|
18
|
+
* adapter-side tool handlers then resolve the robot via
|
|
19
|
+
* `resolveRobotFromArgs(config, args)` and route the underlying
|
|
20
|
+
* publish/subscribe/etc. through that robot's namespace.
|
|
21
|
+
*
|
|
22
|
+
* Precedence (highest wins):
|
|
23
|
+
* 1. Tool args produced by the binding (`binding.buildArgs(inputs)`)
|
|
24
|
+
* — if it sets `robot_id`, that's final.
|
|
25
|
+
* 2. Per-step `inputs.robot_id` (lets a mission mix robots step-by-step).
|
|
26
|
+
* 3. Mission-level `mission.robot_id` (the default for every step).
|
|
27
|
+
* 4. None — the adapter falls back to the active robot.
|
|
28
|
+
*
|
|
29
|
+
* Phase 1.f extends the runner with two additive capabilities:
|
|
30
|
+
* - `cancellation`: a token the runner checks BEFORE each step. The
|
|
31
|
+
* adapter holds the token in its `MissionRegistry` so a sibling
|
|
32
|
+
* tool call (`mission_cancel(mission_id)`) can flip
|
|
33
|
+
* `cancellation.cancelled` mid-run and stop the mission gracefully.
|
|
34
|
+
* Steps that have already completed keep their results; the current
|
|
35
|
+
* step finishes naturally (the runner doesn't preempt
|
|
36
|
+
* `await dispatcher(...)` because that requires per-tool cancel
|
|
37
|
+
* support — out of scope for Phase 1.f); subsequent steps are
|
|
38
|
+
* marked `"cancelled"` and skipped.
|
|
39
|
+
* - `transcript`: a callback invoked after every step with the
|
|
40
|
+
* step's `MissionStepResult`. Adapters wire this to the shared
|
|
41
|
+
* memory subsystem so a second agent can `recall(namespace=
|
|
42
|
+
* "mission:<id>")` and inspect what's run so far. The runner
|
|
43
|
+
* itself owns no I/O — the callback can be a no-op when memory
|
|
44
|
+
* is disabled.
|
|
45
|
+
*
|
|
46
|
+
* What's still deferred:
|
|
47
|
+
* - Parallel step execution (today: sequential only).
|
|
48
|
+
* - Per-tool cancellation (cancel TIES to step boundaries today).
|
|
49
|
+
* - Natural-language plan compilation (today: declarative only).
|
|
50
|
+
* - Retry / backoff policies.
|
|
51
|
+
*
|
|
52
|
+
* See: docs/strategy-ai-agents-plus-ros.md §4 (Phase 1.c / 1.f).
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import type { Capability } from "./capabilities.js";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Cancellation token consumed by `runMission`.
|
|
59
|
+
*
|
|
60
|
+
* Plain object (not AbortController) so it's easy to share across
|
|
61
|
+
* processes via a registry without pulling Web platform shims in. The
|
|
62
|
+
* runner only reads `cancelled`; `reason` is surfaced in the result
|
|
63
|
+
* for traceability.
|
|
64
|
+
*/
|
|
65
|
+
export interface MissionCancellationToken {
|
|
66
|
+
cancelled: boolean;
|
|
67
|
+
/** Optional free-text reason — bubbled up into the cancelled step results. */
|
|
68
|
+
reason?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Per-step transcript sink. Called immediately after a step finishes
|
|
73
|
+
* (including cancelled / skipped steps) so an external store sees the
|
|
74
|
+
* whole timeline, not just the post-mortem `MissionResult`.
|
|
75
|
+
*
|
|
76
|
+
* The callback is best-effort: thrown errors are logged but never
|
|
77
|
+
* propagate up — losing a transcript entry must not abort an
|
|
78
|
+
* otherwise-healthy mission.
|
|
79
|
+
*/
|
|
80
|
+
export type MissionTranscriptSink = (
|
|
81
|
+
entry: MissionTranscriptEntry,
|
|
82
|
+
) => Promise<void> | void;
|
|
83
|
+
|
|
84
|
+
/** One transcript entry per executed step. */
|
|
85
|
+
export interface MissionTranscriptEntry {
|
|
86
|
+
/** Unique id of the running mission (assigned by the adapter, not the runner). */
|
|
87
|
+
mission_id: string;
|
|
88
|
+
/** Free-text mission label. */
|
|
89
|
+
mission_name?: string;
|
|
90
|
+
/** Adapter that ran the mission (e.g. "claude-code", "openclaw", "gemini"). */
|
|
91
|
+
adapter?: string;
|
|
92
|
+
/** When the step started (ms since epoch). */
|
|
93
|
+
started_at: number;
|
|
94
|
+
/** Mission-level robot id (empty when unset). */
|
|
95
|
+
robot_id?: string;
|
|
96
|
+
/** Index of this step in mission.steps (0-based). */
|
|
97
|
+
step_index: number;
|
|
98
|
+
/** Total steps in the mission. */
|
|
99
|
+
step_total: number;
|
|
100
|
+
/** Snapshot of the per-step result. */
|
|
101
|
+
result: MissionStepResult;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One step in a mission. */
|
|
105
|
+
export interface MissionStep {
|
|
106
|
+
/** Unique id within the mission, used by later steps for `{{id.outputs.x}}`. */
|
|
107
|
+
id: string;
|
|
108
|
+
/**
|
|
109
|
+
* Capability id (e.g. `drive_base`, `find_object`). The runner looks this
|
|
110
|
+
* up in the capability registry and uses a dispatch map to find the
|
|
111
|
+
* underlying tool to invoke.
|
|
112
|
+
*/
|
|
113
|
+
capability: string;
|
|
114
|
+
/**
|
|
115
|
+
* Inputs to the capability. Values can be literals or `{{stepId.outputs.x}}`
|
|
116
|
+
* templates that resolve from prior steps' outputs.
|
|
117
|
+
*/
|
|
118
|
+
inputs?: Record<string, unknown>;
|
|
119
|
+
/**
|
|
120
|
+
* Optional behaviour when this step fails. Defaults to "stop".
|
|
121
|
+
* - "stop": halt the mission, mark it failed
|
|
122
|
+
* - "continue": record the failure and run the next step anyway
|
|
123
|
+
*/
|
|
124
|
+
on_fail?: "stop" | "continue";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A complete mission plan. */
|
|
128
|
+
export interface Mission {
|
|
129
|
+
/** Free-text label for logs / chat replies (e.g. "find chair and approach"). */
|
|
130
|
+
name?: string;
|
|
131
|
+
/**
|
|
132
|
+
* Optional planning notes from the agent — surfaced in the result for
|
|
133
|
+
* downstream summarisation. The runner ignores it.
|
|
134
|
+
*/
|
|
135
|
+
goal?: string;
|
|
136
|
+
/**
|
|
137
|
+
* Optional default robot id for every step. Each step's tool args get
|
|
138
|
+
* `robot_id` auto-injected from this when the step doesn't supply its
|
|
139
|
+
* own and the binding's tool args don't already include one. Unknown
|
|
140
|
+
* robot ids surface as a tool error from the adapter, not from the
|
|
141
|
+
* runner. Empty/whitespace is ignored (uses the active robot).
|
|
142
|
+
*/
|
|
143
|
+
robot_id?: string;
|
|
144
|
+
steps: MissionStep[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Result of a single step. */
|
|
148
|
+
export interface MissionStepResult {
|
|
149
|
+
id: string;
|
|
150
|
+
capability: string;
|
|
151
|
+
/**
|
|
152
|
+
* Final step outcome:
|
|
153
|
+
* - "ok": tool returned cleanly, outputs (if any) parsed.
|
|
154
|
+
* - "error": tool returned an error or the binding/build threw.
|
|
155
|
+
* - "skipped": earlier step failed with on_fail=stop.
|
|
156
|
+
* - "cancelled": mission was cancelled (Phase 1.f) before this step ran.
|
|
157
|
+
*/
|
|
158
|
+
status: "ok" | "error" | "skipped" | "cancelled";
|
|
159
|
+
/** Resolved inputs (with `{{...}}` templates substituted). */
|
|
160
|
+
inputs: Record<string, unknown>;
|
|
161
|
+
/** Outputs parsed from the tool response (may be `undefined` for fire-and-forget). */
|
|
162
|
+
outputs?: Record<string, unknown>;
|
|
163
|
+
/** Free-form text the tool returned (e.g. for human display). */
|
|
164
|
+
message?: string;
|
|
165
|
+
/** Error message when status is "error". */
|
|
166
|
+
error?: string;
|
|
167
|
+
/** Wall time in ms. */
|
|
168
|
+
duration_ms: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Result of a mission run. */
|
|
172
|
+
export interface MissionResult {
|
|
173
|
+
/**
|
|
174
|
+
* Overall mission outcome:
|
|
175
|
+
* - "ok": every step succeeded (or skipped on_fail=continue).
|
|
176
|
+
* - "error": one or more steps errored with on_fail=stop.
|
|
177
|
+
* - "cancelled": mission was cancelled mid-run (Phase 1.f). Some
|
|
178
|
+
* steps may have completed successfully — see steps[].
|
|
179
|
+
*/
|
|
180
|
+
status: "ok" | "error" | "cancelled";
|
|
181
|
+
/** Number of steps that ran (skipped/cancelled steps are NOT counted as run). */
|
|
182
|
+
steps_run: number;
|
|
183
|
+
steps_total: number;
|
|
184
|
+
/** Per-step results, in declaration order. */
|
|
185
|
+
steps: MissionStepResult[];
|
|
186
|
+
/** Total wall time across all steps. */
|
|
187
|
+
duration_ms: number;
|
|
188
|
+
/** Brief one-line summary suitable for chat reply. */
|
|
189
|
+
summary: string;
|
|
190
|
+
/**
|
|
191
|
+
* Mission-level default robot id used during this run (surfaced for
|
|
192
|
+
* traceability — useful in chat replies and logs). Empty string when
|
|
193
|
+
* the mission didn't pin a robot.
|
|
194
|
+
*/
|
|
195
|
+
robot_id?: string;
|
|
196
|
+
/**
|
|
197
|
+
* Phase 1.f — the mission id the adapter registered before running.
|
|
198
|
+
* Empty when the adapter didn't pass an id (e.g. ad-hoc dispatcher
|
|
199
|
+
* tests). Use this with `mission_cancel(mission_id)` to abort a
|
|
200
|
+
* mission mid-run, or `memory_recall(namespace="mission:<id>")` to
|
|
201
|
+
* read the transcript later.
|
|
202
|
+
*/
|
|
203
|
+
mission_id?: string;
|
|
204
|
+
/**
|
|
205
|
+
* Phase 1.f — set when the mission was cancelled. Carries the free-text
|
|
206
|
+
* reason the canceller supplied (or "cancelled" when none was given).
|
|
207
|
+
*/
|
|
208
|
+
cancellation_reason?: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A dispatcher invokes a single MCP tool by name and returns:
|
|
213
|
+
* - `text`: the tool's text response (free-form, for human display)
|
|
214
|
+
* - `outputs`: optional structured outputs (parsed JSON when the text
|
|
215
|
+
* payload is a JSON blob). The runner uses these for
|
|
216
|
+
* `{{...}}` template resolution by later steps.
|
|
217
|
+
*
|
|
218
|
+
* Adapters wrap their existing tool entry points to satisfy this contract.
|
|
219
|
+
*/
|
|
220
|
+
export type MissionToolDispatcher = (
|
|
221
|
+
toolName: string,
|
|
222
|
+
args: Record<string, unknown>,
|
|
223
|
+
) => Promise<{ text: string; outputs?: Record<string, unknown>; isError?: boolean }>;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Capability → MCP tool mapping. Each entry says "to satisfy capability X,
|
|
227
|
+
* call tool Y with these arguments". The transform receives the step's
|
|
228
|
+
* (already template-resolved) inputs and returns the args object the tool
|
|
229
|
+
* expects.
|
|
230
|
+
*
|
|
231
|
+
* Why a separate map (not a field on the capability itself)?
|
|
232
|
+
* - Some capabilities map onto different tools per adapter
|
|
233
|
+
* (claude-code's `ros2_find_object` is a single tool call; the OpenClaw
|
|
234
|
+
* adapter equivalent runs in a different process).
|
|
235
|
+
* - Skill-declared capabilities will eventually carry their own
|
|
236
|
+
* implementation hint (`implementation.kind = "in_process"|"external_ros_node"`),
|
|
237
|
+
* but until that wire-up is real, mapping happens here.
|
|
238
|
+
*/
|
|
239
|
+
export interface CapabilityToolBinding {
|
|
240
|
+
/** The MCP tool name to invoke. */
|
|
241
|
+
tool: string;
|
|
242
|
+
/** Transform the step's resolved inputs into the tool's expected arguments. */
|
|
243
|
+
buildArgs: (inputs: Record<string, unknown>) => Record<string, unknown>;
|
|
244
|
+
/**
|
|
245
|
+
* Optional: extract structured outputs from the tool's text response
|
|
246
|
+
* for `{{...}}` template references by later steps. When omitted, the
|
|
247
|
+
* runner attempts to JSON-parse the text and use the result.
|
|
248
|
+
*/
|
|
249
|
+
parseOutputs?: (text: string) => Record<string, unknown> | undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export type CapabilityToolBindings = Record<string, CapabilityToolBinding>;
|
|
253
|
+
|
|
254
|
+
const TEMPLATE_RE = /\{\{\s*([a-zA-Z0-9_]+)\.outputs\.([a-zA-Z0-9_]+)\s*\}\}/g;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Substitute `{{stepId.outputs.field}}` references in `value` using the
|
|
258
|
+
* given step output map. Recurses into nested objects/arrays.
|
|
259
|
+
*
|
|
260
|
+
* String values containing exactly one template reference are replaced with
|
|
261
|
+
* the raw value (preserving type — number stays number, etc.). Strings with
|
|
262
|
+
* interpolated text get string-substituted.
|
|
263
|
+
*/
|
|
264
|
+
function resolveTemplates(
|
|
265
|
+
value: unknown,
|
|
266
|
+
outputs: Record<string, Record<string, unknown> | undefined>,
|
|
267
|
+
): unknown {
|
|
268
|
+
if (typeof value === "string") {
|
|
269
|
+
const matches = [...value.matchAll(TEMPLATE_RE)];
|
|
270
|
+
if (matches.length === 0) return value;
|
|
271
|
+
// Whole-string single template: preserve type.
|
|
272
|
+
if (matches.length === 1) {
|
|
273
|
+
const m = matches[0];
|
|
274
|
+
if (m[0] === value.trim()) {
|
|
275
|
+
const stepOuts = outputs[m[1]];
|
|
276
|
+
return stepOuts ? stepOuts[m[2]] : undefined;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return value.replace(TEMPLATE_RE, (_, stepId: string, field: string) => {
|
|
280
|
+
const stepOuts = outputs[stepId];
|
|
281
|
+
const v = stepOuts ? stepOuts[field] : undefined;
|
|
282
|
+
return v === undefined ? "" : String(v);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (Array.isArray(value)) return value.map((v) => resolveTemplates(v, outputs));
|
|
286
|
+
if (value && typeof value === "object") {
|
|
287
|
+
const out: Record<string, unknown> = {};
|
|
288
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
289
|
+
out[k] = resolveTemplates(v, outputs);
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
return value;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function tryParseJsonOutputs(text: string): Record<string, unknown> | undefined {
|
|
297
|
+
// Tools often return "Summary line.\n{json}" — try parsing each line plus
|
|
298
|
+
// the whole payload, returning the first JSON object we find.
|
|
299
|
+
const candidates = [text, text.trim(), text.split("\n").pop() ?? ""];
|
|
300
|
+
for (const c of candidates) {
|
|
301
|
+
if (!c || !c.trim().startsWith("{")) continue;
|
|
302
|
+
try {
|
|
303
|
+
const parsed = JSON.parse(c);
|
|
304
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
305
|
+
return parsed as Record<string, unknown>;
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Phase 1.f optional behaviour bundle for `runMission`. */
|
|
314
|
+
export interface RunMissionOptions {
|
|
315
|
+
/**
|
|
316
|
+
* Stable identifier for this mission run. Adapters generate it and
|
|
317
|
+
* register a cancellation token under this key, so a sibling
|
|
318
|
+
* `mission_cancel(mission_id)` tool call can flip the token mid-run.
|
|
319
|
+
* Surfaced in the result + every transcript entry.
|
|
320
|
+
*/
|
|
321
|
+
mission_id?: string;
|
|
322
|
+
/**
|
|
323
|
+
* Cancellation token the runner checks BEFORE each step. When
|
|
324
|
+
* `cancelled` is true at a step boundary, the remaining steps are
|
|
325
|
+
* marked "cancelled" and the mission returns with
|
|
326
|
+
* `status: "cancelled"`.
|
|
327
|
+
*/
|
|
328
|
+
cancellation?: MissionCancellationToken;
|
|
329
|
+
/**
|
|
330
|
+
* Best-effort transcript sink — called after every step finishes
|
|
331
|
+
* (including skipped/cancelled). Adapters wire this to the memory
|
|
332
|
+
* subsystem so a downstream agent can read the timeline. Thrown
|
|
333
|
+
* errors are swallowed; transcripts are not gated by the mission's
|
|
334
|
+
* success.
|
|
335
|
+
*/
|
|
336
|
+
transcript?: MissionTranscriptSink;
|
|
337
|
+
/** Adapter label (e.g. "claude-code", "openclaw", "gemini") — copied into transcripts. */
|
|
338
|
+
adapter?: string;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Execute a mission sequentially.
|
|
343
|
+
*
|
|
344
|
+
* @param mission The declarative plan.
|
|
345
|
+
* @param capabilities Capability registry — used to validate that each
|
|
346
|
+
* requested capability id is real.
|
|
347
|
+
* @param bindings Capability → tool mapping; entries missing here yield
|
|
348
|
+
* an "unsupported capability" error for that step.
|
|
349
|
+
* @param dispatcher Adapter-supplied function that invokes a tool by name.
|
|
350
|
+
* @param options Phase 1.f options (cancellation token, transcript sink, mission id).
|
|
351
|
+
* Omit for legacy callers.
|
|
352
|
+
*/
|
|
353
|
+
export async function runMission(
|
|
354
|
+
mission: Mission,
|
|
355
|
+
capabilities: Capability[],
|
|
356
|
+
bindings: CapabilityToolBindings,
|
|
357
|
+
dispatcher: MissionToolDispatcher,
|
|
358
|
+
options?: RunMissionOptions,
|
|
359
|
+
): Promise<MissionResult> {
|
|
360
|
+
const capIds = new Set(capabilities.map((c) => c.id));
|
|
361
|
+
const stepOutputs: Record<string, Record<string, unknown> | undefined> = {};
|
|
362
|
+
const results: MissionStepResult[] = [];
|
|
363
|
+
const t0Mission = Date.now();
|
|
364
|
+
let aborted = false;
|
|
365
|
+
let cancelled = false;
|
|
366
|
+
|
|
367
|
+
// Phase 1.f — best-effort transcript emit. Synchronous helper so the
|
|
368
|
+
// mission loop stays linear; the caller's sink may itself be async
|
|
369
|
+
// but we don't block the next step on it.
|
|
370
|
+
const emitTranscript = (idx: number, started_at: number, result: MissionStepResult): void => {
|
|
371
|
+
if (!options?.transcript) return;
|
|
372
|
+
try {
|
|
373
|
+
const entry: MissionTranscriptEntry = {
|
|
374
|
+
mission_id: options.mission_id ?? "",
|
|
375
|
+
mission_name: mission.name,
|
|
376
|
+
adapter: options.adapter,
|
|
377
|
+
started_at,
|
|
378
|
+
robot_id: typeof mission.robot_id === "string" ? mission.robot_id : "",
|
|
379
|
+
step_index: idx,
|
|
380
|
+
step_total: mission.steps.length,
|
|
381
|
+
result,
|
|
382
|
+
};
|
|
383
|
+
// Fire-and-forget — never throw out of the runner. We catch
|
|
384
|
+
// synchronous throws AND attach a no-op .catch on the promise so
|
|
385
|
+
// a rejected async sink doesn't surface as an unhandled rejection.
|
|
386
|
+
const ret = options.transcript(entry);
|
|
387
|
+
if (ret && typeof (ret as Promise<unknown>).catch === "function") {
|
|
388
|
+
(ret as Promise<unknown>).catch(() => {});
|
|
389
|
+
}
|
|
390
|
+
} catch {
|
|
391
|
+
// Transcript loss never breaks the mission.
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
for (let idx = 0; idx < mission.steps.length; idx++) {
|
|
396
|
+
const step = mission.steps[idx];
|
|
397
|
+
const t0 = Date.now();
|
|
398
|
+
|
|
399
|
+
// Phase 1.f — honour cancellation token at the step boundary. We
|
|
400
|
+
// check BEFORE the step runs so that a cancel arriving while
|
|
401
|
+
// step N is in-flight only affects step N+1 onward (per-tool
|
|
402
|
+
// preemption is out of scope; see the module header).
|
|
403
|
+
if (!cancelled && options?.cancellation?.cancelled) {
|
|
404
|
+
cancelled = true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (cancelled) {
|
|
408
|
+
const result: MissionStepResult = {
|
|
409
|
+
id: step.id,
|
|
410
|
+
capability: step.capability,
|
|
411
|
+
status: "cancelled",
|
|
412
|
+
inputs: {},
|
|
413
|
+
message: `Cancelled: ${options?.cancellation?.reason ?? "cancelled"}`,
|
|
414
|
+
duration_ms: 0,
|
|
415
|
+
};
|
|
416
|
+
results.push(result);
|
|
417
|
+
emitTranscript(idx, t0, result);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (aborted) {
|
|
422
|
+
const result: MissionStepResult = {
|
|
423
|
+
id: step.id,
|
|
424
|
+
capability: step.capability,
|
|
425
|
+
status: "skipped",
|
|
426
|
+
inputs: {},
|
|
427
|
+
message: "Skipped: earlier step failed and on_fail=stop.",
|
|
428
|
+
duration_ms: 0,
|
|
429
|
+
};
|
|
430
|
+
results.push(result);
|
|
431
|
+
emitTranscript(idx, t0, result);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (!capIds.has(step.capability)) {
|
|
436
|
+
const result: MissionStepResult = {
|
|
437
|
+
id: step.id,
|
|
438
|
+
capability: step.capability,
|
|
439
|
+
status: "error",
|
|
440
|
+
inputs: step.inputs ?? {},
|
|
441
|
+
error: `Capability "${step.capability}" not found in registry. Use ros2_list_capabilities to see what's available.`,
|
|
442
|
+
duration_ms: Date.now() - t0,
|
|
443
|
+
};
|
|
444
|
+
results.push(result);
|
|
445
|
+
emitTranscript(idx, t0, result);
|
|
446
|
+
if ((step.on_fail ?? "stop") === "stop") aborted = true;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const binding = bindings[step.capability];
|
|
451
|
+
if (!binding) {
|
|
452
|
+
const result: MissionStepResult = {
|
|
453
|
+
id: step.id,
|
|
454
|
+
capability: step.capability,
|
|
455
|
+
status: "error",
|
|
456
|
+
inputs: step.inputs ?? {},
|
|
457
|
+
error: `Capability "${step.capability}" is registered but has no mission-runner tool binding yet. See docs/strategy-ai-agents-plus-ros.md §4 Phase 1.c.`,
|
|
458
|
+
duration_ms: Date.now() - t0,
|
|
459
|
+
};
|
|
460
|
+
results.push(result);
|
|
461
|
+
emitTranscript(idx, t0, result);
|
|
462
|
+
if ((step.on_fail ?? "stop") === "stop") aborted = true;
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const resolvedInputs = (resolveTemplates(step.inputs ?? {}, stepOutputs) as Record<string, unknown>) ?? {};
|
|
467
|
+
|
|
468
|
+
let toolArgs: Record<string, unknown>;
|
|
469
|
+
try {
|
|
470
|
+
toolArgs = binding.buildArgs(resolvedInputs);
|
|
471
|
+
} catch (err) {
|
|
472
|
+
const result: MissionStepResult = {
|
|
473
|
+
id: step.id,
|
|
474
|
+
capability: step.capability,
|
|
475
|
+
status: "error",
|
|
476
|
+
inputs: resolvedInputs,
|
|
477
|
+
error: `Failed to build args for tool ${binding.tool}: ${err instanceof Error ? err.message : String(err)}`,
|
|
478
|
+
duration_ms: Date.now() - t0,
|
|
479
|
+
};
|
|
480
|
+
results.push(result);
|
|
481
|
+
emitTranscript(idx, t0, result);
|
|
482
|
+
if ((step.on_fail ?? "stop") === "stop") aborted = true;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Inject robot_id when the binding didn't set one. Per-step inputs
|
|
487
|
+
// take precedence over the mission-level default; empty strings
|
|
488
|
+
// mean "use the active robot" so we ignore them. This is what
|
|
489
|
+
// makes per-tool fleet routing work end-to-end from `run_mission`.
|
|
490
|
+
if (!("robot_id" in toolArgs) || typeof toolArgs.robot_id !== "string" || toolArgs.robot_id.trim().length === 0) {
|
|
491
|
+
const stepRid = typeof resolvedInputs.robot_id === "string" ? resolvedInputs.robot_id.trim() : "";
|
|
492
|
+
const missionRid = typeof mission.robot_id === "string" ? mission.robot_id.trim() : "";
|
|
493
|
+
const effective = stepRid || missionRid;
|
|
494
|
+
if (effective) toolArgs.robot_id = effective;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
try {
|
|
498
|
+
const dispatched = await dispatcher(binding.tool, toolArgs);
|
|
499
|
+
const outputs =
|
|
500
|
+
dispatched.outputs ??
|
|
501
|
+
binding.parseOutputs?.(dispatched.text) ??
|
|
502
|
+
tryParseJsonOutputs(dispatched.text);
|
|
503
|
+
stepOutputs[step.id] = outputs;
|
|
504
|
+
const status: MissionStepResult["status"] = dispatched.isError ? "error" : "ok";
|
|
505
|
+
const result: MissionStepResult = {
|
|
506
|
+
id: step.id,
|
|
507
|
+
capability: step.capability,
|
|
508
|
+
status,
|
|
509
|
+
inputs: resolvedInputs,
|
|
510
|
+
outputs,
|
|
511
|
+
message: dispatched.text,
|
|
512
|
+
...(dispatched.isError ? { error: dispatched.text } : {}),
|
|
513
|
+
duration_ms: Date.now() - t0,
|
|
514
|
+
};
|
|
515
|
+
results.push(result);
|
|
516
|
+
emitTranscript(idx, t0, result);
|
|
517
|
+
if (status === "error" && (step.on_fail ?? "stop") === "stop") aborted = true;
|
|
518
|
+
} catch (err) {
|
|
519
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
520
|
+
const result: MissionStepResult = {
|
|
521
|
+
id: step.id,
|
|
522
|
+
capability: step.capability,
|
|
523
|
+
status: "error",
|
|
524
|
+
inputs: resolvedInputs,
|
|
525
|
+
error: msg,
|
|
526
|
+
duration_ms: Date.now() - t0,
|
|
527
|
+
};
|
|
528
|
+
results.push(result);
|
|
529
|
+
emitTranscript(idx, t0, result);
|
|
530
|
+
if ((step.on_fail ?? "stop") === "stop") aborted = true;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// "Ran" counts steps that actually executed — skipped (on_fail=stop)
|
|
535
|
+
// and cancelled (Phase 1.f) are explicitly excluded.
|
|
536
|
+
const ran = results.filter((r) => r.status !== "skipped" && r.status !== "cancelled").length;
|
|
537
|
+
const failed = results.filter((r) => r.status === "error").length;
|
|
538
|
+
let overallStatus: MissionResult["status"] = failed === 0 ? "ok" : "error";
|
|
539
|
+
if (cancelled) overallStatus = "cancelled";
|
|
540
|
+
const summary = summariseMission(mission, results, overallStatus);
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
status: overallStatus,
|
|
544
|
+
steps_run: ran,
|
|
545
|
+
steps_total: mission.steps.length,
|
|
546
|
+
steps: results,
|
|
547
|
+
duration_ms: Date.now() - t0Mission,
|
|
548
|
+
summary,
|
|
549
|
+
robot_id: typeof mission.robot_id === "string" ? mission.robot_id : "",
|
|
550
|
+
...(options?.mission_id ? { mission_id: options.mission_id } : {}),
|
|
551
|
+
...(cancelled
|
|
552
|
+
? { cancellation_reason: options?.cancellation?.reason ?? "cancelled" }
|
|
553
|
+
: {}),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function summariseMission(
|
|
558
|
+
mission: Mission,
|
|
559
|
+
results: MissionStepResult[],
|
|
560
|
+
status: MissionResult["status"],
|
|
561
|
+
): string {
|
|
562
|
+
const label = mission.name ?? "Mission";
|
|
563
|
+
const ran = results.filter((r) => r.status !== "skipped" && r.status !== "cancelled").length;
|
|
564
|
+
const cancelled = results.filter((r) => r.status === "cancelled").length;
|
|
565
|
+
const failed = results.filter((r) => r.status === "error");
|
|
566
|
+
if (status === "ok") {
|
|
567
|
+
return `${label}: completed ${ran}/${results.length} step(s) successfully.`;
|
|
568
|
+
}
|
|
569
|
+
if (status === "cancelled") {
|
|
570
|
+
return `${label}: cancelled after ${ran} step(s) — ${cancelled} remaining step(s) skipped.`;
|
|
571
|
+
}
|
|
572
|
+
const first = failed[0];
|
|
573
|
+
return `${label}: failed at step "${first?.id ?? "?"}" (${first?.capability ?? "?"}): ${first?.error ?? "unknown error"}.`;
|
|
574
|
+
}
|