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