skyloom 1.4.0

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 (225) hide show
  1. package/.github/workflows/ci.yml +36 -0
  2. package/CONVERSION_PLAN.md +191 -0
  3. package/README.md +67 -0
  4. package/dist/agents/dew.d.ts +15 -0
  5. package/dist/agents/dew.d.ts.map +1 -0
  6. package/dist/agents/dew.js +74 -0
  7. package/dist/agents/dew.js.map +1 -0
  8. package/dist/agents/fair.d.ts +15 -0
  9. package/dist/agents/fair.d.ts.map +1 -0
  10. package/dist/agents/fair.js +106 -0
  11. package/dist/agents/fair.js.map +1 -0
  12. package/dist/agents/fog.d.ts +15 -0
  13. package/dist/agents/fog.d.ts.map +1 -0
  14. package/dist/agents/fog.js +52 -0
  15. package/dist/agents/fog.js.map +1 -0
  16. package/dist/agents/frost.d.ts +15 -0
  17. package/dist/agents/frost.d.ts.map +1 -0
  18. package/dist/agents/frost.js +54 -0
  19. package/dist/agents/frost.js.map +1 -0
  20. package/dist/agents/rain.d.ts +15 -0
  21. package/dist/agents/rain.d.ts.map +1 -0
  22. package/dist/agents/rain.js +54 -0
  23. package/dist/agents/rain.js.map +1 -0
  24. package/dist/agents/snow.d.ts +27 -0
  25. package/dist/agents/snow.d.ts.map +1 -0
  26. package/dist/agents/snow.js +226 -0
  27. package/dist/agents/snow.js.map +1 -0
  28. package/dist/cli/main.d.ts +7 -0
  29. package/dist/cli/main.d.ts.map +1 -0
  30. package/dist/cli/main.js +402 -0
  31. package/dist/cli/main.js.map +1 -0
  32. package/dist/cli/mode.d.ts +17 -0
  33. package/dist/cli/mode.d.ts.map +1 -0
  34. package/dist/cli/mode.js +56 -0
  35. package/dist/cli/mode.js.map +1 -0
  36. package/dist/core/agent.d.ts +174 -0
  37. package/dist/core/agent.d.ts.map +1 -0
  38. package/dist/core/agent.js +1332 -0
  39. package/dist/core/agent.js.map +1 -0
  40. package/dist/core/agent_helpers.d.ts +51 -0
  41. package/dist/core/agent_helpers.d.ts.map +1 -0
  42. package/dist/core/agent_helpers.js +477 -0
  43. package/dist/core/agent_helpers.js.map +1 -0
  44. package/dist/core/bus.d.ts +99 -0
  45. package/dist/core/bus.d.ts.map +1 -0
  46. package/dist/core/bus.js +191 -0
  47. package/dist/core/bus.js.map +1 -0
  48. package/dist/core/cache.d.ts +63 -0
  49. package/dist/core/cache.d.ts.map +1 -0
  50. package/dist/core/cache.js +121 -0
  51. package/dist/core/cache.js.map +1 -0
  52. package/dist/core/checkpoint.d.ts +19 -0
  53. package/dist/core/checkpoint.d.ts.map +1 -0
  54. package/dist/core/checkpoint.js +120 -0
  55. package/dist/core/checkpoint.js.map +1 -0
  56. package/dist/core/circuit_breaker.d.ts +46 -0
  57. package/dist/core/circuit_breaker.d.ts.map +1 -0
  58. package/dist/core/circuit_breaker.js +99 -0
  59. package/dist/core/circuit_breaker.js.map +1 -0
  60. package/dist/core/config.d.ts +97 -0
  61. package/dist/core/config.d.ts.map +1 -0
  62. package/dist/core/config.js +281 -0
  63. package/dist/core/config.js.map +1 -0
  64. package/dist/core/constants.d.ts +78 -0
  65. package/dist/core/constants.d.ts.map +1 -0
  66. package/dist/core/constants.js +84 -0
  67. package/dist/core/constants.js.map +1 -0
  68. package/dist/core/factory.d.ts +63 -0
  69. package/dist/core/factory.d.ts.map +1 -0
  70. package/dist/core/factory.js +537 -0
  71. package/dist/core/factory.js.map +1 -0
  72. package/dist/core/icons.d.ts +28 -0
  73. package/dist/core/icons.d.ts.map +1 -0
  74. package/dist/core/icons.js +86 -0
  75. package/dist/core/icons.js.map +1 -0
  76. package/dist/core/index.d.ts +29 -0
  77. package/dist/core/index.d.ts.map +1 -0
  78. package/dist/core/index.js +54 -0
  79. package/dist/core/index.js.map +1 -0
  80. package/dist/core/llm.d.ts +121 -0
  81. package/dist/core/llm.d.ts.map +1 -0
  82. package/dist/core/llm.js +532 -0
  83. package/dist/core/llm.js.map +1 -0
  84. package/dist/core/logger.d.ts +57 -0
  85. package/dist/core/logger.d.ts.map +1 -0
  86. package/dist/core/logger.js +122 -0
  87. package/dist/core/logger.js.map +1 -0
  88. package/dist/core/mcp.d.ts +190 -0
  89. package/dist/core/mcp.d.ts.map +1 -0
  90. package/dist/core/mcp.js +822 -0
  91. package/dist/core/mcp.js.map +1 -0
  92. package/dist/core/mcp_server.d.ts +26 -0
  93. package/dist/core/mcp_server.d.ts.map +1 -0
  94. package/dist/core/mcp_server.js +211 -0
  95. package/dist/core/mcp_server.js.map +1 -0
  96. package/dist/core/memory.d.ts +190 -0
  97. package/dist/core/memory.d.ts.map +1 -0
  98. package/dist/core/memory.js +988 -0
  99. package/dist/core/memory.js.map +1 -0
  100. package/dist/core/middleware.d.ts +114 -0
  101. package/dist/core/middleware.d.ts.map +1 -0
  102. package/dist/core/middleware.js +248 -0
  103. package/dist/core/middleware.js.map +1 -0
  104. package/dist/core/pipelines.d.ts +87 -0
  105. package/dist/core/pipelines.d.ts.map +1 -0
  106. package/dist/core/pipelines.js +301 -0
  107. package/dist/core/pipelines.js.map +1 -0
  108. package/dist/core/profile.d.ts +23 -0
  109. package/dist/core/profile.d.ts.map +1 -0
  110. package/dist/core/profile.js +289 -0
  111. package/dist/core/profile.js.map +1 -0
  112. package/dist/core/router.d.ts +24 -0
  113. package/dist/core/router.d.ts.map +1 -0
  114. package/dist/core/router.js +111 -0
  115. package/dist/core/router.js.map +1 -0
  116. package/dist/core/schemas.d.ts +82 -0
  117. package/dist/core/schemas.d.ts.map +1 -0
  118. package/dist/core/schemas.js +200 -0
  119. package/dist/core/schemas.js.map +1 -0
  120. package/dist/core/semantic.d.ts +92 -0
  121. package/dist/core/semantic.d.ts.map +1 -0
  122. package/dist/core/semantic.js +175 -0
  123. package/dist/core/semantic.js.map +1 -0
  124. package/dist/core/skill.d.ts +68 -0
  125. package/dist/core/skill.d.ts.map +1 -0
  126. package/dist/core/skill.js +350 -0
  127. package/dist/core/skill.js.map +1 -0
  128. package/dist/core/tool.d.ts +99 -0
  129. package/dist/core/tool.d.ts.map +1 -0
  130. package/dist/core/tool.js +341 -0
  131. package/dist/core/tool.js.map +1 -0
  132. package/dist/core/tool_router.d.ts +29 -0
  133. package/dist/core/tool_router.d.ts.map +1 -0
  134. package/dist/core/tool_router.js +172 -0
  135. package/dist/core/tool_router.js.map +1 -0
  136. package/dist/core/workspace.d.ts +48 -0
  137. package/dist/core/workspace.d.ts.map +1 -0
  138. package/dist/core/workspace.js +179 -0
  139. package/dist/core/workspace.js.map +1 -0
  140. package/dist/plugins/loader.d.ts +17 -0
  141. package/dist/plugins/loader.d.ts.map +1 -0
  142. package/dist/plugins/loader.js +96 -0
  143. package/dist/plugins/loader.js.map +1 -0
  144. package/dist/skills/loader.d.ts +9 -0
  145. package/dist/skills/loader.d.ts.map +1 -0
  146. package/dist/skills/loader.js +78 -0
  147. package/dist/skills/loader.js.map +1 -0
  148. package/dist/tools/builtin.d.ts +10 -0
  149. package/dist/tools/builtin.d.ts.map +1 -0
  150. package/dist/tools/builtin.js +414 -0
  151. package/dist/tools/builtin.js.map +1 -0
  152. package/dist/tools/computer.d.ts +12 -0
  153. package/dist/tools/computer.d.ts.map +1 -0
  154. package/dist/tools/computer.js +326 -0
  155. package/dist/tools/computer.js.map +1 -0
  156. package/dist/tools/delegate.d.ts +10 -0
  157. package/dist/tools/delegate.d.ts.map +1 -0
  158. package/dist/tools/delegate.js +45 -0
  159. package/dist/tools/delegate.js.map +1 -0
  160. package/dist/web/server.d.ts +5 -0
  161. package/dist/web/server.d.ts.map +1 -0
  162. package/dist/web/server.js +647 -0
  163. package/dist/web/server.js.map +1 -0
  164. package/dist/web/tts.d.ts +33 -0
  165. package/dist/web/tts.d.ts.map +1 -0
  166. package/dist/web/tts.js +69 -0
  167. package/dist/web/tts.js.map +1 -0
  168. package/package.json +60 -0
  169. package/scripts/install.js +48 -0
  170. package/scripts/link.js +10 -0
  171. package/setup.bat +79 -0
  172. package/skill-test-ty2fOA/test.md +10 -0
  173. package/src/agents/dew.ts +70 -0
  174. package/src/agents/fair.ts +102 -0
  175. package/src/agents/fog.ts +48 -0
  176. package/src/agents/frost.ts +50 -0
  177. package/src/agents/rain.ts +50 -0
  178. package/src/agents/snow.ts +239 -0
  179. package/src/cli/main.ts +405 -0
  180. package/src/cli/mode.ts +58 -0
  181. package/src/core/agent.ts +1506 -0
  182. package/src/core/agent_helpers.ts +461 -0
  183. package/src/core/bus.ts +221 -0
  184. package/src/core/cache.ts +153 -0
  185. package/src/core/checkpoint.ts +94 -0
  186. package/src/core/circuit_breaker.ts +119 -0
  187. package/src/core/config.ts +341 -0
  188. package/src/core/constants.ts +95 -0
  189. package/src/core/factory.ts +627 -0
  190. package/src/core/icons.ts +53 -0
  191. package/src/core/index.ts +31 -0
  192. package/src/core/llm.ts +724 -0
  193. package/src/core/logger.ts +144 -0
  194. package/src/core/mcp.ts +953 -0
  195. package/src/core/mcp_server.ts +176 -0
  196. package/src/core/memory.ts +1169 -0
  197. package/src/core/middleware.ts +350 -0
  198. package/src/core/pipelines.ts +424 -0
  199. package/src/core/profile.ts +255 -0
  200. package/src/core/router.ts +124 -0
  201. package/src/core/schemas.ts +282 -0
  202. package/src/core/semantic.ts +211 -0
  203. package/src/core/skill.ts +342 -0
  204. package/src/core/tool.ts +427 -0
  205. package/src/core/tool_router.ts +193 -0
  206. package/src/core/workspace.ts +150 -0
  207. package/src/plugins/loader.ts +66 -0
  208. package/src/skills/loader.ts +46 -0
  209. package/src/sql.js.d.ts +29 -0
  210. package/src/tools/builtin.ts +382 -0
  211. package/src/tools/computer.ts +269 -0
  212. package/src/tools/delegate.ts +49 -0
  213. package/src/web/server.ts +634 -0
  214. package/src/web/tts.ts +93 -0
  215. package/tests/bus.test.ts +121 -0
  216. package/tests/icons.test.ts +45 -0
  217. package/tests/router.test.ts +86 -0
  218. package/tests/schemas.test.ts +51 -0
  219. package/tests/semantic.test.ts +83 -0
  220. package/tests/setup.ts +10 -0
  221. package/tests/skill.test.ts +172 -0
  222. package/tests/tool.test.ts +108 -0
  223. package/tests/tool_router.test.ts +71 -0
  224. package/tsconfig.json +37 -0
  225. package/vitest.config.ts +17 -0
