moshcode 0.58.0 → 0.60.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/README.md +299 -6
- package/bin/moshcode.mjs +3 -3
- package/examples/account.mosh +48 -0
- package/examples/aliases.mosh +56 -0
- package/examples/research-desk.mosh +49 -0
- package/package.json +1 -1
- package/prd/0011-herd-agent-protocol.md +391 -0
- package/prd/README.md +1 -0
- package/src/auth.mjs +59 -64
- package/src/cli-schema.mjs +133 -4
- package/src/commands.mjs +389 -39
- package/src/cost-cli.mjs +232 -0
- package/src/cost-pricing.mjs +159 -0
- package/src/cost.mjs +753 -0
- package/src/engines.mjs +32 -0
- package/src/games-breakout.mjs +64 -10
- package/src/games-paddle.mjs +128 -0
- package/src/games-pong.mjs +53 -4
- package/src/games.mjs +164 -12
- package/src/herd-cli.mjs +816 -20
- package/src/herd-eval.mjs +301 -0
- package/src/herd-hooks.mjs +285 -0
- package/src/herd-remote.mjs +365 -0
- package/src/herd-serve.mjs +515 -0
- package/src/herd-state.mjs +167 -10
- package/src/herd-tasks.mjs +377 -0
- package/src/herd.mjs +89 -7
- package/src/templates.mjs +32 -5
- package/src/tools.mjs +43 -0
- package/src/tui.mjs +1 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// Remote herd members — the roster stops at the edge of the box (PRD 0011 R11–R12).
|
|
2
|
+
//
|
|
3
|
+
// A deployed agent — a DigitalOcean Gradient ADK deployment answering at
|
|
4
|
+
// `agents.do-ai.run/<workspace>/<deployment>/run`, say — could not be on the
|
|
5
|
+
// roster, and nothing off the box could drive the herd. That is a strange place
|
|
6
|
+
// for the herd to stop, because the ecosystem already converged on the shape we
|
|
7
|
+
// need: A2A v0.3.0 gives an agent a card at a well-known URL, tasks with ids and
|
|
8
|
+
// status history, and a state vocabulary whose `input-required` is our
|
|
9
|
+
// `blocked` under another name.
|
|
10
|
+
//
|
|
11
|
+
// TWO KINDS, because half the deployed agents in the world do not speak A2A:
|
|
12
|
+
//
|
|
13
|
+
// "a2a" — discovery at /.well-known/agent-card.json, then JSON-RPC:
|
|
14
|
+
// message/send, tasks/get, tasks/cancel. State comes from the task.
|
|
15
|
+
//
|
|
16
|
+
// "run" — a bare request/response endpoint: POST {"prompt": …}, get an
|
|
17
|
+
// answer. The shape every `gradient agent deploy` prints. It has no
|
|
18
|
+
// task model and no state, so the herd says so: it is `idle` when it
|
|
19
|
+
// answers, `working` while a call is in flight, and honest about
|
|
20
|
+
// knowing nothing else.
|
|
21
|
+
//
|
|
22
|
+
// AUTH IS NEVER WRITTEN DOWN. The token for a remote comes from the environment
|
|
23
|
+
// (MOSHCODE_REMOTE_<NAME>_TOKEN) and never touches the manifest, which is
|
|
24
|
+
// PRD 0010's allowlist reasoning verbatim: settings sync exists, the manifest is
|
|
25
|
+
// on the list of things that can be synced, and a bearer token for someone
|
|
26
|
+
// else's agent is exactly the thing that must not ride along to another machine.
|
|
27
|
+
import { forgetSession, readManifest, recordRemoteStatus, rememberSession, remoteStatus, validName, clearRemoteStatus } from "./herd.mjs";
|
|
28
|
+
|
|
29
|
+
// The status cache lives in herd.mjs so herd-state.mjs can read it without
|
|
30
|
+
// importing this module (and, with it, the network). Re-exported under a name
|
|
31
|
+
// that says whose status it is, for callers that already have this module.
|
|
32
|
+
export { remoteStatus as remoteStatusOf } from "./herd.mjs";
|
|
33
|
+
|
|
34
|
+
/** How a remote is driven. */
|
|
35
|
+
export const REMOTE_KINDS = ["a2a", "run"];
|
|
36
|
+
|
|
37
|
+
/** A2A's task states, and what the herd calls each one. */
|
|
38
|
+
export const A2A_TO_HERD = {
|
|
39
|
+
submitted: "working",
|
|
40
|
+
working: "working",
|
|
41
|
+
"input-required": "blocked",
|
|
42
|
+
"auth-required": "blocked",
|
|
43
|
+
completed: "done",
|
|
44
|
+
// A2A's three ways of stopping without an answer all leave the agent not
|
|
45
|
+
// working and not asking, which is `done` in a vocabulary that has no word
|
|
46
|
+
// for "gave up". The A2A state travels alongside in the cache so `--json`
|
|
47
|
+
// never has to round-trip through our smaller set to find out what happened.
|
|
48
|
+
canceled: "done",
|
|
49
|
+
rejected: "done",
|
|
50
|
+
failed: "done",
|
|
51
|
+
unknown: "unknown",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The herd state for an A2A task state. */
|
|
55
|
+
export const herdStateFor = (a2a) => A2A_TO_HERD[String(a2a || "").toLowerCase()] || "unknown";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The environment variable holding this remote's bearer token.
|
|
59
|
+
*
|
|
60
|
+
* Named per member rather than one shared secret, because two remotes are
|
|
61
|
+
* routinely two different people's infrastructure.
|
|
62
|
+
*/
|
|
63
|
+
export const tokenEnvVar = (name) => `MOSHCODE_REMOTE_${String(name).toUpperCase().replace(/[^A-Z0-9]/g, "_")}_TOKEN`;
|
|
64
|
+
|
|
65
|
+
export function remoteToken(name, env = process.env) {
|
|
66
|
+
return env[tokenEnvVar(name)] || "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Only http(s), and only an absolute URL.
|
|
71
|
+
*
|
|
72
|
+
* `herd prompt` on a remote is a POST of user text to whatever this says, so
|
|
73
|
+
* the one thing that must not be possible is a scheme that means something
|
|
74
|
+
* other than "a request over the network".
|
|
75
|
+
*/
|
|
76
|
+
export function parseRemoteUrl(raw) {
|
|
77
|
+
let url;
|
|
78
|
+
try { url = new URL(String(raw)); }
|
|
79
|
+
catch { return { ok: false, error: new Error(`${JSON.stringify(String(raw))} is not a URL`) }; }
|
|
80
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
81
|
+
return { ok: false, error: new Error(`${url.protocol} is not a transport the herd speaks — use http or https`) };
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, url: url.toString() };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Every remote member on the roster. */
|
|
87
|
+
export function listRemotes() {
|
|
88
|
+
return Object.entries(readManifest().sessions)
|
|
89
|
+
.filter(([, meta]) => meta?.kind === "remote")
|
|
90
|
+
.map(([name, meta]) => ({ name, ...meta, status: remoteStatus(name) }));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function isRemote(name) {
|
|
94
|
+
return readManifest().sessions[name]?.kind === "remote";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function remoteEntry(name) {
|
|
98
|
+
const meta = readManifest().sessions[name];
|
|
99
|
+
return meta?.kind === "remote" ? { name, ...meta } : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Register a remote. Nothing is contacted here — `ping` is the verb for that. */
|
|
103
|
+
export function addRemote(name, url, { kind = "run", now = Date.now() } = {}) {
|
|
104
|
+
if (!validName(name)) return { ok: false, error: new Error(`invalid member name ${JSON.stringify(name)}`) };
|
|
105
|
+
if (!REMOTE_KINDS.includes(kind)) return { ok: false, error: new Error(`unknown kind ${JSON.stringify(kind)} — one of ${REMOTE_KINDS.join(", ")}`) };
|
|
106
|
+
const parsed = parseRemoteUrl(url);
|
|
107
|
+
if (!parsed.ok) return parsed;
|
|
108
|
+
if (readManifest().sessions[name] && !isRemote(name)) {
|
|
109
|
+
return { ok: false, error: new Error(`"${name}" is already a local session — pick another name`) };
|
|
110
|
+
}
|
|
111
|
+
rememberSession(name, {
|
|
112
|
+
kind: "remote", remoteKind: kind, url: parsed.url,
|
|
113
|
+
engine: "remote", cwd: new URL(parsed.url).host, created: now, herd: "main",
|
|
114
|
+
});
|
|
115
|
+
return { ok: true, name, url: parsed.url, kind };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function removeRemote(name) {
|
|
119
|
+
if (!isRemote(name)) return { ok: false, error: new Error(`no remote member named ${JSON.stringify(name)}`) };
|
|
120
|
+
clearRemoteStatus(name);
|
|
121
|
+
forgetSession(name);
|
|
122
|
+
return { ok: true, name };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Talking to one
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
130
|
+
|
|
131
|
+
function authHeaders(name, env) {
|
|
132
|
+
const token = remoteToken(name, env);
|
|
133
|
+
return token ? { authorization: `Bearer ${token}` } : {};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function request(url, { method = "GET", body, headers = {}, timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch } = {}) {
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetchImpl(url, {
|
|
139
|
+
method,
|
|
140
|
+
headers: { ...(body ? { "content-type": "application/json" } : {}), ...headers },
|
|
141
|
+
...(body ? { body: typeof body === "string" ? body : JSON.stringify(body) } : {}),
|
|
142
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
143
|
+
});
|
|
144
|
+
const text = await res.text().catch(() => "");
|
|
145
|
+
let json = null;
|
|
146
|
+
try { json = text ? JSON.parse(text) : null; } catch { /* not every endpoint answers JSON */ }
|
|
147
|
+
return { ok: res.ok, status: res.status, text, json };
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return { ok: false, status: 0, error, text: "", json: null };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const trimSlash = (u) => String(u).replace(/\/+$/, "");
|
|
154
|
+
|
|
155
|
+
/** Where an A2A agent publishes its card. */
|
|
156
|
+
export const cardUrl = (url) => `${trimSlash(url)}/.well-known/agent-card.json`;
|
|
157
|
+
|
|
158
|
+
/** Fetch and lightly validate an agent card. */
|
|
159
|
+
export async function discoverCard(name, { url = remoteEntry(name)?.url, env = process.env, fetchImpl = fetch } = {}) {
|
|
160
|
+
if (!url) return { ok: false, error: new Error(`no remote member named ${JSON.stringify(name)}`) };
|
|
161
|
+
const res = await request(cardUrl(url), { headers: authHeaders(name, env), fetchImpl });
|
|
162
|
+
if (!res.ok || !res.json) {
|
|
163
|
+
return { ok: false, status: res.status, error: res.error || new Error(`no agent card at ${cardUrl(url)} (${res.status || "unreachable"})`) };
|
|
164
|
+
}
|
|
165
|
+
return { ok: true, card: res.json };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** One JSON-RPC call against an A2A endpoint. */
|
|
169
|
+
export async function rpc(name, method, params, { url = remoteEntry(name)?.url, env = process.env, fetchImpl = fetch, timeoutMs } = {}) {
|
|
170
|
+
if (!url) return { ok: false, error: new Error(`no remote member named ${JSON.stringify(name)}`) };
|
|
171
|
+
const res = await request(trimSlash(url), {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: authHeaders(name, env),
|
|
174
|
+
// The id is per-call and never reused; nothing here multiplexes.
|
|
175
|
+
body: { jsonrpc: "2.0", id: `${Date.now()}`, method, params },
|
|
176
|
+
fetchImpl, timeoutMs,
|
|
177
|
+
});
|
|
178
|
+
if (!res.json) return { ok: false, status: res.status, error: res.error || new Error(`${method}: ${res.status || "unreachable"}`) };
|
|
179
|
+
if (res.json.error) return { ok: false, status: res.status, error: new Error(`${method}: ${res.json.error.message || "error"} (${res.json.error.code})`), rpcError: res.json.error };
|
|
180
|
+
return { ok: true, result: res.json.result };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The text parts of an A2A message or artifact, joined. */
|
|
184
|
+
export function partsText(container) {
|
|
185
|
+
const parts = Array.isArray(container?.parts) ? container.parts : [];
|
|
186
|
+
return parts.filter((p) => p?.kind === "text" || typeof p?.text === "string").map((p) => String(p.text ?? "")).join("\n").trim();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Everything the herd wants out of an A2A Task object. */
|
|
190
|
+
export function readA2aTask(task) {
|
|
191
|
+
const a2aState = task?.status?.state || "unknown";
|
|
192
|
+
const artifact = [
|
|
193
|
+
...(Array.isArray(task?.artifacts) ? task.artifacts.map(partsText) : []),
|
|
194
|
+
partsText(task?.status?.message),
|
|
195
|
+
].filter(Boolean).join("\n\n");
|
|
196
|
+
return { taskId: task?.id || null, contextId: task?.contextId || null, a2aState, state: herdStateFor(a2aState), artifact };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Hand work to a remote member.
|
|
201
|
+
*
|
|
202
|
+
* Both kinds record what they learned in the status cache, because that cache
|
|
203
|
+
* is what `moshcode ps` reads — a remote that has just been prompted should not
|
|
204
|
+
* still show whatever it was doing an hour ago.
|
|
205
|
+
*/
|
|
206
|
+
export async function promptRemote(name, text, { env = process.env, fetchImpl = fetch, timeoutMs, now = Date.now() } = {}) {
|
|
207
|
+
const entry = remoteEntry(name);
|
|
208
|
+
if (!entry) return { ok: false, error: new Error(`no remote member named ${JSON.stringify(name)}`) };
|
|
209
|
+
recordRemoteStatus(name, { state: "working", at: now, kind: entry.remoteKind, note: "request in flight" });
|
|
210
|
+
|
|
211
|
+
if (entry.remoteKind === "a2a") {
|
|
212
|
+
const sent = await rpc(name, "message/send", {
|
|
213
|
+
message: {
|
|
214
|
+
kind: "message",
|
|
215
|
+
role: "user",
|
|
216
|
+
messageId: `m-${now}`,
|
|
217
|
+
parts: [{ kind: "text", text: String(text) }],
|
|
218
|
+
},
|
|
219
|
+
}, { env, fetchImpl, timeoutMs });
|
|
220
|
+
if (!sent.ok) {
|
|
221
|
+
recordRemoteStatus(name, { state: "unknown", at: Date.now(), kind: entry.remoteKind, error: String(sent.error?.message || sent.error) });
|
|
222
|
+
return sent;
|
|
223
|
+
}
|
|
224
|
+
// message/send may answer with a Task or with a Message. A Message means
|
|
225
|
+
// the agent answered in one shot and there is nothing to poll.
|
|
226
|
+
const result = sent.result;
|
|
227
|
+
if (result?.kind === "message" || (!result?.status && result?.parts)) {
|
|
228
|
+
const artifact = partsText(result);
|
|
229
|
+
recordRemoteStatus(name, { state: "idle", at: Date.now(), kind: entry.remoteKind, artifact, a2aState: "completed" });
|
|
230
|
+
return { ok: true, taskId: null, state: "done", artifact };
|
|
231
|
+
}
|
|
232
|
+
const task = readA2aTask(result);
|
|
233
|
+
recordRemoteStatus(name, { ...task, at: Date.now(), kind: entry.remoteKind });
|
|
234
|
+
return { ok: true, ...task };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// "run": one request, one answer, no task model to consult.
|
|
238
|
+
const res = await request(trimSlash(entry.url), {
|
|
239
|
+
method: "POST", headers: authHeaders(name, env), body: { prompt: String(text) }, fetchImpl, timeoutMs,
|
|
240
|
+
});
|
|
241
|
+
if (!res.ok) {
|
|
242
|
+
recordRemoteStatus(name, { state: "unknown", at: Date.now(), kind: "run", error: String(res.error?.message || `HTTP ${res.status}`) });
|
|
243
|
+
return { ok: false, status: res.status, error: res.error || new Error(`HTTP ${res.status}`) };
|
|
244
|
+
}
|
|
245
|
+
const artifact = runAnswer(res.json, res.text);
|
|
246
|
+
recordRemoteStatus(name, { state: "idle", at: Date.now(), kind: "run", artifact });
|
|
247
|
+
return { ok: true, taskId: null, state: "done", artifact };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The answer inside a bare `run` response.
|
|
252
|
+
*
|
|
253
|
+
* No standard says what key it is under, so this checks the ones the ADK and
|
|
254
|
+
* its neighbours actually use and falls back to the raw body. Returning the
|
|
255
|
+
* whole JSON when nothing matches beats returning "" and calling it an answer.
|
|
256
|
+
*/
|
|
257
|
+
export function runAnswer(json, text = "") {
|
|
258
|
+
if (json && typeof json === "object") {
|
|
259
|
+
for (const key of ["output", "response", "result", "answer", "text", "message", "content"]) {
|
|
260
|
+
const value = json[key];
|
|
261
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
262
|
+
if (value && typeof value === "object") {
|
|
263
|
+
const nested = partsText(value) || (typeof value.text === "string" ? value.text : "");
|
|
264
|
+
if (nested.trim()) return nested;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return JSON.stringify(json, null, 2);
|
|
268
|
+
}
|
|
269
|
+
return String(text || "");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Refresh what we know about a remote without giving it work. */
|
|
273
|
+
export async function pingRemote(name, { env = process.env, fetchImpl = fetch, timeoutMs = 8000, now = Date.now() } = {}) {
|
|
274
|
+
const entry = remoteEntry(name);
|
|
275
|
+
if (!entry) return { ok: false, error: new Error(`no remote member named ${JSON.stringify(name)}`) };
|
|
276
|
+
|
|
277
|
+
if (entry.remoteKind === "a2a") {
|
|
278
|
+
const cached = remoteStatus(name);
|
|
279
|
+
// A live task outranks the card: "what is it doing" is a better answer than
|
|
280
|
+
// "it is up", and only tasks/get can give it.
|
|
281
|
+
if (cached?.taskId) {
|
|
282
|
+
const got = await rpc(name, "tasks/get", { id: cached.taskId }, { env, fetchImpl, timeoutMs });
|
|
283
|
+
if (got.ok) {
|
|
284
|
+
const task = readA2aTask(got.result);
|
|
285
|
+
recordRemoteStatus(name, { ...task, at: now, kind: "a2a" });
|
|
286
|
+
return { ok: true, ...task };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const card = await discoverCard(name, { env, fetchImpl });
|
|
290
|
+
if (!card.ok) {
|
|
291
|
+
recordRemoteStatus(name, { state: "unknown", at: now, kind: "a2a", error: String(card.error?.message || card.error) });
|
|
292
|
+
return card;
|
|
293
|
+
}
|
|
294
|
+
recordRemoteStatus(name, { state: "idle", at: now, kind: "a2a", card: { name: card.card?.name, version: card.card?.version } });
|
|
295
|
+
return { ok: true, state: "idle", card: card.card };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// A request/response endpoint is `idle` when it is up. There is no third
|
|
299
|
+
// thing to know about it and we do not invent one.
|
|
300
|
+
const res = await request(trimSlash(entry.url), { method: "GET", headers: authHeaders(name, env), timeoutMs, fetchImpl });
|
|
301
|
+
const reachable = res.ok || (res.status >= 200 && res.status < 500);
|
|
302
|
+
recordRemoteStatus(name, {
|
|
303
|
+
state: reachable ? "idle" : "unknown", at: now, kind: "run",
|
|
304
|
+
...(reachable ? {} : { error: String(res.error?.message || `HTTP ${res.status}`) }),
|
|
305
|
+
});
|
|
306
|
+
return reachable ? { ok: true, state: "idle" } : { ok: false, status: res.status, error: res.error || new Error(`HTTP ${res.status}`) };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** What a remote last produced — what `herd read` shows for one. */
|
|
310
|
+
export function readRemote(name) {
|
|
311
|
+
const status = remoteStatus(name);
|
|
312
|
+
return status?.artifact ? String(status.artifact) : "";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Block until a remote reaches one of `states`.
|
|
317
|
+
*
|
|
318
|
+
* For `run` members this returns as soon as the in-flight call has landed,
|
|
319
|
+
* because a request/response endpoint has no state to move through: the answer
|
|
320
|
+
* IS the transition.
|
|
321
|
+
*/
|
|
322
|
+
export async function waitRemote(name, states, {
|
|
323
|
+
timeoutMs = 30 * 60 * 1000, intervalMs = 2000, env = process.env, fetchImpl = fetch,
|
|
324
|
+
now = () => Date.now(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
325
|
+
} = {}) {
|
|
326
|
+
const entry = remoteEntry(name);
|
|
327
|
+
if (!entry) return { outcome: "gone", state: "gone" };
|
|
328
|
+
const wanted = new Set(states);
|
|
329
|
+
const deadline = now() + timeoutMs;
|
|
330
|
+
for (;;) {
|
|
331
|
+
const status = remoteStatus(name);
|
|
332
|
+
if (entry.remoteKind !== "a2a") {
|
|
333
|
+
const state = status?.state || "unknown";
|
|
334
|
+
if (wanted.has(state)) return { outcome: "matched", state };
|
|
335
|
+
if (state !== "working") return { outcome: "ended", state };
|
|
336
|
+
} else {
|
|
337
|
+
const refreshed = await pingRemote(name, { env, fetchImpl });
|
|
338
|
+
const state = refreshed?.state || status?.state || "unknown";
|
|
339
|
+
if (wanted.has(state)) return { outcome: "matched", state, a2aState: refreshed?.a2aState };
|
|
340
|
+
if (state === "done") return { outcome: "ended", state };
|
|
341
|
+
}
|
|
342
|
+
if (now() >= deadline) return { outcome: "timeout", state: remoteStatus(name)?.state || "unknown" };
|
|
343
|
+
await sleep(intervalMs);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Stop whatever a remote is doing. Best effort by design: A2A says an agent may
|
|
349
|
+
* refuse to cancel a task it has already finished, and a `run` endpoint has
|
|
350
|
+
* nothing to cancel at all — the request either lands or it does not.
|
|
351
|
+
*/
|
|
352
|
+
export async function cancelRemote(name, { env = process.env, fetchImpl = fetch, now = Date.now() } = {}) {
|
|
353
|
+
const entry = remoteEntry(name);
|
|
354
|
+
if (!entry) return { ok: false, error: new Error(`no remote member named ${JSON.stringify(name)}`) };
|
|
355
|
+
if (entry.remoteKind !== "a2a") {
|
|
356
|
+
return { ok: false, error: new Error(`${name} is a request/response endpoint — there is nothing to cancel`) };
|
|
357
|
+
}
|
|
358
|
+
const cached = remoteStatus(name);
|
|
359
|
+
if (!cached?.taskId) return { ok: false, error: new Error(`${name} has no task to cancel`) };
|
|
360
|
+
const cancelled = await rpc(name, "tasks/cancel", { id: cached.taskId }, { env, fetchImpl });
|
|
361
|
+
if (!cancelled.ok) return cancelled;
|
|
362
|
+
const task = readA2aTask(cancelled.result);
|
|
363
|
+
recordRemoteStatus(name, { ...task, at: now, kind: "a2a" });
|
|
364
|
+
return { ok: true, ...task };
|
|
365
|
+
}
|