@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
@@ -0,0 +1,576 @@
1
+ import { createAgent as createLangChainAgent } from "langchain";
2
+ import type { BaseMessageLike } from "@langchain/core/messages";
3
+ import { DynamicStructuredTool } from "@langchain/core/tools";
4
+ import type { LanguageModelLike } from "@langchain/core/language_models/base";
5
+ import { z } from "zod";
6
+ import { applyToolChoiceAuto } from "@easynet/agent-model";
7
+ import {
8
+ extractLastMessageText,
9
+ stripNullishObjectFields,
10
+ summarizeForLog,
11
+ } from "@easynet/agent-common";
12
+ import type { Skill } from "@easynet/agent-skill";
13
+ import type { BaseAppConfig, BotContext } from "./context.js";
14
+
15
+ export interface ReactAgentRuntime {
16
+ agent: unknown;
17
+ run: (userMessage: string) => Promise<{ text: string; messages?: unknown }>;
18
+ remember: (content: string, type?: "thread" | "cross_thread" | "knowledge" | "auto") => Promise<void>;
19
+ }
20
+
21
+ export interface ReactAgentOptions {
22
+ agentName?: string;
23
+ systemPrompt?: string;
24
+ namespace?: string;
25
+ topK?: number;
26
+ budgetTokens?: number;
27
+ maxSteps?: number;
28
+ printSteps?: boolean;
29
+ fallbackText?: string;
30
+ commandWindowLabel?: string;
31
+ /** LangChain agent middleware (e.g. malformedToolCallMiddleware). */
32
+ middleware?: unknown[];
33
+ autoWriteMemory?: boolean;
34
+ /** Prompted Tool Correction: retry with structured tool feedback. */
35
+ ptc?: {
36
+ enabled?: boolean;
37
+ maxRetries?: number;
38
+ };
39
+ }
40
+
41
+ interface MessageLike {
42
+ type?: string;
43
+ role?: string;
44
+ content?: string | unknown;
45
+ tool_calls?: Array<{ name?: string; args?: unknown; id?: string }>;
46
+ name?: string;
47
+ tool_call_id?: string;
48
+ }
49
+
50
+ type RuntimeTool = {
51
+ name: string;
52
+ description?: string;
53
+ schema: Record<string, unknown>;
54
+ invoke: (args: unknown) => Promise<unknown>;
55
+ };
56
+
57
+ interface ToolRunStats {
58
+ invoked: number;
59
+ succeeded: number;
60
+ failed: number;
61
+ lastError?: string;
62
+ }
63
+
64
+ function toMessageText(content: unknown): string {
65
+ if (typeof content === "string") return content;
66
+ if (Array.isArray(content)) {
67
+ return content
68
+ .map((block) => {
69
+ if (typeof block === "string") return block;
70
+ if (typeof block === "object" && block !== null && "text" in block) {
71
+ const text = (block as { text?: unknown }).text;
72
+ return typeof text === "string" ? text : "";
73
+ }
74
+ return "";
75
+ })
76
+ .join("");
77
+ }
78
+ return "";
79
+ }
80
+
81
+ function inferMemoryTypeByStructure(content: string): "thread" | "cross_thread" {
82
+ const text = content.trim();
83
+ if (!text) return "thread";
84
+
85
+ const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
86
+ const lineCount = lines.length;
87
+ const uniqueLineCount = new Set(lines).size;
88
+ const duplicateRatio = lineCount > 0 ? 1 - uniqueLineCount / lineCount : 0;
89
+ const symbolCount = (text.match(/[^\p{L}\p{N}\s]/gu) ?? []).length;
90
+ const symbolRatio = symbolCount / Math.max(1, text.length);
91
+
92
+ // Terminal/log-like content is usually long, repetitive, and symbol-dense.
93
+ if (text.length > 1200) return "thread";
94
+ if (lineCount > 18) return "thread";
95
+ if (duplicateRatio > 0.35) return "thread";
96
+ if (symbolRatio > 0.22) return "thread";
97
+
98
+ return "cross_thread";
99
+ }
100
+
101
+ async function inferMemoryTypeWithModel(
102
+ llm: LanguageModelLike,
103
+ content: string
104
+ ): Promise<"thread" | "cross_thread" | null> {
105
+ const classifierPrompt = [
106
+ "Classify memory scope for an AI agent.",
107
+ "Return strict JSON only: {\"type\":\"thread|cross_thread\",\"confidence\":0..1}.",
108
+ "Decision policy:",
109
+ "- thread: temporary session context, command output, logs, transient task state, step-by-step investigation details.",
110
+ "- cross_thread: stable user preferences, durable profile facts, long-lived constraints reusable in future sessions.",
111
+ "Use semantic judgment, not keyword matching.",
112
+ ].join("\n");
113
+
114
+ try {
115
+ const response = await llm.invoke([
116
+ { role: "system", content: classifierPrompt },
117
+ { role: "user", content },
118
+ ]);
119
+ const text = toMessageText((response as { content?: unknown }).content).trim();
120
+ const jsonBlob = text.match(/\{[\s\S]*\}/)?.[0] ?? text;
121
+ const parsed = JSON.parse(jsonBlob) as { type?: unknown; confidence?: unknown };
122
+ const type = parsed.type === "thread" || parsed.type === "cross_thread" ? parsed.type : null;
123
+ const confidence = typeof parsed.confidence === "number" ? parsed.confidence : 0;
124
+ if (!type) return null;
125
+ return confidence >= 0.6 ? type : null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ function deriveFallbackTextFromMessages(
132
+ messages: BaseMessageLike[],
133
+ opts: { fallbackText: string; commandWindowLabel?: string }
134
+ ): string {
135
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
136
+ const msg = messages[i] as MessageLike;
137
+ const type = msg?.type ?? msg?.role ?? "";
138
+ if (type !== "tool" && !msg?.tool_call_id) continue;
139
+
140
+ const raw =
141
+ typeof msg.content === "string"
142
+ ? msg.content
143
+ : msg.content != null
144
+ ? String(msg.content)
145
+ : "";
146
+ if (!raw.trim()) continue;
147
+
148
+ try {
149
+ const parsed = JSON.parse(raw) as {
150
+ result?: {
151
+ command?: string;
152
+ windowId?: number;
153
+ tabIndex?: number;
154
+ to?: string;
155
+ serviceType?: string;
156
+ };
157
+ evidence?: Array<{ summary?: string }>;
158
+ };
159
+
160
+ const summary = parsed.evidence?.find((e) => e?.summary)?.summary;
161
+ if (typeof summary === "string" && summary.trim()) return summary.trim();
162
+
163
+ const to = parsed.result?.to?.trim();
164
+ const serviceType = parsed.result?.serviceType?.trim();
165
+ if (to) {
166
+ return serviceType ? `Sent ${serviceType} message to ${to}.` : `Sent message to ${to}.`;
167
+ }
168
+
169
+ const command = parsed.result?.command?.trim();
170
+ if (command) {
171
+ const w = parsed.result?.windowId;
172
+ const t = parsed.result?.tabIndex;
173
+ if (typeof w === "number" && typeof t === "number") {
174
+ const label = opts.commandWindowLabel ?? "window";
175
+ return `Executed command in ${label} ${w}, tab ${t}: ${command}`;
176
+ }
177
+ return `Executed command: ${command}`;
178
+ }
179
+ } catch {
180
+ return raw.length > 200 ? raw.slice(0, 200) + "..." : raw;
181
+ }
182
+ }
183
+
184
+ return opts.fallbackText;
185
+ }
186
+
187
+ function hasToolMessages(messages: unknown): boolean {
188
+ const list = Array.isArray(messages) ? messages : [];
189
+ for (const item of list) {
190
+ const msg = item as MessageLike;
191
+ const type = msg?.type ?? msg?.role ?? "";
192
+ if (type === "tool" || Boolean(msg?.tool_call_id) || (msg?.tool_calls?.length ?? 0) > 0) {
193
+ return true;
194
+ }
195
+ }
196
+ return false;
197
+ }
198
+
199
+ function looksLikeToolRefusal(text: string): boolean {
200
+ const t = text.toLowerCase();
201
+ return (
202
+ t.includes("don't have") ||
203
+ t.includes("do not have") ||
204
+ t.includes("no direct") ||
205
+ t.includes("cannot directly") ||
206
+ t.includes("can't directly") ||
207
+ t.includes("no tool") ||
208
+ t.includes("schema") ||
209
+ t.includes("invalid")
210
+ );
211
+ }
212
+
213
+ function extractSchemaKeys(schema: unknown): string[] {
214
+ if (!schema || typeof schema !== "object") return [];
215
+ const rec = schema as Record<string, unknown>;
216
+ const properties = rec.properties;
217
+ if (properties && typeof properties === "object" && !Array.isArray(properties)) {
218
+ return Object.keys(properties as Record<string, unknown>);
219
+ }
220
+ const shape = rec.shape;
221
+ if (shape && typeof shape === "object" && !Array.isArray(shape)) {
222
+ return Object.keys(shape as Record<string, unknown>);
223
+ }
224
+ return [];
225
+ }
226
+
227
+ function buildPtcHint(input: {
228
+ taskIntent: string;
229
+ stats: ToolRunStats;
230
+ toolSignatures: string[];
231
+ }): string {
232
+ const lines = [
233
+ "[PTC_FEEDBACK]",
234
+ `TaskIntent: ${input.taskIntent}`,
235
+ `ToolStats: invoked=${input.stats.invoked}, succeeded=${input.stats.succeeded}, failed=${input.stats.failed}`,
236
+ input.stats.lastError ? `LastToolError: ${input.stats.lastError}` : "",
237
+ "Action: Retry using available tools. Do not claim tools are unavailable.",
238
+ "Action: If previous tool args were invalid, correct arguments strictly by schema.",
239
+ "Action: If a tool is needed, emit a valid tool call instead of plain explanation.",
240
+ `AvailableTools: ${input.toolSignatures.join("; ")}`,
241
+ ].filter((v) => v.length > 0);
242
+ return lines.join("\n");
243
+ }
244
+
245
+ export function printReactSteps(messages: unknown): void {
246
+ const list = Array.isArray(messages) ? messages : [];
247
+ let stepIndex = 0;
248
+ for (let i = 0; i < list.length; i++) {
249
+ const msg = list[i] as MessageLike;
250
+ const type = msg?.type ?? msg?.role ?? "";
251
+ const content = msg?.content;
252
+ const contentStr =
253
+ typeof content === "string"
254
+ ? content
255
+ : typeof content === "object" && content !== null && Array.isArray(content)
256
+ ? (content as { type?: string; text?: string }[])
257
+ .map((b) => ("text" in b ? b.text : JSON.stringify(b)))
258
+ .join("")
259
+ : content != null
260
+ ? String(content)
261
+ : "";
262
+
263
+ if (type === "human" || (type === "user" && !msg.tool_calls)) {
264
+ if (contentStr.trim()) {
265
+ console.error(`\n--- Step (user) ---\n${contentStr.trim()}`);
266
+ }
267
+ continue;
268
+ }
269
+
270
+ if (type === "ai" || type === "assistant") {
271
+ if (msg.tool_calls?.length) {
272
+ stepIndex += 1;
273
+ if (contentStr.trim()) {
274
+ console.error(`\n--- ReAct Step ${stepIndex}: Thought ---\n${contentStr.trim()}`);
275
+ }
276
+ for (const tc of msg.tool_calls) {
277
+ const name = tc?.name ?? "?";
278
+ const args =
279
+ typeof tc?.args === "object" && tc?.args !== null
280
+ ? JSON.stringify(tc.args)
281
+ : String(tc?.args ?? "");
282
+ console.error(`--- ReAct Step ${stepIndex}: Action ---\n${name}(${args})`);
283
+ }
284
+ }
285
+ continue;
286
+ }
287
+
288
+ if (type === "tool" || msg?.tool_call_id) {
289
+ const toolName = msg?.name ?? "tool";
290
+ const obs = contentStr.length > 200 ? contentStr.slice(0, 200) + "..." : contentStr;
291
+ console.error(`--- ReAct Step ${stepIndex}: Observation (${toolName}) ---\n${obs || "(empty)"}`);
292
+ }
293
+ }
294
+ console.error("");
295
+ }
296
+
297
+ export function createReactAgent<TConfig extends BaseAppConfig>(
298
+ ctx: BotContext<TConfig>,
299
+ options: ReactAgentOptions = {}
300
+ ) {
301
+ const publish = (name: string, from: string, to: string, payload?: unknown) => {
302
+ ctx.events.publish({ name, from, to, payload });
303
+ };
304
+
305
+ const runtimeTools = ctx.tools as unknown as RuntimeTool[];
306
+ const agentName = options.agentName ?? "react";
307
+ const configAgent = ctx.config.app?.agent?.[agentName];
308
+ const configReact = configAgent;
309
+ const namespace = options.namespace ?? configReact?.memory?.namespace;
310
+ const topK = options.topK ?? configReact?.memory?.top_k;
311
+ const budgetTokens = options.budgetTokens ?? configReact?.memory?.budget_tokens;
312
+ const promptFromConfig = configReact?.system_prompt;
313
+ const systemPrompt = options.systemPrompt ?? promptFromConfig;
314
+ const maxSteps = options.maxSteps ?? configReact?.max_steps;
315
+ const printSteps = options.printSteps ?? configReact?.print_steps;
316
+ const fallbackText = options.fallbackText ?? configReact?.fallback_text;
317
+ const commandWindowLabel =
318
+ options.commandWindowLabel ?? configReact?.command_window_label;
319
+ const autoWriteMemory = options.autoWriteMemory ?? (configReact?.memory?.auto_write ?? true);
320
+ const autoWriteScope = configReact?.memory?.auto_write_scope ?? "thread";
321
+ const autoWriteMaxChars = configReact?.memory?.auto_write_max_chars ?? 2400;
322
+ const autoWriteUser = configReact?.memory?.auto_write_user ?? true;
323
+ const autoWriteAssistant = configReact?.memory?.auto_write_assistant ?? true;
324
+ const ptcEnabled = options.ptc?.enabled ?? true;
325
+ const ptcMaxRetries = Math.max(0, options.ptc?.maxRetries ?? 1);
326
+
327
+ const usedNames = new Set<string>();
328
+ const toAlias = (raw: string, index: number): string => {
329
+ const segments = raw.split(".");
330
+ // For npm tools (npm.scope.pkg.version.toolName), last segment is the tool name (camelCase).
331
+ // For file/skill tools (file.skills.disk.usage.investigate), strip the "file.skills." prefix
332
+ // and rejoin remaining segments with hyphens to reconstruct the original name.
333
+ let base: string;
334
+ if (segments[0] === "file" && segments.length > 2) {
335
+ // Strip "file.<dirName>." prefix (e.g. "file.skills.") and rejoin with hyphens
336
+ base = segments.slice(2).join("-");
337
+ } else {
338
+ base = segments.pop() || `tool_${index + 1}`;
339
+ }
340
+ const normalized = base.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 48) || `tool_${index + 1}`;
341
+ let name = normalized;
342
+ let n = 2;
343
+ while (usedNames.has(name)) {
344
+ name = `${normalized}_${n}`;
345
+ n += 1;
346
+ }
347
+ usedNames.add(name);
348
+ return name;
349
+ };
350
+ let currentToolStats: ToolRunStats | null = null;
351
+
352
+ const aliasedTools = runtimeTools.map((tool, idx) => {
353
+ const alias = toAlias(tool.name, idx);
354
+ return new DynamicStructuredTool({
355
+ name: alias,
356
+ description: tool.description ?? `Tool ${alias}`,
357
+ schema: tool.schema,
358
+ func: async (args: unknown) => {
359
+ const sanitizedArgs = stripNullishObjectFields((args ?? {}) as Record<string, unknown>);
360
+ if (currentToolStats) currentToolStats.invoked += 1;
361
+ publish("agent.react.tool.invoke.start", "react-agent", alias, { args: sanitizedArgs });
362
+ try {
363
+ const raw = await tool.invoke(sanitizedArgs);
364
+ if (currentToolStats) currentToolStats.succeeded += 1;
365
+ publish("agent.react.tool.invoke.done", alias, "react-agent", {
366
+ result: raw,
367
+ resultPreview: summarizeForLog(raw),
368
+ });
369
+ return typeof raw === "string" ? raw : raw == null ? "" : JSON.stringify(raw);
370
+ } catch (err) {
371
+ const message = err instanceof Error ? err.message : String(err);
372
+ if (currentToolStats) {
373
+ currentToolStats.failed += 1;
374
+ currentToolStats.lastError = message;
375
+ }
376
+ publish("agent.react.tool.invoke.error", alias, "react-agent", { error: message });
377
+ throw err;
378
+ }
379
+ },
380
+ });
381
+ });
382
+ const toolSignatures = aliasedTools.map((tool) => {
383
+ const keys = extractSchemaKeys((tool as unknown as { schema?: unknown }).schema);
384
+ return keys.length > 0 ? `${tool.name}(${keys.join(",")})` : `${tool.name}(...)`;
385
+ });
386
+
387
+ // In subagent mode, add an activateSkill tool that reads SKILL.md on demand
388
+ const allTools = [...aliasedTools];
389
+ const skillsConfig = ctx.config.app?.agent?.[agentName]?.skills;
390
+ if (ctx.skillSet && skillsConfig?.mode === "subagent" && ctx.skillSet.list().length > 0) {
391
+ const skillNames = ctx.skillSet.list().map((s: Skill) => s.name);
392
+
393
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- schema generics mismatch
394
+ allTools.push(
395
+ new DynamicStructuredTool({
396
+ name: "activateSkill",
397
+ description:
398
+ `Activate a skill by name to get its detailed instructions. Available skills: ${skillNames.join(", ")}`,
399
+ schema: z.object({
400
+ skillName: z.enum(skillNames as [string, ...string[]]).describe("Name of the skill to activate"),
401
+ }),
402
+ func: async (args: { skillName: string }) => {
403
+ try {
404
+ return await ctx.skillSet!.read(args.skillName);
405
+ } catch (err) {
406
+ return JSON.stringify({
407
+ error: err instanceof Error ? err.message : String(err),
408
+ });
409
+ }
410
+ },
411
+ }) as any,
412
+ );
413
+ }
414
+
415
+ applyToolChoiceAuto(ctx.llm as Parameters<typeof applyToolChoiceAuto>[0]);
416
+
417
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- heavily overloaded createAgent
418
+ const agent = createLangChainAgent({
419
+ model: ctx.llm as LanguageModelLike,
420
+ tools: allTools,
421
+ systemPrompt,
422
+ ...(options.middleware?.length ? { middleware: options.middleware } : {}),
423
+ } as any);
424
+ const conversationHistory: BaseMessageLike[] = [];
425
+ const maxHistoryMessages = 24;
426
+
427
+ return {
428
+ agent,
429
+ async run(userMessage: string): Promise<{ text: string; messages?: unknown }> {
430
+ publish("agent.react.run.start", "user", "react-agent", { userMessage });
431
+
432
+ const injectedText = namespace
433
+ ? (await ctx.memory.recall({
434
+ namespace,
435
+ query: userMessage,
436
+ ...(typeof topK === "number" ? { topK } : {}),
437
+ ...(typeof budgetTokens === "number" ? { budgetTokens } : {}),
438
+ })).injectedText
439
+ : "";
440
+
441
+ // Auto-match skills and inject instructions if matched
442
+ let skillBlock = "";
443
+ if (ctx.skillSet) {
444
+ const skillMatchOptions = {
445
+ ...(typeof configReact?.skills?.embedding_threshold === "number"
446
+ ? { embeddingThreshold: configReact.skills.embedding_threshold }
447
+ : {}),
448
+ ...(typeof configReact?.skills?.keyword_threshold === "number"
449
+ ? { keywordThreshold: configReact.skills.keyword_threshold }
450
+ : {}),
451
+ };
452
+ const match = await ctx.skillSet.match(userMessage, skillMatchOptions);
453
+ if (match) {
454
+ publish("agent.react.skill.matched", "react-agent", "skill", {
455
+ skill: match.skill.name,
456
+ score: match.score,
457
+ });
458
+ skillBlock = `[Matched skill: ${match.skill.name}]\nFollow these instructions step by step:\n\n${match.instructions}\n\n`;
459
+ }
460
+ }
461
+
462
+ const contextBlock = injectedText.trim().length > 0 ? `[Relevant memory]\n${injectedText}\n\n` : "";
463
+ const effectiveMessage = skillBlock + contextBlock + userMessage;
464
+
465
+ const runAttempt = async (messageContent: string) => {
466
+ const invokeInput = {
467
+ messages: [...conversationHistory, { role: "user" as const, content: messageContent }],
468
+ };
469
+ currentToolStats = { invoked: 0, succeeded: 0, failed: 0 };
470
+ try {
471
+ const out =
472
+ typeof maxSteps === "number"
473
+ ? await agent.invoke(invokeInput, { recursionLimit: maxSteps })
474
+ : await agent.invoke(invokeInput);
475
+ return { out, stats: currentToolStats };
476
+ } finally {
477
+ currentToolStats = null;
478
+ }
479
+ };
480
+
481
+ let { out: result, stats } = await runAttempt(effectiveMessage);
482
+ let messages = (result as { messages?: unknown } | null | undefined)?.messages ?? result;
483
+ let extractedText = extractLastMessageText(result).trim();
484
+
485
+ if (ptcEnabled && ptcMaxRetries > 0) {
486
+ let retries = 0;
487
+ while (retries < ptcMaxRetries) {
488
+ const noToolActivity = (stats?.invoked ?? 0) === 0 && !hasToolMessages(messages);
489
+ const onlyToolFailure = (stats?.invoked ?? 0) > 0 && (stats?.succeeded ?? 0) === 0;
490
+ const refusal = looksLikeToolRefusal(extractedText);
491
+ if (!noToolActivity && !onlyToolFailure && !refusal) break;
492
+
493
+ publish("agent.react.ptc.retry", "react-agent", "react-agent", {
494
+ retry: retries + 1,
495
+ reason: noToolActivity ? "no_tool_activity" : onlyToolFailure ? "tool_failed" : "tool_refusal_text",
496
+ stats,
497
+ });
498
+ const ptcHint = buildPtcHint({
499
+ taskIntent: userMessage,
500
+ stats: stats ?? { invoked: 0, succeeded: 0, failed: 0 },
501
+ toolSignatures,
502
+ });
503
+ const retried = await runAttempt(`${effectiveMessage}\n\n${ptcHint}`);
504
+ result = retried.out;
505
+ stats = retried.stats;
506
+ messages = (result as { messages?: unknown } | null | undefined)?.messages ?? result;
507
+ extractedText = extractLastMessageText(result).trim();
508
+ retries += 1;
509
+ }
510
+ }
511
+
512
+ if (printSteps === true) {
513
+ printReactSteps(messages);
514
+ }
515
+ const text = extractedText || (fallbackText
516
+ ? deriveFallbackTextFromMessages((Array.isArray(messages) ? messages : []) as BaseMessageLike[], {
517
+ fallbackText,
518
+ commandWindowLabel,
519
+ })
520
+ : extractedText);
521
+ conversationHistory.push({ role: "user", content: userMessage });
522
+ if (text.trim().length > 0) {
523
+ conversationHistory.push({ role: "assistant", content: text });
524
+ }
525
+ if (conversationHistory.length > maxHistoryMessages) {
526
+ conversationHistory.splice(0, conversationHistory.length - maxHistoryMessages);
527
+ }
528
+
529
+ if (namespace && autoWriteMemory) {
530
+ const scope = autoWriteScope === "auto" ? inferMemoryTypeByStructure(`${userMessage}\n${text}`) : autoWriteScope;
531
+ const writeItems: string[] = [];
532
+ if (autoWriteUser && userMessage.trim().length > 0) {
533
+ writeItems.push(`[user]\n${userMessage.trim()}`);
534
+ }
535
+ if (autoWriteAssistant && text.trim().length > 0) {
536
+ writeItems.push(`[assistant]\n${text.trim()}`);
537
+ }
538
+ const content = writeItems.join("\n\n").slice(0, Math.max(0, autoWriteMaxChars));
539
+ if (content.length > 0) {
540
+ try {
541
+ publish("agent.react.memory.write.start", "react-agent", "memory", { type: scope, auto: true });
542
+ await ctx.memory.memorize(namespace, scope, content);
543
+ publish("agent.react.memory.write.done", "memory", "react-agent", { type: scope, auto: true });
544
+ } catch (err) {
545
+ publish("agent.react.memory.write.error", "memory", "react-agent", {
546
+ auto: true,
547
+ error: err instanceof Error ? err.message : String(err),
548
+ });
549
+ }
550
+ }
551
+ }
552
+
553
+ publish("agent.react.run.done", "react-agent", "user", { text });
554
+ return { text, messages };
555
+ },
556
+ async remember(
557
+ content: string,
558
+ type: "thread" | "cross_thread" | "knowledge" | "auto" = "auto"
559
+ ) {
560
+ const resolvedType =
561
+ type === "auto"
562
+ ? (await inferMemoryTypeWithModel(ctx.llm as LanguageModelLike, content)) ??
563
+ inferMemoryTypeByStructure(content)
564
+ : type;
565
+ publish("agent.react.memory.write.start", "react-agent", "memory", { type: resolvedType });
566
+ if (!namespace) {
567
+ publish("agent.react.memory.write.skipped", "react-agent", "memory", {
568
+ reason: "namespace_not_configured",
569
+ });
570
+ return;
571
+ }
572
+ await ctx.memory.memorize(namespace, resolvedType, content);
573
+ publish("agent.react.memory.write.done", "memory", "react-agent", { type: resolvedType });
574
+ },
575
+ };
576
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Parent-Child sub-agent pattern.
3
+ *
4
+ * A parent agent can spawn child agents (react or deep) that reuse the parent's
5
+ * BotContext (LLM, tools, memory) but run with their own system prompt and options.
6
+ * The child runs to completion and returns its result to the parent.
7
+ */
8
+ import { createReactAgent } from "./react-agent.js";
9
+ import { createDeepAgent } from "./deep-agent.js";
10
+ import { malformedToolCallMiddleware } from "./malformed-tool-call-middleware.js";
11
+ import type { BotContext } from "./context.js";
12
+
13
+ export interface SubAgentOptions {
14
+ /** Agent kind: react or deep. */
15
+ kind: "react" | "deep";
16
+ /** Agent name from config (default: same as kind). */
17
+ agentName?: string;
18
+ /** Override system prompt for this child. */
19
+ systemPrompt?: string;
20
+ /** Max steps (react) or recursion limit (deep) for the child. */
21
+ maxSteps?: number;
22
+ }
23
+
24
+ export interface SubAgentResult {
25
+ text: string;
26
+ messages?: unknown;
27
+ }
28
+
29
+ export interface SubAgentRunner {
30
+ /**
31
+ * Spawn a child agent, run it with the given message, and return the result.
32
+ * The child reuses the parent's BotContext (LLM, tools, memory, config).
33
+ */
34
+ run(message: string, options: SubAgentOptions): Promise<SubAgentResult>;
35
+ }
36
+
37
+ /**
38
+ * Create a SubAgentRunner bound to a parent BotContext.
39
+ *
40
+ * Usage:
41
+ * ```ts
42
+ * const sub = createSubAgentRunner(parentCtx);
43
+ * const result = await sub.run("Summarize the logs", { kind: "react" });
44
+ * console.log(result.text);
45
+ * ```
46
+ */
47
+ export function createSubAgentRunner(parentCtx: BotContext): SubAgentRunner {
48
+ const publish = (name: string, from: string, to: string, payload?: unknown) => {
49
+ parentCtx.events.publish({ name, from, to, payload });
50
+ };
51
+
52
+ return {
53
+ async run(message: string, options: SubAgentOptions): Promise<SubAgentResult> {
54
+ const { kind, agentName, systemPrompt, maxSteps } = options;
55
+ const childName = agentName ?? kind;
56
+
57
+ publish("agent.sub.spawn", "parent", childName, { kind, message: message.slice(0, 100) });
58
+
59
+ if (kind === "react") {
60
+ const runtime = createReactAgent(parentCtx, {
61
+ agentName: childName,
62
+ systemPrompt,
63
+ maxSteps,
64
+ middleware: [malformedToolCallMiddleware()],
65
+ });
66
+ const result = await runtime.run(message);
67
+ publish("agent.sub.done", childName, "parent", { text: result.text.slice(0, 200) });
68
+ return result;
69
+ }
70
+
71
+ // deep
72
+ const runtime = await createDeepAgent(parentCtx, {
73
+ agentName: childName,
74
+ systemPrompt,
75
+ recursionLimit: maxSteps,
76
+ });
77
+ const result = await runtime.run(message);
78
+ publish("agent.sub.done", childName, "parent", { text: result.text.slice(0, 200) });
79
+ return result;
80
+ },
81
+ };
82
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true,
12
+ "declarationMap": true,
13
+ "sourceMap": true,
14
+ "resolveJsonModule": true
15
+ },
16
+ "include": ["src/**/*"],
17
+ "exclude": ["node_modules", "dist"]
18
+ }