@easynet/agent-runtime 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.github/workflows/ci.yml +80 -0
  2. package/.github/workflows/release.yml +82 -0
  3. package/.releaserc.cjs +26 -0
  4. package/dist/cli.d.ts +43 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +617 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/config.d.ts +86 -0
  9. package/dist/config.d.ts.map +1 -0
  10. package/dist/config.js +84 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/context.d.ts +104 -0
  13. package/dist/context.d.ts.map +1 -0
  14. package/dist/context.js +111 -0
  15. package/dist/context.js.map +1 -0
  16. package/dist/deep-agent.d.ts +29 -0
  17. package/dist/deep-agent.d.ts.map +1 -0
  18. package/dist/deep-agent.js +77 -0
  19. package/dist/deep-agent.js.map +1 -0
  20. package/dist/index.d.ts +8 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +8 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/malformed-tool-call-middleware.d.ts +8 -0
  25. package/dist/malformed-tool-call-middleware.d.ts.map +1 -0
  26. package/dist/malformed-tool-call-middleware.js +191 -0
  27. package/dist/malformed-tool-call-middleware.js.map +1 -0
  28. package/dist/react-agent.d.ts +38 -0
  29. package/dist/react-agent.d.ts.map +1 -0
  30. package/dist/react-agent.js +465 -0
  31. package/dist/react-agent.js.map +1 -0
  32. package/dist/sub-agent.d.ts +34 -0
  33. package/dist/sub-agent.d.ts.map +1 -0
  34. package/dist/sub-agent.js +53 -0
  35. package/dist/sub-agent.js.map +1 -0
  36. package/example/basic-usage.ts +49 -0
  37. package/package.json +53 -0
  38. package/src/cli.ts +745 -0
  39. package/src/config.ts +177 -0
  40. package/src/context.ts +247 -0
  41. package/src/deep-agent.ts +104 -0
  42. package/src/index.ts +53 -0
  43. package/src/malformed-tool-call-middleware.ts +239 -0
  44. package/src/markdown-it-terminal.d.ts +4 -0
  45. package/src/marked-terminal.d.ts +16 -0
  46. package/src/react-agent.ts +576 -0
  47. package/src/sub-agent.ts +82 -0
  48. package/tsconfig.json +18 -0
