@inceptionstack/roundhouse 0.5.22 → 0.5.25
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/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/src/agents/pi/pi-adapter.ts +156 -17
- package/src/agents/shared/message-validator.test.ts +351 -0
- package/src/agents/shared/message-validator.ts +200 -0
- package/src/agents/shared/session-repair.test.ts +378 -0
- package/src/agents/shared/session-repair.ts +328 -0
- package/src/cli/cron-commands.ts +54 -39
- package/src/gateway/command-registry.ts +158 -0
- package/src/gateway/gateway.ts +239 -102
- package/src/gateway/inline-keyboard.ts +64 -0
- package/src/gateway/model-command.ts +11 -15
- package/src/gateway/topic-command.ts +147 -18
- package/src/memory/lifecycle.ts +45 -11
- package/src/subagents/orchestrator.ts +31 -4
- package/src/subagents/process-launcher.ts +1 -1
- package/src/subagents/types.ts +1 -0
- package/src/transports/telegram/telegram-adapter.ts +5 -2
- package/src/transports/types.ts +1 -1
package/src/gateway/gateway.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
207
|
+
const verboseThreads = this.verboseThreads;
|
|
214
208
|
|
|
215
209
|
// Per-thread abort signal for /stop
|
|
216
|
-
const abortControllers =
|
|
210
|
+
const abortControllers = this.abortControllers;
|
|
217
211
|
|
|
218
212
|
// Per-thread lock to serialize prompts (concurrent mode lets /stop through)
|
|
219
|
-
const threadLocks =
|
|
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
|
-
|
|
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 (
|
|
252
|
+
if (_isCmd(userText, "/start", _botUsername)) return;
|
|
244
253
|
if (!userText.trim() && !rawAttachments.length) return;
|
|
245
254
|
|
|
246
|
-
// ── Command dispatch (
|
|
255
|
+
// ── Command dispatch (in-turn stage) ───
|
|
247
256
|
const trimmed = userText.trim();
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
271
|
+
const agentThreadId = applyTopicOverride(_resolveThread(thread, message), thread);
|
|
287
272
|
const text = (message.text ?? "").trim();
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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,7 @@ export class Gateway {
|
|
|
453
439
|
|
|
454
440
|
stopTyping = startTypingLoop(thread);
|
|
455
441
|
|
|
442
|
+
let deferredSoftFlush: { thread: any; agentThreadId: string; agent: AgentAdapter; memoryRoot: string } | undefined;
|
|
456
443
|
try {
|
|
457
444
|
let turnUsedTools = false;
|
|
458
445
|
if (agent.promptStream) {
|
|
@@ -481,7 +468,12 @@ export class Gateway {
|
|
|
481
468
|
agent, memoryRoot, this.config.memory,
|
|
482
469
|
);
|
|
483
470
|
const effectivePressure = maxPressure(memoryPrepared?.pendingCompact, pressure);
|
|
484
|
-
if (effectivePressure
|
|
471
|
+
if (effectivePressure === "soft") {
|
|
472
|
+
// Soft flush deferred to OUTSIDE the lock (no memory state invariants affected)
|
|
473
|
+
deferredSoftFlush = { thread, agentThreadId, agent, memoryRoot };
|
|
474
|
+
} else if (effectivePressure !== "none") {
|
|
475
|
+
// Hard/emergency: must run INSIDE the lock (compact changes session state,
|
|
476
|
+
// prepareMemoryForTurn on next turn needs post-compact reinjection)
|
|
485
477
|
try {
|
|
486
478
|
await this.handleContextPressure(thread, agentThreadId, agent, memoryRoot, effectivePressure);
|
|
487
479
|
} catch (err) {
|
|
@@ -493,7 +485,7 @@ export class Gateway {
|
|
|
493
485
|
}
|
|
494
486
|
} catch (err) {
|
|
495
487
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
496
|
-
const safeMsg = errMsg.split('\n')[0].slice(0,
|
|
488
|
+
const safeMsg = errMsg.split('\n')[0].slice(0, MAX_ERROR_PREVIEW);
|
|
497
489
|
console.error(`[roundhouse] agent error:`, err);
|
|
498
490
|
try {
|
|
499
491
|
await thread.post(`⚠️ Error: ${safeMsg}`);
|
|
@@ -507,6 +499,22 @@ export class Gateway {
|
|
|
507
499
|
threadLocks.delete(agentThreadId);
|
|
508
500
|
}
|
|
509
501
|
}
|
|
502
|
+
|
|
503
|
+
// Soft flush runs OUTSIDE the thread lock.
|
|
504
|
+
// Soft flush only prompts the agent to save facts to MEMORY.md — no compact,
|
|
505
|
+
// no session state change, no force-reinject needed. Safe to run concurrently.
|
|
506
|
+
if (deferredSoftFlush && !this.flushInProgress.has(deferredSoftFlush.agentThreadId)) {
|
|
507
|
+
const { thread: t, agentThreadId: tid, agent: a, memoryRoot: mr } = deferredSoftFlush;
|
|
508
|
+
this.flushInProgress.add(tid);
|
|
509
|
+
console.log(`[roundhouse] soft flush for thread=${tid} (lock released, running async)`);
|
|
510
|
+
try {
|
|
511
|
+
await this.handleContextPressure(t, tid, a, mr, "soft");
|
|
512
|
+
} catch (err) {
|
|
513
|
+
console.error(`[roundhouse] soft flush error:`, (err as Error).message);
|
|
514
|
+
} finally {
|
|
515
|
+
this.flushInProgress.delete(tid);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
510
518
|
}
|
|
511
519
|
|
|
512
520
|
/**
|
|
@@ -632,18 +640,19 @@ export class Gateway {
|
|
|
632
640
|
|
|
633
641
|
/**
|
|
634
642
|
* Handle context pressure — flush memory and/or compact.
|
|
635
|
-
*
|
|
643
|
+
* Soft: runs OUTSIDE the thread lock (non-blocking to user messages).
|
|
644
|
+
* Hard/emergency: runs INSIDE the thread lock (memory state invariants).
|
|
636
645
|
*/
|
|
637
646
|
private async handleContextPressure(thread: any, agentThreadId: string, agent: AgentAdapter, memoryRoot: string, pressure: PressureLevel) {
|
|
638
647
|
if (pressure === "none") return;
|
|
639
648
|
|
|
640
|
-
console.log(`[roundhouse] context pressure: ${pressure} for thread=${thread.id} agentThread=${agentThreadId}`);
|
|
641
|
-
|
|
642
649
|
if (pressure === "soft") {
|
|
643
650
|
// Soft: prompt agent to save facts, no compact
|
|
644
651
|
// Cooldown is checked inside flushMemoryThenCompact (returns null if skipped)
|
|
645
652
|
try {
|
|
646
|
-
await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, "soft", this.config.memory);
|
|
653
|
+
const result = await flushMemoryThenCompact(agentThreadId, agent, memoryRoot, "soft", this.config.memory);
|
|
654
|
+
// result is null if cooldown skipped OR if soft flush ran (soft always returns null)
|
|
655
|
+
// Log only — don't message user for soft flush (it's background housekeeping)
|
|
647
656
|
} catch (err) {
|
|
648
657
|
console.error(`[roundhouse] soft flush error:`, (err as Error).message);
|
|
649
658
|
}
|
|
@@ -700,6 +709,111 @@ export class Gateway {
|
|
|
700
709
|
};
|
|
701
710
|
}
|
|
702
711
|
|
|
712
|
+
/**
|
|
713
|
+
* Build the full list of command descriptors.
|
|
714
|
+
*
|
|
715
|
+
* Each descriptor self-describes its triggers, dispatch stage, argument
|
|
716
|
+
* acceptance, and optional inline-keyboard action handlers. The gateway
|
|
717
|
+
* iterates this list — no per-command branching in the message handler.
|
|
718
|
+
*
|
|
719
|
+
* Stage:
|
|
720
|
+
* - "in-turn" (default): runs after allowlist + pairing inside handle()
|
|
721
|
+
* - "pre-turn": runs first in handleOrAbort() so commands like /stop
|
|
722
|
+
* can interrupt an in-flight agent turn
|
|
723
|
+
*
|
|
724
|
+
* Per-request state (thread, message, text) comes in via CommandInvocation;
|
|
725
|
+
* long-lived deps (cronScheduler, verboseThreads, abortControllers, …) are
|
|
726
|
+
* captured here from the surrounding start() closure.
|
|
727
|
+
*/
|
|
728
|
+
private buildCommandDescriptors(deps: {
|
|
729
|
+
allowedUsers: string[];
|
|
730
|
+
allowedUserIds: number[];
|
|
731
|
+
verboseThreads: Set<string>;
|
|
732
|
+
threadLocks: Map<string, Promise<void>>;
|
|
733
|
+
abortControllers: Map<string, AbortController>;
|
|
734
|
+
}): CommandDescriptor[] {
|
|
735
|
+
const { allowedUsers, allowedUserIds, verboseThreads, threadLocks, abortControllers } = deps;
|
|
736
|
+
const post = (t: any, txt: string) => this.postWithFallback(t, txt);
|
|
737
|
+
|
|
738
|
+
// Shorthand: wrap a standard-CommandContext handler as a descriptor invoker.
|
|
739
|
+
const withCtx = (handler: (ctx: CommandContext) => Promise<void>) =>
|
|
740
|
+
async ({ thread, message, agentThreadId }: CommandInvocation) => {
|
|
741
|
+
const authorName = message.author?.userName ?? message.author?.userId ?? "?";
|
|
742
|
+
await handler(this.buildCommandContext(
|
|
743
|
+
thread, message, agentThreadId, authorName,
|
|
744
|
+
allowedUsers, allowedUserIds, verboseThreads, threadLocks,
|
|
745
|
+
));
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
return [
|
|
749
|
+
// ── Standard CommandContext commands (in-turn, no args) ──
|
|
750
|
+
{ triggers: ["/new"], invoke: withCtx(handleNew) },
|
|
751
|
+
{ triggers: ["/restart"], invoke: withCtx(handleRestart) },
|
|
752
|
+
{ triggers: ["/update"], invoke: withCtx(handleUpdate) },
|
|
753
|
+
{ triggers: ["/compact"], invoke: withCtx(handleCompact) },
|
|
754
|
+
{ triggers: ["/status"], invoke: withCtx(handleStatus) },
|
|
755
|
+
|
|
756
|
+
// ── In-turn commands that accept args ──
|
|
757
|
+
{
|
|
758
|
+
triggers: ["/model"],
|
|
759
|
+
acceptsArgs: true,
|
|
760
|
+
invoke: ({ thread, text }) => handleModel({ thread, text, postWithFallback: post }),
|
|
761
|
+
actions: {
|
|
762
|
+
[MODEL_ACTION_ID]: (ev) => handleModelAction({ value: ev.value, thread: ev.thread }),
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
triggers: ["/later"],
|
|
767
|
+
acceptsArgs: true,
|
|
768
|
+
invoke: ({ thread, text }) => handleLater({ thread, text, postWithFallback: post }),
|
|
769
|
+
},
|
|
770
|
+
{
|
|
771
|
+
triggers: ["/topic"],
|
|
772
|
+
acceptsArgs: true,
|
|
773
|
+
invoke: ({ thread, text }) => handleTopic({ thread, text, postWithFallback: post }),
|
|
774
|
+
actions: {
|
|
775
|
+
[TOPIC_ACTION_ID]: (ev) => handleTopicAction({ value: ev.value, thread: ev.thread }),
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
|
|
779
|
+
// ── Pre-turn commands (abort-style; fire even during agent turn) ──
|
|
780
|
+
{
|
|
781
|
+
triggers: ["/stop"],
|
|
782
|
+
stage: "pre-turn",
|
|
783
|
+
invoke: ({ thread, agentThreadId }) => handleStop({
|
|
784
|
+
thread, agentThreadId,
|
|
785
|
+
agent: this.router.resolve(agentThreadId),
|
|
786
|
+
abortControllers,
|
|
787
|
+
}),
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
triggers: ["/verbose"],
|
|
791
|
+
stage: "pre-turn",
|
|
792
|
+
invoke: ({ thread, agentThreadId }) => handleVerbose({
|
|
793
|
+
thread, agentThreadId, verboseThreads,
|
|
794
|
+
}),
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
triggers: ["/doctor"],
|
|
798
|
+
stage: "pre-turn",
|
|
799
|
+
invoke: ({ thread }) => handleDoctor({
|
|
800
|
+
thread, runDoctor, createDoctorContext, formatDoctorTelegram,
|
|
801
|
+
postWithFallback: post,
|
|
802
|
+
}),
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
triggers: ["/crons", "/jobs"],
|
|
806
|
+
stage: "pre-turn",
|
|
807
|
+
acceptsArgs: true,
|
|
808
|
+
invoke: ({ thread, text }) => handleCrons({
|
|
809
|
+
thread, text,
|
|
810
|
+
cronScheduler: this.cronScheduler,
|
|
811
|
+
postWithFallback: post,
|
|
812
|
+
}),
|
|
813
|
+
},
|
|
814
|
+
];
|
|
815
|
+
}
|
|
816
|
+
|
|
703
817
|
private async handleStreaming(thread: any, stream: AsyncIterable<AgentStreamEvent>, verbose: boolean, signal?: AbortSignal): Promise<{ usedTools: boolean }> {
|
|
704
818
|
return _handleStream(stream, {
|
|
705
819
|
thread,
|
|
@@ -715,7 +829,7 @@ export class Gateway {
|
|
|
715
829
|
await this.transport.postMessage(thread, text);
|
|
716
830
|
return;
|
|
717
831
|
}
|
|
718
|
-
for (const chunk of splitMessage(text,
|
|
832
|
+
for (const chunk of splitMessage(text, MAX_MESSAGE_CHUNK)) {
|
|
719
833
|
try {
|
|
720
834
|
await thread.post({ markdown: chunk });
|
|
721
835
|
} catch {
|
|
@@ -754,7 +868,7 @@ export class Gateway {
|
|
|
754
868
|
const now = new Date().toISOString().replace("T", " ").slice(0, 19) + " UTC";
|
|
755
869
|
const nodeVer = process.version;
|
|
756
870
|
const memMB = (process.memoryUsage.rss() / 1024 / 1024).toFixed(1);
|
|
757
|
-
const sys =
|
|
871
|
+
const sys = _getSysRes();
|
|
758
872
|
|
|
759
873
|
// Get agent info if available (use first resolve — SingleAgentRouter always returns same agent)
|
|
760
874
|
let agentInfo = "";
|
|
@@ -843,21 +957,44 @@ export class Gateway {
|
|
|
843
957
|
console.log("[roundhouse] stopped");
|
|
844
958
|
}
|
|
845
959
|
|
|
846
|
-
/** Handle sub-agent completion —
|
|
960
|
+
/** Handle sub-agent completion — notify user AND inject result into agent session */
|
|
847
961
|
private async handleSubagentCompletion(status: RunStatus, routing: RoutingInfo): Promise<void> {
|
|
962
|
+
const chatId = Number(routing.chatId);
|
|
963
|
+
if (!chatId) return;
|
|
964
|
+
|
|
965
|
+
await this.notifySubagentResult(status, chatId);
|
|
966
|
+
await this.injectSubagentResult(status, chatId);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** Notify user of sub-agent completion via transport */
|
|
970
|
+
private async notifySubagentResult(status: RunStatus, chatId: number): Promise<void> {
|
|
848
971
|
const emoji = status.status === "complete" ? "✅" : status.status === "timeout" ? "⏰" : "❌";
|
|
849
972
|
const duration = status.completedAt && status.startedAt
|
|
850
973
|
? Math.round((Date.parse(status.completedAt) - Date.parse(status.startedAt)) / 1000)
|
|
851
974
|
: 0;
|
|
852
|
-
const summary = `${emoji}
|
|
853
|
-
|
|
975
|
+
const summary = `${emoji} **Sub-agent ${status.status}** (${status.role})\n⏱ ${duration}s | run: \`${status.runId.slice(0, 8)}\``;
|
|
854
976
|
try {
|
|
855
|
-
|
|
856
|
-
if (chatId) {
|
|
857
|
-
await this.transport.notify([chatId], summary, { parseMode: "HTML" });
|
|
858
|
-
}
|
|
977
|
+
await this.transport.notify([chatId], summary);
|
|
859
978
|
} catch (err) {
|
|
860
979
|
console.error("[roundhouse] sub-agent completion notification failed:", err);
|
|
861
980
|
}
|
|
862
981
|
}
|
|
982
|
+
|
|
983
|
+
/** Inject sub-agent output into agent session as synthetic turn */
|
|
984
|
+
private async injectSubagentResult(status: RunStatus, chatId: number): Promise<void> {
|
|
985
|
+
try {
|
|
986
|
+
const runDir = join(process.env.HOME || "/home/ec2-user", ".roundhouse", "subagents", status.runId);
|
|
987
|
+
let stdout = "";
|
|
988
|
+
try { stdout = await readFile(join(runDir, "stdout.log"), "utf-8"); } catch {}
|
|
989
|
+
|
|
990
|
+
const resultText = stdout.trim()
|
|
991
|
+
? `[Sub-agent ${status.role} completed (${status.status})]\n\nResult:\n${stdout.trim().slice(0, MAX_SUBAGENT_STDOUT_CHARS)}`
|
|
992
|
+
: `[Sub-agent ${status.role} ${status.status} — no output]`;
|
|
993
|
+
|
|
994
|
+
const syntheticThread = this.transport.createThread(chatId);
|
|
995
|
+
await this.handleAgentTurn(syntheticThread, "main", resultText, [], this.verboseThreads, this.threadLocks, this.abortControllers);
|
|
996
|
+
} catch (err) {
|
|
997
|
+
console.error("[roundhouse] sub-agent result injection failed:", err);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
863
1000
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
105
|
+
const chatId = extractTelegramChatId(thread);
|
|
110
106
|
if (chatId) {
|
|
111
107
|
try {
|
|
112
108
|
await adapter.telegramFetch("sendMessage", {
|