@particle-academy/agent-integrations 0.51.0 → 0.53.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.
@@ -126,28 +126,248 @@ function serialize(entry) {
126
126
  };
127
127
  }
128
128
 
129
+ // src/bridges/host-commit.ts
130
+ function hostErrorMessage(e, fallback) {
131
+ let msg;
132
+ if (e instanceof Error) msg = e.message;
133
+ else if (typeof e === "string") msg = e;
134
+ else if (e && typeof e === "object" && typeof e.message === "string") {
135
+ msg = e.message;
136
+ }
137
+ const firstFrame = msg ? msg.search(/\n\s+at\s/) : -1;
138
+ if (msg && firstFrame >= 0) msg = msg.slice(0, firstFrame);
139
+ msg = msg?.trim();
140
+ return msg ? msg : fallback;
141
+ }
142
+ async function commit(run, fallback) {
143
+ try {
144
+ const r = await run();
145
+ if (r && typeof r === "object" && r.ok === false) {
146
+ return { ok: false, error: hostErrorMessage(r.error, fallback) };
147
+ }
148
+ return { ok: true };
149
+ } catch (e) {
150
+ return { ok: false, error: hostErrorMessage(e, fallback) };
151
+ }
152
+ }
153
+
154
+ // src/bridges/multi-instance.ts
155
+ var routers = /* @__PURE__ */ new WeakMap();
156
+ var quoteAll = (ids) => ids.map((id) => `"${id}"`).join(", ");
157
+ function joinInstanceRouter(host, options, id, instance) {
158
+ if (typeof id !== "string" || id.trim() === "") {
159
+ throw new Error(
160
+ `A ${options.family} on a shared host needs a non-empty string id (got ${JSON.stringify(id)}); agents select it by that id through the "${options.argument}" argument.`
161
+ );
162
+ }
163
+ let byFamily = routers.get(host);
164
+ if (!byFamily) {
165
+ byFamily = /* @__PURE__ */ new Map();
166
+ routers.set(host, byFamily);
167
+ }
168
+ let state = byFamily.get(options.family);
169
+ if (!state) {
170
+ const instances = /* @__PURE__ */ new Map();
171
+ const router = {
172
+ ids: () => [...instances.keys()],
173
+ entries: () => [...instances.entries()],
174
+ resolve: (requested) => resolve(options, instances, requested)
175
+ };
176
+ const uninstall = options.install(router);
177
+ state = { instances, router, uninstall };
178
+ byFamily.set(options.family, state);
179
+ }
180
+ const current = state;
181
+ current.instances.set(id, instance);
182
+ let left = false;
183
+ const leave = () => {
184
+ if (left) return;
185
+ left = true;
186
+ if (current.instances.get(id) !== instance) return;
187
+ current.instances.delete(id);
188
+ if (current.instances.size === 0 && byFamily.get(options.family) === current) {
189
+ byFamily.delete(options.family);
190
+ current.uninstall();
191
+ }
192
+ };
193
+ return { router: current.router, leave };
194
+ }
195
+ function resolve(options, instances, requested) {
196
+ const ids = [...instances.keys()];
197
+ const { family, argument, listTool } = options;
198
+ if (requested === void 0 || requested === null || requested === "") {
199
+ if (ids.length === 1) return { ok: true, id: ids[0], instance: instances.get(ids[0]) };
200
+ if (ids.length === 0) return { ok: false, error: `No ${family} is mounted.` };
201
+ return {
202
+ ok: false,
203
+ error: `${ids.length} ${family}s are mounted, so pass "${argument}" with one of: ${quoteAll(ids)}. ${listTool} describes them.`
204
+ };
205
+ }
206
+ if (typeof requested !== "string") {
207
+ return { ok: false, error: `"${argument}" must be a ${family} id string. Mounted: ${quoteAll(ids)}.` };
208
+ }
209
+ if (!instances.has(requested)) {
210
+ return {
211
+ ok: false,
212
+ error: `No ${family} "${requested}" is mounted. ` + (ids.length ? `Mounted: ${quoteAll(ids)}. ` : "") + `${listTool} lists what is open.`
213
+ };
214
+ }
215
+ return { ok: true, id: requested, instance: instances.get(requested) };
216
+ }
217
+
129
218
  // src/bridges/forms.ts
130
219
  var DEFAULT_AGENT = { id: "agent", name: "Agent", color: "#a855f7" };
