rterm-backend 3.8.7 → 3.8.9

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/bin/gybackend.cjs CHANGED
@@ -346328,6 +346328,7 @@ var AgentRunLedger = class {
346328
346328
  var CHECKPOINT_STRING_CAP = 8 * 1024;
346329
346329
  var CHECKPOINT_BYTES_CAP = 16 * 1024 * 1024;
346330
346330
  var CHECKPOINT_MAX_MESSAGES = 40;
346331
+ var CHECKPOINT_RETENTION = 8;
346331
346332
  function truncateString(input, max = CHECKPOINT_STRING_CAP) {
346332
346333
  if (input.length <= max) return input;
346333
346334
  const omitted = input.length - max;
@@ -346395,8 +346396,18 @@ function isAllocationError(error40) {
346395
346396
  var SafeMemorySaver = class extends MemorySaver {
346396
346397
  async put(config2, checkpoint, metadata) {
346397
346398
  const pruned = pruneCheckpointForSaver(checkpoint);
346399
+ const threadId = config2?.configurable?.thread_id;
346400
+ const ns = config2?.configurable?.checkpoint_ns ?? "";
346401
+ const checkpointId = checkpoint?.id;
346402
+ if (threadId && checkpointId) {
346403
+ const order = this.putOrderFor(threadId, ns);
346404
+ order.push(checkpointId);
346405
+ this.setPutOrder(threadId, ns, order.slice(-CHECKPOINT_RETENTION * 4));
346406
+ }
346398
346407
  try {
346399
- return await super.put(config2, pruned, pruneValue(metadata));
346408
+ const result = await super.put(config2, pruned, pruneValue(metadata));
346409
+ this.evictOldCheckpoints(config2);
346410
+ return result;
346400
346411
  } catch (error40) {
346401
346412
  if (!isAllocationError(error40)) throw error40;
346402
346413
  console.warn(
@@ -346409,12 +346420,15 @@ var SafeMemorySaver = class extends MemorySaver {
346409
346420
  channels.messages = channels.messages.slice(-8);
346410
346421
  }
346411
346422
  tighter.channel_values = channels;
346412
- return await super.put(config2, tighter, { source: "oom-pruned" });
346423
+ const result = await super.put(config2, tighter, { source: "oom-pruned" });
346424
+ this.evictOldCheckpoints(config2);
346425
+ return result;
346413
346426
  } catch (retryError) {
346414
346427
  if (!isAllocationError(retryError)) throw retryError;
346415
346428
  console.warn(
346416
346429
  "[SafeMemorySaver] checkpoint still too large \u2014 skipping persist so the run can finish"
346417
346430
  );
346431
+ this.evictOldCheckpoints(config2);
346418
346432
  return {
346419
346433
  configurable: {
346420
346434
  thread_id: config2?.configurable?.thread_id,
@@ -346425,6 +346439,46 @@ var SafeMemorySaver = class extends MemorySaver {
346425
346439
  }
346426
346440
  }
346427
346441
  }
346442
+ /**
346443
+ * Keep only the newest CHECKPOINT_RETENTION checkpoints for the thread/ns
346444
+ * in this config. MemorySaver stores them as
346445
+ * storage[threadId][ns][checkpointId] = [serializedCheckpoint, metadata,
346446
+ * parentId]; there is no timestamp, but checkpoint ids from langgraph are
346447
+ * UUIDv4-orderable-ish in practice and — more reliably — the number of
346448
+ * entries is what matters for memory. We cannot know insertion order from
346449
+ * the object shape alone, so we track it ourselves: the ids we just put
346450
+ * are recorded, and anything beyond the newest window is dropped.
346451
+ */
346452
+ evictOldCheckpoints(config2) {
346453
+ try {
346454
+ const threadId = config2?.configurable?.thread_id;
346455
+ const ns = config2?.configurable?.checkpoint_ns ?? "";
346456
+ if (!threadId) return;
346457
+ const storage = this.storage;
346458
+ const nsMap = storage?.[threadId]?.[ns];
346459
+ if (!nsMap) return;
346460
+ const ids = Object.keys(nsMap);
346461
+ if (ids.length <= CHECKPOINT_RETENTION) return;
346462
+ const order = this.putOrderFor(threadId, ns);
346463
+ const keep = new Set(order.slice(-CHECKPOINT_RETENTION));
346464
+ for (const id of ids) {
346465
+ if (!keep.has(id) && !order.includes(id)) keep.add(id);
346466
+ }
346467
+ for (const id of ids) {
346468
+ if (!keep.has(id)) delete nsMap[id];
346469
+ }
346470
+ const remaining = order.filter((id) => id in nsMap);
346471
+ this.setPutOrder(threadId, ns, remaining.slice(-CHECKPOINT_RETENTION * 2));
346472
+ } catch {
346473
+ }
346474
+ }
346475
+ putOrders = /* @__PURE__ */ new Map();
346476
+ putOrderFor(threadId, ns) {
346477
+ return this.putOrders.get(`${threadId}\0${ns}`) ?? [];
346478
+ }
346479
+ setPutOrder(threadId, ns, ids) {
346480
+ this.putOrders.set(`${threadId}\0${ns}`, ids);
346481
+ }
346428
346482
  async putWrites(config2, writes, taskId) {
346429
346483
  const prunedWrites = Array.isArray(writes) ? writes.map((w) => {
346430
346484
  if (Array.isArray(w) && w.length >= 2) {
@@ -366235,6 +366289,15 @@ var AgentHelpers = class {
366235
366289
  setEventPublisher(publisher) {
366236
366290
  this.eventPublisher = publisher;
366237
366291
  }
366292
+ /**
366293
+ * Current event publisher (or null). Lets a runtime CHAIN onto the
366294
+ * publisher temporarily — e.g. the plugin runAgentTask hook captures
366295
+ * `say` events for one mesh-invoke session while passing everything
366296
+ * through to the gateway's broadcast unchanged.
366297
+ */
366298
+ getEventPublisher() {
366299
+ return this.eventPublisher;
366300
+ }
366238
366301
  /**
366239
366302
  * Build a temporary history for the action model to make decisions.
366240
366303
  * This includes the last 3 special marker messages and recent execution details.
@@ -368273,6 +368336,11 @@ var AgentService_v2 = class {
368273
368336
  setEventPublisher(publisher) {
368274
368337
  this.helpers.setEventPublisher(publisher);
368275
368338
  }
368339
+ /** Current event publisher (or null) — lets a runtime chain onto it
368340
+ * temporarily (plugin runAgentTask hook) without replacing it. */
368341
+ getEventPublisher() {
368342
+ return this.helpers.getEventPublisher();
368343
+ }
368276
368344
  /** Wire the connection manager so `manage_ssh_connection` can mutate saved
368277
368345
  * connections + notify the UI. Optional; only needed in full runtimes. */
368278
368346
  setConnectionManager(manager) {
@@ -371778,6 +371846,23 @@ ${reminder}`;
371778
371846
  baseMessages = mapStoredMessagesToChatMessages(
371779
371847
  sanitizedStoredMessages.messages
371780
371848
  );
371849
+ const interruptedRun = this.chatHistoryService.getRunMarker(sessionId);
371850
+ if (interruptedRun) {
371851
+ const minutesAgo = Math.max(
371852
+ 0,
371853
+ Math.round((Date.now() - (interruptedRun.startedAt || 0)) / 6e4)
371854
+ );
371855
+ baseMessages.push(
371856
+ new SystemMessage(
371857
+ `[INTERRUPTION NOTICE] The previous task on this session was interrupted ${minutesAgo > 0 ? `${minutesAgo} minutes ` : ""}ago (the application stopped before the response completed \u2014 likely a force quit or crash). The last assistant response above may be PARTIAL: it can end mid-sentence, mid-tool-call, or before the task was actually finished. ` + (interruptedRun.inputPreview ? `The interrupted task began with: "${interruptedRun.inputPreview}"
371858
+ ` : "") + `Do not assume the prior task completed. If the user's next message relates to it, offer to continue or finish the interrupted work rather than starting from scratch.`
371859
+ )
371860
+ );
371861
+ this.chatHistoryService.clearRunMarker(sessionId);
371862
+ console.warn(
371863
+ `[AgentService_v2] Restored session ${sessionId} after an interrupted run (${interruptedRun.runId}); injected interruption notice into context.`
371864
+ );
371865
+ }
371781
371866
  }
371782
371867
  const runExperimentalFlags = resolveRunExperimentalFlags(
371783
371868
  context2,
@@ -371797,6 +371882,11 @@ ${reminder}`;
371797
371882
  execCommandActionModelEnabled: runExperimentalFlags.execCommandActionModelEnabled,
371798
371883
  writeStdinActionModelEnabled: runExperimentalFlags.writeStdinActionModelEnabled
371799
371884
  };
371885
+ this.chatHistoryService.setRunMarker(sessionId, {
371886
+ runId: ledgerRunId,
371887
+ startedAt: Date.now(),
371888
+ inputPreview: ledgerInputPreview.slice(0, 500)
371889
+ });
371800
371890
  try {
371801
371891
  const result = await this.graph.invoke(initialState, {
371802
371892
  recursionLimit,
@@ -371855,6 +371945,7 @@ ${reminder}`;
371855
371945
  this.currentRunIdBySession.delete(sessionId);
371856
371946
  this.agentRunLedger?.finishRun(ledgerRunId, ledgerExitStatus, ledgerExitError);
371857
371947
  void this.recordCompoundingLessons(sessionId, ledgerRunId, ledgerExitStatus, ledgerExitError);
371948
+ this.chatHistoryService.clearRunMarker(sessionId);
371858
371949
  await this.clearCheckpoint(sessionId);
371859
371950
  }
371860
371951
  }
@@ -373426,9 +373517,11 @@ var ChatHistoryService = class {
373426
373517
  saveSession(session) {
373427
373518
  const createdAt = this.store.getChatSessionCreatedAt(session.id);
373428
373519
  const now = Date.now();
373520
+ const storedMeta = this.store.getChatSessionMeta(session.id);
373521
+ const title = storedMeta?.title ?? session.title;
373429
373522
  this.store.saveChatSession({
373430
373523
  id: session.id,
373431
- title: session.title,
373524
+ title,
373432
373525
  messages: Array.from(session.messages.entries()).map(([id, message]) => ({
373433
373526
  id,
373434
373527
  type: message._getType ? message._getType() : "unknown",
@@ -373448,6 +373541,32 @@ var ChatHistoryService = class {
373448
373541
  getSessionMeta(sessionId) {
373449
373542
  return this.store.getChatSessionMeta(sessionId);
373450
373543
  }
373544
+ // ─── v3.8.9: run interruption markers ─────────────────────────────────────
373545
+ // A marker is SET at run start and CLEARED in the run's finally block.
373546
+ // Only a hard kill (force quit, crash, power loss) skips the finally —
373547
+ // so a leftover marker is a durable, restart-surviving record that the
373548
+ // session's previous run was interrupted. Stored in the history_meta
373549
+ // table (plain SQLite, survives anything short of disk loss).
373550
+ runMarkerKey(sessionId) {
373551
+ return `run-marker:${sessionId}`;
373552
+ }
373553
+ setRunMarker(sessionId, marker) {
373554
+ this.store.setMeta(this.runMarkerKey(sessionId), JSON.stringify(marker));
373555
+ }
373556
+ getRunMarker(sessionId) {
373557
+ const raw = this.store.getMeta(this.runMarkerKey(sessionId));
373558
+ if (!raw) return null;
373559
+ try {
373560
+ const parsed = JSON.parse(raw);
373561
+ if (typeof parsed?.runId === "string") return parsed;
373562
+ return null;
373563
+ } catch {
373564
+ return null;
373565
+ }
373566
+ }
373567
+ clearRunMarker(sessionId) {
373568
+ this.store.setMeta(this.runMarkerKey(sessionId), "");
373569
+ }
373451
373570
  loadSession(sessionId) {
373452
373571
  const storedSession = this.store.loadChatSession(sessionId);
373453
373572
  if (!storedSession) {
@@ -392163,7 +392282,7 @@ var PluginRegistry = class {
392163
392282
  }
392164
392283
  /** Build the default PluginContext for a record (registers into the record's
392165
392284
  * capability lists; exec/readLedger/log are delegated to the injected fns). */
392166
- static defaultContext(record2, exec, readLedger, log, spawnProcess, getSettings) {
392285
+ static defaultContext(record2, exec, readLedger, log, spawnProcess, getSettings, runAgentTask) {
392167
392286
  return {
392168
392287
  registerTool: (tool2) => {
392169
392288
  record2.tools.push(tool2);
@@ -392184,7 +392303,8 @@ var PluginRegistry = class {
392184
392303
  ...spawnProcess ? { spawnProcess } : {},
392185
392304
  ...getSettings ? { getSettings, get settings() {
392186
392305
  return getSettings();
392187
- } } : {}
392306
+ } } : {},
392307
+ ...runAgentTask ? { runAgentTask } : {}
392188
392308
  };
392189
392309
  }
392190
392310
  };
@@ -394723,7 +394843,50 @@ function createObservability(deps) {
394723
394843
  });
394724
394844
  },
394725
394845
  // Live settings snapshot for plugins that read config blocks (webIntel, agentspan, …).
394726
- () => deps.settingsService?.getSettings?.() ?? {}
394846
+ () => deps.settingsService?.getSettings?.() ?? {},
394847
+ // Real agent turns for plugins that serve an inbound `invoke` skill
394848
+ // (reactorpro-bridge): a mesh peer sends a prompt, RTerm runs it as a
394849
+ // genuine agent turn and the answer goes back over the mesh.
394850
+ // Chained onto the agent's event publisher for the duration of the
394851
+ // turn: `say` events for OUR session are captured, everything passes
394852
+ // through to the original (gateway) publisher unchanged, and the
394853
+ // publisher is restored in finally. dispatchTask resolves when the
394854
+ // run completes, so the last captured `say` is the final answer.
394855
+ deps.agentService ? async (prompt, opts) => {
394856
+ const agent = deps.agentService;
394857
+ const sessionId = opts?.sessionId ?? `mesh-invoke-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
394858
+ const timeoutMs = Math.min(Math.max(opts?.timeoutMs ?? 17e4, 5e3), 6e5);
394859
+ const previous = agent.getEventPublisher();
394860
+ const sayParts = [];
394861
+ const chained = (sid, ev) => {
394862
+ if (sid === sessionId && ev?.type === "say" && typeof ev.content === "string" && ev.content) {
394863
+ sayParts.push(ev.content);
394864
+ }
394865
+ if (previous) {
394866
+ try {
394867
+ previous(sid, ev);
394868
+ } catch {
394869
+ }
394870
+ }
394871
+ };
394872
+ agent.setEventPublisher(chained);
394873
+ let timer;
394874
+ try {
394875
+ await Promise.race([
394876
+ deps.gatewayService.dispatchTask(sessionId, prompt, void 0),
394877
+ new Promise((_, reject) => {
394878
+ timer = setTimeout(() => reject(new Error(`agent turn timed out after ${timeoutMs}ms`)), timeoutMs);
394879
+ })
394880
+ ]);
394881
+ } finally {
394882
+ if (timer) clearTimeout(timer);
394883
+ agent.setEventPublisher((sid, ev) => {
394884
+ if (previous) previous(sid, ev);
394885
+ });
394886
+ }
394887
+ const answer = sayParts.join("");
394888
+ return { ok: answer.trim() !== "", answer, sessionId };
394889
+ } : void 0
394727
394890
  ),
394728
394891
  onLog: deps.onLog
394729
394892
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rterm-backend",
3
- "version": "3.8.7",
3
+ "version": "3.8.9",
4
4
  "description": "AI-native terminal & agentic-AI operations platform for Forward Deployed Engineers & SREs: AIOps closed-loop remediation, AI SRE, self-healing infrastructure, runbook automation, ChatOps; executes over SSH/WinRM/serial under policy with tamper-evident audit.",
5
5
  "keywords": [
6
6
  "forward-deployed-engineer",
@@ -406,7 +406,7 @@ export async function register(ctx) {
406
406
  const nc = await connectMesh(ctx)
407
407
  const identity = loadOrCreateIdentity(cfg)
408
408
  const effCfg = { ...cfg, fingerprint: identity.fingerprint }
409
- const serveCtx = { skills: skills ?? defaultServeSkills(effCfg, { startedAt }), identity, getSkills: () => serveCtx.skills }
409
+ const serveCtx = { skills: skills ?? defaultServeSkills(effCfg, { startedAt, runAgentTask: ctx.runAgentTask }), identity, getSkills: () => serveCtx.skills }
410
410
  if (responderStop) responderStop()
411
411
  responderStop = await startResponder(nc, effCfg, serveCtx, log)
412
412
  if (heartbeatStop) heartbeatStop()
@@ -426,7 +426,7 @@ export async function register(ctx) {
426
426
  skills: { type: 'object', description: 'Map of skillId -> async (input, ctx) => output (defaults to ping/describe/status/invoke)', optional: true },
427
427
  },
428
428
  handler: async (p) => guarded(async () => {
429
- const base = defaultServeSkills({ ...cfg, fingerprint: loadOrCreateIdentity(cfg).fingerprint }, { startedAt })
429
+ const base = defaultServeSkills({ ...cfg, fingerprint: loadOrCreateIdentity(cfg).fingerprint }, { startedAt, runAgentTask: ctx.runAgentTask })
430
430
  const skills = p?.skills ?? base
431
431
  const served = await serveSkills(skills)
432
432
  return { serving: true, inbox: `${cfg.prefix}.agent.${cfg.agentId}.inbox`, skills: served, note: 'RTerm is now a full ReactorPro mesh citizen (responder + heartbeat + registry live)' }
@@ -487,7 +487,7 @@ export async function register(ctx) {
487
487
  if (cfg.enabled && cfg.autoServe) {
488
488
  setTimeout(() => {
489
489
  Promise.race([
490
- serveSkills(defaultServeSkills({ ...cfg, fingerprint: loadOrCreateIdentity(cfg, log).fingerprint }, { startedAt })),
490
+ serveSkills(defaultServeSkills({ ...cfg, fingerprint: loadOrCreateIdentity(cfg, log).fingerprint }, { startedAt, runAgentTask: ctx.runAgentTask })),
491
491
  new Promise((_, reject) => setTimeout(() => reject(new Error('auto-serve connect timeout (5s)')), 5000)),
492
492
  ])
493
493
  .then((skills) => log(`[reactorpro] auto-started citizen on ${cfg.prefix}.agent.${cfg.agentId}.inbox (skills: ${skills.join(', ')})`))
@@ -29,6 +29,7 @@ import {
29
29
  buildManifest,
30
30
  buildRespond,
31
31
  checkIdentity,
32
+ defaultServeSkills,
32
33
  envelope,
33
34
  fingerprintFor,
34
35
  mintIdentity,
@@ -267,6 +268,85 @@ function ok(cond: unknown, label: string, note = ''): void {
267
268
  }
268
269
  }
269
270
 
271
+ // ─── A3. Inbound invoke skill (v3.8.8) ───────────────────────────────────────
272
+ // Found live: the manifest ADVERTISED invoke but defaultServeSkills had no
273
+ // handler, so a peer dispatching invoke got SKILL_NOT_FOUND while the
274
+ // manifest promised it. These pin the corrected contract.
275
+
276
+ {
277
+ // A3.1 invoke exists in the default skills and routes to runAgentTask.
278
+ const cfg = { agentId: 'rterm/xcheck/invoke', dispatchTimeout: 180000 }
279
+ const skills = defaultServeSkills(cfg, {
280
+ startedAt: Date.now(),
281
+ runAgentTask: async (prompt: string, opts?: { sessionId?: string; timeoutMs?: number }) => {
282
+ ok(prompt === 'Reply with exactly one word: PONG',
283
+ 'invoke passes the prompt through to runAgentTask (arguments.prompt)',
284
+ JSON.stringify({ prompt }))
285
+ ok(typeof opts?.timeoutMs === 'number' && opts.timeoutMs === 170000,
286
+ 'invoke stays under the mesh dispatch budget (dispatchTimeout - 10s)',
287
+ JSON.stringify(opts))
288
+ return { ok: true, answer: 'PONG', sessionId: 'mesh-invoke-test' }
289
+ },
290
+ })
291
+ ok(typeof skills.invoke === 'function', 'defaultServeSkills includes an invoke handler')
292
+
293
+ const r1 = await skills.invoke({ arguments: { prompt: 'Reply with exactly one word: PONG' } } as never)
294
+ ok(r1?.output === 'PONG', 'invoke returns the agent answer as output', JSON.stringify(r1))
295
+ ok(r1?.conversation_id === 'mesh-invoke-test', 'invoke returns conversation_id for session persistence')
296
+
297
+ // A3.2 prompt extraction: text/ prompt/ message all work.
298
+ const skills2 = defaultServeSkills({ agentId: 'a' }, {
299
+ runAgentTask: async (prompt: string) => ({ ok: true, answer: `got:${prompt}`, sessionId: 's' }),
300
+ })
301
+ const viaText = await skills2.invoke({ text: 'hello' } as never)
302
+ ok(viaText?.output === 'got:hello', 'invoke extracts input.text')
303
+ const viaPrompt = await skills2.invoke({ prompt: 'hi' } as never)
304
+ ok(viaPrompt?.output === 'got:hi', 'invoke extracts input.prompt')
305
+ const viaString = await skills2.invoke('plain' as never)
306
+ ok(viaString?.output === 'got:plain', 'invoke extracts a bare string input')
307
+
308
+ // A3.3 conversation_id routes the follow-up to the same agent session.
309
+ const seenSessions: Array<string | undefined> = []
310
+ const skills3 = defaultServeSkills({ agentId: 'a' }, {
311
+ runAgentTask: async (_p: string, o?: { sessionId?: string }) => {
312
+ seenSessions.push(o?.sessionId)
313
+ return { ok: true, answer: 'x', sessionId: o?.sessionId ?? 'new' }
314
+ },
315
+ })
316
+ const first = await skills3.invoke({ text: 'one' } as never)
317
+ // First call: no conversation_id yet -> the hook generates a session.
318
+ ok(seenSessions[0] === undefined, 'first invoke has no session yet (the hook mints one)')
319
+ await skills3.invoke({ text: 'two', conversation_id: first.conversation_id } as never)
320
+ ok(seenSessions[1] === first.conversation_id && first.conversation_id !== undefined,
321
+ 'conversation_id reuses the same agent session on the follow-up',
322
+ JSON.stringify({ seenSessions, firstId: first.conversation_id }))
323
+
324
+ // A3.4 no runAgentTask hook -> a CLEAR error, not SKILL_NOT_FOUND.
325
+ const skills4 = defaultServeSkills({ agentId: 'a' }, {})
326
+ const r4 = await skills4.invoke({ text: 'x' } as never)
327
+ ok(r4?.error?.code === 3002 && String(r4?.error?.message).includes('INVOKE_UNAVAILABLE'),
328
+ 'invoke without the runAgentTask hook returns INVOKE_UNAVAILABLE (not SKILL_NOT_FOUND)',
329
+ JSON.stringify(r4))
330
+
331
+ // A3.5 empty prompt -> a clear error.
332
+ const skills5 = defaultServeSkills({ agentId: 'a' }, {
333
+ runAgentTask: async () => ({ ok: true, answer: 'should not run', sessionId: 's' }),
334
+ })
335
+ const r5 = await skills5.invoke({} as never)
336
+ ok(r5?.error?.code === 3003 && String(r5?.error?.message).includes('INVOKE_NEEDS_PROMPT'),
337
+ 'invoke with no prompt returns INVOKE_NEEDS_PROMPT without running the agent',
338
+ JSON.stringify(r5))
339
+
340
+ // A3.6 a failed agent turn -> a retryable error carrying the reason.
341
+ const skills6 = defaultServeSkills({ agentId: 'a' }, {
342
+ runAgentTask: async () => ({ ok: false, answer: '', error: 'model offline', sessionId: 's' }),
343
+ })
344
+ const r6 = await skills6.invoke({ text: 'x' } as never)
345
+ ok(r6?.error?.code === 5001 && String(r6?.error?.message).includes('model offline'),
346
+ 'a failed agent turn surfaces as a retryable error with the reason',
347
+ JSON.stringify(r6))
348
+ }
349
+
270
350
  // ─── B. Go cross-check against the REAL gateway ──────────────────────────────
271
351
 
272
352
  const RP_CHECKOUT = process.env.RP_CHECKOUT ?? join(process.env.HOME ?? '', 'work', 'ReactorPro')
@@ -224,6 +224,39 @@ export function defaultServeSkills(cfg, deps = {}) {
224
224
  skills: ['ping', 'describe', 'status', 'invoke'],
225
225
  uptime_seconds: Math.floor((Date.now() - (deps.startedAt ?? Date.now())) / 1000),
226
226
  }),
227
+ // `invoke` routes a prompt into a REAL RTerm agent turn (tools, policy,
228
+ // audit all apply) and returns the final assistant text. Requires the
229
+ // runAgentTask PluginContext hook — present on the daemon and desktop,
230
+ // absent in bare test harnesses. When absent, return a clear error
231
+ // instead of pretending: a peer must know invoke is unavailable here.
232
+ invoke: async (input = {}) => {
233
+ const runner = deps.runAgentTask
234
+ if (typeof runner !== 'function') {
235
+ return { error: { code: 3002, message: 'INVOKE_UNAVAILABLE: this runtime does not expose agent turns (runAgentTask hook absent)', retryable: false } }
236
+ }
237
+ const prompt = input?.arguments?.prompt
238
+ ?? input?.prompt
239
+ ?? input?.text
240
+ ?? input?.message
241
+ ?? (typeof input === 'string' ? input : '')
242
+ if (!prompt || !String(prompt).trim()) {
243
+ return { error: { code: 3003, message: 'INVOKE_NEEDS_PROMPT: pass arguments.prompt (or text)', retryable: false } }
244
+ }
245
+ const conversationId = typeof input?.conversation_id === 'string' ? input.conversation_id : undefined
246
+ const result = await runner(String(prompt), {
247
+ sessionId: conversationId,
248
+ // Stay under the mesh dispatch budget (180s default) so the reply
249
+ // reaches the caller before its own timeout fires.
250
+ timeoutMs: Math.max(5_000, (cfg.dispatchTimeout ?? 180_000) - 10_000),
251
+ })
252
+ if (!result?.ok) {
253
+ return { error: { code: 5001, message: result?.error || 'agent turn produced no answer', retryable: true } }
254
+ }
255
+ return {
256
+ output: result.answer,
257
+ conversation_id: result.sessionId,
258
+ }
259
+ },
227
260
  ...(deps.skills ?? {}),
228
261
  }
229
262
  }