@inceptionstack/roundhouse 0.5.22 → 0.5.26

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.
@@ -20,44 +20,38 @@ import type { PressureLevel } from "../memory/types";
20
20
  // TODO: move progress into TransportAdapter when multi-transport lands
21
21
  import { createProgressMessage } from "../transports/telegram/progress";
22
22
  import { isCommand as _isCmd, isCommandWithArgs as _isCmdArgs, resolveAgentThreadId as _resolveThread, getSystemResources as _getSysRes } from "./helpers";
23
- import { saveAttachments as _saveAttachments, type AttachmentResult } from "./attachments";
23
+ import { saveAttachments, type AttachmentResult } from "./attachments";
24
24
  import { handleStreaming as _handleStream } from "./streaming";
25
25
  import { handleNew, handleRestart, handleUpdate, handleCompact, handleStatus, handleStop, handleVerbose, handleDoctor, handleCrons, type CommandContext } from "./commands";
26
26
  import { handleModel, handleModelAction, MODEL_ACTION_ID } from "./model-command";
27
27
  import { handleLater } from "./later-command";
28
- import { handleTopic, applyTopicOverride } from "./topic-command";
28
+ import { handleTopic, handleTopicAction, TOPIC_ACTION_ID, applyTopicOverride } from "./topic-command";
29
+ import {
30
+ type CommandDescriptor,
31
+ type CommandInvocation,
32
+ collectAndValidateActions,
33
+ isPreTurn,
34
+ matchesDescriptor,
35
+ } from "./command-registry";
29
36
  import { TelegramAdapter } from "../transports";
30
37
  import type { TransportAdapter } from "../transports";
31
38
  import { SubAgentOrchestratorImpl, SubAgentWatcher } from "../subagents";
32
39
  import type { RunStatus, RoutingInfo } from "../subagents";
33
40
  import { hostname } from "node:os";
34
41
  import { join } from "node:path";
42
+ import { readFile } from "node:fs/promises";
35
43
  import { injectToolsSection } from "./tools-inject";
36
44
  import { injectPersonaSection, loadPersona } from "./persona-inject";
37
45
  import { checkVersionChange } from "./whats-new";
38
46
 
47
+ /** Limits */
48
+ const MAX_SUBAGENT_STDOUT_CHARS = 3000;
49
+ const MAX_MESSAGE_CHUNK = 4000;
50
+ const MAX_ERROR_PREVIEW = 200;
51
+
39
52
  /** Bot username for command suffix validation (set during gateway init) */
40
53
  let _botUsername = "";
41
54
 
42
- /** Match a bot command, handling optional @botname suffix */
43
- function isCommand(text: string, cmd: string): boolean {
44
- return _isCmd(text, cmd, _botUsername);
45
- }
46
-
47
- /** Match a command that accepts subcommands (e.g. /crons trigger <id>) */
48
- function isCommandWithArgs(text: string, cmd: string): boolean {
49
- return _isCmdArgs(text, cmd, _botUsername);
50
- }
51
-
52
- function getSystemResources() {
53
- return _getSysRes();
54
- }
55
-
56
-
57
- function resolveAgentThreadId(thread: any, message: any): string {
58
- return _resolveThread(thread, message);
59
- }
60
-
61
55
  // ── Chat SDK adapter factories ───────────────────────
62
56
  // Lazy-imported so we don't crash if an adapter package isn't installed.
63
57
 
@@ -76,10 +70,6 @@ async function buildChatAdapters(
76
70
  return adapters;
77
71
  }
78
72
 
79
- async function saveAttachments(threadId: string, attachments: any[]): Promise<AttachmentResult> {
80
- return _saveAttachments(threadId, attachments);
81
- }
82
-
83
73
  // ── Gateway ──────────────────────────────────────────
84
74
 