package/src/cli.ts ADDED
@@ -0,0 +1,745 @@
1
+ /**
2
+ * Shared CLI runner for agent apps.
3
+ * Provides: arg parsing, single-shot mode, interactive REPL, event logging.
4
+ */
5
+ import os from "node:os";
6
+ import { createInterface } from "node:readline";
7
+ import MarkdownIt from "markdown-it";
8
+ import markdownItTerminal from "markdown-it-terminal";
9
+ import {
10
+ createConsoleAgentEventListener,
11
+ type AgentEvent,
12
+ type AgentEventListener,
13
+ } from "@easynet/agent-common";
14
+ import { createReactAgent, type ReactAgentRuntime } from "./react-agent.js";
15
+ import { createDeepAgent, type DeepAgentRuntime } from "./deep-agent.js";
16
+ import { malformedToolCallMiddleware } from "./malformed-tool-call-middleware.js";
17
+ import type { BotContext } from "./context.js";
18
+
19
+ const REACT = "react" as const;
20
+ const DEEP = "deep" as const;
21
+
22
+ type AgentKind = typeof REACT | typeof DEEP;
23
+
24
+ export interface AppCliUiOptions {
25
+ /** Prompt/section label for user. Default: current OS user (username(uid)). */
26
+ userLabel?: string;
27
+ /** Prompt/section label for assistant. Default: agent kind (ReAct/Deep). */
28
+ assistantLabel?: string;
29
+ /** Enable ANSI colors in interactive output. Default: true when TTY and NO_COLOR is not set. */
30
+ useColor?: boolean;
31
+ /** Render assistant output as terminal-friendly markdown. Default: true. */
32
+ renderMarkdown?: boolean;
33
+ /** Echo the user question in the response section. Default: true. */
34
+ echoUserQuestion?: boolean;
35
+ /** Startup loading line. Set false to suppress. */
36
+ loadingText?: string | false;
37
+ /** Show animated loading progress while creating bot context. */
38
+ loadingSpinner?: boolean;
39
+ /** Startup ready line. Set false to suppress. */
40
+ readyText?: string | false;
41
+ /** Interactive intro line before prompt. Set false to suppress. */
42
+ interactiveIntro?: string | false;
43
+ /** Show animated spinner while a user request is being processed. */
44
+ processingSpinner?: boolean;
45
+ /** Spinner label shown while processing. Set false to suppress label/spinner. */
46
+ processingText?: string | false;
47
+ }
48
+
49
+ export interface AppCliOptions {
50
+ appName: string;
51
+ createBotContext: () => Promise<BotContext>;
52
+ /** Extra interactive commands, e.g. { "list tools": (ctx) => ... } */
53
+ interactiveCommands?: Record<string, (ctx: BotContext) => void | Promise<void>>;
54
+ /** Extra event listener(s) for structured runtime logs. */
55
+ eventListener?: AgentEventListener | AgentEventListener[];
56
+ /** Called after context is ready, before the REPL starts. Failures are logged but do not crash. */
57
+ onReady?: (ctx: BotContext) => void | Promise<void>;
58
+ /** Called during shutdown (exit/signal). Should be synchronous and fast. */
59
+ onShutdown?: (ctx: BotContext) => void;
60
+ /** Interactive UI style options. */
61
+ ui?: AppCliUiOptions;
62
+ }
63
+
64
+ function parseArgs(): { kind: AgentKind; query?: string } {
65
+ const args = process.argv.slice(2);
66
+ const first = args[0]?.toLowerCase();
67
+ const kind: AgentKind = first === DEEP ? DEEP : REACT;
68
+ const query =
69
+ first === REACT || first === DEEP ? (args[1] ?? undefined) : (first ?? undefined);
70
+ return { kind, query };
71
+ }
72
+
73
+ function escapeRegExp(value: string): string {
74
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
75
+ }
76
+
77
+ async function createRuntime(
78
+ ctx: BotContext,
79
+ kind: AgentKind
80
+ ): Promise<ReactAgentRuntime | DeepAgentRuntime> {
81
+ if (kind === REACT) {
82
+ return createReactAgent(ctx, {
83
+ middleware: [malformedToolCallMiddleware()],
84
+ });
85
+ }
86
+ return createDeepAgent(ctx);
87
+ }
88
+
89
+ async function runOne(ctx: BotContext, kind: AgentKind, query: string): Promise<string> {
90
+ const runtime = await createRuntime(ctx, kind);
91
+ const { text } = await runtime.run(query);
92
+ return text;
93
+ }
94
+
95
+ function createAnsi(useColor: boolean): Record<string, string> {
96
+ return {
97
+ reset: useColor ? "\x1b[0m" : "",
98
+ dim: useColor ? "\x1b[2m" : "",
99
+ heading: useColor ? "\x1b[1;38;5;45m" : "",
100
+ bullet: useColor ? "\x1b[38;5;81m" : "",
101
+ code: useColor ? "\x1b[38;5;221m" : "",
102
+ quote: useColor ? "\x1b[38;5;245m" : "",
103
+ hr: useColor ? "\x1b[38;5;240m" : "",
104
+ };
105
+ }
106
+
107
+ function styleInlineMarkdown(line: string, ansi: Record<string, string>): string {
108
+ return line
109
+ .replace(/`([^`]+)`/g, `${ansi.code}\`$1\`${ansi.reset}`)
110
+ .replace(/\*\*([^*]+)\*\*/g, `${ansi.heading}$1${ansi.reset}`);
111
+ }
112
+
113
+ function parseTableRow(line: string): string[] {
114
+ const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
115
+ return trimmed.split("|").map((cell) => cell.trim());
116
+ }
117
+
118
+ function isTableSeparatorRow(cells: string[]): boolean {
119
+ return cells.length > 0 && cells.every((c) => /^:?-{3,}:?$/.test(c));
120
+ }
121
+
122
+ function renderTable(rows: string[][], ansi: Record<string, string>): string[] {
123
+ const normalized = rows.map((r) => [...r]);
124
+ const colCount = Math.max(...normalized.map((r) => r.length));
125
+ for (const row of normalized) {
126
+ while (row.length < colCount) row.push("");
127
+ }
128
+
129
+ const widths = Array.from({ length: colCount }, (_, i) =>
130
+ Math.max(...normalized.map((r) => (r[i] ?? "").length)),
131
+ );
132
+
133
+ const line = `+${widths.map((w) => "-".repeat(w + 2)).join("+")}+`;
134
+ const out: string[] = [`${ansi.hr}${line}${ansi.reset}`];
135
+
136
+ normalized.forEach((row, idx) => {
137
+ const body = row
138
+ .map((cell, i) => ` ${(cell ?? "").padEnd(widths[i] ?? 0)} `)
139
+ .join("|");
140
+ const styledBody =
141
+ idx === 0
142
+ ? `${ansi.heading}|${styleInlineMarkdown(body, ansi)}|${ansi.reset}`
143
+ : `|${styleInlineMarkdown(body, ansi)}|`;
144
+ out.push(styledBody);
145
+ if (idx === 0) out.push(`${ansi.hr}${line}${ansi.reset}`);
146
+ });
147
+
148
+ out.push(`${ansi.hr}${line}${ansi.reset}`);
149
+ return out;
150
+ }
151
+
152
+ function formatMarkdownForTerminal(
153
+ markdown: string,
154
+ options: { useColor: boolean } = { useColor: true },
155
+ ): string {
156
+ const ansi = createAnsi(options.useColor);
157
+ const lines = markdown.replace(/\r\n/g, "\n").split("\n");
158
+ const out: string[] = [];
159
+ let inCodeBlock = false;
160
+ let codeFenceLang = "";
161
+ let codeBoxWidth = 0;
162
+
163
+ for (let i = 0; i < lines.length; i += 1) {
164
+ const rawLine = lines[i];
165
+ const line = rawLine ?? "";
166
+
167
+ if (line.trim().startsWith("```")) {
168
+ if (!inCodeBlock) {
169
+ inCodeBlock = true;
170
+ codeFenceLang = line.trim().slice(3).trim();
171
+ const title = codeFenceLang ? ` code:${codeFenceLang} ` : " code ";
172
+ codeBoxWidth = Math.max(16, title.length + 4);
173
+ out.push(`${ansi.hr}┌${"─".repeat(codeBoxWidth)}┐${ansi.reset}`);
174
+ out.push(`${ansi.hr}│${ansi.reset}${ansi.dim}${title.padEnd(codeBoxWidth)}${ansi.reset}${ansi.hr}│${ansi.reset}`);
175
+ } else {
176
+ inCodeBlock = false;
177
+ codeFenceLang = "";
178
+ out.push(`${ansi.hr}└${"─".repeat(codeBoxWidth)}┘${ansi.reset}`);
179
+ }
180
+ continue;
181
+ }
182
+
183
+ if (inCodeBlock) {
184
+ const content = line.length > codeBoxWidth ? line.slice(0, codeBoxWidth) : line.padEnd(codeBoxWidth);
185
+ out.push(`${ansi.hr}│${ansi.reset}${ansi.code}${content}${ansi.reset}${ansi.hr}│${ansi.reset}`);
186
+ continue;
187
+ }
188
+
189
+ // Markdown table: header row + separator row + data rows
190
+ if (
191
+ line.includes("|") &&
192
+ i + 1 < lines.length &&
193
+ (lines[i + 1] ?? "").includes("|")
194
+ ) {
195
+ const header = parseTableRow(line);
196
+ const sep = parseTableRow(lines[i + 1] ?? "");
197
+ if (header.length > 1 && header.length === sep.length && isTableSeparatorRow(sep)) {
198
+ const rows: string[][] = [header];
199
+ let j = i + 2;
200
+ while (j < lines.length && (lines[j] ?? "").includes("|")) {
201
+ rows.push(parseTableRow(lines[j] ?? ""));
202
+ j += 1;
203
+ }
204
+ out.push(...renderTable(rows, ansi));
205
+ i = j - 1;
206
+ continue;
207
+ }
208
+ }
209
+
210
+ if (/^\s*#{1,6}\s+/.test(line)) {
211
+ const headingText = line.replace(/^\s*#{1,6}\s+/, "").trim();
212
+ out.push(`${ansi.heading}${headingText}${ansi.reset}`);
213
+ continue;
214
+ }
215
+
216
+ if (/^\s*>\s?/.test(line)) {
217
+ out.push(`${ansi.quote}${line}${ansi.reset}`);
218
+ continue;
219
+ }
220
+
221
+ if (/^\s*([-*]|\d+\.)\s+/.test(line)) {
222
+ out.push(`${ansi.bullet}${styleInlineMarkdown(line, ansi)}${ansi.reset}`);
223
+ continue;
224
+ }
225
+
226
+ if (/^\s*---+\s*$/.test(line)) {
227
+ out.push(`${ansi.hr}${"─".repeat(56)}${ansi.reset}`);
228
+ continue;
229
+ }
230
+
231
+ out.push(styleInlineMarkdown(line, ansi));
232
+ }
233
+
234
+ return out.join("\n");
235
+ }
236
+
237
+ const markdownRenderers = new Map<"color" | "plain", MarkdownIt>();
238
+
239
+ function getMarkdownRenderer(useColor: boolean): MarkdownIt {
240
+ const key = useColor ? "color" : "plain";
241
+ const cached = markdownRenderers.get(key);
242
+ if (cached) return cached;
243
+
244
+ const md = new MarkdownIt({
245
+ html: false,
246
+ linkify: true,
247
+ typographer: true,
248
+ breaks: false,
249
+ });
250
+ md.use(markdownItTerminal, {
251
+ indent: "",
252
+ ...(useColor
253
+ ? {}
254
+ : {
255
+ styleOptions: {
256
+ code: (s: string) => s,
257
+ blockquote: (s: string) => s,
258
+ html: (s: string) => s,
259
+ heading: (s: string) => s,
260
+ firstHeading: (s: string) => s,
261
+ hr: (s: string) => s,
262
+ listitem: (s: string) => s,
263
+ table: (s: string) => s,
264
+ paragraph: (s: string) => s,
265
+ strong: (s: string) => s,
266
+ em: (s: string) => s,
267
+ codespan: (s: string) => s,
268
+ del: (s: string) => s,
269
+ link: (s: string) => s,
270
+ href: (s: string) => s,
271
+ },
272
+ }),
273
+ });
274
+
275
+ markdownRenderers.set(key, md);
276
+ return md;
277
+ }
278
+
279
+ function normalizeAssistantMarkdown(text: string): string {
280
+ const source = text.replace(/\r\n/g, "\n");
281
+ const hasMarkdownHeadings = /^\s*#{1,6}\s+/m.test(source);
282
+ const sanitizeLine = (line: string): string => {
283
+ let next = line;
284
+ const trimmed = next.trim();
285
+ // Remove stray inline fence markers that frequently break markdown rendering.
286
+ if (next.includes("```") && !trimmed.startsWith("```")) {
287
+ next = next.replace(/```+/g, "").replace(/\s+$/, "");
288
+ }
289
+ // Keep headings stable and readable in terminal output.
290
+ if (/^\s*#{3,}\s+/.test(next)) {
291
+ next = next.replace(/^\s*#{3,}\s+/, "## ");
292
+ }
293
+ return next;
294
+ };
295
+ const normalizeListLine = (line: string): string => {
296
+ if (/^(\s{2,}|\t+)([*-])\s+/.test(line)) {
297
+ return line.replace(/^(\s{2,}|\t+)([*-])\s+/, "- ");
298
+ }
299
+ if (/^(\s{2,}|\t+)(\d+\.)\s+/.test(line)) {
300
+ return line.replace(/^(\s{2,}|\t+)(\d+\.)\s+/, "$2 ");
301
+ }
302
+ return line;
303
+ };
304
+ const lines = source
305
+ .split("\n")
306
+ .map(sanitizeLine)
307
+ .map(normalizeListLine);
308
+
309
+ const titled = lines.map((line) => {
310
+ const trimmed = line.trim();
311
+ const bulletHeading = trimmed.match(/^[-*]\s+\*\*([^*]+)\*\*:?\s*$/);
312
+ if (bulletHeading?.[1]) {
313
+ return `### ${bulletHeading[1].trim()}`;
314
+ }
315
+ const plainBulletHeading = trimmed.match(/^[-*]\s+([^`].+):\s*$/);
316
+ if (plainBulletHeading?.[1] && plainBulletHeading[1].length <= 48) {
317
+ return `### ${plainBulletHeading[1].trim()}`;
318
+ }
319
+ if (hasMarkdownHeadings) return trimmed === "Summary (3‑8 bullets)" ? "## Summary" : line;
320
+ const unwrapped = trimmed.replace(/^\*\*(.+)\*\*$/, "$1").trim();
321
+ if (/^(Key terminal output.*|Current terminal buffer.*|Summary.*|Next steps.*)$/i.test(unwrapped)) {
322
+ return `## ${unwrapped}`;
323
+ }
324
+ if (/^(Key observations|Findings|Analysis|Conclusion)$/i.test(unwrapped)) {
325
+ return `## ${unwrapped}`;
326
+ }
327
+ return line;
328
+ });
329
+
330
+ // Merge accidental line breaks inside list items so markdown renders as one bullet.
331
+ const merged: string[] = [];
332
+ for (let i = 0; i < titled.length; i += 1) {
333
+ const current = titled[i] ?? "";
334
+ const next = titled[i + 1] ?? "";
335
+ const currentTrim = current.trim();
336
+ const nextTrim = next.trim();
337
+ const isListLine = /^([-*]|\d+\.)\s+/.test(currentTrim);
338
+ const nextStartsNewBlock =
339
+ nextTrim.length === 0 ||
340
+ /^([-*]|\d+\.)\s+/.test(nextTrim) ||
341
+ /^#{1,6}\s+/.test(nextTrim) ||
342
+ /^```/.test(nextTrim) ||
343
+ /^---+$/.test(nextTrim);
344
+ const looksLikeWrappedContinuation =
345
+ isListLine &&
346
+ !nextStartsNewBlock &&
347
+ /^[0-9./~]/.test(nextTrim);
348
+
349
+ if (looksLikeWrappedContinuation) {
350
+ merged.push(`${current.replace(/\s+$/, "")} ${nextTrim}`);
351
+ i += 1;
352
+ continue;
353
+ }
354
+ merged.push(current);
355
+ }
356
+
357
+ const out: string[] = [];
358
+ for (let i = 0; i < merged.length; i += 1) {
359
+ const line = merged[i] ?? "";
360
+ out.push(line);
361
+
362
+ const heading = line.trim();
363
+ const isOutputHeading = /^##\s*(Key terminal output|Current terminal buffer|Terminal output)/i.test(heading);
364
+ if (!isOutputHeading) continue;
365
+ if ((merged[i + 1] ?? "").trim().startsWith("```")) continue;
366
+
367
+ let j = i + 1;
368
+ const block: string[] = [];
369
+ while (j < merged.length) {
370
+ const current = merged[j] ?? "";
371
+ if (!current.trim()) {
372
+ if (block.length === 0) {
373
+ j += 1;
374
+ continue;
375
+ }
376
+ break;
377
+ }
378
+ if (!/^\s{2,}\S/.test(current)) break;
379
+ block.push(current.replace(/^\s+/, ""));
380
+ j += 1;
381
+ }
382
+
383
+ if (block.length >= 2) {
384
+ out.push("```text");
385
+ out.push(...block);
386
+ out.push("```");
387
+ i = j - 1;
388
+ }
389
+ }
390
+
391
+ const fencedCount = out.reduce((count, line) => count + (/^\s*```/.test(line) ? 1 : 0), 0);
392
+ if (fencedCount % 2 === 1) {
393
+ out.push("```");
394
+ }
395
+
396
+ return out.join("\n");
397
+ }
398
+
399
+ function renderForTerminal(
400
+ text: string,
401
+ options: { renderMarkdown: boolean; useColor: boolean },
402
+ ): string {
403
+ if (!options.renderMarkdown) return text;
404
+ const normalizedText = normalizeAssistantMarkdown(text);
405
+ try {
406
+ return getMarkdownRenderer(options.useColor).render(normalizedText, {});
407
+ } catch {
408
+ return formatMarkdownForTerminal(normalizedText, { useColor: options.useColor });
409
+ }
410
+ }
411
+
412
+ function startLoadingSpinner(message: string): () => void {
413
+ const frames = ["", ".", "..", "..."];
414
+ let frameIndex = 0;
415
+ let lastLength = 0;
416
+ const render = () => {
417
+ const frame = frames[frameIndex % frames.length] ?? "";
418
+ const trimmed = message.trim();
419
+ const base = trimmed.startsWith("⏳") ? trimmed : `⏳ ${trimmed}`;
420
+ const line = `${base}${frame}`;
421
+ const padded = lastLength > line.length ? line.padEnd(lastLength, " ") : line;
422
+ process.stderr.write(`\r${padded}\r`);
423
+ lastLength = padded.length;
424
+ frameIndex += 1;
425
+ };
426
+ render();
427
+ const timer = setInterval(render, 90);
428
+ return () => {
429
+ clearInterval(timer);
430
+ process.stderr.write(`\r${" ".repeat(lastLength)}\r`);
431
+ };
432
+ }
433
+
434
+ async function interactive(
435
+ ctx: BotContext,
436
+ kind: AgentKind,
437
+ options: AppCliOptions,
438
+ exitApp: (code: number) => never
439
+ ) {
440
+ const name = kind === REACT ? "ReAct" : "Deep";
441
+ const runtime = await createRuntime(ctx, kind);
442
+ const userInfo = os.userInfo();
443
+ const userLabel = options.ui?.userLabel ?? `${userInfo.username}`;
444
+ const assistantLabel = options.ui?.assistantLabel ?? name;
445
+ const useColor = options.ui?.useColor ?? (Boolean(process.stdout.isTTY) && !process.env.NO_COLOR);
446
+ const renderMarkdown = options.ui?.renderMarkdown ?? true;
447
+ const echoUserQuestion = options.ui?.echoUserQuestion ?? true;
448
+ const showProcessingSpinner =
449
+ options.ui?.processingSpinner ?? Boolean(process.stderr.isTTY);
450
+ const processingText = options.ui?.processingText ?? "Processing";
451
+
452
+ const color = {
453
+ reset: useColor ? "\x1b[0m" : "",
454
+ dim: useColor ? "\x1b[2m" : "",
455
+ user: useColor ? "\x1b[38;5;39m" : "",
456
+ bot: useColor ? "\x1b[38;5;48m" : "",
457
+ prompt: useColor ? "\x1b[38;5;245m" : "",
458
+ promptUser: useColor ? "\x1b[1;38;5;45m" : "",
459
+ };
460
+ const hr = `${color.dim}${"-".repeat(56)}${color.reset}`;
461
+ const promptText = `${color.prompt}[${color.promptUser}${userLabel}${color.reset}${color.prompt}]${color.reset} `;
462
+
463
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
464
+ const userLabelPrefixPattern = new RegExp(`^(?:\\[${escapeRegExp(userLabel)}\\]\\s*)+`, "i");
465
+ const prompt = () =>
466
+ rl.question(promptText, async (line) => {
467
+ const qRaw = line?.trim();
468
+ const q = qRaw?.replace(userLabelPrefixPattern, "").trim();
469
+ if (!q) {
470
+ prompt();
471
+ return;
472
+ }
473
+ if (q === "exit" || q === "quit") {
474
+ rl.close();
475
+ exitApp(0);
476
+ }
477
+
478
+ const handler = options.interactiveCommands?.[q.toLowerCase()];
479
+ if (handler) {
480
+ await handler(ctx);
481
+ prompt();
482
+ return;
483
+ }
484
+
485
+ let stopProcessingSpinner: (() => void) | null = null;
486
+ try {
487
+ console.log(`\n${hr}`);
488
+ if (echoUserQuestion) {
489
+ console.log(`${color.user}[${userLabel}]${color.reset}`);
490
+ console.log(`> ${q}`);
491
+ console.log("");
492
+ }
493
+ console.log(`${color.bot}[${assistantLabel}]${color.reset}`);
494
+ stopProcessingSpinner = showProcessingSpinner
495
+ ? startLoadingSpinner(processingText === false ? "⏳" : `⏳ ${processingText}`)
496
+ : null;
497
+ const { text } = await runtime.run(q);
498
+ if (stopProcessingSpinner) {
499
+ stopProcessingSpinner();
500
+ stopProcessingSpinner = null;
501
+ }
502
+ console.log(renderForTerminal(text, { renderMarkdown, useColor }));
503
+ console.log(`${hr}\n`);
504
+ } catch (err) {
505
+ if (stopProcessingSpinner) {
506
+ stopProcessingSpinner();
507
+ stopProcessingSpinner = null;
508
+ }
509
+ console.error("Error:", err instanceof Error ? err.message : err);
510
+ } finally {
511
+ if (stopProcessingSpinner) {
512
+ stopProcessingSpinner();
513
+ stopProcessingSpinner = null;
514
+ }
515
+ }
516
+ prompt();
517
+ });
518
+ const introText =
519
+ options.ui?.interactiveIntro ??
520
+ `${options.appName} (${name} agent). Type your message or "exit" to quit.`;
521
+ if (introText !== false) {
522
+ console.log(`${introText}\n`);
523
+ }
524
+ prompt();
525
+ }
526
+
527
+ export function createStructuredRunEventListener(
528
+ writer: (line: string) => void = console.error
529
+ ): AgentEventListener {
530
+ let step = 0;
531
+ let lastCommandSignature = "";
532
+ const stepActionByNumber = new Map<number, string>();
533
+ const okMark = process.stderr.isTTY && !process.env.NO_COLOR ? "\x1b[32m✔\x1b[0m" : "✔";
534
+
535
+ const asRecord = (value: unknown): Record<string, unknown> | null =>
536
+ typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
537
+
538
+ const asNumber = (value: unknown): number | null =>
539
+ typeof value === "number" && Number.isFinite(value) ? value : null;
540
+
541
+ const asString = (value: unknown): string | null =>
542
+ typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
543
+ const asAnyString = (value: unknown): string | null =>
544
+ typeof value === "string" ? value : null;
545
+
546
+ const parseJsonObject = (value: unknown): Record<string, unknown> | null => {
547
+ if (typeof value !== "string") return null;
548
+ try {
549
+ const parsed = JSON.parse(value) as unknown;
550
+ return asRecord(parsed);
551
+ } catch {
552
+ return null;
553
+ }
554
+ };
555
+
556
+ const extractCommandMeta = (args: unknown) => {
557
+ const rec = asRecord(args) ?? parseJsonObject(args);
558
+ if (!rec) return null;
559
+ const commandRaw = asAnyString(rec.command);
560
+ if (commandRaw === null) return null;
561
+ const command = commandRaw.trim();
562
+ return {
563
+ command,
564
+ windowId: asNumber(rec.windowId),
565
+ tabIndex: asNumber(rec.tabIndex),
566
+ sessionId: asString(rec.sessionId),
567
+ };
568
+ };
569
+
570
+ const isNoopCaptureCommand = (command: string): boolean => {
571
+ const normalized = command.trim().replace(/\s+/g, " ");
572
+ return (
573
+ normalized === "" ||
574
+ normalized === ":" ||
575
+ normalized === "true" ||
576
+ normalized === "printf ''" ||
577
+ normalized === "printf \"\""
578
+ );
579
+ };
580
+
581
+ const extractToolResultMeta = (payload: unknown) => {
582
+ const payloadRec = asRecord(payload);
583
+ const rawResult = payloadRec ? (payloadRec.result ?? null) : null;
584
+ const resultRec = asRecord(rawResult) ?? parseJsonObject(rawResult);
585
+ const nestedResult = resultRec ? asRecord(resultRec.result) : null;
586
+ const output =
587
+ asString((nestedResult ?? resultRec ?? {}).output) ??
588
+ asString((nestedResult ?? resultRec ?? {}).result as unknown);
589
+ const outputLines = output ? output.split(/\r?\n/).filter((line) => line.length > 0).length : null;
590
+ const error = asString((nestedResult ?? resultRec ?? {}).error);
591
+ return { outputLines, error };
592
+ };
593
+
594
+ return (event: AgentEvent) => {
595
+ switch (event.name) {
596
+ case "agent.react.run.start":
597
+ case "agent.deep.run.start":
598
+ step = 0;
599
+ lastCommandSignature = "";
600
+ stepActionByNumber.clear();
601
+ writer("Analyzing started");
602
+ return;
603
+ case "agent.react.skill.matched": {
604
+ const payload = (event.payload ?? {}) as { skill?: string; score?: number };
605
+ const score = typeof payload.score === "number" ? payload.score.toFixed(3) : "?";
606
+ writer(`[skill] matched ${payload.skill ?? "unknown"} (score ${score})`);
607
+ return;
608
+ }
609
+ case "agent.react.tool.invoke.start": {
610
+ step += 1;
611
+ const payload = (event.payload ?? {}) as { args?: unknown };
612
+ const commandMeta = extractCommandMeta(payload.args);
613
+ if (commandMeta) {
614
+ const signature = `${commandMeta.command}|${commandMeta.windowId ?? ""}|${commandMeta.tabIndex ?? ""}|${commandMeta.sessionId ?? ""}`;
615
+ const isRepeat = signature === lastCommandSignature;
616
+ lastCommandSignature = signature;
617
+ const action = isNoopCaptureCommand(commandMeta.command)
618
+ ? "capture current screen"
619
+ : commandMeta.command;
620
+ stepActionByNumber.set(step, `${action}${isRepeat ? " (repeat)" : ""}`);
621
+ } else {
622
+ stepActionByNumber.set(step, `tool: ${event.to}`);
623
+ }
624
+ return;
625
+ }
626
+ case "agent.react.tool.invoke.done": {
627
+ const resultMeta = extractToolResultMeta(event.payload);
628
+ const action = stepActionByNumber.get(step) ?? `tool: ${event.to}`;
629
+ if (resultMeta.error) {
630
+ writer(`[step ${step}] ${action} ✖ (${resultMeta.error})`);
631
+ stepActionByNumber.delete(step);
632
+ return;
633
+ }
634
+ writer(`[step ${step}] ${action} ${okMark}`);
635
+ stepActionByNumber.delete(step);
636
+ return;
637
+ }
638
+ case "agent.react.tool.invoke.error": {
639
+ const payload = (event.payload ?? {}) as { error?: unknown };
640
+ const action = stepActionByNumber.get(step) ?? `tool: ${event.to}`;
641
+ const message =
642
+ typeof payload.error === "string"
643
+ ? payload.error
644
+ : payload.error instanceof Error
645
+ ? payload.error.message
646
+ : "unknown error";
647
+ writer(`[step ${step}] ${action} ✖ (${message})`);
648
+ stepActionByNumber.delete(step);
649
+ return;
650
+ }
651
+ case "agent.react.run.done":
652
+ case "agent.deep.run.done":
653
+ writer("completed");
654
+ return;
655
+ default:
656
+ return;
657
+ }
658
+ };
659
+ }
660
+
661
+ export function runAppCli(options: AppCliOptions): void {
662
+ const { appName, createBotContext } = options;
663
+
664
+ async function main() {
665
+ const { kind, query } = parseArgs();
666
+ const agentLabel = kind === REACT ? "ReAct (LangChain)" : "Deep (DeepAgents)";
667
+ const loadingText =
668
+ options.ui?.loadingText ?? `${appName}: loading config, LLM, memory, tools...`;
669
+ const useLoadingSpinner = options.ui?.loadingSpinner ?? false;
670
+ let stopLoadingSpinner: (() => void) | null = null;
671
+ if (loadingText !== false) {
672
+ if (useLoadingSpinner && Boolean(process.stderr.isTTY)) {
673
+ stopLoadingSpinner = startLoadingSpinner(loadingText);
674
+ } else {
675
+ console.error(loadingText);
676
+ }
677
+ }
678
+ const ctx = await createBotContext();
679
+ if (stopLoadingSpinner) {
680
+ stopLoadingSpinner();
681
+ }
682
+ let didCleanup = false;
683
+ const cleanup = () => {
684
+ if (didCleanup) return;
685
+ didCleanup = true;
686
+ try {
687
+ options.onShutdown?.(ctx);
688
+ } catch (err) {
689
+ console.error(
690
+ `${appName}: onShutdown hook failed:`,
691
+ err instanceof Error ? err.message : err,
692
+ );
693
+ }
694
+ };
695
+ const exitApp = (code: number): never => {
696
+ cleanup();
697
+ process.exit(code);
698
+ };
699
+ process.once("exit", cleanup);
700
+ process.once("SIGINT", () => exitApp(130));
701
+ process.once("SIGTERM", () => exitApp(143));
702
+
703
+ if (process.env.AGENT_EVENT_STDERR === "1") {
704
+ ctx.events.subscribe(createConsoleAgentEventListener());
705
+ }
706
+ const eventListeners = Array.isArray(options.eventListener)
707
+ ? options.eventListener
708
+ : options.eventListener
709
+ ? [options.eventListener]
710
+ : [];
711
+ for (const listener of eventListeners) {
712
+ ctx.events.subscribe(listener);
713
+ }
714
+ if (options.onReady) {
715
+ try {
716
+ await options.onReady(ctx);
717
+ } catch (err) {
718
+ console.error(
719
+ `${appName}: onReady hook failed:`,
720
+ err instanceof Error ? err.message : err,
721
+ );
722
+ }
723
+ }
724
+ const readyText = options.ui?.readyText ?? `Ready. Agent: ${agentLabel}`;
725
+ if (readyText !== false) {
726
+ console.error(readyText);
727
+ }
728
+
729
+ if (query) {
730
+ const text = await runOne(ctx, kind, query);
731
+ const useColor = options.ui?.useColor ?? (Boolean(process.stdout.isTTY) && !process.env.NO_COLOR);
732
+ const renderMarkdown = options.ui?.renderMarkdown ?? true;
733
+ console.log(renderForTerminal(text, { renderMarkdown, useColor }));
734
+ cleanup();
735
+ return;
736
+ }
737
+
738
+ await interactive(ctx, kind, options, exitApp);
739
+ }
740
+
741
+ main().catch((err) => {
742
+ console.error(err);
743
+ process.exit(1);
744
+ });
745
+ }