@agentprojectcontext/apx 1.60.1 → 1.62.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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/super-agent.js +7 -1
  3. package/src/core/agent/tools/handlers/_github.js +22 -0
  4. package/src/core/agent/tools/handlers/call-runtime.js +185 -76
  5. package/src/core/agent/tools/handlers/github-create-issue.js +30 -0
  6. package/src/core/agent/tools/handlers/github-list-repos.js +20 -0
  7. package/src/core/agent/tools/names.js +6 -0
  8. package/src/core/agent/tools/registry.js +4 -0
  9. package/src/core/channels/telegram/dispatch.js +24 -1
  10. package/src/core/channels/telegram/reply.js +79 -2
  11. package/src/core/integrations/catalog.js +17 -27
  12. package/src/core/integrations/plugins/asana.js +22 -0
  13. package/src/core/integrations/plugins/github.js +160 -0
  14. package/src/core/stores/runtime-callbacks.js +107 -0
  15. package/src/host/daemon/callback-reconciler.js +87 -0
  16. package/src/host/daemon/index.js +7 -0
  17. package/src/interfaces/web/dist/assets/index-BuII-tAi.css +1 -0
  18. package/src/interfaces/web/dist/assets/index-CFcs16SV.js +778 -0
  19. package/src/interfaces/web/dist/assets/index-CFcs16SV.js.map +1 -0
  20. package/src/interfaces/web/dist/index.html +2 -2
  21. package/src/interfaces/web/package-lock.json +3 -3
  22. package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +3 -6
  23. package/src/interfaces/web/src/components/integrations/PluginConnect.tsx +300 -0
  24. package/src/interfaces/web/src/components/integrations/PluginToolsSection.tsx +7 -8
  25. package/src/interfaces/web/src/components/settings/SkillsInspectorPanel.tsx +28 -26
  26. package/src/interfaces/web/src/i18n/en.ts +57 -0
  27. package/src/interfaces/web/src/i18n/es.ts +57 -0
  28. package/src/interfaces/web/src/lib/api/integrations.ts +28 -5
  29. package/src/interfaces/web/src/screens/project/IntegrationsTab.tsx +28 -38
  30. package/src/interfaces/web/src/screens/project/McpsTab.tsx +113 -81
  31. package/src/interfaces/web/dist/assets/index-DFNV6BWh.js +0 -761
  32. package/src/interfaces/web/dist/assets/index-DFNV6BWh.js.map +0 -1
  33. package/src/interfaces/web/dist/assets/index-HU-Wt2l9.css +0 -1
  34. package/src/interfaces/web/src/components/integrations/AsanaPlugin.tsx +0 -275
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.60.1",
3
+ "version": "1.62.0",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -60,6 +60,12 @@ export async function runSuperAgent({
60
60
  // Null disables human-in-the-loop (tools that need confirmation fail
61
61
  // immediately instead of waiting for user input).
62
62
  requestConfirmation = null,
63
+ // A2A callback sink: when a background tool (call_runtime) finishes out of
64
+ // band, it feeds the result back into the super-agent via this function so
65
+ // the agent — not a raw dump — relays it to the user. Channel-specific
66
+ // (telegram wires it to a follow-up streamed turn). Null → tools fall back to
67
+ // a direct channel send. See call-runtime.js.
68
+ backgroundResultSink = null,
63
69
  // When true, suppress the static "Available skills" slug-dump hint block
64
70
  // because a per-turn skill inspector already injected the right context.
65
71
  // Set by the daemon's super-agent endpoint when config.skills.inspector is on.
@@ -131,7 +137,7 @@ export async function runSuperAgent({
131
137
  overrideModel,
132
138
  toolSchemas,
133
139
  makeToolHandlers,
134
- toolHandlerCtx: { projects, plugins, registries, globalConfig, channel, toolSession, requestConfirmation },
140
+ toolHandlerCtx: { projects, plugins, registries, globalConfig, channel, channelMeta, toolSession, requestConfirmation, backgroundResultSink },
135
141
  onEvent,
136
142
  signal,
137
143
  onToken,
@@ -0,0 +1,22 @@
1
+ // Shared helper for the GitHub agent tools (github-*.js). Underscore file — no
2
+ // tool `name:` of its own. Resolves the project's effective GitHub integration
3
+ // (its own record wins over the default project's) and hands back the token.
4
+ import { resolveProject } from "../helpers.js";
5
+ import { resolveIntegration } from "#core/integrations/index.js";
6
+
7
+ export function resolveGithub(projects, project) {
8
+ const p = resolveProject(projects, project);
9
+ const resolved = resolveIntegration({ projectStorage: p.storagePath, slug: "github" });
10
+ if (!resolved) {
11
+ throw new Error(
12
+ "GitHub is not connected for this project. Ask the user to connect it in the web panel → Integrations → Plugins → GitHub.",
13
+ );
14
+ }
15
+ const token = resolved.record.config?.token;
16
+ if (!token) throw new Error("GitHub integration has no token configured");
17
+ return { token, scope: resolved.scope };
18
+ }
19
+
20
+ export const PROJECT_ARG = {
21
+ project: { type: "string", description: "APX project id/name (optional; defaults to current)" },
22
+ };
@@ -8,6 +8,7 @@ import {
8
8
  createRuntimeSession,
9
9
  extractRuntimeResult as extractApfResult,
10
10
  } from "#core/stores/runtime-sessions.js";
11
+ import { writePendingCallback, deletePendingCallback } from "#core/stores/runtime-callbacks.js";
11
12
  import { buildRuntimeBridgeHint as buildApfHint } from "#core/agent/runtime-bridge.js";
12
13
  import { detectAll } from "#core/runtimes/detect.js";
13
14
  import {
@@ -167,15 +168,38 @@ export default {
167
168
  type: "string",
168
169
  description: "Optional prior session id (claude/codex/apx) — APX prepends that session's title + last prompt to the prompt so the runtime has context.",
169
170
  },
170
- timeout_s: { type: "integer", description: "seconds before SIGTERM; default 300" },
171
+ timeout_s: { type: "integer", description: "seconds before SIGTERM. Foreground default 300; background runs default to 3600 (1h)." },
172
+ background: {
173
+ type: "boolean",
174
+ description: "Run detached instead of blocking this turn. On Telegram this is the DEFAULT (runtimes like claude-code can take many minutes to an hour; blocking would freeze the super-agent). Returns immediately with status:\"launched\"; when the runtime finishes, its result is delivered to this same chat as an automatic message. Set false ONLY when you genuinely need the runtime's output within THIS turn for an immediate follow-up (short tasks).",
175
+ },
171
176
  },
172
177
  required: ["runtime", "prompt"],
173
178
  },
174
179
  },
175
180
  },
176
- makeHandler: ({ projects, requirePermission }) => async ({ project, agent: slug, runtime, prompt, resume_session_id = null, timeout_s = 300, confirmed = false }) => {
181
+ makeHandler: ({ projects, requirePermission, plugins, channel, channelMeta, backgroundResultSink = null }) => async ({ project, agent: slug, runtime, prompt, resume_session_id = null, timeout_s = null, background = null, confirmed = false }) => {
177
182
  await requirePermission("call_runtime", { dangerous: true, confirmed, args: { runtime } });
178
183
 
184
+ // Async delivery sink: on Telegram we can push the runtime's result back to
185
+ // the originating chat when it finishes, so the call doesn't have to block
186
+ // the turn. channelMeta.chatId + the telegram plugin are what make that
187
+ // possible; without them there's nowhere to deliver a late result, so we
188
+ // stay synchronous (web/desktop/exec keep their current behavior).
189
+ const chatId = channelMeta?.chatId ?? null;
190
+ const tgChannelName = channelMeta?.channelName ?? null;
191
+ const telegramPlugin = channel === "telegram" && chatId != null ? plugins?.get?.("telegram") : null;
192
+ // We can report a late result either by re-entering the super-agent (A2A,
193
+ // preferred — the agent relays in its own voice) or by a direct channel
194
+ // send (fallback). Either makes background mode viable.
195
+ const canCallback = !!backgroundResultSink || !!telegramPlugin;
196
+ // Background by default when we can call back; the model opts out with
197
+ // background:false when it needs the output inside this same turn.
198
+ const runInBackground = canCallback && background !== false;
199
+ // Long runtimes (claude-code sessions) can run for an hour; a detached run
200
+ // must not be SIGTERM'd at the 5-min foreground default.
201
+ const effectiveTimeoutS = Number(timeout_s) || (runInBackground ? 3600 : 300);
202
+
179
203
  const p = slug ? resolveProjectForAgent(projects, project, slug) : resolveProject(projects, project);
180
204
  const agent = slug ? readAgents(p.path).find((a) => a.slug === slug) : null;
181
205
  if (slug && !agent) {
@@ -226,106 +250,191 @@ export default {
226
250
  project: p.path,
227
251
  resume_session_id: resume_session_id || null,
228
252
  resume_resolved: resume.meta ? `${resume.meta.engine}:${resume.meta.id}` : null,
229
- timeout_s,
253
+ timeout_s: effectiveTimeoutS,
254
+ background: runInBackground,
230
255
  });
231
256
 
232
- try {
233
- const r = await rt.run({
234
- system: buildRuntimeSystem(p, agent, runtime, session.id, "super_agent_tool"),
235
- prompt: effectivePrompt,
236
- cwd: p.path,
237
- timeoutMs: timeout_s * 1000,
238
- });
257
+ // Run the runtime to completion and finalize (close the session record, log
258
+ // the transcript, shape the result object). NEVER throws — a thrown spawn is
259
+ // caught and returned as an error object, so the background path can deliver
260
+ // it as a callback instead of crashing an un-awaited promise.
261
+ const runToCompletion = async () => {
262
+ try {
263
+ const r = await rt.run({
264
+ system: buildRuntimeSystem(p, agent, runtime, session.id, "super_agent_tool"),
265
+ prompt: effectivePrompt,
266
+ cwd: p.path,
267
+ timeoutMs: effectiveTimeoutS * 1000,
268
+ });
239
269
 
240
- const failure = runtimeLooksLikeFailure(r);
241
- const result = extractApfResult(r.output) || (r.output || "").slice(0, 200);
242
- closeRuntimeSession({
243
- filePath: session.path,
244
- externalSessionPath: r.externalSessionPath || null,
245
- exitCode: failure.failed && r.exitCode === 0 ? -1 : r.exitCode,
246
- result: failure.failed ? `failed: ${failure.reason}` : result,
247
- });
270
+ const failure = runtimeLooksLikeFailure(r);
271
+ const result = extractApfResult(r.output) || (r.output || "").slice(0, 200);
272
+ closeRuntimeSession({
273
+ filePath: session.path,
274
+ externalSessionPath: r.externalSessionPath || null,
275
+ exitCode: failure.failed && r.exitCode === 0 ? -1 : r.exitCode,
276
+ result: failure.failed ? `failed: ${failure.reason}` : result,
277
+ });
248
278
 
249
- p.logMessage({
250
- agent_slug: actor,
251
- channel: "runtime",
252
- direction: "in",
253
- author: "user",
254
- body: effectivePrompt,
255
- meta: { runtime, invoked_by: "super_agent_tool", apc_session: session.id, resume_session_id: resume_session_id || null },
256
- });
257
- p.logMessage({
258
- agent_slug: actor,
259
- channel: "runtime",
260
- direction: "out",
261
- type: "agent",
262
- actor_id: agent?.slug || runtime,
263
- actor_kind: agent?.slug ? "agent" : "engine",
264
- author: agent?.slug || runtime,
265
- body: r.output || "",
266
- meta: {
267
- runtime,
268
- exit_code: r.exitCode,
269
- external_session_path: r.externalSessionPath || null,
270
- session_id: r.sessionId || null,
271
- apc_session: session.id,
272
- invoked_by: "super_agent_tool",
273
- failed: failure.failed || false,
274
- failure_reason: failure.failed ? failure.reason : null,
275
- },
276
- });
279
+ p.logMessage({
280
+ agent_slug: actor,
281
+ channel: "runtime",
282
+ direction: "in",
283
+ author: "user",
284
+ body: effectivePrompt,
285
+ meta: { runtime, invoked_by: "super_agent_tool", apc_session: session.id, resume_session_id: resume_session_id || null },
286
+ });
287
+ p.logMessage({
288
+ agent_slug: actor,
289
+ channel: "runtime",
290
+ direction: "out",
291
+ type: "agent",
292
+ actor_id: agent?.slug || runtime,
293
+ actor_kind: agent?.slug ? "agent" : "engine",
294
+ author: agent?.slug || runtime,
295
+ body: r.output || "",
296
+ meta: {
297
+ runtime,
298
+ exit_code: r.exitCode,
299
+ external_session_path: r.externalSessionPath || null,
300
+ session_id: r.sessionId || null,
301
+ apc_session: session.id,
302
+ invoked_by: "super_agent_tool",
303
+ failed: failure.failed || false,
304
+ failure_reason: failure.failed ? failure.reason : null,
305
+ },
306
+ });
307
+
308
+ if (failure.failed) {
309
+ log.error(`${runtime} run failed: ${failure.reason}`, {
310
+ apc_session: session.id,
311
+ exit_code: r.exitCode,
312
+ stderr: String(r.stderr || "").slice(0, 500),
313
+ external_session_path: r.externalSessionPath || null,
314
+ });
315
+ return {
316
+ error: `runtime "${runtime}" did not complete successfully: ${failure.reason}`,
317
+ runtime,
318
+ agent: agent?.slug || null,
319
+ apc_session: session.id,
320
+ exit_code: r.exitCode,
321
+ stderr: (r.stderr || "").slice(0, 2000),
322
+ output: (r.output || "").slice(0, 2000),
323
+ external_session_path: r.externalSessionPath || null,
324
+ session_id: r.sessionId || null,
325
+ };
326
+ }
277
327
 
278
- if (failure.failed) {
279
- log.error(`${runtime} run failed: ${failure.reason}`, {
328
+ log.info(`${runtime} run ok`, {
280
329
  apc_session: session.id,
281
330
  exit_code: r.exitCode,
282
- stderr: String(r.stderr || "").slice(0, 500),
283
331
  external_session_path: r.externalSessionPath || null,
332
+ session_id: r.sessionId || null,
333
+ output_bytes: (r.output || "").length,
284
334
  });
335
+
285
336
  return {
286
- error: `runtime "${runtime}" did not complete successfully: ${failure.reason}`,
287
337
  runtime,
288
338
  agent: agent?.slug || null,
289
339
  apc_session: session.id,
290
340
  exit_code: r.exitCode,
341
+ result,
342
+ output: (r.output || "").slice(0, 4000),
291
343
  stderr: (r.stderr || "").slice(0, 2000),
292
- output: (r.output || "").slice(0, 2000),
344
+ truncated: (r.output || "").length > 4000,
293
345
  external_session_path: r.externalSessionPath || null,
294
346
  session_id: r.sessionId || null,
295
347
  };
348
+ } catch (e) {
349
+ log.error(`${runtime} run threw: ${e.message}`, { apc_session: session.id });
350
+ try {
351
+ closeRuntimeSession({
352
+ filePath: session.path,
353
+ exitCode: -1,
354
+ result: `error: ${e.message.slice(0, 200)}`,
355
+ });
356
+ } catch {}
357
+ return { error: `runtime "${runtime}" threw: ${e.message}`, runtime, agent: agent?.slug || null, apc_session: session.id };
296
358
  }
359
+ };
297
360
 
298
- log.info(`${runtime} run ok`, {
299
- apc_session: session.id,
300
- exit_code: r.exitCode,
301
- external_session_path: r.externalSessionPath || null,
302
- session_id: r.sessionId || null,
303
- output_bytes: (r.output || "").length,
304
- });
361
+ // Deliver a finished background run. Preferred path (A2A): feed the result
362
+ // back into the super-agent so it relays in its own voice / chains the next
363
+ // step — an internal agent-to-agent hand-off. Fallback: a direct channel
364
+ // send of the raw result. Best-effort: never throws (nothing awaits it).
365
+ const who = `${runtime}${agent ? ` (agente ${agent.slug})` : ""}`;
366
+ const deliverCallback = async (res) => {
367
+ // Take ownership of delivery: drop the durable IOU so the reconciler in
368
+ // another/next daemon can't also deliver this one. Fallbacks below still
369
+ // run in-process, so a delete here doesn't risk losing the callback.
370
+ deletePendingCallback(session.id);
371
+ const body = res.error ? "" : String(res.result || res.output || "").trim();
372
+
373
+ if (backgroundResultSink) {
374
+ // A2A report phrased for Roby (not the end user). Roby decides how to
375
+ // relay it and whether a next step is needed.
376
+ const report = res.error
377
+ ? `[callback A2A] La tarea que delegaste a ${who} (sesión ${session.id}) FALLÓ: ${res.error}. ` +
378
+ `Avisale al usuario en tu voz y proponé cómo seguir.`
379
+ : `[callback A2A] Terminó la tarea que delegaste a ${who} (sesión ${session.id}). ` +
380
+ `Resultado del agente:\n${body.slice(0, 6000)}\n\n` +
381
+ `Contale al usuario el resultado en tu voz, breve y claro. No vuelvas a delegar salvo que falte un paso siguiente.`;
382
+ try {
383
+ await backgroundResultSink(report);
384
+ log.info(`${runtime} callback relayed via A2A sink`, { apc_session: session.id });
385
+ return;
386
+ } catch (e) {
387
+ log.error(`${runtime} A2A sink failed, falling back to direct send: ${e.message}`, { apc_session: session.id });
388
+ }
389
+ }
390
+
391
+ if (!telegramPlugin) return;
392
+ const head = res.error
393
+ ? `⚠️ La sesión de ${who} terminó con error (sesión \`${session.id}\`): ${res.error}`
394
+ : `✅ Terminó la sesión de ${who} (\`${session.id}\`).`;
395
+ const text = body ? `${head}\n\n${body.slice(0, 3500)}` : head;
396
+ try {
397
+ await telegramPlugin.send({ channel: tgChannelName, chat_id: chatId, text });
398
+ log.info(`${runtime} callback delivered (direct)`, { apc_session: session.id, chat_id: chatId });
399
+ } catch (e) {
400
+ log.error(`${runtime} callback send failed: ${e.message}`, { apc_session: session.id });
401
+ }
402
+ };
305
403
 
404
+ if (runInBackground) {
405
+ // Durable IOU: if this daemon dies before the run finishes (crash, pull,
406
+ // or a task that restarts the daemon), the reconciler on the next daemon
407
+ // delivers the result from the session record. The in-process path below
408
+ // deletes it the moment it takes over. Only telegram delivery is wired.
409
+ if (chatId != null) {
410
+ writePendingCallback({
411
+ session_id: session.id,
412
+ session_path: session.path,
413
+ channel: "telegram",
414
+ chat_id: chatId,
415
+ tg_channel: tgChannelName,
416
+ runtime,
417
+ agent: agent?.slug || null,
418
+ who,
419
+ });
420
+ }
421
+ // Fire-and-forget: don't await into the turn. runToCompletion never
422
+ // rejects, so this promise is safe un-awaited; the result is pushed to the
423
+ // chat when the runtime exits.
424
+ runToCompletion().then(deliverCallback);
306
425
  return {
307
426
  runtime,
308
427
  agent: agent?.slug || null,
309
428
  apc_session: session.id,
310
- exit_code: r.exitCode,
311
- output: (r.output || "").slice(0, 4000),
312
- stderr: (r.stderr || "").slice(0, 2000),
313
- truncated: (r.output || "").length > 4000,
314
- external_session_path: r.externalSessionPath || null,
315
- session_id: r.sessionId || null,
429
+ status: "launched",
430
+ background: true,
431
+ note:
432
+ `La sesión de ${runtime} arrancó en segundo plano y puede tardar varios minutos u horas. ` +
433
+ `NO esperes ni vuelvas a llamar a call_runtime para esto: el resultado llegará AUTOMÁTICAMENTE a este chat cuando termine. ` +
434
+ `Respondé al usuario ahora, en una línea, avisándole que la lanzaste y que le vas a avisar acá cuando esté lista.`,
316
435
  };
317
- } catch (e) {
318
- log.error(`${runtime} run threw: ${e.message}`, {
319
- apc_session: session.id,
320
- });
321
- try {
322
- closeRuntimeSession({
323
- filePath: session.path,
324
- exitCode: -1,
325
- result: `error: ${e.message.slice(0, 200)}`,
326
- });
327
- } catch {}
328
- throw e;
329
436
  }
437
+
438
+ return await runToCompletion();
330
439
  },
331
440
  };
@@ -0,0 +1,30 @@
1
+ import * as github from "#core/integrations/plugins/github.js";
2
+ import { resolveGithub, PROJECT_ARG } from "./_github.js";
3
+
4
+ export default {
5
+ name: "github_create_issue",
6
+ category: "integrations",
7
+ schema: {
8
+ type: "function",
9
+ function: {
10
+ name: "github_create_issue",
11
+ description: "Open an issue in a GitHub repository.",
12
+ parameters: {
13
+ type: "object",
14
+ properties: {
15
+ owner: { type: "string", description: "Repo owner (user or org)" },
16
+ repo: { type: "string", description: "Repo name" },
17
+ title: { type: "string", description: "Issue title" },
18
+ body: { type: "string", description: "Issue body (markdown)" },
19
+ ...PROJECT_ARG,
20
+ },
21
+ required: ["owner", "repo", "title"],
22
+ },
23
+ },
24
+ },
25
+ makeHandler: ({ projects }) => async ({ project, owner, repo, title, body } = {}) => {
26
+ const { token } = resolveGithub(projects, project);
27
+ const issue = await github.createIssue(token, { owner, repo, title, body });
28
+ return { issue: { number: issue.number, url: issue.html_url, title: issue.title } };
29
+ },
30
+ };
@@ -0,0 +1,20 @@
1
+ import * as github from "#core/integrations/plugins/github.js";
2
+ import { resolveGithub, PROJECT_ARG } from "./_github.js";
3
+
4
+ export default {
5
+ name: "github_list_repos",
6
+ category: "integrations",
7
+ schema: {
8
+ type: "function",
9
+ function: {
10
+ name: "github_list_repos",
11
+ description: "List GitHub repositories accessible to the connected token.",
12
+ parameters: { type: "object", properties: { ...PROJECT_ARG } },
13
+ },
14
+ },
15
+ makeHandler: ({ projects }) => async ({ project } = {}) => {
16
+ const { token } = resolveGithub(projects, project);
17
+ const repos = await github.listRepos(token);
18
+ return { repos: repos.map((r) => ({ full_name: r.full_name, private: r.private, url: r.html_url, description: r.description })) };
19
+ },
20
+ };
@@ -56,6 +56,10 @@ export const TOOLS = Object.freeze({
56
56
  ASANA_CREATE_TASK: "asana_create_task",
57
57
  ASANA_UPDATE_TASK: "asana_update_task",
58
58
 
59
+ // Integrations — GitHub plugin (see core/integrations/plugins/github.js)
60
+ GITHUB_LIST_REPOS: "github_list_repos",
61
+ GITHUB_CREATE_ISSUE: "github_create_issue",
62
+
59
63
  // Side-effects
60
64
  SEND_TELEGRAM: "send_telegram",
61
65
  SET_IDENTITY: "set_identity",
@@ -104,6 +108,8 @@ export const NATIVE_TOOL_NAMES = new Set([
104
108
  TOOLS.ASANA_LIST_TASKS,
105
109
  TOOLS.ASANA_CREATE_TASK,
106
110
  TOOLS.ASANA_UPDATE_TASK,
111
+ TOOLS.GITHUB_LIST_REPOS,
112
+ TOOLS.GITHUB_CREATE_ISSUE,
107
113
  TOOLS.SEND_TELEGRAM,
108
114
  TOOLS.SET_IDENTITY,
109
115
  TOOLS.SET_PERMISSION_MODE,
@@ -37,6 +37,8 @@ import asanaListProjects from "./handlers/asana-list-projects.js";
37
37
  import asanaListTasks from "./handlers/asana-list-tasks.js";
38
38
  import asanaCreateTask from "./handlers/asana-create-task.js";
39
39
  import asanaUpdateTask from "./handlers/asana-update-task.js";
40
+ import githubListRepos from "./handlers/github-list-repos.js";
41
+ import githubCreateIssue from "./handlers/github-create-issue.js";
40
42
  import { createPermissionGuard } from "./helpers.js";
41
43
  import { buildBridgedTools, DEFAULT_CATEGORIES } from "./registry-bridge.js";
42
44
  import { TOOLS, CODE_CHANNEL_TOOLS } from "./names.js";
@@ -85,6 +87,8 @@ const NATIVE_TOOLS = [
85
87
  asanaListTasks,
86
88
  asanaCreateTask,
87
89
  asanaUpdateTask,
90
+ githubListRepos,
91
+ githubCreateIssue,
88
92
  ];
89
93
 
90
94
  // Registry-backed bridges. Categories can be overridden per-process via env
@@ -26,7 +26,7 @@ import * as askFlow from "./ask.js";
26
26
  import { telegramAuthorLabel } from "./helpers.js";
27
27
  import { handleIncomingPhoto } from "./inbound/photo.js";
28
28
  import { handleIncomingAudio } from "./inbound/audio.js";
29
- import { buildStreamHandler, runTelegramSuperAgent, telegramErrorText, sendFinalReply } from "./reply.js";
29
+ import { buildStreamHandler, runTelegramSuperAgent, telegramErrorText, sendFinalReply, runFollowupTurn } from "./reply.js";
30
30
  import { t, resolveLang } from "#core/i18n/index.js";
31
31
 
32
32
  const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
@@ -285,6 +285,28 @@ export async function handleUpdate(self, u) {
285
285
  // strip the prefix from the user prompt before sending to the loop.
286
286
  const slashed = tryResolveSkillCommand(text, { projectPath: target?.path });
287
287
 
288
+ // A2A callback sink: when a background call_runtime finishes out of band,
289
+ // it invokes this to feed the sub-agent/runtime result back into a fresh
290
+ // super-agent turn — so Roby relays it in its own voice instead of dumping
291
+ // raw output. Self-referential so a relay turn that delegates again keeps
292
+ // the loop. Only wired when we have a chat to stream back to.
293
+ let backgroundResultSink = null;
294
+ if (chat_id) {
295
+ backgroundResultSink = async (reportText) =>
296
+ runFollowupTurn(self, {
297
+ chat_id,
298
+ reportText,
299
+ target,
300
+ author,
301
+ authorId: msg.from?.id,
302
+ relationshipBlock,
303
+ allowedTools,
304
+ agentDisplay,
305
+ update_id: u.update_id,
306
+ backgroundResultSink,
307
+ });
308
+ }
309
+
288
310
  try {
289
311
  const sa = await runTelegramSuperAgent(self, {
290
312
  chat_id,
@@ -298,6 +320,7 @@ export async function handleUpdate(self, u) {
298
320
  contextNote: slashed.handled ? slashed.contextNote : "",
299
321
  signal: abortCtrl.signal,
300
322
  onEvent,
323
+ backgroundResultSink,
301
324
  });
302
325
  replyText = sa.text;
303
326
  replyAuthor = sa.name || agentDisplay;
@@ -8,7 +8,7 @@
8
8
  import { runSuperAgent } from "#core/agent/super-agent.js";
9
9
  import { TELEGRAM_TOOL_ITERS } from "#core/agent/constants.js";
10
10
  import { stripThinking } from "#core/util/thinking.js";
11
- import { appendGlobalMessage } from "#core/stores/messages.js";
11
+ import { appendGlobalMessage, getRecentTelegramTurnsFromFs } from "#core/stores/messages.js";
12
12
  import { CHANNELS } from "#core/constants/channels.js";
13
13
  import { SUPERAGENT_ACTOR_ID } from "#core/identity/index.js";
14
14
  import { createTelegramConfirmAdapter } from "#core/confirmation/adapters/telegram.js";
@@ -100,7 +100,7 @@ export function buildStreamHandler(self, { chat_id, update_id, agentDisplay }) {
100
100
  */
101
101
  export function runTelegramSuperAgent(self, {
102
102
  chat_id, prompt, previousMessages, target, author, authorId, relationshipBlock,
103
- allowedTools, contextNote, signal, onEvent,
103
+ allowedTools, contextNote, signal, onEvent, backgroundResultSink = null,
104
104
  }) {
105
105
  const confirmAdapter = createTelegramConfirmAdapter({
106
106
  token: resolveBotToken(self.channel),
@@ -130,6 +130,7 @@ export function runTelegramSuperAgent(self, {
130
130
  signal,
131
131
  onEvent,
132
132
  requestConfirmation: confirmAdapter.requestConfirmation,
133
+ backgroundResultSink,
133
134
  // Autonomy budget: Telegram is the "do the whole task for me" surface, so it
134
135
  // gets a real multi-step budget instead of the conversational default (which
135
136
  // cut tasks off after ~9 actions to ask "continue?"). Tunable via
@@ -138,6 +139,82 @@ export function runTelegramSuperAgent(self, {
138
139
  });
139
140
  }
140
141
 
142
+ /**
143
+ * Run a follow-up super-agent turn triggered internally (not by an inbound
144
+ * message) — the A2A callback path. A background tool (call_runtime) finished
145
+ * out of band; `reportText` is the sub-agent/runtime result phrased as an
146
+ * internal report. We log it as a synthetic inbound so it lands in history,
147
+ * then run a normal streamed turn so Roby relays it to the user in its own
148
+ * voice (and can chain the next step). The same `backgroundResultSink` is
149
+ * forwarded so a relay turn that delegates again keeps the A2A loop intact.
150
+ * Best-effort: never throws (nothing awaits it).
151
+ */
152
+ export async function runFollowupTurn(self, {
153
+ chat_id, reportText, target, author, authorId, relationshipBlock,
154
+ allowedTools, agentDisplay, update_id, backgroundResultSink = null,
155
+ }) {
156
+ if (!chat_id || !reportText) return;
157
+ try {
158
+ // Synthetic inbound so the report is part of the rolling history. Tagged
159
+ // a2a_callback + a distinct author so it reads as an internal hand-off, not
160
+ // a user turn.
161
+ appendGlobalMessage({
162
+ channel: CHANNELS.TELEGRAM,
163
+ direction: "in",
164
+ type: "user",
165
+ actor_id: "a2a",
166
+ external_id: `a2a-${update_id}-${chat_id}`,
167
+ author: "a2a",
168
+ body: reportText,
169
+ meta: { chat_id, tg_channel: self.channel.name, a2a_callback: true },
170
+ });
171
+
172
+ const previousMessages = getRecentTelegramTurnsFromFs({ chat_id, keepRecent: 40, max_age_hours: 24 });
173
+ const { onEvent, state } = buildStreamHandler(self, { chat_id, update_id, agentDisplay });
174
+ const stopTyping = self._startTyping(chat_id);
175
+ let replyText;
176
+ let replyAuthor;
177
+ let saUsage = null;
178
+ try {
179
+ const sa = await runTelegramSuperAgent(self, {
180
+ chat_id,
181
+ prompt: reportText,
182
+ previousMessages,
183
+ target,
184
+ author,
185
+ authorId,
186
+ relationshipBlock,
187
+ allowedTools,
188
+ onEvent,
189
+ backgroundResultSink,
190
+ });
191
+ replyText = sa.text;
192
+ replyAuthor = sa.name || agentDisplay;
193
+ saUsage = sa.usage;
194
+ } catch (e) {
195
+ self.log(`telegram[${self.channel.name}] a2a followup failed: ${e.message}`);
196
+ replyText = telegramErrorText(self, e);
197
+ replyAuthor = agentDisplay;
198
+ }
199
+ stopTyping();
200
+ await sendFinalReply(self, {
201
+ chat_id,
202
+ update_id,
203
+ replyText,
204
+ replyAuthor,
205
+ replyActorId: SUPERAGENT_ACTOR_ID,
206
+ replyKind: "superagent",
207
+ saUsage,
208
+ streamedCount: state.streamedCount,
209
+ lastStreamedText: state.lastStreamedText,
210
+ agentDisplay,
211
+ extraMeta: { a2a_relay: true },
212
+ });
213
+ } catch (e) {
214
+ self.log(`telegram[${self.channel.name}] a2a followup crashed: ${e.message}`);
215
+ }
216
+ }
217
+
141
218
  /** Localized "couldn't reply" text for a failed super-agent turn (model itself
142
219
  * failed, so it can't author this — templated, but follows the user's language). */
143
220
  export function telegramErrorText(self, e) {