85
75
  export class Gateway {
@@ -93,6 +83,10 @@ export class Gateway {
93
83
  private ipcServer: IpcServer | null = null;
94
84
  private subagentOrchestrator: SubAgentOrchestratorImpl | null = null;
95
85
  private subagentWatcher: SubAgentWatcher | null = null;
86
+ private verboseThreads = new Set<string>();
87
+ private threadLocks = new Map<string, Promise<void>>();
88
+ private abortControllers = new Map<string, AbortController>();
89
+ private flushInProgress = new Set<string>();
96
90
 
97
91
  constructor(router: AgentRouter, config: GatewayConfig) {
98
92
  this.router = router;
@@ -210,17 +204,32 @@ export class Gateway {
210
204
  }
211
205
 
212
206
  // Per-thread verbose toggle (shows tool_start messages)
213
- const verboseThreads = new Set<string>();
207
+ const verboseThreads = this.verboseThreads;
214
208
 
215
209
  // Per-thread abort signal for /stop
216
- const abortControllers = new Map<string, AbortController>();
210
+ const abortControllers = this.abortControllers;
217
211
 
218
212
  // Per-thread lock to serialize prompts (concurrent mode lets /stop through)
219
- const threadLocks = new Map<string, Promise<void>>();
213
+ const threadLocks = this.threadLocks;
214
+
215
+ // ── Build command descriptors ──────────────────────
216
+ // Each descriptor self-describes its triggers, dispatch stage, and
217
+ // optional inline-keyboard callbacks. The gateway iterates this list
218
+ // to wire everything — no more per-command if-blocks or onAction calls.
219
+ // Adding a new command = one more entry here + (optionally) a new module.
220
+ const allDescriptors = this.buildCommandDescriptors({
221
+ allowedUsers, allowedUserIds, verboseThreads, threadLocks, abortControllers,
222
+ });
223
+ const preTurnCommands = allDescriptors.filter(isPreTurn);
224
+ const inTurnCommands = allDescriptors.filter(d => !isPreTurn(d));
225
+ const matchers = {
226
+ isCommand: (t: string, c: string) => _isCmd(t, c, _botUsername),
227
+ isCommandWithArgs: (t: string, c: string) => _isCmdArgs(t, c, _botUsername),
228
+ };
220
229
 
221
- // ── Unified handler ────────────────────────────
230
+ // ── Unified handler ──────────────────────────────
222
231
  const handle = async (thread: any, message: any) => {
223
- let agentThreadId = applyTopicOverride(resolveAgentThreadId(thread, message), thread);
232
+ const agentThreadId = applyTopicOverride(_resolveThread(thread, message), thread);
224
233
  const userText = message.text ?? "";
225
234
  const authorName = message.author?.userName ?? message.author?.userId ?? "?";
226
235
  const rawAttachments = message.attachments ?? [];
@@ -240,75 +249,39 @@ export class Gateway {
240
249
  return;
241
250
  }
242
251
 
243
- if (isCommand(userText, "/start")) return;
252
+ if (_isCmd(userText, "/start", _botUsername)) return;
244
253
  if (!userText.trim() && !rawAttachments.length) return;
245
254
 
246
- // ── Command dispatch (registry-based) ───
255
+ // ── Command dispatch (in-turn stage) ───
247
256
  const trimmed = userText.trim();
248
-
249
- // Commands using standard CommandContext
250
- const COMMAND_REGISTRY: Record<string, (ctx: CommandContext) => Promise<void>> = {
251
- "/new": handleNew,
252
- "/restart": handleRestart,
253
- "/update": handleUpdate,
254
- "/compact": handleCompact,
255
- "/status": handleStatus,
256
- };
257
-
258
- for (const [cmd, handler] of Object.entries(COMMAND_REGISTRY)) {
259
- if (isCommand(trimmed, cmd)) {
260
- await handler(this.buildCommandContext(thread, message, agentThreadId, authorName, allowedUsers, allowedUserIds, verboseThreads, threadLocks));
257
+ const inv: CommandInvocation = { thread, message, text: trimmed, agentThreadId };
258
+ for (const desc of inTurnCommands) {
259
+ if (matchesDescriptor(desc, trimmed, matchers)) {
260
+ await desc.invoke(inv);
261
261
  return;
262
262
  }
263
263
  }
264
264
 
265
- // Commands with custom context (accept args)
266
- if (isCommandWithArgs(trimmed, "/model") || isCommand(trimmed, "/model")) {
267
- await handleModel({ thread, text: trimmed, postWithFallback: (t, txt) => this.postWithFallback(t, txt) });
268
- return;
269
- }
270
- if (isCommandWithArgs(trimmed, "/later") || isCommand(trimmed, "/later")) {
271
- await handleLater({ thread, text: trimmed, postWithFallback: (t, txt) => this.postWithFallback(t, txt) });
272
- return;
273
- }
274
-
275
- if (isCommandWithArgs(trimmed, "/topic") || isCommand(trimmed, "/topic")) {
276
- await handleTopic({ thread, text: trimmed, postWithFallback: (t, txt) => this.postWithFallback(t, txt) });
277
- return;
278
- }
279
-
280
265
  // Dispatch to agent turn handler
281
266
  await this.handleAgentTurn(thread, agentThreadId, userText, rawAttachments, verboseThreads, threadLocks, abortControllers);
282
267
  };
283
268
 
284
269
  // ── Wire Chat SDK events ───────────────────────
285
270
  const handleOrAbort = async (thread: any, message: any) => {
286
- let agentThreadId = applyTopicOverride(resolveAgentThreadId(thread, message), thread);
271
+ const agentThreadId = applyTopicOverride(_resolveThread(thread, message), thread);
287
272
  const text = (message.text ?? "").trim();
288
- // /stop — abort the in-flight agent run immediately
289
- if (isCommand(text, "/stop")) {
290
- if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
291
- await handleStop({ thread, agentThreadId, agent: this.router.resolve(agentThreadId), abortControllers });
292
- return;
293
- }
294
- // /verbose toggle verbose mode immediately
295
- if (isCommand(text, "/verbose")) {
296
- if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
297
- await handleVerbose({ thread, agentThreadId, verboseThreads });
298
- return;
299
- }
300
- // /doctor — run health checks immediately
301
- if (isCommand(text, "/doctor")) {
302
- if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
303
- await handleDoctor({ thread, runDoctor, createDoctorContext, formatDoctorTelegram, postWithFallback: (t, txt) => this.postWithFallback(t, txt) });
304
- return;
305
- }
306
- // /crons manages scheduled jobs
307
- if (isCommandWithArgs(text, "/crons") || isCommandWithArgs(text, "/jobs")) {
308
- if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
309
- await handleCrons({ thread, text, cronScheduler: this.cronScheduler, postWithFallback: (t, txt) => this.postWithFallback(t, txt) });
310
- return;
273
+
274
+ // Pre-turn commands fire before the main handler (and before the
275
+ // session-pressure gate), so /stop etc. still interrupt a mid-run
276
+ // agent. Allowlist is enforced here for all pre-turn handlers.
277
+ for (const desc of preTurnCommands) {
278
+ if (matchesDescriptor(desc, text, matchers)) {
279
+ if (!isAllowed(message, allowedUsers, allowedUserIds)) return;
280
+ await desc.invoke({ thread, message, text, agentThreadId });
281
+ return;
282
+ }
311
283
  }
284
+
312
285
  await handle(thread, message);
313
286
  };
314
287
 
@@ -330,9 +303,15 @@ export class Gateway {
330
303
  loadPersona();
331
304
 
332
305
  // ── Handle inline keyboard callbacks ───
333
- this.chat.onAction(MODEL_ACTION_ID, async (event: any) => {
334
- await handleModelAction({ value: event.value, thread: event.thread });
335
- });
306
+ // ── Register inline-keyboard action handlers from all descriptors ───
307
+ // `collectAndValidateActions` throws if two descriptors claim the same
308
+ // action id — duplicates would silently misbehave on chat.onAction, so
309
+ // fail fast at startup.
310
+ for (const { actionId, handler } of collectAndValidateActions(allDescriptors)) {
311
+ this.chat.onAction(actionId, async (event: any) => {
312
+ await handler({ value: event.value, thread: event.thread });
313
+ });
314
+ }
336
315
 
337
316
  await this.chat.initialize();
338
317
 
@@ -368,6 +347,13 @@ export class Gateway {
368
347
 
369
348
  // Start sub-agent orchestrator + watcher
370
349
  this.subagentOrchestrator = new SubAgentOrchestratorImpl();
350
+ this.subagentOrchestrator.onSpawn(async (status) => {
351
+ const chatId = Number(status.routing?.chatId);
352
+ if (chatId) {
353
+ const msg = `🔬 **Sub-agent launched** (${status.role})\nrun: \`${status.runId.slice(0, 8)}\``;
354
+ try { await this.transport.notify([chatId], msg); } catch {}
355
+ }
356
+ });
371
357
  this.subagentWatcher = new SubAgentWatcher(
372
358
  this.subagentOrchestrator,
373
359
  async (status, routing) => {
@@ -453,6 +439,27 @@ export class Gateway {
453
439
 
454
440
  stopTyping = startTypingLoop(thread);
455
441
 
442
+ // Pre-turn recovery: if a prior turn failed to compact (state has
443
+ // pendingCompact === "emergency"), the live session is almost certainly
444
+ // still over the model's context limit, so calling agent.prompt() now
445
+ // will throw with "prompt is too long" before our post-turn pressure
446
+ // handler ever runs — perpetuating the loop. Run the pressure handler
447
+ // BEFORE the agent call to recover. Best-effort: if it fails, fall
448
+ // through to the normal turn (which will then post the error and let
449
+ // the user see something is wrong).
450
+ if (memoryPrepared?.pendingCompact === "emergency") {
451
+ console.log(`[roundhouse] pre-turn recovery: pendingCompact=emergency, compacting before agent.prompt for thread=${agentThreadId}`);
452
+ try {
453
+ await this.handleContextPressure(thread, agentThreadId, agent, memoryRoot, "emergency");
454
+ // Clear the pending flag in our prepared snapshot so the post-turn
455
+ // pressure logic doesn't double-up on a redundant emergency pass.
456
+ memoryPrepared.pendingCompact = undefined;
457
+ } catch (err) {
458
+ console.error(`[roundhouse] pre-turn emergency compact failed:`, (err as Error).message);
459
+ }
460
+ }
461
+
462
+ let deferredSoftFlush: { thread: any; agentThreadId: string; agent: AgentAdapter; memoryRoot: string } | undefined;
456
463
  try {
457
464
  let turnUsedTools = false;
458
465
  if (agent.promptStream) {
@@ -481,7 +488,12 @@ export class Gateway {
481
488
  agent, memoryRoot, this.config.memory,
482
489
  );
483
490
  const effectivePressure = maxPressure(memoryPrepared?.pendingCompact, pressure);
484
- if (effectivePressure !== "none") {
491
+ if (effectivePressure === "soft") {
492
+ // Soft flush deferred to OUTSIDE the lock (no memory state invariants affected)
493
+ deferredSoftFlush = { thread, agentThreadId, agent, memoryRoot };
494
+ } else if (effectivePressure !== "none") {
495
+ // Hard/emergency: must run INSIDE the lock (compact changes session state,
496
+ // prepareMemoryForTurn on next turn needs post-compact reinjection)
485
497
  try {
486
498
  await this.handleContextPressure(thread, agentThreadId, agent, memoryRoot, effectivePressure);
487
499
  } catch (err) {
@@ -493,7 +505,7 @@ export class Gateway {
493
505
  }
494
506
  } catch (err) {
495
507
  const errMsg = err instanceof Error ? err.message : String(err);
496
- const safeMsg = errMsg.split('\n')[0].slice(0, 200);
508
+ const safeMsg = errMsg.split('\n')[0].slice(0, MAX_ERROR_PREVIEW);
497
509
  console.error(`[roundhouse] agent error:`, err);
498
510
  try {
499
511
  await thread.post(`⚠️ Error: ${safeMsg}`);
@@ -507,6 +519,22 @@ export class Gateway {
507
519
  threadLocks.delete(agentThreadId);
508
520
  }
509
521
  }
522
+
523
+ // Soft flush runs OUTSIDE the thread lock.
524
+ // Soft flush only prompts the agent to save facts to MEMORY.md — no compact,
525
+ // no session state change, no force-reinject needed. Safe to run concurrently.
526
+ if (deferredSoftFlush && !this.flushInProgress.has(deferredSoftFlush.agentThreadId)) {
527
+ const { thread: t, agentThreadId: tid, agent: a, memoryRoot: mr } = deferredSoftFlush;
528
+ this.flushInProgress.add(tid);
529
+ console.log(`[roundhouse] soft flush for thread=${tid} (lock released, running async)`);
530
+ try {
531
+ await this.handleContextPressure(t, tid, a, mr, "soft");
532
+ } catch (err) {
533
+ console.error(`[roundhouse] soft flush error:`, (err as Error).message);
534
+ } finally {
535
+ this.flushInProgress.delete(tid);
536
+ }
537
+ }
510
538
  }
511
539
 
512
540
  /**
@@ -632,18 +660,19 @@ export class Gateway {
632
660
 
633
661
  /**
634
662
  * Handle context pressure — flush memory and/or compact.
635
- * Runs inside the thread lock after a turn completes.
663
+ * Soft: runs OUTSIDE the thread lock (non-blocking to user messages).
664
+ * Hard/emergency: runs INSIDE the thread lock (memory state invariants).
636
665
  */
637
666
  private async handleContextPressure(thread: any, agentThreadId: string, agent: AgentAdapter, memoryRoot: string, pressure: PressureLevel) {
638
667
  if (pressure === "none") return;
639
668
 
640
- console.log(`[roundhouse] context pressure: ${pressure} for thread=${thread.id} agentThread=${agentThreadId}`);
641
-
642
669
  if (pressure === "soft") {
643
670
  // Soft: prompt agent to save facts, no compact
644
671
  // Cooldown is checked inside flushMemoryThenCompact (returns null if skipped)
645
672
  try {
646
- await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, "soft", this.config.memory);
673
+ const result = await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, "soft", this.config.memory);
674
+ // result is null if cooldown skipped OR if soft flush ran (soft always returns null)
675
+ // Log only — don't message user for soft flush (it's background housekeeping)
647
676
  } catch (err) {
648
677
  console.error(`[roundhouse] soft flush error:`, (err as Error).message);
649
678
  }
@@ -700,6 +729,111 @@ export class Gateway {
700
729
  };
701
730
  }
702
731
 
732
+ /**
733
+ * Build the full list of command descriptors.
734
+ *
735
+ * Each descriptor self-describes its triggers, dispatch stage, argument
736
+ * acceptance, and optional inline-keyboard action handlers. The gateway
737
+ * iterates this list — no per-command branching in the message handler.
738
+ *
739
+ * Stage:
740
+ * - "in-turn" (default): runs after allowlist + pairing inside handle()
741
+ * - "pre-turn": runs first in handleOrAbort() so commands like /stop
742
+ * can interrupt an in-flight agent turn
743
+ *
744
+ * Per-request state (thread, message, text) comes in via CommandInvocation;
745
+ * long-lived deps (cronScheduler, verboseThreads, abortControllers, …) are
746
+ * captured here from the surrounding start() closure.
747
+ */
748
+ private buildCommandDescriptors(deps: {
749
+ allowedUsers: string[];
750
+ allowedUserIds: number[];
751
+ verboseThreads: Set<string>;
752
+ threadLocks: Map<string, Promise<void>>;
753
+ abortControllers: Map<string, AbortController>;
754
+ }): CommandDescriptor[] {
755
+ const { allowedUsers, allowedUserIds, verboseThreads, threadLocks, abortControllers } = deps;
756
+ const post = (t: any, txt: string) => this.postWithFallback(t, txt);
757
+
758
+ // Shorthand: wrap a standard-CommandContext handler as a descriptor invoker.
759
+ const withCtx = (handler: (ctx: CommandContext) => Promise<void>) =>
760
+ async ({ thread, message, agentThreadId }: CommandInvocation) => {
761
+ const authorName = message.author?.userName ?? message.author?.userId ?? "?";
762
+ await handler(this.buildCommandContext(
763
+ thread, message, agentThreadId, authorName,
764
+ allowedUsers, allowedUserIds, verboseThreads, threadLocks,
765
+ ));
766
+ };
767
+
768
+ return [
769
+ // ── Standard CommandContext commands (in-turn, no args) ──
770
+ { triggers: ["/new"], invoke: withCtx(handleNew) },
771
+ { triggers: ["/restart"], invoke: withCtx(handleRestart) },
772
+ { triggers: ["/update"], invoke: withCtx(handleUpdate) },
773
+ { triggers: ["/compact"], invoke: withCtx(handleCompact) },
774
+ { triggers: ["/status"], invoke: withCtx(handleStatus) },
775
+
776
+ // ── In-turn commands that accept args ──
777
+ {
778
+ triggers: ["/model"],
779
+ acceptsArgs: true,
780
+ invoke: ({ thread, text }) => handleModel({ thread, text, postWithFallback: post }),
781
+ actions: {
782
+ [MODEL_ACTION_ID]: (ev) => handleModelAction({ value: ev.value, thread: ev.thread }),
783
+ },
784
+ },
785
+ {
786
+ triggers: ["/later"],
787
+ acceptsArgs: true,
788
+ invoke: ({ thread, text }) => handleLater({ thread, text, postWithFallback: post }),
789
+ },
790
+ {
791
+ triggers: ["/topic"],
792
+ acceptsArgs: true,
793
+ invoke: ({ thread, text }) => handleTopic({ thread, text, postWithFallback: post }),
794
+ actions: {
795
+ [TOPIC_ACTION_ID]: (ev) => handleTopicAction({ value: ev.value, thread: ev.thread }),
796
+ },
797
+ },
798
+
799
+ // ── Pre-turn commands (abort-style; fire even during agent turn) ──
800
+ {
801
+ triggers: ["/stop"],
802
+ stage: "pre-turn",
803
+ invoke: ({ thread, agentThreadId }) => handleStop({
804
+ thread, agentThreadId,
805
+ agent: this.router.resolve(agentThreadId),
806
+ abortControllers,
807
+ }),
808
+ },
809
+ {
810
+ triggers: ["/verbose"],
811
+ stage: "pre-turn",
812
+ invoke: ({ thread, agentThreadId }) => handleVerbose({
813
+ thread, agentThreadId, verboseThreads,
814
+ }),
815
+ },
816
+ {
817
+ triggers: ["/doctor"],
818
+ stage: "pre-turn",
819
+ invoke: ({ thread }) => handleDoctor({
820
+ thread, runDoctor, createDoctorContext, formatDoctorTelegram,
821
+ postWithFallback: post,
822
+ }),
823
+ },
824
+ {
825
+ triggers: ["/crons", "/jobs"],
826
+ stage: "pre-turn",
827
+ acceptsArgs: true,
828
+ invoke: ({ thread, text }) => handleCrons({
829
+ thread, text,
830
+ cronScheduler: this.cronScheduler,
831
+ postWithFallback: post,
832
+ }),
833
+ },
834
+ ];
835
+ }
836
+
703
837
  private async handleStreaming(thread: any, stream: AsyncIterable<AgentStreamEvent>, verbose: boolean, signal?: AbortSignal): Promise<{ usedTools: boolean }> {
704
838
  return _handleStream(stream, {
705
839
  thread,
@@ -715,7 +849,7 @@ export class Gateway {
715
849
  await this.transport.postMessage(thread, text);
716
850
  return;
717
851
  }
718
- for (const chunk of splitMessage(text, 4000)) {
852
+ for (const chunk of splitMessage(text, MAX_MESSAGE_CHUNK)) {
719
853
  try {
720
854
  await thread.post({ markdown: chunk });
721
855
  } catch {
@@ -754,7 +888,7 @@ export class Gateway {
754
888
  const now = new Date().toISOString().replace("T", " ").slice(0, 19) + " UTC";
755
889
  const nodeVer = process.version;
756
890
  const memMB = (process.memoryUsage.rss() / 1024 / 1024).toFixed(1);
757
- const sys = getSystemResources();
891
+ const sys = _getSysRes();
758
892
 
759
893
  // Get agent info if available (use first resolve — SingleAgentRouter always returns same agent)
760
894
  let agentInfo = "";
@@ -843,21 +977,44 @@ export class Gateway {
843
977
  console.log("[roundhouse] stopped");
844
978
  }
845
979
 
846
- /** Handle sub-agent completion — post result to originating thread */
980
+ /** Handle sub-agent completion — notify user AND inject result into agent session */
847
981
  private async handleSubagentCompletion(status: RunStatus, routing: RoutingInfo): Promise<void> {
982
+ const chatId = Number(routing.chatId);
983
+ if (!chatId) return;
984
+
985
+ await this.notifySubagentResult(status, chatId);
986
+ await this.injectSubagentResult(status, chatId);
987
+ }
988
+
989
+ /** Notify user of sub-agent completion via transport */
990
+ private async notifySubagentResult(status: RunStatus, chatId: number): Promise<void> {
848
991
  const emoji = status.status === "complete" ? "✅" : status.status === "timeout" ? "⏰" : "❌";
849
992
  const duration = status.completedAt && status.startedAt
850
993
  ? Math.round((Date.parse(status.completedAt) - Date.parse(status.startedAt)) / 1000)
851
994
  : 0;
852
- const summary = `${emoji} <b>Sub-agent ${status.status}</b> (${status.role})\n⏱ ${duration}s | run: <code>${status.runId.slice(0, 8)}</code>`;
853
-
995
+ const summary = `${emoji} **Sub-agent ${status.status}** (${status.role})\n⏱ ${duration}s | run: \`${status.runId.slice(0, 8)}\``;
854
996
  try {
855
- const chatId = Number(routing.chatId);
856
- if (chatId) {
857
- await this.transport.notify([chatId], summary, { parseMode: "HTML" });
858
- }
997
+ await this.transport.notify([chatId], summary);
859
998
  } catch (err) {
860
999
  console.error("[roundhouse] sub-agent completion notification failed:", err);
861
1000
  }
862
1001
  }
1002
+
1003
+ /** Inject sub-agent output into agent session as synthetic turn */
1004
+ private async injectSubagentResult(status: RunStatus, chatId: number): Promise<void> {
1005
+ try {
1006
+ const runDir = join(process.env.HOME || "/home/ec2-user", ".roundhouse", "subagents", status.runId);
1007
+ let stdout = "";
1008
+ try { stdout = await readFile(join(runDir, "stdout.log"), "utf-8"); } catch {}
1009
+
1010
+ const resultText = stdout.trim()
1011
+ ? `[Sub-agent ${status.role} completed (${status.status})]\n\nResult:\n${stdout.trim().slice(0, MAX_SUBAGENT_STDOUT_CHARS)}`
1012
+ : `[Sub-agent ${status.role} ${status.status} — no output]`;
1013
+
1014
+ const syntheticThread = this.transport.createThread(chatId);
1015
+ await this.handleAgentTurn(syntheticThread, "main", resultText, [], this.verboseThreads, this.threadLocks, this.abortControllers);
1016
+ } catch (err) {
1017
+ console.error("[roundhouse] sub-agent result injection failed:", err);
1018
+ }
1019
+ }
863
1020
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * gateway/inline-keyboard.ts — Shared helpers for Telegram inline keyboards
3
+ *
4
+ * Centralizes the callback-data protocol used by @chat-adapter/telegram so
5
+ * that commands like /model and /topic stay in sync. If the adapter's prefix
6
+ * ever changes, update it here once and every command keeps working.
7
+ */
8
+
9
+ /**
10
+ * Callback data prefix used by @chat-adapter/telegram.
11
+ *
12
+ * COUPLING: this must match the prefix the adapter listens for when routing
13
+ * `callback_query` events to `chat.onAction(...)`. If the adapter package
14
+ * changes this protocol, buttons silently stop working — watch this constant
15
+ * during adapter upgrades.
16
+ */
17
+ export const CALLBACK_PREFIX = "chat:";
18
+
19
+ export interface InlineButton {
20
+ text: string;
21
+ callback_data: string;
22
+ }
23
+
24
+ export interface InlineKeyboard {
25
+ inline_keyboard: InlineButton[][];
26
+ }
27
+
28
+ /** Encode an action+value pair into a Telegram `callback_data` string. */
29
+ export function encodeCallbackData(actionId: string, value: string): string {
30
+ return `${CALLBACK_PREFIX}${JSON.stringify({ a: actionId, v: value })}`;
31
+ }
32
+
33
+ /**
34
+ * Chunk a flat list of buttons into rows for a compact keyboard layout.
35
+ * Default is 2 columns, matching /model and /topic.
36
+ */
37
+ export function toKeyboardRows(buttons: InlineButton[], columns = 2): InlineKeyboard {
38
+ const rows: InlineButton[][] = [];
39
+ for (let i = 0; i < buttons.length; i += columns) {
40
+ rows.push(buttons.slice(i, i + columns));
41
+ }
42
+ return { inline_keyboard: rows };
43
+ }
44
+
45
+ /**
46
+ * Minimal shape of a thread passed to command handlers. Captures just the
47
+ * fields both /model and /topic need — avoids `any` without dragging in
48
+ * the full Chat SDK types.
49
+ */
50
+ export interface ChatThreadLike {
51
+ id?: string;
52
+ platformThreadId?: string;
53
+ /** Present on Telegram threads; undefined on other transports. */
54
+ adapter?: {
55
+ telegramFetch?: (method: string, payload: Record<string, unknown>) => Promise<unknown>;
56
+ };
57
+ /** Post a message back to the thread; accepts raw text or `{ markdown }`. */
58
+ post?: (arg: string | { markdown: string }) => Promise<unknown>;
59
+ }
60
+
61
+ /** Extract the numeric Telegram chat id from a thread's id string. */
62
+ export function extractTelegramChatId(thread: ChatThreadLike | undefined): string | undefined {
63
+ return thread?.platformThreadId?.split(":")?.[1] ?? thread?.id?.split(":")?.[1];
64
+ }
@@ -11,6 +11,13 @@
11
11
  import { homedir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
14
+ import {
15
+ encodeCallbackData,
16
+ toKeyboardRows,
17
+ extractTelegramChatId,
18
+ type InlineButton,
19
+ type InlineKeyboard,
20
+ } from "./inline-keyboard";
14
21
 
15
22
  /** Known model aliases → Bedrock model IDs */
16
23
  export const MODEL_ALIASES: Record<string, { provider: string; model: string; label: string }> = {
@@ -40,9 +47,6 @@ export const MODEL_ACTION_ID = "model_select";
40
47
 
41
48
  const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
42
49
 
43
- /** Callback data prefix used by @chat-adapter/telegram (coupled: if adapter changes this, buttons break) */
44
- const CALLBACK_PREFIX = "chat:";
45
-
46
50
  export interface ModelCommandContext {
47
51
  thread: any;
48
52
  text: string;
@@ -71,24 +75,16 @@ function getCurrentModel(settings: Record<string, any>): string {
71
75
  return `${model}`;
72
76
  }
73
77
 
74
- function encodeCallbackData(actionId: string, value: string): string {
75
- return `${CALLBACK_PREFIX}${JSON.stringify({ a: actionId, v: value })}`;
76
- }
77
-
78
- function buildInlineKeyboard(): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
78
+ function buildInlineKeyboard(): InlineKeyboard {
79
79
  // Layout: 2 buttons per row for compact display
80
- const buttons = KEYBOARD_MODELS.map(alias => {
80
+ const buttons: InlineButton[] = KEYBOARD_MODELS.map(alias => {
81
81
  const info = MODEL_ALIASES[alias];
82
82
  return {
83
83
  text: info.label,
84
84
  callback_data: encodeCallbackData(MODEL_ACTION_ID, alias),
85
85
  };
86
86
  });
87
- const rows: Array<Array<{ text: string; callback_data: string }>> = [];
88
- for (let i = 0; i < buttons.length; i += 2) {
89
- rows.push(buttons.slice(i, i + 2));
90
- }
91
- return { inline_keyboard: rows };
87
+ return toKeyboardRows(buttons);
92
88
  }
93
89
 
94
90
  export async function handleModel(ctx: ModelCommandContext): Promise<void> {
@@ -106,7 +102,7 @@ export async function handleModel(ctx: ModelCommandContext): Promise<void> {
106
102
  // Try to send with inline keyboard via telegramFetch
107
103
  const adapter = thread?.adapter;
108
104
  if (adapter?.telegramFetch) {
109
- const chatId = thread?.platformThreadId?.split(":")?.[1] ?? thread?.id?.split(":")?.[1];
105
+ const chatId = extractTelegramChatId(thread);
110
106
  if (chatId) {
111
107
  try {
112
108
  await adapter.telegramFetch("sendMessage", {