@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.
- 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 +259 -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 +55 -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,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
|
|
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,
|
|
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
|
-
*
|
|
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,
|
|
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 =
|
|
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 —
|
|
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}
|
|
853
|
-
|
|
995
|
+
const summary = `${emoji} **Sub-agent ${status.status}** (${status.role})\n⏱ ${duration}s | run: \`${status.runId.slice(0, 8)}\``;
|
|
854
996
|
try {
|
|
855
|
-
|
|
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
|
|
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", {
|