@@ -0,0 +1,1506 @@
1
+ /**
2
+ * Base agent class for all Skyloom agents.
3
+ *
4
+ * Provides the core LLM reasoning loop, tool execution, memory management,
5
+ * skill activation, and inter-agent communication.
6
+ */
7
+
8
+ import { Event, EventType, MessageBus } from './bus';
9
+ import { TASK_DONE_SENTINEL } from './constants';
10
+ import { LLMClient, type LLMResponse, type ToolCall } from './llm';
11
+ import { getLogger } from './logger';
12
+ import { Memory, Message } from './memory';
13
+ import { Skill, SkillRegistry } from './skill';
14
+ import { type ToolDefinition, ToolRegistry } from './tool';
15
+ import {
16
+ parseToolArgs,
17
+ looksLikeFailedToolResult,
18
+ extractFilePathsFromMessages,
19
+ enrichResponseWithArtifacts,
20
+ toolCallSignature,
21
+ textSimilarity,
22
+ formatArgsParseError,
23
+ suggestToolNames,
24
+ toolStatusLabel,
25
+ synthesizeDelegationSummary,
26
+ SIG_WINDOW,
27
+ SIG_LOOP_HINT,
28
+ SIG_LOOP_HARDSTOP,
29
+ } from './agent_helpers';
30
+ import { selectRelevantTools } from './tool_router';
31
+
32
+ const log = getLogger('agent');
33
+
34
+ export enum AgentState {
35
+ IDLE = 'idle',
36
+ THINKING = 'thinking',
37
+ ACTING = 'acting',
38
+ WAITING = 'waiting',
39
+ ERROR = 'error',
40
+ }
41
+
42
+ export enum TaskState {
43
+ PENDING = 'pending',
44
+ RUNNING = 'running',
45
+ COMPLETED = 'completed',
46
+ FAILED = 'failed',
47
+ SKIPPED = 'skipped',
48
+ }
49
+
50
+ const VALID_TRANSITIONS: Record<TaskState, Set<TaskState>> = {
51
+ [TaskState.PENDING]: new Set([TaskState.RUNNING, TaskState.SKIPPED, TaskState.FAILED]),
52
+ [TaskState.RUNNING]: new Set([TaskState.RUNNING, TaskState.COMPLETED, TaskState.FAILED]),
53
+ [TaskState.FAILED]: new Set([TaskState.RUNNING, TaskState.SKIPPED]),
54
+ [TaskState.COMPLETED]: new Set(),
55
+ [TaskState.SKIPPED]: new Set(),
56
+ };
57
+
58
+ export class Task {
59
+ id: string;
60
+ description: string;
61
+ assignedTo: string | null = null;
62
+ parentId: string | null = null;
63
+ dependsOn: string[] = [];
64
+ status: TaskState = TaskState.PENDING;
65
+ priority: number = 0;
66
+ result: string | null = null;
67
+ metadata: Record<string, any> = {};
68
+
69
+ constructor(config: {
70
+ id: string;
71
+ description: string;
72
+ assignedTo?: string | null;
73
+ parentId?: string | null;
74
+ dependsOn?: string[];
75
+ status?: TaskState;
76
+ priority?: number;
77
+ result?: string | null;
78
+ metadata?: Record<string, any>;
79
+ }) {
80
+ this.id = config.id;
81
+ this.description = config.description;
82
+ this.assignedTo = config.assignedTo ?? null;
83
+ this.parentId = config.parentId ?? null;
84
+ this.dependsOn = config.dependsOn || [];
85
+ this.status = config.status ?? TaskState.PENDING;
86
+ this.priority = config.priority ?? 0;
87
+ this.result = config.result ?? null;
88
+ this.metadata = config.metadata || {};
89
+ }
90
+
91
+ transitionTo(newState: TaskState): void {
92
+ const allowed = VALID_TRANSITIONS[this.status] || new Set();
93
+ if (!allowed.has(newState)) {
94
+ throw new Error(
95
+ `Invalid task state transition: ${this.status} -> ${newState}`
96
+ );
97
+ }
98
+ this.status = newState;
99
+ }
100
+
101
+ get allDeps(): string[] {
102
+ const deps = [...this.dependsOn];
103
+ if (this.parentId && !deps.includes(this.parentId)) {
104
+ deps.push(this.parentId);
105
+ }
106
+ return deps;
107
+ }
108
+ }
109
+
110
+ export class TaskResult {
111
+ success: boolean;
112
+ content: string;
113
+ data: Record<string, any> = {};
114
+
115
+ constructor(success: boolean, content: string, data?: Record<string, any>) {
116
+ this.success = success;
117
+ this.content = content;
118
+ this.data = data || {};
119
+ }
120
+ }
121
+
122
+ // Re-export Message type from memory for convenience
123
+ export type { Message };
124
+
125
+ export class BaseAgent {
126
+ name: string = '';
127
+ displayName: string = '';
128
+ emoji: string = '';
129
+ specialty: string = '';
130
+ systemPrompt: string = '';
131
+ toolNames: string[] = [];
132
+ skillNames: string[] = [];
133
+
134
+ protected config: any; // SkyloomConfig type
135
+ protected llm: LLMClient;
136
+ protected bus: MessageBus;
137
+ protected toolRegistry: ToolRegistry;
138
+ protected skillRegistry: SkillRegistry;
139
+ public state: AgentState = AgentState.IDLE;
140
+ public memory: Memory;
141
+ protected _tools: ToolDefinition[] = [];
142
+ protected _skills: Skill[] = [];
143
+ protected _activeSkills: Set<string> = new Set();
144
+ protected _skillTools: Map<string, string[]> = new Map();
145
+ protected _skillConfigOverrides: Map<string, Record<string, any>> = new Map();
146
+ protected _baseSystemPrompt: string = '';
147
+ protected _maxToolRounds: number = 20;
148
+ protected _maxToolRoundsHardCap: number = 40;
149
+ protected _userTurnsSinceExtract: number = 0;
150
+ protected _pendingExtracts: Set<Promise<any>> = new Set();
151
+ protected _pendingRequests: Map<string, { resolve: (value: string) => void; reject: (err: Error) => void }> = new Map();
152
+ protected _bgTasks: Set<Promise<void>> = new Set();
153
+ approvalCallback: ((toolName: string, args: Record<string, any>) => Promise<boolean>) | null = null;
154
+ protected _turnLock: Promise<void> = Promise.resolve();
155
+ private _turnLockCounter: number = 0;
156
+ private _turnLockResolve: (() => void) | null = null;
157
+
158
+ // Time-tag cache (shared across all instances, 30s TTL)
159
+ private static _timeTag: string | null = null;
160
+ private static _timeTagTs: number = 0.0;
161
+
162
+ constructor(
163
+ config: any,
164
+ llm: LLMClient,
165
+ bus: MessageBus,
166
+ toolRegistry: ToolRegistry,
167
+ skillRegistry?: SkillRegistry | null
168
+ ) {
169
+ this.config = config;
170
+ this.llm = llm;
171
+ this.bus = bus;
172
+ this.toolRegistry = toolRegistry;
173
+ this.skillRegistry = skillRegistry || new SkillRegistry();
174
+ this.memory = new Memory((config as any).memory || { dbPath: '~/.skyloom', shortTermLimit: 100 }, this.name);
175
+ this._maxToolRounds = 20;
176
+ }
177
+
178
+ // ── System prompt resolution ──
179
+
180
+ protected resolveSystemPrompt(): string {
181
+ // Custom persona loading
182
+ try {
183
+ const { loadPersona } = require('./profile');
184
+ const custom = loadPersona(this.name);
185
+ if (custom) return custom;
186
+ } catch { /* ignore */ }
187
+
188
+ const lang = (this.config as any).llm?.language || 'zh';
189
+ if (lang === 'en' && (this as any).systemPromptEn) {
190
+ return (this as any).systemPromptEn;
191
+ }
192
+ return this.systemPrompt;
193
+ }
194
+
195
+ protected injectWorkspaceInfo(prompt: string): string {
196
+ try {
197
+ const { resolveWorkspacePath, initWorkspace } = require('./workspace');
198
+ const wsRoot = resolveWorkspacePath((this.config as any).workspace?.path || 'auto');
199
+ initWorkspace(wsRoot);
200
+ const lang = (this.config as any).llm?.language || 'zh';
201
+ if (lang === 'en') {
202
+ return prompt + `\n\n## Workspace\n\`${wsRoot}\` — write to \`files/\`, \`output/\`, \`temp/\`. Prefer workspace paths for all file ops.`;
203
+ }
204
+ return prompt + `\n\n## 工作空间\n\`${wsRoot}\` — 产物写到 \`files/\` / \`output/\` / \`temp/\`。文件操作优先用此路径。`;
205
+ } catch {
206
+ return prompt;
207
+ }
208
+ }
209
+
210
+ protected currentTimeTag(): string {
211
+ const now = Date.now() / 1000;
212
+ if (BaseAgent._timeTag !== null && now - BaseAgent._timeTagTs < 30) {
213
+ return BaseAgent._timeTag;
214
+ }
215
+ const date = new Date();
216
+ const tag = `Today is ${date.toISOString().slice(0, 10)}. Current time: ${date.toISOString().slice(0, 19).replace('T', ' ')}.`;
217
+ BaseAgent._timeTag = tag;
218
+ BaseAgent._timeTagTs = now;
219
+ return tag;
220
+ }
221
+
222
+ protected injectBehaviorRules(prompt: string): string {
223
+ const lang = (this.config as any).llm?.language || 'zh';
224
+ if (lang === 'en') {
225
+ return prompt + `\n\n## Behavior\n- Act, don't narrate. No "I will..." before tool calls.\n- Stay in scope. Do what's asked, then stop.\n- Batch independent tool calls in one response.\n- Verify writes: read back, report verified state.\n- Call list_skills when the task needs specialized capabilities.`;
226
+ }
227
+ return prompt + `\n\n## 行为守则\n- 直接行动,不预告。不说「我将要...」,直接调用工具\n- 不擅自扩大范围。用户要什么做什么,核心完成即止\n- 独立的工具调用一次发出,并行执行\n- 写入后回读验证,汇报已验证状态而非仅尝试\n- 任务涉及专业能力时(PPT/Excel/PDF/网页设计/代码审查等),先调 list_skills 查看可用技能,再用 use_skill 激活`;
228
+ }
229
+
230
+ protected injectProgrammingWisdom(prompt: string): string {
231
+ const lang = (this.config as any).llm?.language || 'zh';
232
+ if (lang === 'en') {
233
+ return prompt + `\n\n## Engineering\nTop-tier engineer: type-safe code, real error handling, debugging by root cause, reviewing for security & perf.`;
234
+ }
235
+ return prompt + `\n\n## 工程能力\n顶级工程师:类型安全、真实的错误处理、按根因调试、按安全与性能审查。你可以阅读和修改 Skyloom 自身源码。`;
236
+ }
237
+
238
+ reinitLanguage(): void {
239
+ this._baseSystemPrompt = '';
240
+ this._baseSystemPrompt = this.resolveSystemPrompt();
241
+ this._baseSystemPrompt = this.injectWorkspaceInfo(this._baseSystemPrompt);
242
+ this._baseSystemPrompt = this.injectBehaviorRules(this._baseSystemPrompt);
243
+ this._baseSystemPrompt = this.injectProgrammingWisdom(this._baseSystemPrompt);
244
+ this._baseSystemPrompt += '\n\n' + this.currentTimeTag();
245
+ this.rebuildSystemPrompt();
246
+ }
247
+
248
+ async init(): Promise<void> {
249
+ if (this._baseSystemPrompt) return;
250
+ await this.memory.initDb();
251
+
252
+ if (this.memory.getActiveSession() === null) {
253
+ const resumed = process.env.WA_NO_RESUME !== '1'
254
+ ? await this.memory.resumeLatestSession()
255
+ : null;
256
+ if (resumed === null) {
257
+ await this.memory.createSession();
258
+ }
259
+ }
260
+
261
+ this._baseSystemPrompt = this.resolveSystemPrompt();
262
+ this._baseSystemPrompt = this.injectWorkspaceInfo(this._baseSystemPrompt);
263
+ this._baseSystemPrompt = this.injectBehaviorRules(this._baseSystemPrompt);
264
+ this._baseSystemPrompt = this.injectProgrammingWisdom(this._baseSystemPrompt);
265
+ this._baseSystemPrompt += '\n\n' + this.currentTimeTag();
266
+ this.rebuildSystemPrompt();
267
+ this._tools = this.toolRegistry.getTools();
268
+ this.loadSkills();
269
+ this.bus.subscribe(this.name, this.handleEvent.bind(this));
270
+ }
271
+
272
+ refreshTools(): void {
273
+ this._tools = this.toolRegistry.getTools();
274
+ }
275
+
276
+ loadSkills(): void {
277
+ this._skills = this.skillRegistry.getSkills();
278
+ this.registerSkillTools();
279
+ }
280
+
281
+ registerSkillTools(): void {
282
+ if (this.toolRegistry.get('use_skill')) return;
283
+
284
+ const self = this;
285
+
286
+ this.toolRegistry.register({
287
+ name: 'list_skills',
288
+ description: 'List all available skills with their names and descriptions. Use this first to discover what skills you can activate.',
289
+ parameters: [],
290
+ handler: async () => {
291
+ const skills = self.getAvailableSkills();
292
+ if (!skills.length) return 'No skills available.';
293
+ const maxName = Math.max(...skills.map(s => s.name.length), 1);
294
+ const lines = skills.map(s => {
295
+ const name = s.name.padEnd(maxName);
296
+ const active = s.active ? ' ★' : '';
297
+ return ` ${name} — ${s.description}${active}`;
298
+ });
299
+ return 'Available skills:\n' + lines.join('\n');
300
+ },
301
+ });
302
+
303
+ this.toolRegistry.register({
304
+ name: 'use_skill',
305
+ description: 'Activate a named skill to gain specialized capabilities. Call list_skills first.',
306
+ parameters: [{
307
+ name: 'name',
308
+ type: 'string',
309
+ description: 'The name of the skill to activate',
310
+ required: true,
311
+ }],
312
+ handler: async (kwargs: Record<string, any>) => {
313
+ const name = kwargs.name as string;
314
+ if (self.activateSkill(name)) {
315
+ const skill = self._skills.find(s => s.name === name);
316
+ const desc = skill?.description || '';
317
+ return `✓ Skill '${name}' activated: ${desc}`;
318
+ }
319
+ return `✗ Skill '${name}' not found. Call list_skills to see available options.`;
320
+ },
321
+ });
322
+
323
+ this.toolRegistry.register({
324
+ name: 'extend_rounds',
325
+ description: 'Extend the tool-call budget for the current turn.',
326
+ parameters: [{
327
+ name: 'n',
328
+ type: 'number',
329
+ description: 'Number of additional rounds to add (default 10)',
330
+ required: false,
331
+ }],
332
+ handler: async (kwargs: Record<string, any>) => {
333
+ const n = (kwargs.n as number) || 10;
334
+ const old = this._maxToolRounds;
335
+ this._maxToolRounds += n;
336
+ return `✓ Tool-round limit extended by ${n} (was ${old}, now ${this._maxToolRounds}).`;
337
+ },
338
+ });
339
+ }
340
+
341
+ activateSkill(name: string): boolean {
342
+ let skill = this._skills.find(s => s.name === name);
343
+ if (!skill) {
344
+ const globalSkill = this.skillRegistry.get(name);
345
+ if (globalSkill) {
346
+ this._skills.push(globalSkill);
347
+ skill = globalSkill;
348
+ }
349
+ }
350
+ if (!skill) return false;
351
+
352
+ this._activeSkills.add(name);
353
+ if (skill.handler) {
354
+ const handlerTools = skill.handler(this, this.toolRegistry);
355
+ if (handlerTools) {
356
+ this._skillTools.set(name, handlerTools.map((t: any) => t.name));
357
+ }
358
+ }
359
+
360
+ const overrides: Record<string, any> = {};
361
+ if (skill.model) overrides.model = skill.model;
362
+ if (skill.temperature != null) overrides.temperature = skill.temperature;
363
+ if (skill.maxTokens != null) overrides.maxTokens = skill.maxTokens;
364
+ if (Object.keys(overrides).length > 0) {
365
+ this._skillConfigOverrides.set(name, overrides);
366
+ }
367
+
368
+ this.rebuildSystemPrompt();
369
+ return true;
370
+ }
371
+
372
+ deactivateSkill(name: string): boolean {
373
+ if (!this._activeSkills.has(name)) return false;
374
+ this._activeSkills.delete(name);
375
+
376
+ const toolNames = this._skillTools.get(name);
377
+ if (toolNames) {
378
+ for (const tn of toolNames) {
379
+ this.toolRegistry.unregister(tn);
380
+ }
381
+ this._skillTools.delete(name);
382
+ }
383
+ this._skillConfigOverrides.delete(name);
384
+ this.rebuildSystemPrompt();
385
+ return true;
386
+ }
387
+
388
+ deactivateAllSkills(): void {
389
+ for (const name of [...this._activeSkills]) {
390
+ this.deactivateSkill(name);
391
+ }
392
+ }
393
+
394
+ protected autoActivateSkills(message: string): string[] {
395
+ if (!message) return [];
396
+ const lowered = message.toLowerCase();
397
+ const candidates = [...this._skills];
398
+ for (const s of this.skillRegistry.getSkills()) {
399
+ if (!candidates.find(c => c.name === s.name)) {
400
+ candidates.push(s);
401
+ }
402
+ }
403
+
404
+ const activated: string[] = [];
405
+ for (const skill of candidates) {
406
+ if (this._activeSkills.has(skill.name)) continue;
407
+ if (!skill.triggers || !skill.triggers.length) continue;
408
+ for (const trig of skill.triggers) {
409
+ if (trig && lowered.includes(trig.toLowerCase())) {
410
+ if (this.activateSkill(skill.name)) {
411
+ activated.push(skill.name);
412
+ }
413
+ break;
414
+ }
415
+ }
416
+ }
417
+ return activated;
418
+ }
419
+
420
+ protected runtimeIdentityBlock(): string {
421
+ const lang = (this.config as any).llm?.language || 'zh';
422
+ let model = (this.config as any).llm?.defaultModel || 'gpt-4o';
423
+ try {
424
+ const agentCfg = (this.config as any).agents?.[this.name];
425
+ if (agentCfg?.model) model = agentCfg.model;
426
+ } catch { /* ignore */ }
427
+
428
+ let userBlock = '';
429
+ try {
430
+ const { formatProfileForPrompt, formatMemoriesForPrompt } = require('./profile');
431
+ userBlock = formatProfileForPrompt(lang) + formatMemoriesForPrompt(lang);
432
+ } catch { /* ignore */ }
433
+
434
+ if (lang === 'en') {
435
+ return `\n\n## Runtime\nYou are the ${this.displayName} agent in Skyloom, powered by the **${model}** language model. Always reply in English unless the user clearly writes in another language.` + userBlock;
436
+ }
437
+ return `\n\n## 运行环境\n你是 Skyloom 中的「${this.displayName}」智能体,底层语言模型为 **${model}**。默认始终用中文回复;除非用户明确用其他语言提问,才用对应语言。` + userBlock;
438
+ }
439
+
440
+ protected rebuildSystemPrompt(): void {
441
+ const identity = this.runtimeIdentityBlock();
442
+ let prompt: string;
443
+
444
+ if (this._activeSkills.size === 0) {
445
+ prompt = this._baseSystemPrompt + identity;
446
+ } else {
447
+ const byName = new Map(this._skills.map(s => [s.name, s]));
448
+ const skillPrompts: string[] = [];
449
+ const lang = (this.config as any).llm?.language || 'zh';
450
+
451
+ for (const name of [...this._activeSkills].sort()) {
452
+ const s = byName.get(name);
453
+ if (!s) continue;
454
+ const parts: string[] = [];
455
+ if (s.systemPrompt) parts.push(s.systemPrompt);
456
+ if (s.bodyTruncated && s.sourcePath) {
457
+ parts.push(lang === 'en'
458
+ ? `[Lazy-loaded skill: full guide at \`${s.sourcePath}\`]`
459
+ : `[此技能为懒加载:完整指南位于 \`${s.sourcePath}\`]`);
460
+ }
461
+ if (s.resourceDir) {
462
+ parts.push(lang === 'en' ? `Resource directory: ${s.resourceDir}` : `资源目录: ${s.resourceDir}`);
463
+ }
464
+ skillPrompts.push(parts.join('\n\n'));
465
+ }
466
+
467
+ prompt = this._baseSystemPrompt;
468
+ if (skillPrompts.length > 0) {
469
+ prompt += '\n\n' + skillPrompts.join('\n\n');
470
+ }
471
+ prompt += identity;
472
+ }
473
+
474
+ // Find and replace system message, or add one
475
+ for (const msg of this.memory.shortTerm) {
476
+ if (msg.role === 'system') {
477
+ msg.content = prompt;
478
+ return;
479
+ }
480
+ }
481
+ this.memory.addMessage('system', prompt);
482
+ }
483
+
484
+ getActiveSkills(): string[] {
485
+ return [...this._activeSkills];
486
+ }
487
+
488
+ getSkillConfigOverrides(): Record<string, any> {
489
+ const merged: Record<string, any> = {};
490
+ for (const overrides of this._skillConfigOverrides.values()) {
491
+ Object.assign(merged, overrides);
492
+ }
493
+ return merged;
494
+ }
495
+
496
+ getAvailableSkills(): Array<{ name: string; description: string; active: boolean }> {
497
+ return this._skills.map(s => ({
498
+ name: s.name,
499
+ description: s.description,
500
+ active: this._activeSkills.has(s.name),
501
+ }));
502
+ }
503
+
504
+ /**
505
+ * Shared tool execution pipeline — parse, deduplicate, execute, record.
506
+ *
507
+ * Both chatStreamImpl (streaming) and llmLoop (batch) use the same tool
508
+ * execution flow. Extracting it here eliminates ~80 lines of duplicated
509
+ * Phase-A/B/C/D logic and ensures consistent behavior (dangerous-tool
510
+ * approval, dedup, error handling) across both paths.
511
+ *
512
+ * @returns Array of { tc, result, success, toolName } for each tool call
513
+ */
514
+ protected async executeToolCalls(
515
+ toolCalls: ToolCall[],
516
+ options?: {
517
+ dedupCacheable?: boolean; // Enable dedup for cacheable tools
518
+ onStatus?: (label: string) => void;
519
+ suppressedTools?: Set<string>; // Tools to mark as suppressed on error
520
+ ephemeral?: boolean; // Don't persist tool messages
521
+ }
522
+ ): Promise<Array<{ tc: ToolCall; result: string; success: boolean; toolName: string }>> {
523
+ const suppressed = options?.suppressedTools;
524
+ const ephemeral = options?.ephemeral ?? false;
525
+ const onStatus = options?.onStatus;
526
+
527
+ // Phase A: Parse all tool calls and resolve tools
528
+ const parsed = toolCalls.map((tc) => {
529
+ const toolName = tc.function.name;
530
+ const rawArgs = tc.function.arguments;
531
+ let toolArgs: Record<string, any> | null = null;
532
+ let parseError: string | null = null;
533
+
534
+ if (typeof rawArgs === 'string') {
535
+ toolArgs = parseToolArgs(rawArgs);
536
+ if (toolArgs === null) parseError = formatArgsParseError(toolName, rawArgs);
537
+ } else {
538
+ toolArgs = rawArgs;
539
+ }
540
+
541
+ this.bus.addEvent(new Event(EventType.TOOL_CALL, this.name, null, {
542
+ tool: toolName, args: toolArgs || {},
543
+ }));
544
+
545
+ const tool = this.toolRegistry.get(toolName);
546
+ const label = toolArgs ? toolStatusLabel(toolName, toolArgs) : `${toolName} (unparseable args)`;
547
+
548
+ return { tc, toolName, toolArgs, tool, parseError, label, denied: false };
549
+ });
550
+
551
+ // Phase B: Approve dangerous tools (serial — may prompt user)
552
+ const dangerousCalls = parsed.filter(p => p.tool && (p.tool as ToolDefinition).dangerous);
553
+ if (dangerousCalls.length > 0) {
554
+ for (const p of dangerousCalls) {
555
+ if (!await this.checkToolApproval(p.toolName, p.toolArgs || {})) {
556
+ p.denied = true;
557
+ }
558
+ }
559
+ }
560
+
561
+ // Build execution plan with optional dedup
562
+ const execPlan: Array<{ idx: number; prep: typeof parsed[0]; isDuplicate: boolean }> = [];
563
+ const seenDedupKeys = new Map<string, number>();
564
+
565
+ for (let i = 0; i < parsed.length; i++) {
566
+ const p = parsed[i];
567
+ // Dedup: only for cacheable, non-dangerous tools with identical args
568
+ if (options?.dedupCacheable && p.toolArgs && p.tool && (p.tool as ToolDefinition).cacheable && !(p.tool as ToolDefinition).dangerous) {
569
+ const key = `${p.toolName}:${JSON.stringify(p.toolArgs, Object.keys(p.toolArgs).sort())}`;
570
+ if (seenDedupKeys.has(key)) {
571
+ execPlan.push({ idx: i, prep: p, isDuplicate: true });
572
+ continue;
573
+ }
574
+ seenDedupKeys.set(key, i);
575
+ }
576
+ execPlan.push({ idx: i, prep: p, isDuplicate: false });
577
+ }
578
+
579
+ // Phase C: Execute all unique tool calls in parallel
580
+ const results = new Array<{ tc: ToolCall; result: string; success: boolean; toolName: string } | null>(parsed.length).fill(null);
581
+ const uniqueExecutions = execPlan
582
+ .filter(e => !e.isDuplicate)
583
+ .map(async ({ idx, prep }) => {
584
+ const p = prep;
585
+
586
+ if (p.parseError) {
587
+ return { idx, result: { tc: p.tc, result: p.parseError, success: false, toolName: p.toolName } };
588
+ }
589
+ if (p.denied) {
590
+ return { idx, result: { tc: p.tc, result: `[denied] dangerous tool '${p.toolName}' blocked`, success: false, toolName: p.toolName } };
591
+ }
592
+ if (!p.tool) {
593
+ if (suppressed) suppressed.add(p.toolName);
594
+ const suggestions = suggestToolNames(p.toolName, this.toolRegistry);
595
+ const hint = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(', ')}?` : '';
596
+ return { idx, result: { tc: p.tc, result: `Error: Tool '${p.toolName}' does not exist.${hint}`, success: false, toolName: p.toolName } };
597
+ }
598
+
599
+ if (onStatus) onStatus(p.label);
600
+ await this.setState(AgentState.ACTING);
601
+
602
+ try {
603
+ const toolResult = await this.toolRegistry.execute(p.toolName, p.toolArgs || {});
604
+ const resultStr = toolResult.result || toolResult.error || '(no output)';
605
+ return { idx, result: { tc: p.tc, result: resultStr, success: toolResult.success, toolName: p.toolName } };
606
+ } catch (e) {
607
+ return { idx, result: { tc: p.tc, result: `Tool '${p.toolName}' execution failed: ${e}`, success: false, toolName: p.toolName } };
608
+ }
609
+ });
610
+
611
+ const completed = await Promise.all(uniqueExecutions);
612
+ for (const { idx, result } of completed) {
613
+ results[idx] = result;
614
+ }
615
+
616
+ // Fill in dedup results from originals
617
+ for (const e of execPlan) {
618
+ if (e.isDuplicate && e.prep.toolArgs) {
619
+ const dedupKey = `${e.prep.toolName}:${JSON.stringify(e.prep.toolArgs, Object.keys(e.prep.toolArgs).sort())}`;
620
+ const originalIdx = seenDedupKeys.get(dedupKey);
621
+ if (originalIdx !== undefined && results[originalIdx]) {
622
+ results[e.idx] = { ...results[originalIdx]!, tc: e.prep.tc };
623
+ }
624
+ }
625
+ }
626
+
627
+ // Phase D: Record results to memory
628
+ for (const r of results) {
629
+ if (!r) continue;
630
+
631
+ if (typeof r.result === 'string' && r.result.includes('[CircuitBreakerOpen]')) {
632
+ if (suppressed) suppressed.add(r.toolName);
633
+ }
634
+
635
+ this.memory.addMessage('tool', r.result, {
636
+ name: r.toolName,
637
+ toolCallId: r.tc.id,
638
+ ephemeral,
639
+ });
640
+ }
641
+
642
+ return results.filter(Boolean) as Array<{ tc: ToolCall; result: string; success: boolean; toolName: string }>;
643
+ }
644
+
645
+ async close(): Promise<void> {
646
+ // Drain in-flight background work BEFORE closing memory
647
+ const pending = [...this._pendingExtracts];
648
+ if (pending.length > 0) {
649
+ try {
650
+ await Promise.all(pending);
651
+ } catch { /* ignore */ }
652
+ }
653
+ await this.memory.close();
654
+ this.bus.unsubscribe(this.name);
655
+ }
656
+
657
+ protected async setState(newState: AgentState): Promise<void> {
658
+ if (this.state !== newState) {
659
+ const oldState = this.state;
660
+ this.state = newState;
661
+ const event = new Event(
662
+ EventType.STATE_CHANGE,
663
+ this.name,
664
+ null,
665
+ { old_state: oldState, new_state: newState }
666
+ );
667
+ this.bus.addEvent(event);
668
+ await this.bus.notifyStateChange(event);
669
+ }
670
+ }
671
+
672
+ async handleEvent(event: Event): Promise<void> {
673
+ if (event.type === EventType.TASK_ASSIGNED && event.target === this.name) {
674
+ const task = new Task(event.data as any);
675
+ const result = await this.executeTask(task);
676
+ await this.bus.publish(new Event(
677
+ EventType.TASK_COMPLETED,
678
+ this.name,
679
+ event.source,
680
+ { task_id: task.id, success: result.success, content: result.content }
681
+ ));
682
+ } else if (event.type === EventType.AGENT_REQUEST && event.target === this.name) {
683
+ const p = this.handleRequest(event);
684
+ this._bgTasks.add(p);
685
+ p.then(() => this._bgTasks.delete(p)).catch(() => this._bgTasks.delete(p));
686
+ } else if (event.type === EventType.AGENT_RESPONSE && event.target === this.name) {
687
+ this.handleResponse(event);
688
+ }
689
+ }
690
+
691
+ async chatOneshot(
692
+ prompt: string,
693
+ options?: { model?: string; temperature?: number; maxTokens?: number }
694
+ ): Promise<string> {
695
+ const overrides: Record<string, any> = {};
696
+ if (options?.model) overrides.model = options.model;
697
+ if (options?.temperature != null) overrides.temperature = options.temperature;
698
+ if (options?.maxTokens != null) overrides.maxTokens = options.maxTokens;
699
+
700
+ const messages = [{ role: 'user', content: prompt }];
701
+ const response = await this.llm.complete(
702
+ messages,
703
+ this.name,
704
+ undefined,
705
+ false,
706
+ Object.keys(overrides).length > 0 ? overrides : undefined
707
+ );
708
+ return response.content;
709
+ }
710
+
711
+ async chat(
712
+ message: string,
713
+ onStatus?: ((status: string) => void) | null
714
+ ): Promise<string> {
715
+ return this.withTurnLock(() => this.chatImpl(message, onStatus));
716
+ }
717
+
718
+ protected async chatImpl(
719
+ message: string,
720
+ onStatus?: ((status: string) => void) | null
721
+ ): Promise<string> {
722
+ await this.setState(AgentState.THINKING);
723
+ this.memory.addMessage('user', message);
724
+
725
+ if (this.shouldAutoCompact()) {
726
+ try { await this.compact(); } catch (e) { log.warn('auto_compact_failed', { error: String(e) }); }
727
+ }
728
+
729
+ try {
730
+ if (onStatus) onStatus('thinking...');
731
+ const response = await this.llmLoop({ onStatus });
732
+ this.memory.addMessage('assistant', response.content, {
733
+ toolCalls: response.toolCalls,
734
+ reasoningContent: response.reasoningContent,
735
+ });
736
+ await this.setState(AgentState.IDLE);
737
+ this.maybeExtractFacts();
738
+ return response.content;
739
+ } catch (e) {
740
+ await this.setState(AgentState.ERROR);
741
+ this.popLastUserMessage();
742
+ this.memory.pruneToolMessages();
743
+ const errorMsg = `[${this.displayName}] Error: ${e}`;
744
+ this.memory.addMessage('assistant', errorMsg);
745
+ return errorMsg;
746
+ }
747
+ }
748
+
749
+ async *chatStream(message: string): AsyncGenerator<Record<string, any>> {
750
+ const activatedNow = this.autoActivateSkills(message);
751
+ const self = this;
752
+
753
+ try {
754
+ for await (const ev of self.chatStreamImpl(message, activatedNow.length > 0 ? activatedNow : undefined)) {
755
+ yield ev;
756
+ }
757
+ } catch (err) {
758
+ const st = this.memory.shortTerm;
759
+ if (st.length > 0 && st[st.length - 1].role === 'user') {
760
+ this.popLastUserMessage();
761
+ }
762
+ throw err;
763
+ }
764
+ }
765
+
766
+ protected async *chatStreamImpl(
767
+ message: string,
768
+ autoActivated?: string[]
769
+ ): AsyncGenerator<Record<string, any>> {
770
+ await this.setState(AgentState.THINKING);
771
+ this.memory.addMessage('user', message);
772
+ let assistantStored = false;
773
+
774
+ if (this.shouldAutoCompact()) {
775
+ try { await this.compact(); } catch (e) { log.warn('auto_compact_failed', { error: String(e) }); }
776
+ }
777
+
778
+ const delegations: Array<[string, boolean]> = [];
779
+ const suppressedTools = new Set<string>();
780
+
781
+ if (autoActivated && autoActivated.length > 0) {
782
+ suppressedTools.add('list_skills');
783
+ this.memory.addMessage('system',
784
+ '[Auto-activated skills: ' + autoActivated.join(', ') +
785
+ '] These were chosen from your message\'s keywords. Do NOT call list_skills.'
786
+ );
787
+ }
788
+
789
+ const recentToolOutcomes: boolean[] = [];
790
+ let stuckHintInjected = false;
791
+ const recentResponseTexts: string[] = [];
792
+ let repetitionHintInjected = false;
793
+ const recentToolSigs: string[] = [];
794
+ let toolLoopHintInjected = false;
795
+
796
+ let toolNamesCache: string[] | null = null;
797
+ let cacheKey: string | null = null;
798
+
799
+ const resolveToolNames = (): string[] => {
800
+ const key = JSON.stringify([[...suppressedTools].sort(), [...this._activeSkills].sort()]);
801
+ if (toolNamesCache !== null && cacheKey === key) return toolNamesCache;
802
+ let candidates = this.activeToolNames().filter(t => !suppressedTools.has(t));
803
+ const must = new Set<string>();
804
+ for (const s of this._skills) {
805
+ if (this._activeSkills.has(s.name)) {
806
+ for (const t of s.requiredTools) must.add(t);
807
+ }
808
+ }
809
+ toolNamesCache = selectRelevantTools(this.toolRegistry, candidates, message, { mustInclude: must });
810
+ cacheKey = key;
811
+ return toolNamesCache;
812
+ };
813
+
814
+ try {
815
+ let fullContent = '';
816
+ let roundLimit = this._maxToolRounds;
817
+ let roundCount = 0;
818
+
819
+ while (true) {
820
+ if (roundCount >= roundLimit) {
821
+ if (roundLimit >= this._maxToolRoundsHardCap) break;
822
+ const extendBy = Math.min(15, this._maxToolRoundsHardCap - roundLimit);
823
+ roundLimit += extendBy;
824
+ this._maxToolRounds = roundLimit;
825
+ this.memory.addMessage('system', `[Auto-extended tool-round limit by ${extendBy} to ${roundLimit}. Continue working.]`);
826
+ continue;
827
+ }
828
+ roundCount++;
829
+ roundLimit = Math.max(roundLimit, this._maxToolRounds);
830
+
831
+ const messages = await this.messagesWithRecall();
832
+ const toolNames = resolveToolNames();
833
+ const toolCallsReceived: ToolCall[] = [];
834
+ let streamingReasoning: string | undefined;
835
+ let streamUsage: any = null;
836
+ let roundContent = '';
837
+
838
+ for await (const event of this.llm.streamWithTools(
839
+ messages,
840
+ this.name,
841
+ toolNames.length > 0 ? toolNames : undefined,
842
+ toolNames.length > 0 ? this.toolRegistry : undefined,
843
+ Object.keys(this.getSkillConfigOverrides()).length > 0 ? this.getSkillConfigOverrides() : undefined
844
+ )) {
845
+ if (event.type === 'content') {
846
+ fullContent += event.text;
847
+ roundContent += event.text;
848
+ yield { type: 'content', text: event.text };
849
+ } else if (event.type === 'tool_call' && event.toolCall) {
850
+ toolCallsReceived.push(event.toolCall);
851
+ } else if (event.type === 'error') {
852
+ yield { type: 'content', text: `\n[Error: ${event.text}]` };
853
+ if (!assistantStored) this.popLastUserMessage();
854
+ await this.setState(AgentState.IDLE);
855
+ return;
856
+ } else if (event.type === 'reasoning' && event.text) {
857
+ yield { type: 'reasoning', text: event.text };
858
+ } else if (event.type === 'done') {
859
+ streamUsage = event.usage;
860
+ streamingReasoning = event.reasoningContent;
861
+ }
862
+ }
863
+
864
+ if (toolCallsReceived.length === 0) {
865
+ let finalContent = roundContent;
866
+ if (!fullContent.trim() && delegations.length > 0) {
867
+ finalContent = synthesizeDelegationSummary(delegations);
868
+ }
869
+ this.memory.addMessage('assistant', finalContent, { reasoningContent: streamingReasoning });
870
+ assistantStored = true;
871
+ await this.setState(AgentState.IDLE);
872
+ this.maybeExtractFacts();
873
+ if (finalContent !== roundContent) yield { type: 'content', text: finalContent };
874
+ yield { type: 'done' };
875
+ return;
876
+ }
877
+
878
+ // Record assistant message with tool calls
879
+ this.memory.addMessage('assistant', roundContent, {
880
+ toolCalls: toolCallsReceived,
881
+ reasoningContent: streamingReasoning,
882
+ });
883
+ assistantStored = true;
884
+
885
+ if (streamUsage) {
886
+ this.bus.addEvent(new Event(EventType.LLM_CALL, this.name, null, {
887
+ model: '', usage: streamUsage,
888
+ }));
889
+ }
890
+
891
+ // ── Execute all tools via shared pipeline ──
892
+ // Emit tool_status events before execution
893
+ for (const tc of toolCallsReceived) {
894
+ const toolName = tc.function.name;
895
+ const rawArgs = tc.function.arguments;
896
+ const toolArgs = typeof rawArgs === 'string' ? parseToolArgs(rawArgs) : rawArgs;
897
+ const label = toolArgs ? toolStatusLabel(toolName, toolArgs) : `${toolName} (unparseable args)`;
898
+ yield { type: 'tool_status', label, tool_name: toolName, args: toolArgs || {} };
899
+ }
900
+
901
+ const execResults = await this.executeToolCalls(toolCallsReceived, {
902
+ dedupCacheable: true,
903
+ suppressedTools,
904
+ });
905
+
906
+ // ── Record results with streaming ──
907
+ let taskCompleted = false;
908
+ for (const r of execResults) {
909
+ if (r.toolName === 'task_done' && r.result === TASK_DONE_SENTINEL) {
910
+ taskCompleted = true;
911
+ const tc = toolCallsReceived.find(t => t.id === r.tc.id);
912
+ const rawArgs = tc?.function?.arguments;
913
+ const args = typeof rawArgs === 'string' ? parseToolArgs(rawArgs) : rawArgs;
914
+ const summary = (args?.summary as string) || '';
915
+ const displayResult = summary ? `[Task completed: ${summary}]` : '[Task completed]';
916
+ this.memory.addMessage('tool', displayResult, { name: r.toolName, toolCallId: r.tc.id });
917
+ yield { type: 'tool_done', label: `task_done: ${summary}` || 'task_done', success: true, tool_name: 'task_done', result: displayResult };
918
+ continue;
919
+ }
920
+
921
+ const tc = toolCallsReceived.find(t => t.id === r.tc.id);
922
+ const rawArgs = tc?.function?.arguments;
923
+ const args = typeof rawArgs === 'string' ? parseToolArgs(rawArgs) : rawArgs;
924
+ const label = args ? toolStatusLabel(r.toolName, args) : r.toolName;
925
+ const truncated = (r.result || '').slice(0, 800);
926
+ yield { type: 'tool_done', label, success: r.success, tool_name: r.toolName, result: truncated };
927
+ if (r.toolName === 'delegate_to') {
928
+ const target = (args?.agent as string) || '?';
929
+ delegations.push([target, r.success]);
930
+ }
931
+ }
932
+
933
+ if (taskCompleted) {
934
+ if (!assistantStored) this.popLastUserMessage();
935
+ await this.setState(AgentState.IDLE);
936
+ yield { type: 'done' };
937
+ return;
938
+ }
939
+
940
+ // ── Narration-loop detection ──
941
+ const normalizedRound = roundContent.trim();
942
+ if (normalizedRound && recentResponseTexts.length > 0) {
943
+ const highSim = recentResponseTexts.slice(-2).some(prev => textSimilarity(normalizedRound, prev) >= 0.7);
944
+ if (highSim && !repetitionHintInjected) {
945
+ this.memory.addMessage('system', '[Stop narrating] Your last response is highly similar to your previous one. Stop writing prose. Either: (1) emit ONLY the next tool call, or (2) output the final deliverable.');
946
+ repetitionHintInjected = true;
947
+ }
948
+ }
949
+ recentResponseTexts.push(normalizedRound);
950
+ if (recentResponseTexts.length > 3) recentResponseTexts.shift();
951
+
952
+ // ── Tool-signature loop detection ──
953
+ for (const tc of toolCallsReceived) {
954
+ const tName = tc.function.name;
955
+ if (['task_done', 'list_skills', 'use_skill'].includes(tName)) continue;
956
+ const rawArgs = tc.function.arguments;
957
+ const tArgs = typeof rawArgs === 'string' ? parseToolArgs(rawArgs) : rawArgs;
958
+ const sig = toolCallSignature(tName, tArgs);
959
+ if (sig) recentToolSigs.push(sig);
960
+ }
961
+ if (recentToolSigs.length > SIG_WINDOW) {
962
+ recentToolSigs.splice(0, recentToolSigs.length - SIG_WINDOW);
963
+ }
964
+ if (recentToolSigs.length > 0) {
965
+ const counts = new Map<string, number>();
966
+ for (const s of recentToolSigs) counts.set(s, (counts.get(s) || 0) + 1);
967
+ let topSig = '';
968
+ let topCount = 0;
969
+ for (const [s, c] of counts) { if (c > topCount) { topSig = s; topCount = c; } }
970
+ if (topCount >= SIG_LOOP_HINT && !toolLoopHintInjected) {
971
+ this.memory.addMessage('system', `[Tool loop] You have called \`${topSig}\` ${topCount}x in the last ${recentToolSigs.length} tool calls — you are iterating without converging. STOP repeating it.`);
972
+ toolLoopHintInjected = true;
973
+ }
974
+ if (topCount >= SIG_LOOP_HARDSTOP) {
975
+ this.memory.addMessage('assistant', `I have repeated \`${topSig}\` ${topCount} times without converging. Stopping.`);
976
+ yield { type: 'content', text: `\n\n[stuck] tool \`${topSig}\` repeated ${topCount}x — stopping.` };
977
+ await this.setState(AgentState.IDLE);
978
+ yield { type: 'done' };
979
+ return;
980
+ }
981
+ }
982
+
983
+ // ── Stuck-loop detection ──
984
+ for (const r of execResults) {
985
+ if (!r || r.toolName === 'task_done') continue;
986
+ const failed = !r.success || (typeof r.result === 'string' && looksLikeFailedToolResult(r.result));
987
+ recentToolOutcomes.push(!failed);
988
+ if (recentToolOutcomes.length > 6) recentToolOutcomes.shift();
989
+ }
990
+
991
+ if (!stuckHintInjected && recentToolOutcomes.length >= 5 &&
992
+ recentToolOutcomes.filter(Boolean).length <= 1) {
993
+ this.memory.addMessage('system', '[Recovery hint] Your last several tool calls have mostly failed. Synthesize a partial answer from what worked or ask the user for guidance.');
994
+ stuckHintInjected = true;
995
+ }
996
+
997
+ if (recentToolOutcomes.length >= 8 && recentToolOutcomes.every(x => !x)) {
998
+ this.memory.addMessage('assistant', 'Every recent tool call failed. Please give me more context.');
999
+ yield { type: 'content', text: '\n\n[stuck] every recent tool call failed — stopping.\n' };
1000
+ await this.setState(AgentState.IDLE);
1001
+ yield { type: 'done' };
1002
+ return;
1003
+ }
1004
+
1005
+ // ── Search-storm detection ──
1006
+ const searchStormCount = recentToolSigs.filter(s =>
1007
+ s.startsWith('web_search:') || ['fetch_page', 'http_get'].includes(s)
1008
+ ).length;
1009
+ if (searchStormCount >= 8 && !toolLoopHintInjected) {
1010
+ this.memory.addMessage('system', `[Search storm] ${searchStormCount} search calls. STOP searching and synthesize.`);
1011
+ toolLoopHintInjected = true;
1012
+ }
1013
+ if (searchStormCount >= 12) {
1014
+ this.memory.addMessage('assistant', 'Too many search requests. Synthesizing best answer.');
1015
+ yield { type: 'content', text: `\n\n[stuck] excessive web searching (${searchStormCount} calls) — stopping.\n` };
1016
+ await this.setState(AgentState.IDLE);
1017
+ yield { type: 'done' };
1018
+ return;
1019
+ }
1020
+ }
1021
+
1022
+ // Max iterations reached
1023
+ if (!assistantStored) this.popLastUserMessage();
1024
+ await this.setState(AgentState.IDLE);
1025
+ if (!fullContent.trim() && delegations.length > 0) {
1026
+ const synth = synthesizeDelegationSummary(delegations);
1027
+ this.memory.addMessage('assistant', synth);
1028
+ yield { type: 'content', text: synth };
1029
+ }
1030
+ yield { type: 'truncated', reason: `max tool rounds (${this._maxToolRounds}) reached` };
1031
+ yield { type: 'done' };
1032
+ } catch (e: any) {
1033
+ if (!assistantStored) this.popLastUserMessage();
1034
+ await this.setState(AgentState.ERROR);
1035
+ yield { type: 'content', text: `\n[Error: ${e.message || e}]` };
1036
+ } finally {
1037
+ this.memory.pruneToolMessages();
1038
+ }
1039
+ }
1040
+
1041
+ protected popLastUserMessage(): void {
1042
+ for (let i = this.memory.shortTerm.length - 1; i >= 0; i--) {
1043
+ if (this.memory.shortTerm[i].role === 'user') {
1044
+ this.memory.shortTerm.splice(i, 1);
1045
+ break;
1046
+ }
1047
+ }
1048
+ }
1049
+
1050
+ async compact(keepRecent: number = 12): Promise<string> {
1051
+ const systemMsgs = this.memory.shortTerm.filter(
1052
+ m => m.role === 'system' && !(m.content || '').startsWith('[Earlier-context digest')
1053
+ );
1054
+ const nonSystem = this.memory.shortTerm.filter(m => m.role !== 'system');
1055
+
1056
+ if (nonSystem.length <= keepRecent + 4) return 'context is already compact';
1057
+
1058
+ const toSummarize = nonSystem.slice(0, -keepRecent);
1059
+ const recent = nonSystem.slice(-keepRecent);
1060
+
1061
+ // Extract directives
1062
+ const directiveKeywords = ['don\'t', 'do not', 'never', 'always', 'must', 'no ', '不要', '不准', '禁止', '必须', '一定', '记住'];
1063
+ const directives: string[] = [];
1064
+ for (const m of toSummarize) {
1065
+ if (m.role !== 'user') continue;
1066
+ const content = (m.content || '').trim();
1067
+ if (!content || content.length > 300) continue;
1068
+ if (directiveKeywords.some(k => content.toLowerCase().includes(k))) {
1069
+ directives.push(content);
1070
+ }
1071
+ }
1072
+
1073
+ const text = toSummarize.map(m => {
1074
+ let content = (m.content || '').slice(0, 300);
1075
+ if (m.toolCalls) {
1076
+ const names = m.toolCalls.map((tc: any) => tc.function?.name).join(',');
1077
+ content += ` [tools: ${names}]`;
1078
+ }
1079
+ return `[${m.role}] ${content}`;
1080
+ }).join('\n');
1081
+
1082
+ const resp = await this.llm.complete(
1083
+ [{ role: 'user', content: `Produce a TERSE factual digest. Bullet points only. Max 12 bullets. Preserve directives. \n\n${text}` }],
1084
+ this.name,
1085
+ undefined,
1086
+ false,
1087
+ Object.keys(this.getSkillConfigOverrides()).length > 0 ? this.getSkillConfigOverrides() : undefined
1088
+ );
1089
+ const summary = resp.content.trim().slice(0, 800);
1090
+
1091
+ const digestParts = [
1092
+ `[Earlier-context digest — ${toSummarize.length} messages compressed. Reference only.]`,
1093
+ summary,
1094
+ ];
1095
+ if (directives.length > 0) {
1096
+ digestParts.push('Verbatim directives:');
1097
+ digestParts.push(...directives.slice(-8).map(d => ` - "${d}"`));
1098
+ }
1099
+
1100
+ // Atomic update
1101
+ this.memory.shortTerm = [...systemMsgs];
1102
+ this.memory.addMessage('system', digestParts.join('\n'));
1103
+ for (const m of recent) {
1104
+ this.memory.shortTerm.push(m);
1105
+ }
1106
+ this.memory.pruneToolMessages();
1107
+
1108
+ return `compressed ${toSummarize.length} messages (${summary.length} char digest)`;
1109
+ }
1110
+
1111
+ contextUsage(): Record<string, any> {
1112
+ const usage = this.memory.getContextWindowUsage();
1113
+ return {
1114
+ estimatedTokens: usage.estimatedTokens,
1115
+ maxTokens: 128000,
1116
+ pct: Math.min(100, Math.round((usage.estimatedTokens / 128000) * 100)),
1117
+ messageCount: usage.messageCount,
1118
+ model: (this.config as any).llm?.defaultModel || 'unknown',
1119
+ };
1120
+ }
1121
+
1122
+ protected shouldAutoCompact(): boolean {
1123
+ const usage = this.memory.getContextWindowUsage();
1124
+ return (usage.estimatedTokens / 128000) > 0.92;
1125
+ }
1126
+
1127
+ protected activeToolNames(): string[] {
1128
+ const names = this.toolRegistry.listNames();
1129
+ const seen = new Set(names);
1130
+ let restriction: Set<string> | null = null;
1131
+ let anyUnrestricted = false;
1132
+
1133
+ for (const skill of this._skills) {
1134
+ if (!this._activeSkills.has(skill.name)) continue;
1135
+ for (const tn of skill.requiredTools) {
1136
+ if (!seen.has(tn)) {
1137
+ names.push(tn);
1138
+ seen.add(tn);
1139
+ }
1140
+ }
1141
+ if (skill.allowedTools === null) {
1142
+ anyUnrestricted = true;
1143
+ } else {
1144
+ if (restriction === null) restriction = new Set();
1145
+ for (const t of skill.allowedTools) restriction.add(t);
1146
+ }
1147
+ }
1148
+
1149
+ if (restriction !== null && !anyUnrestricted) {
1150
+ return names.filter(n => restriction!.has(n));
1151
+ }
1152
+ return names;
1153
+ }
1154
+
1155
+ // ── Fact extraction ──
1156
+
1157
+ private readonly EXTRACT_PROMPT = `你是一个事实抽取助手。从下面的对话中抽取**用户透露的稳定、可复用的事实**。
1158
+
1159
+ **应该抽取**:
1160
+ - 工具/技术偏好(pkg_mgr=pnpm, editor=neovim, framework=FastAPI)
1161
+ - 项目信息(project_lang=Python, project_name=skyloom)
1162
+ - 长期目标(goal=build_url_shortener)
1163
+ - 关键约束(os=Windows, python_version=3.13)
1164
+
1165
+ **输出格式**:纯 JSON 数组:
1166
+ [{"key": "pkg_mgr", "value": "pnpm", "category": "user_pref"}]
1167
+
1168
+ 对话:
1169
+ {conversation}
1170
+
1171
+ 输出:`;
1172
+
1173
+ protected maybeExtractFacts(): void {
1174
+ if (process.env.WA_NO_EXTRACT === '1') return;
1175
+ const everyN = parseInt(process.env.WA_EXTRACT_EVERY_N || '20', 10);
1176
+ if (everyN <= 0) return;
1177
+
1178
+ this._userTurnsSinceExtract++;
1179
+ if (this._userTurnsSinceExtract < everyN) return;
1180
+ this._userTurnsSinceExtract = 0;
1181
+
1182
+ const p = this.extractFactsAsync();
1183
+ this._pendingExtracts.add(p);
1184
+ p.then(() => this._pendingExtracts.delete(p)).catch(() => this._pendingExtracts.delete(p));
1185
+ }
1186
+
1187
+ private async extractFactsAsync(): Promise<number> {
1188
+ try {
1189
+ const recent = this.memory.shortTerm.slice(-20);
1190
+ const convoMsgs = recent.filter(m => (m.role === 'user' || m.role === 'assistant') && m.content);
1191
+ if (convoMsgs.length < 4) return 0;
1192
+ const convoText = convoMsgs.map(m => `${m.role}: ${(m.content || '').slice(0, 500)}`).join('\n');
1193
+ const prompt = this.EXTRACT_PROMPT.replace('{conversation}', convoText);
1194
+ const response = await this.llm.complete([{ role: 'user', content: prompt }], `${this.name}_extract`, undefined);
1195
+ const facts = this.parseExtractedFacts(response.content);
1196
+ let written = 0;
1197
+ for (const f of facts) {
1198
+ const key = f.key;
1199
+ const value = f.value;
1200
+ const category = f.category || 'auto_extracted';
1201
+ if (typeof key !== 'string' || !key.trim() || value == null || value === '') continue;
1202
+ await this.memory.remember(key.trim(), value, String(category));
1203
+ written++;
1204
+ }
1205
+ if (written) log.info('auto_extracted_facts', { agent: this.name, count: written });
1206
+ return written;
1207
+ } catch (e) {
1208
+ log.warn('fact_extract_failed', { error: String(e) });
1209
+ return 0;
1210
+ }
1211
+ }
1212
+
1213
+ private parseExtractedFacts(content: string): Array<{ key: string; value: any; category?: string }> {
1214
+ const text = (content || '').trim();
1215
+ if (!text) return [];
1216
+
1217
+ // 1. Direct parse
1218
+ try {
1219
+ const data = JSON.parse(text);
1220
+ if (Array.isArray(data)) return data.filter(f => typeof f === 'object');
1221
+ } catch { /* continue */ }
1222
+
1223
+ // 2. Markdown-fenced JSON
1224
+ const fenceMatch = text.match(/```(?:json)?\s*(\[[\s\S]*?\])\s*```/);
1225
+ if (fenceMatch) {
1226
+ try {
1227
+ const data = JSON.parse(fenceMatch[1]);
1228
+ if (Array.isArray(data)) return data.filter(f => typeof f === 'object');
1229
+ } catch { /* continue */ }
1230
+ }
1231
+
1232
+ // 3. First JSON array substring
1233
+ const arrayMatch = text.match(/\[[\s\S]*?\]/);
1234
+ if (arrayMatch) {
1235
+ try {
1236
+ const data = JSON.parse(arrayMatch[0]);
1237
+ if (Array.isArray(data)) return data.filter(f => typeof f === 'object');
1238
+ } catch { /* continue */ }
1239
+ }
1240
+ return [];
1241
+ }
1242
+
1243
+ protected async messagesWithRecall(): Promise<Record<string, any>[]> {
1244
+ const messages = this.memory.getMessages();
1245
+ if (!messages || process.env.WA_NO_RECALL === '1') return messages;
1246
+
1247
+ const lastUserIdx = messages.length - 1 - [...messages].reverse().findIndex(m => m.role === 'user');
1248
+ if (lastUserIdx < 0) return messages;
1249
+
1250
+ const query = String(messages[lastUserIdx].content || '').slice(0, 200);
1251
+ const stripped = query.trim();
1252
+ if (stripped.length < 4) return messages;
1253
+
1254
+ try {
1255
+ const facts = await this.memory.recallForInjection(query, 3);
1256
+ if (!facts.length) return messages;
1257
+ const block = Memory.formatFactsBlock(facts);
1258
+ if (!block) return messages;
1259
+ messages.splice(lastUserIdx, 0, { role: 'system', content: block });
1260
+ } catch { /* ignore */ }
1261
+ return messages;
1262
+ }
1263
+
1264
+ protected async llmLoop(options?: {
1265
+ maxIterations?: number;
1266
+ onStatus?: ((status: string) => void) | null;
1267
+ ephemeral?: boolean;
1268
+ }): Promise<LLMResponse> {
1269
+ const maxIterations = options?.maxIterations ?? this._maxToolRounds;
1270
+ const ephemeral = options?.ephemeral ?? false;
1271
+ const onStatus = options?.onStatus ?? null;
1272
+
1273
+ let response: LLMResponse = { content: '', toolCalls: [], model: '', usage: { promptTokens: 0, completionTokens: 0 }, cost: 0, truncated: false };
1274
+ const fullToolNames = this.activeToolNames();
1275
+
1276
+ const lastUser = [...this.memory.shortTerm].reverse().find(m => m.role === 'user');
1277
+ const must = new Set<string>();
1278
+ for (const s of this._skills) {
1279
+ if (this._activeSkills.has(s.name)) {
1280
+ for (const t of s.requiredTools) must.add(t);
1281
+ }
1282
+ }
1283
+ const toolNames = selectRelevantTools(
1284
+ this.toolRegistry, fullToolNames, lastUser?.content || '', { mustInclude: must }
1285
+ );
1286
+
1287
+ try {
1288
+ let limit = maxIterations;
1289
+ let rounds = 0;
1290
+ while (true) {
1291
+ if (rounds >= limit) {
1292
+ if (limit >= this._maxToolRoundsHardCap) break;
1293
+ const extendBy = Math.min(15, this._maxToolRoundsHardCap - limit);
1294
+ limit += extendBy;
1295
+ this._maxToolRounds = limit;
1296
+ this.memory.addMessage('system', `[Auto-extended limit by ${extendBy} to ${limit}.]`);
1297
+ continue;
1298
+ }
1299
+ rounds++;
1300
+ limit = Math.max(limit, this._maxToolRounds);
1301
+
1302
+ const messages = await this.messagesWithRecall();
1303
+ if (onStatus) onStatus('thinking...');
1304
+ response = await this.llm.complete(
1305
+ messages, this.name,
1306
+ toolNames.length > 0 ? toolNames : undefined, false,
1307
+ Object.keys(this.getSkillConfigOverrides()).length > 0 ? this.getSkillConfigOverrides() : undefined
1308
+ );
1309
+
1310
+ if (!response.toolCalls || response.toolCalls.length === 0) {
1311
+ return response;
1312
+ }
1313
+
1314
+ this.bus.addEvent(new Event(EventType.LLM_CALL, this.name, null, {
1315
+ model: response.model, usage: response.usage,
1316
+ }));
1317
+
1318
+ // Record assistant message
1319
+ this.memory.addMessage('assistant', response.content || '', {
1320
+ toolCalls: response.toolCalls,
1321
+ reasoningContent: response.reasoningContent,
1322
+ ephemeral,
1323
+ });
1324
+
1325
+ // ── Execute all tools via shared pipeline ──
1326
+ await this.executeToolCalls(response.toolCalls, { onStatus: onStatus ?? undefined, ephemeral });
1327
+ await this.setState(AgentState.THINKING);
1328
+ }
1329
+
1330
+ response.truncated = true;
1331
+ if (!response.content) {
1332
+ response.content = `[truncated] max tool rounds (${maxIterations}) reached.`;
1333
+ }
1334
+ return response;
1335
+ } catch (e) {
1336
+ this.memory.pruneToolMessages();
1337
+ throw e;
1338
+ }
1339
+ }
1340
+
1341
+ async executeTask(
1342
+ task: Task,
1343
+ onStatus?: ((status: string) => void) | null
1344
+ ): Promise<TaskResult> {
1345
+ return this.withTurnLock(() => this.executeTaskImpl(task, onStatus));
1346
+ }
1347
+
1348
+ private async executeTaskImpl(
1349
+ task: Task,
1350
+ onStatus?: ((status: string) => void) | null
1351
+ ): Promise<TaskResult> {
1352
+ await this.setState(AgentState.THINKING);
1353
+ task.transitionTo(TaskState.RUNNING);
1354
+ this.memory.setWorking('current_task', task);
1355
+
1356
+ const prompt = `Complete this task NOW using your available tools. Then write the actual deliverable content in your final reply.\n\nTask: ${task.description}`;
1357
+ if (task.metadata) {
1358
+ const ctxData: Record<string, any> = {};
1359
+ for (const [k, v] of Object.entries(task.metadata)) {
1360
+ if (k !== 'goal') ctxData[k] = v;
1361
+ }
1362
+ if (Object.keys(ctxData).length > 0) {
1363
+ prompt + `\nContext: ${JSON.stringify(ctxData)}`;
1364
+ }
1365
+ }
1366
+
1367
+ // Save and isolate short-term for task execution
1368
+ let savedShortTerm: Message[];
1369
+ try {
1370
+ // @ts-ignore - accessing private lock
1371
+ savedShortTerm = [...this.memory.shortTerm];
1372
+ this.memory.shortTerm = this.memory.shortTerm.filter(m => m.role === 'system');
1373
+ } catch {
1374
+ savedShortTerm = [...this.memory.shortTerm];
1375
+ this.memory.shortTerm = this.memory.shortTerm.filter(m => m.role === 'system');
1376
+ }
1377
+
1378
+ this.memory.addMessage('user', prompt);
1379
+ const preLen = this.memory.shortTerm.length;
1380
+
1381
+ try {
1382
+ const response = await this.llmLoop({ onStatus, ephemeral: true });
1383
+ const filePaths = extractFilePathsFromMessages(this.memory.shortTerm.slice(preLen));
1384
+ const enriched = enrichResponseWithArtifacts(response.content, filePaths);
1385
+ this.memory.addMessage('assistant', enriched, { toolCalls: response.toolCalls, reasoningContent: response.reasoningContent });
1386
+
1387
+ task.transitionTo(TaskState.COMPLETED);
1388
+ task.result = enriched;
1389
+ await this.setState(AgentState.IDLE);
1390
+ return new TaskResult(true, enriched);
1391
+ } catch (e) {
1392
+ task.transitionTo(TaskState.FAILED);
1393
+ task.result = String(e);
1394
+ this.memory.pruneToolMessages();
1395
+ await this.setState(AgentState.ERROR);
1396
+ return new TaskResult(false, String(e));
1397
+ } finally {
1398
+ // Restore chat history
1399
+ this.memory.shortTerm = savedShortTerm!;
1400
+ }
1401
+ }
1402
+
1403
+ protected async checkToolApproval(toolName: string, toolArgs: Record<string, any>): Promise<boolean> {
1404
+ const mode = (this.config as any).cli?.approvalMode || 'auto';
1405
+ if (mode === 'strict') return false;
1406
+ if (mode === 'interactive' && this.approvalCallback) {
1407
+ return this.approvalCallback(toolName, toolArgs);
1408
+ }
1409
+ return true;
1410
+ }
1411
+
1412
+ async requestHelp(targetAgent: string, description: string, timeout: number = 60): Promise<string> {
1413
+ const correlationId = Math.random().toString(36).slice(2, 14);
1414
+
1415
+ const promise = new Promise<string>((resolve, reject) => {
1416
+ this._pendingRequests.set(correlationId, { resolve, reject });
1417
+ });
1418
+
1419
+ await this.bus.publish(new Event(
1420
+ EventType.AGENT_REQUEST, this.name, targetAgent,
1421
+ { correlation_id: correlationId, description, source: this.name }
1422
+ ));
1423
+
1424
+ try {
1425
+ const result = await Promise.race([
1426
+ promise,
1427
+ new Promise<string>((_, reject) =>
1428
+ setTimeout(() => reject(new Error(`Timeout after ${timeout}s`)), timeout * 1000)
1429
+ ),
1430
+ ]);
1431
+ return result;
1432
+ } catch {
1433
+ this._pendingRequests.delete(correlationId);
1434
+ return `[${targetAgent} did not respond within ${timeout}s]`;
1435
+ }
1436
+ }
1437
+
1438
+ protected async handleRequest(event: Event): Promise<void> {
1439
+ const description = event.data?.description || '';
1440
+ const correlationId = event.data?.correlation_id || '';
1441
+ const source = event.data?.source || '';
1442
+ if (!correlationId) return;
1443
+
1444
+ const task = new Task({
1445
+ id: `req-${correlationId.slice(0, 8)}`,
1446
+ description,
1447
+ assignedTo: this.name,
1448
+ });
1449
+
1450
+ try {
1451
+ const result = await this.executeTask(task);
1452
+ await this.bus.publish(new Event(
1453
+ EventType.AGENT_RESPONSE, this.name, source,
1454
+ { correlation_id: correlationId, content: result.content, success: result.success }
1455
+ ));
1456
+ } catch (e) {
1457
+ await this.bus.publish(new Event(
1458
+ EventType.AGENT_RESPONSE, this.name, source,
1459
+ { correlation_id: correlationId, content: `[error] ${e}`, success: false }
1460
+ ));
1461
+ }
1462
+ }
1463
+
1464
+ protected handleResponse(event: Event): void {
1465
+ const correlationId = event.data?.correlation_id || '';
1466
+ if (!correlationId) return;
1467
+ const pending = this._pendingRequests.get(correlationId);
1468
+ if (pending) {
1469
+ this._pendingRequests.delete(correlationId);
1470
+ pending.resolve(event.data?.content || '');
1471
+ }
1472
+ }
1473
+
1474
+ getStatus(): Record<string, any> {
1475
+ return {
1476
+ name: this.name,
1477
+ displayName: this.displayName,
1478
+ emoji: this.emoji,
1479
+ specialty: this.specialty,
1480
+ state: this.state,
1481
+ skills: this.getAvailableSkills(),
1482
+ };
1483
+ }
1484
+
1485
+ // ── Turn lock ──
1486
+
1487
+ private async withTurnLock<T>(fn: () => Promise<T>): Promise<T> {
1488
+ while (this._turnLockCounter > 0) {
1489
+ await new Promise<void>(resolve => {
1490
+ const oldResolve = this._turnLockResolve;
1491
+ this._turnLockResolve = () => { oldResolve?.(); resolve(); };
1492
+ });
1493
+ }
1494
+ this._turnLockCounter++;
1495
+ try {
1496
+ return await fn();
1497
+ } finally {
1498
+ this._turnLockCounter--;
1499
+ if (this._turnLockResolve) {
1500
+ const r = this._turnLockResolve;
1501
+ this._turnLockResolve = null;
1502
+ r();
1503
+ }
1504
+ }
1505
+ }
1506
+ }