220
+ var FORM_TOOLS_OWNER = "form-bridge";
221
+ var FORM_ARG = {
222
+ type: "string",
223
+ description: "Id of the form to act on (see form_list). Optional while exactly one form is mounted; required when several are."
224
+ };
225
+ var FORM_TOOL_DEFS = [
226
+ {
227
+ name: "form_describe",
228
+ description: "Describe a form \u2014 fields, types, options, required flags. Call this first to know what's fillable.",
229
+ properties: {},
230
+ required: []
231
+ },
232
+ { name: "form_get_value", description: "Read a single field's current value.", properties: { field: { type: "string" } }, required: ["field"] },
233
+ { name: "form_get_values", description: "Read every field's current value as a JSON object.", properties: {}, required: [] },
234
+ {
235
+ name: "form_set_value",
236
+ description: "Set one field's value. The host's controlled state updates and the human sees the field change.",
237
+ properties: {
238
+ field: { type: "string" },
239
+ value: { description: "Value to set. Type depends on the field's `type`." }
240
+ },
241
+ required: ["field", "value"]
242
+ },
243
+ {
244
+ name: "form_set_values",
245
+ description: "Set multiple fields atomically. Pass a `values` object keyed by field name.",
246
+ properties: { values: { type: "object" } },
247
+ required: ["values"]
248
+ },
249
+ {
250
+ name: "form_focus",
251
+ description: "Move browser focus to a field (host-implemented). Useful before streaming text into it.",
252
+ properties: { field: { type: "string" } },
253
+ required: ["field"]
254
+ },
255
+ {
256
+ name: "form_submit",
257
+ description: "Submit the form. Host returns ok + values (or an error). In pendingMode this is staged for the human to confirm first.",
258
+ properties: {},
259
+ required: []
260
+ }
261
+ ];
262
+ function installFormTools(host, router) {
263
+ const disposers = [];
264
+ try {
265
+ for (const def of FORM_TOOL_DEFS) {
266
+ disposers.push(
267
+ host.registerTool(
268
+ {
269
+ name: def.name,
270
+ description: def.description,
271
+ inputSchema: {
272
+ type: "object",
273
+ properties: { form: FORM_ARG, ...def.properties },
274
+ required: def.required,
275
+ additionalProperties: false
276
+ }
277
+ },
278
+ async (args) => {
279
+ const target = router.resolve(args?.form);
280
+ if (!target.ok) return errorResult(target.error);
281
+ return target.instance.handlers[def.name](args);
282
+ },
283
+ { owner: FORM_TOOLS_OWNER }
284
+ )
285
+ );
286
+ }
287
+ disposers.push(
288
+ host.registerTool(
289
+ {
290
+ name: "form_list",
291
+ description: "List the forms mounted on this host \u2014 id, title, screen and fields. Pass an id as `form` to the other form tools when more than one is mounted.",
292
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
293
+ },
294
+ () => {
295
+ try {
296
+ const forms = router.entries().map(([id, { adapter }]) => ({
297
+ id,
298
+ title: adapter.title,
299
+ screenId: adapter.screenId,
300
+ fields: adapter.getFields()
301
+ }));
302
+ const text = forms.map((f) => `${f.id}${f.title ? ` \u2014 ${f.title}` : ""}: ${f.fields.map((x) => x.name).join(", ") || "(no fields)"}`).join("\n");
303
+ return textResult(text || "(no forms)", { forms });
304
+ } catch (e) {
305
+ return errorResult(hostErrorMessage(e, "form_list failed."));
306
+ }
307
+ },
308
+ { owner: FORM_TOOLS_OWNER }
309
+ )
310
+ );
311
+ } catch (e) {
312
+ for (const d of disposers) d();
313
+ throw e;
314
+ }
315
+ return () => {
316
+ for (const d of disposers) d();
317
+ };
318
+ }
131
319
  function registerFormBridge(host, options) {
132
320
  const { adapter } = options;
133
321
  const agent = { ...DEFAULT_AGENT, ...options.agent ?? {} };
134
322
  const pendingMode = options.pendingMode ?? true;
135
- const disposers = [];
136
- const bridgeId = `form:${adapter.id}`;
323
+ const formId = adapter?.id == null ? "" : String(adapter.id);
324
+ if (formId.trim() === "") {
325
+ throw new Error(
326
+ "registerFormBridge: adapter.id must be a non-empty string. It is how agents pick this form (the `form` argument) when several forms share a host."
327
+ );
328
+ }
329
+ const bridgeId = `form:${formId}`;
137
330
  ensureUndoToolsRegistered(host, { defaultAgentId: agent.id });
138
- const formId = adapter.id;
331
+ const handlers = {};
332
+ let disposed = false;
333
+ const onDispose = /* @__PURE__ */ new Set();
334
+ const CLOSED = /* @__PURE__ */ Symbol("form-closed");
335
+ const untilClosed = (p) => {
336
+ if (disposed) {
337
+ p.catch(() => {
338
+ });
339
+ return Promise.resolve(CLOSED);
340
+ }
341
+ return new Promise((resolve2, reject) => {
342
+ const close = () => resolve2(CLOSED);
343
+ onDispose.add(close);
344
+ p.then(
345
+ (v) => {
346
+ onDispose.delete(close);
347
+ resolve2(v);
348
+ },
349
+ (e) => {
350
+ onDispose.delete(close);
351
+ reject(e);
352
+ }
353
+ );
354
+ });
355
+ };
356
+ const closedUnknown = (what) => errorResult(
357
+ `Form "${formId}" was closed before the host confirmed ${what}, so whether it was applied is unknown. Do not assume it was. If the form is open again, read it back (form_get_values) before retrying.`
358
+ );
139
359
  const target = (args) => ({
140
360
  kind: "form",
141
361
  screenId: adapter.screenId,
142
362
  elementId: typeof args?.field === "string" ? `${formId}:${args.field}` : formId,
143
363
  label: typeof args?.field === "string" ? `${adapter.title ?? formId} \u2192 ${args.field}` : adapter.title ?? formId
144
364
  });
145
- const reg = (name, description, properties, required, handler, isMutation) => {
365
+ const reg = (name, handler, isMutation) => {
146
366
  const wrapped = async (args) => {
147
367
  try {
148
368
  return await handler(args);
149
369
  } catch (e) {
150
- return errorResult(e instanceof Error ? e.message : String(e));
370
+ return errorResult(hostErrorMessage(e, `${name} failed.`));
151
371
  }
152
372
  };
153
373
  const final = isMutation ? wrapToolWithActivity(wrapped, {
@@ -157,23 +377,10 @@ function registerFormBridge(host, options) {
157
377
  screenId: adapter.screenId,
158
378
  resolveTarget: ({ args }) => target(args)
159
379
  }) : wrapped;
160
- disposers.push(
161
- host.registerTool(
162
- {
163
- name,
164
- description,
165
- inputSchema: { type: "object", properties, required, additionalProperties: false }
166
- },
167
- final,
168
- { owner: bridgeId }
169
- )
170
- );
380
+ handlers[name] = final;
171
381
  };
172
382
  reg(
173
383
  "form_describe",
174
- `Describe the form "${formId}" \u2014 fields, types, options, required flags. Call this first to know what's fillable.`,
175
- {},
176
- [],
177
384
  () => {
178
385
  const fields = adapter.getFields();
179
386
  const text = fields.map((f) => `${f.name}${f.required ? "*" : ""} (${f.type})${f.label ? ` \u2014 ${f.label}` : ""}`).join("\n");
@@ -183,9 +390,6 @@ function registerFormBridge(host, options) {
183
390
  );
184
391
  reg(
185
392
  "form_get_value",
186
- "Read a single field's current value.",
187
- { field: { type: "string" } },
188
- ["field"],
189
393
  (args) => {
190
394
  const name = String(args.field ?? "");
191
395
  if (!adapter.getFields().find((f) => f.name === name)) {
@@ -198,9 +402,6 @@ function registerFormBridge(host, options) {
198
402
  );
199
403
  reg(
200
404
  "form_get_values",
201
- "Read every field's current value as a JSON object.",
202
- {},
203
- [],
204
405
  () => {
205
406
  const values = adapter.getValues();
206
407
  return textResult(JSON.stringify(values, null, 2), values);
@@ -209,36 +410,67 @@ function registerFormBridge(host, options) {
209
410
  );
210
411
  reg(
211
412
  "form_set_value",
212
- "Set one field's value. The host's controlled state updates and the human sees the field change.",
213
- {
214
- field: { type: "string" },
215
- value: { description: "Value to set. Type depends on the field's `type`." }
216
- },
217
- ["field", "value"],
218
- (args) => {
413
+ async (args) => {
219
414
  const name = String(args.field ?? "");
220
415
  const fieldDef = adapter.getFields().find((f) => f.name === name);
221
416
  if (!fieldDef) return errorResult(`Unknown field: ${name}`);
222
- adapter.setValue(name, args.value);
417
+ const done = await untilClosed(
418
+ commit(() => adapter.setValue(name, args.value), `Host refused the write to "${name}".`)
419
+ );
420
+ if (done === CLOSED) return closedUnknown(`the write to "${name}"`);
421
+ if (!done.ok) return errorResult(done.error);
223
422
  return textResult(`${name} \u2190 ${JSON.stringify(args.value)}`, { field: name, value: args.value });
224
423
  },
225
424
  true
226
425
  );
227
426
  reg(
228
427
  "form_set_values",
229
- "Set multiple fields atomically. Pass a `values` object keyed by field name.",
230
- { values: { type: "object" } },
231
- ["values"],
232
- (args) => {
428
+ async (args) => {
233
429
  const values = args.values && typeof args.values === "object" ? args.values : {};
234
430
  const fields = adapter.getFields();
235
431
  const known = new Set(fields.map((f) => f.name));
236
432
  const unknownKeys = Object.keys(values).filter((k) => !known.has(k));
237
433
  if (unknownKeys.length) return errorResult(`Unknown fields: ${unknownKeys.join(", ")}`);
238
- if (adapter.setValues) {
239
- adapter.setValues(values);
434
+ const setValues = adapter.setValues;
435
+ if (setValues) {
436
+ const done = await untilClosed(commit(() => setValues(values), "Host refused the write."));
437
+ if (done === CLOSED) return closedUnknown("the write");
438
+ if (!done.ok) return errorResult(done.error);
240
439
  } else {
241
- for (const [k, v] of Object.entries(values)) adapter.setValue(k, v);
440
+ const keys = Object.keys(values);
441
+ const committed = [];
442
+ for (const [i, k] of keys.entries()) {
443
+ const done = await untilClosed(
444
+ commit(() => adapter.setValue(k, values[k]), `Host refused the write to "${k}".`)
445
+ );
446
+ if (done === CLOSED) {
447
+ return {
448
+ ...closedUnknown(`the write to "${k}"`),
449
+ structuredContent: { committed, pending: k, notAttempted: keys.slice(i + 1) }
450
+ };
451
+ }
452
+ if (!done.ok) {
453
+ const state = { committed, failed: k, notAttempted: keys.slice(i + 1) };
454
+ if (committed.length) {
455
+ fancyAutoCommon.emitActivity({
456
+ agentId: agent.id,
457
+ agentName: agent.name,
458
+ agentColor: agent.color,
459
+ target: target({}),
460
+ action: "form_set_values",
461
+ timestamp: Date.now(),
462
+ meta: state
463
+ });
464
+ }
465
+ return {
466
+ ...errorResult(
467
+ `"${k}" was refused: ${done.error} ` + (committed.length ? `Already committed and NOT rolled back: ${committed.join(", ")}. ` : "Nothing was committed. ") + (state.notAttempted.length ? `Not attempted: ${state.notAttempted.join(", ")}.` : "")
468
+ ),
469
+ structuredContent: state
470
+ };
471
+ }
472
+ committed.push(k);
473
+ }
242
474
  }
243
475
  return textResult(`Set ${Object.keys(values).length} field(s)`, { values });
244
476
  },
@@ -246,9 +478,6 @@ function registerFormBridge(host, options) {
246
478
  );
247
479
  reg(
248
480
  "form_focus",
249
- "Move browser focus to a field (host-implemented). Useful before streaming text into it.",
250
- { field: { type: "string" } },
251
- ["field"],
252
481
  (args) => {
253
482
  const name = String(args.field ?? "");
254
483
  if (!adapter.focus) return errorResult("Host did not provide a focus implementation.");
@@ -262,31 +491,44 @@ function registerFormBridge(host, options) {
262
491
  );
263
492
  reg(
264
493
  "form_submit",
265
- "Submit the form. Host returns ok + values (or an error). In pendingMode this is staged for the human to confirm first.",
266
- {},
267
- [],
268
494
  async () => {
269
495
  if (!adapter.submit) return errorResult("Host did not provide a submit implementation.");
270
496
  if (pendingMode && adapter.confirm) {
271
- const ok = await adapter.confirm({
497
+ const confirm = adapter.confirm;
498
+ const ok = await untilClosed(Promise.resolve().then(() => confirm({
272
499
  action: "submit",
273
500
  form: formId,
274
501
  title: adapter.title,
275
502
  values: adapter.getValues()
276
- });
503
+ })));
504
+ if (ok === CLOSED) {
505
+ return errorResult(`Form "${formId}" was closed before the submit was confirmed. Nothing was submitted.`);
506
+ }
277
507
  if (!ok) return errorResult("Declined by user");
278
508
  }
279
- const result = await adapter.submit();
280
- if (!result.ok) return errorResult(result.error ?? "Submit failed");
509
+ const submit = adapter.submit;
510
+ const result = await untilClosed(Promise.resolve().then(() => submit()));
511
+ if (result === CLOSED) return closedUnknown("the submit");
512
+ if (!result.ok) return errorResult(hostErrorMessage(result.error, "Submit failed"));
281
513
  return textResult("Submitted", { values: result.values });
282
514
  },
283
515
  true
284
516
  );
517
+ const membership = joinInstanceRouter(
518
+ host,
519
+ { family: "form", argument: "form", listTool: "form_list", install: (router) => installFormTools(host, router) },
520
+ formId,
521
+ { adapter, handlers }
522
+ );
285
523
  return {
286
524
  id: bridgeId,
287
525
  title: adapter.title ?? formId,
288
526
  dispose: () => {
289
- for (const d of disposers) d();
527
+ if (disposed) return;
528
+ disposed = true;
529
+ membership.leave();
530
+ for (const close of [...onDispose]) close();
531
+ onDispose.clear();
290
532
  }
291
533
  };
292
534
  }