chainlesschain 0.40.2 → 0.41.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.
@@ -0,0 +1,1160 @@
1
+ /**
2
+ * Agent Core — transport-independent agentic logic
3
+ *
4
+ * Extracted from agent-repl.js so that both the terminal REPL and the
5
+ * WebSocket session handler can share the same tool definitions,
6
+ * execution logic, and LLM-driven agent loop.
7
+ *
8
+ * Key exports:
9
+ * - AGENT_TOOLS — OpenAI function-calling tool definitions
10
+ * - getBaseSystemPrompt — system prompt generator
11
+ * - executeTool — tool execution with plan-mode + hook pipeline
12
+ * - chatWithTools — LLM call with tool definitions injected
13
+ * - agentLoop — async generator yielding structured events
14
+ * - formatToolArgs — human-readable tool argument formatting
15
+ */
16
+
17
+ import fs from "fs";
18
+ import path from "path";
19
+ import { execSync } from "child_process";
20
+ import os from "os";
21
+ import { getPlanModeManager } from "./plan-mode.js";
22
+ import { CLISkillLoader } from "./skill-loader.js";
23
+ import { executeHooks, HookEvents } from "./hook-manager.js";
24
+ import { detectPython } from "./cli-anything-bridge.js";
25
+
26
+ // ─── Tool definitions ────────────────────────────────────────────────────
27
+
28
+ export const AGENT_TOOLS = [
29
+ {
30
+ type: "function",
31
+ function: {
32
+ name: "read_file",
33
+ description: "Read a file's content",
34
+ parameters: {
35
+ type: "object",
36
+ properties: {
37
+ path: { type: "string", description: "File path to read" },
38
+ },
39
+ required: ["path"],
40
+ },
41
+ },
42
+ },
43
+ {
44
+ type: "function",
45
+ function: {
46
+ name: "write_file",
47
+ description: "Write content to a file (create or overwrite)",
48
+ parameters: {
49
+ type: "object",
50
+ properties: {
51
+ path: { type: "string", description: "File path" },
52
+ content: { type: "string", description: "File content" },
53
+ },
54
+ required: ["path", "content"],
55
+ },
56
+ },
57
+ },
58
+ {
59
+ type: "function",
60
+ function: {
61
+ name: "edit_file",
62
+ description: "Replace a specific string in a file with new content",
63
+ parameters: {
64
+ type: "object",
65
+ properties: {
66
+ path: { type: "string", description: "File path" },
67
+ old_string: {
68
+ type: "string",
69
+ description: "Exact string to find and replace",
70
+ },
71
+ new_string: {
72
+ type: "string",
73
+ description: "Replacement string",
74
+ },
75
+ },
76
+ required: ["path", "old_string", "new_string"],
77
+ },
78
+ },
79
+ },
80
+ {
81
+ type: "function",
82
+ function: {
83
+ name: "run_shell",
84
+ description:
85
+ "Execute a shell command and return the output. Use for running tests, installing packages, git operations, etc.",
86
+ parameters: {
87
+ type: "object",
88
+ properties: {
89
+ command: { type: "string", description: "Shell command to execute" },
90
+ cwd: {
91
+ type: "string",
92
+ description: "Working directory (optional)",
93
+ },
94
+ },
95
+ required: ["command"],
96
+ },
97
+ },
98
+ },
99
+ {
100
+ type: "function",
101
+ function: {
102
+ name: "search_files",
103
+ description: "Search for files by name pattern or content",
104
+ parameters: {
105
+ type: "object",
106
+ properties: {
107
+ pattern: {
108
+ type: "string",
109
+ description: "Glob pattern or search string",
110
+ },
111
+ directory: {
112
+ type: "string",
113
+ description: "Directory to search in (default: cwd)",
114
+ },
115
+ content_search: {
116
+ type: "boolean",
117
+ description: "If true, search file contents instead of names",
118
+ },
119
+ },
120
+ required: ["pattern"],
121
+ },
122
+ },
123
+ },
124
+ {
125
+ type: "function",
126
+ function: {
127
+ name: "list_dir",
128
+ description: "List contents of a directory",
129
+ parameters: {
130
+ type: "object",
131
+ properties: {
132
+ path: {
133
+ type: "string",
134
+ description: "Directory path (default: cwd)",
135
+ },
136
+ },
137
+ },
138
+ },
139
+ },
140
+ {
141
+ type: "function",
142
+ function: {
143
+ name: "run_skill",
144
+ description:
145
+ "Run a built-in ChainlessChain skill. Available skills include: code-review, summarize, translate, refactor, unit-test, debug, explain-code, browser-automation, data-analysis, git-history-analyzer, and 130+ more. Use list_skills first to discover available skills.",
146
+ parameters: {
147
+ type: "object",
148
+ properties: {
149
+ skill_name: {
150
+ type: "string",
151
+ description:
152
+ "Name of the skill to run (e.g. code-review, summarize, translate)",
153
+ },
154
+ input: {
155
+ type: "string",
156
+ description: "Input text or parameters for the skill",
157
+ },
158
+ },
159
+ required: ["skill_name", "input"],
160
+ },
161
+ },
162
+ },
163
+ {
164
+ type: "function",
165
+ function: {
166
+ name: "list_skills",
167
+ description:
168
+ "List available built-in skills, optionally filtered by category or keyword",
169
+ parameters: {
170
+ type: "object",
171
+ properties: {
172
+ category: {
173
+ type: "string",
174
+ description:
175
+ "Filter by category (e.g. development, automation, data)",
176
+ },
177
+ query: {
178
+ type: "string",
179
+ description: "Search keyword to filter skills",
180
+ },
181
+ },
182
+ },
183
+ },
184
+ },
185
+ {
186
+ type: "function",
187
+ function: {
188
+ name: "run_code",
189
+ description:
190
+ "Write and execute code in Python, Node.js, or Bash. Use this when the user needs data processing, calculations, file batch operations, API calls, or any task best solved with a script. Scripts are saved for reference. Missing Python packages are auto-installed.",
191
+ parameters: {
192
+ type: "object",
193
+ properties: {
194
+ language: {
195
+ type: "string",
196
+ enum: ["python", "node", "bash"],
197
+ description: "Programming language",
198
+ },
199
+ code: { type: "string", description: "Code to execute" },
200
+ timeout: {
201
+ type: "number",
202
+ description: "Execution timeout in seconds (default: 60, max: 300)",
203
+ },
204
+ persist: {
205
+ type: "boolean",
206
+ description:
207
+ "If true (default), save script in .chainlesschain/agent-scripts/. If false, use temp file and clean up.",
208
+ },
209
+ },
210
+ required: ["language", "code"],
211
+ },
212
+ },
213
+ },
214
+ ];
215
+
216
+ // ─── Shared skill loader ──────────────────────────────────────────────────
217
+
218
+ const _defaultSkillLoader = new CLISkillLoader();
219
+
220
+ // ─── Cached environment detection ────────────────────────────────────────
221
+
222
+ let _cachedPython = null;
223
+ let _cachedEnvInfo = null;
224
+
225
+ /**
226
+ * Get cached Python interpreter info (reuses cli-anything-bridge detection).
227
+ * @returns {{ found: boolean, command?: string, version?: string }}
228
+ */
229
+ export function getCachedPython() {
230
+ if (!_cachedPython) {
231
+ _cachedPython = detectPython();
232
+ }
233
+ return _cachedPython;
234
+ }
235
+
236
+ /**
237
+ * Gather environment info (cached once per process).
238
+ * @returns {{ os: string, arch: string, python: string|null, pip: boolean, node: string|null, git: boolean }}
239
+ */
240
+ export function getEnvironmentInfo() {
241
+ if (_cachedEnvInfo) return _cachedEnvInfo;
242
+
243
+ const py = getCachedPython();
244
+
245
+ let pipAvailable = false;
246
+ if (py.found) {
247
+ try {
248
+ execSync(`${py.command} -m pip --version`, {
249
+ encoding: "utf-8",
250
+ timeout: 10000,
251
+ stdio: ["pipe", "pipe", "pipe"],
252
+ });
253
+ pipAvailable = true;
254
+ } catch {
255
+ // pip not available
256
+ }
257
+ }
258
+
259
+ let nodeVersion = null;
260
+ try {
261
+ nodeVersion = execSync("node --version", {
262
+ encoding: "utf-8",
263
+ timeout: 5000,
264
+ }).trim();
265
+ } catch {
266
+ // Node not available (unlikely since we're running in Node)
267
+ }
268
+
269
+ let gitAvailable = false;
270
+ try {
271
+ execSync("git --version", {
272
+ encoding: "utf-8",
273
+ timeout: 5000,
274
+ stdio: ["pipe", "pipe", "pipe"],
275
+ });
276
+ gitAvailable = true;
277
+ } catch {
278
+ // git not available
279
+ }
280
+
281
+ _cachedEnvInfo = {
282
+ os: process.platform,
283
+ arch: process.arch,
284
+ python: py.found ? `${py.command} (${py.version})` : null,
285
+ pip: pipAvailable,
286
+ node: nodeVersion,
287
+ git: gitAvailable,
288
+ };
289
+ return _cachedEnvInfo;
290
+ }
291
+
292
+ // ─── System prompt ────────────────────────────────────────────────────────
293
+
294
+ export function getBaseSystemPrompt(cwd) {
295
+ const env = getEnvironmentInfo();
296
+ const envLines = [
297
+ `OS: ${env.os} (${env.arch})`,
298
+ env.python
299
+ ? `Python: ${env.python}${env.pip ? " + pip" : ""}`
300
+ : "Python: not found",
301
+ env.node ? `Node.js: ${env.node}` : "Node.js: not found",
302
+ `Git: ${env.git ? "available" : "not found"}`,
303
+ ];
304
+
305
+ return `You are ChainlessChain AI Assistant, a powerful agentic coding assistant running in the terminal.
306
+
307
+ You have access to tools that let you read files, write files, edit files, run shell commands, and search the codebase. When the user asks you to do something, USE THE TOOLS to actually do it — don't just describe what should be done.
308
+
309
+ Key behaviors:
310
+ - When asked to modify code, read the file first, then edit it
311
+ - When asked to create something, use write_file to create it
312
+ - When asked to run/test something, use run_shell to execute it
313
+ - When asked about files or code, use read_file and search_files to find information
314
+ - You have multi-layer skills (built-in, marketplace, global, project-level) — use list_skills to discover them and run_skill to execute them
315
+ - Always explain what you're doing and show results
316
+ - Be concise but thorough
317
+
318
+ When the user's problem involves data processing, calculations, file operations, text parsing, API calls, web scraping, or any task that can be solved programmatically:
319
+ - Proactively write and execute code using run_code tool
320
+ - Choose the best language: Python for data/math/scraping, Node.js for JSON/API, Bash for system tasks
321
+ - Missing Python packages are auto-installed via pip when import errors are detected
322
+ - Scripts are persisted in .chainlesschain/agent-scripts/ for reference
323
+ - Show the results and explain them clearly
324
+ - If the first attempt fails, debug and retry with a different approach
325
+
326
+ You are not just a chatbot — you are a capable coding agent. Think step by step, write code when needed, and deliver real results.
327
+
328
+ ## Environment
329
+ ${envLines.join("\n")}
330
+
331
+ Current working directory: ${cwd || process.cwd()}`;
332
+ }
333
+
334
+ // ─── Tool execution ──────────────────────────────────────────────────────
335
+
336
+ /**
337
+ * Execute a single tool call with plan-mode filtering and hook pipeline.
338
+ *
339
+ * @param {string} name - tool name
340
+ * @param {object} args - tool arguments
341
+ * @param {object} [context] - optional context
342
+ * @param {object} [context.hookDb] - DB for hooks
343
+ * @param {CLISkillLoader} [context.skillLoader] - skill loader instance
344
+ * @param {string} [context.cwd] - working directory override
345
+ * @returns {Promise<object>} tool result
346
+ */
347
+ export async function executeTool(name, args, context = {}) {
348
+ const hookDb = context.hookDb || null;
349
+ const skillLoader = context.skillLoader || _defaultSkillLoader;
350
+ const cwd = context.cwd || process.cwd();
351
+
352
+ // Plan mode: check if tool is allowed
353
+ const planManager = getPlanModeManager();
354
+ if (planManager.isActive() && !planManager.isToolAllowed(name)) {
355
+ planManager.addPlanItem({
356
+ title: `${name}: ${formatToolArgs(name, args)}`,
357
+ tool: name,
358
+ params: args,
359
+ estimatedImpact:
360
+ name === "run_shell" || name === "run_code"
361
+ ? "high"
362
+ : name === "write_file"
363
+ ? "medium"
364
+ : "low",
365
+ });
366
+ return {
367
+ error: `[Plan Mode] Tool "${name}" is blocked during planning. It has been added to the plan. Use /plan approve to execute.`,
368
+ };
369
+ }
370
+
371
+ // PreToolUse hook
372
+ if (hookDb) {
373
+ try {
374
+ await executeHooks(hookDb, HookEvents.PreToolUse, {
375
+ tool: name,
376
+ args,
377
+ timestamp: new Date().toISOString(),
378
+ });
379
+ } catch (_err) {
380
+ // Hook failure should not block tool execution
381
+ }
382
+ }
383
+
384
+ let toolResult;
385
+ try {
386
+ toolResult = await executeToolInner(name, args, { skillLoader, cwd });
387
+ } catch (err) {
388
+ if (hookDb) {
389
+ try {
390
+ await executeHooks(hookDb, HookEvents.ToolError, {
391
+ tool: name,
392
+ args,
393
+ error: err.message,
394
+ });
395
+ } catch (_err) {
396
+ // Non-critical
397
+ }
398
+ }
399
+ throw err;
400
+ }
401
+
402
+ // PostToolUse hook
403
+ if (hookDb) {
404
+ try {
405
+ await executeHooks(hookDb, HookEvents.PostToolUse, {
406
+ tool: name,
407
+ args,
408
+ result:
409
+ typeof toolResult === "object"
410
+ ? JSON.stringify(toolResult).substring(0, 500)
411
+ : String(toolResult).substring(0, 500),
412
+ });
413
+ } catch (_err) {
414
+ // Non-critical
415
+ }
416
+ }
417
+
418
+ return toolResult;
419
+ }
420
+
421
+ /**
422
+ * Inner tool execution — no hooks, no plan-mode checks.
423
+ */
424
+ async function executeToolInner(name, args, { skillLoader, cwd }) {
425
+ switch (name) {
426
+ case "read_file": {
427
+ const filePath = path.resolve(cwd, args.path);
428
+ if (!fs.existsSync(filePath)) {
429
+ return { error: `File not found: ${filePath}` };
430
+ }
431
+ const content = fs.readFileSync(filePath, "utf8");
432
+ if (content.length > 50000) {
433
+ return {
434
+ content: content.substring(0, 50000) + "\n...(truncated)",
435
+ size: content.length,
436
+ };
437
+ }
438
+ return { content };
439
+ }
440
+
441
+ case "write_file": {
442
+ const filePath = path.resolve(cwd, args.path);
443
+ const dir = path.dirname(filePath);
444
+ if (!fs.existsSync(dir)) {
445
+ fs.mkdirSync(dir, { recursive: true });
446
+ }
447
+ fs.writeFileSync(filePath, args.content, "utf8");
448
+ return { success: true, path: filePath, size: args.content.length };
449
+ }
450
+
451
+ case "edit_file": {
452
+ const filePath = path.resolve(cwd, args.path);
453
+ if (!fs.existsSync(filePath)) {
454
+ return { error: `File not found: ${filePath}` };
455
+ }
456
+ const content = fs.readFileSync(filePath, "utf8");
457
+ if (!content.includes(args.old_string)) {
458
+ return { error: "old_string not found in file" };
459
+ }
460
+ const newContent = content.replace(args.old_string, args.new_string);
461
+ fs.writeFileSync(filePath, newContent, "utf8");
462
+ return { success: true, path: filePath };
463
+ }
464
+
465
+ case "run_shell": {
466
+ try {
467
+ const output = execSync(args.command, {
468
+ cwd: args.cwd || cwd,
469
+ encoding: "utf8",
470
+ timeout: 60000,
471
+ maxBuffer: 1024 * 1024,
472
+ });
473
+ return { stdout: output.substring(0, 30000) };
474
+ } catch (err) {
475
+ return {
476
+ error: err.message.substring(0, 2000),
477
+ stderr: (err.stderr || "").substring(0, 2000),
478
+ exitCode: err.status,
479
+ };
480
+ }
481
+ }
482
+
483
+ case "run_code": {
484
+ return _executeRunCode(args, cwd);
485
+ }
486
+
487
+ case "search_files": {
488
+ const dir = args.directory ? path.resolve(cwd, args.directory) : cwd;
489
+ try {
490
+ if (args.content_search) {
491
+ const cmd =
492
+ process.platform === "win32"
493
+ ? `findstr /s /i /n "${args.pattern}" *`
494
+ : `grep -r -l -i "${args.pattern}" . --include="*" 2>/dev/null | head -20`;
495
+ const output = execSync(cmd, {
496
+ cwd: dir,
497
+ encoding: "utf8",
498
+ timeout: 10000,
499
+ });
500
+ return { matches: output.trim().split("\n").slice(0, 20) };
501
+ } else {
502
+ const cmd =
503
+ process.platform === "win32"
504
+ ? `dir /s /b *${args.pattern}* 2>NUL`
505
+ : `find . -name "*${args.pattern}*" -type f 2>/dev/null | head -20`;
506
+ const output = execSync(cmd, {
507
+ cwd: dir,
508
+ encoding: "utf8",
509
+ timeout: 10000,
510
+ });
511
+ return {
512
+ files: output.trim().split("\n").filter(Boolean).slice(0, 20),
513
+ };
514
+ }
515
+ } catch {
516
+ return { files: [], message: "No matches found" };
517
+ }
518
+ }
519
+
520
+ case "list_dir": {
521
+ const dirPath = args.path ? path.resolve(cwd, args.path) : cwd;
522
+ if (!fs.existsSync(dirPath)) {
523
+ return { error: `Directory not found: ${dirPath}` };
524
+ }
525
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
526
+ return {
527
+ entries: entries.map((e) => ({
528
+ name: e.name,
529
+ type: e.isDirectory() ? "dir" : "file",
530
+ })),
531
+ };
532
+ }
533
+
534
+ case "run_skill": {
535
+ const allSkills = skillLoader.getResolvedSkills();
536
+ if (allSkills.length === 0) {
537
+ return {
538
+ error:
539
+ "No skills found. Make sure you're in the ChainlessChain project root or have skills installed.",
540
+ };
541
+ }
542
+ const match = allSkills.find(
543
+ (s) => s.id === args.skill_name || s.dirName === args.skill_name,
544
+ );
545
+ if (!match || !match.hasHandler) {
546
+ return {
547
+ error: `Skill "${args.skill_name}" not found or has no handler. Use list_skills to see available skills.`,
548
+ };
549
+ }
550
+ try {
551
+ const handlerPath = path.join(match.skillDir, "handler.js");
552
+ const imported = await import(
553
+ `file://${handlerPath.replace(/\\/g, "/")}`
554
+ );
555
+ const handler = imported.default || imported;
556
+ if (handler.init) await handler.init(match);
557
+ const task = {
558
+ params: { input: args.input },
559
+ input: args.input,
560
+ action: args.input,
561
+ };
562
+ const taskContext = {
563
+ projectRoot: cwd,
564
+ workspacePath: cwd,
565
+ };
566
+ const result = await handler.execute(task, taskContext, match);
567
+ return result;
568
+ } catch (err) {
569
+ return { error: `Skill execution failed: ${err.message}` };
570
+ }
571
+ }
572
+
573
+ case "list_skills": {
574
+ let skills = skillLoader.getResolvedSkills();
575
+ if (skills.length === 0) {
576
+ return { error: "No skills found." };
577
+ }
578
+ if (args.category) {
579
+ skills = skills.filter(
580
+ (s) => s.category.toLowerCase() === args.category.toLowerCase(),
581
+ );
582
+ }
583
+ if (args.query) {
584
+ const q = args.query.toLowerCase();
585
+ skills = skills.filter(
586
+ (s) =>
587
+ s.id.includes(q) ||
588
+ s.description.toLowerCase().includes(q) ||
589
+ s.category.toLowerCase().includes(q),
590
+ );
591
+ }
592
+ return {
593
+ count: skills.length,
594
+ skills: skills.map((s) => ({
595
+ id: s.id,
596
+ category: s.category,
597
+ source: s.source,
598
+ hasHandler: s.hasHandler,
599
+ description: (s.description || "").substring(0, 80),
600
+ })),
601
+ };
602
+ }
603
+
604
+ default:
605
+ return { error: `Unknown tool: ${name}` };
606
+ }
607
+ }
608
+
609
+ // ─── run_code implementation ──────────────────────────────────────────────
610
+
611
+ /**
612
+ * Classify an error from code execution into a structured type with hints.
613
+ * @param {string} stderr - stderr output
614
+ * @param {string} message - error message
615
+ * @param {number|null} exitCode - process exit code
616
+ * @param {string} lang - language (python, node, bash)
617
+ * @returns {{ errorType: string, hint: string }}
618
+ */
619
+ export function classifyError(stderr, message, exitCode, lang) {
620
+ const text = stderr || message || "";
621
+
622
+ // Import / module errors
623
+ if (/ModuleNotFoundError|ImportError|No module named/i.test(text)) {
624
+ const modMatch = text.match(/No module named ['"]([^'"]+)['"]/);
625
+ return {
626
+ errorType: "import_error",
627
+ hint: modMatch
628
+ ? `Missing Python module "${modMatch[1]}". Will attempt auto-install.`
629
+ : "Missing module. Check your imports.",
630
+ };
631
+ }
632
+
633
+ // Syntax errors
634
+ if (/SyntaxError|IndentationError|TabError/i.test(text)) {
635
+ const lineMatch = text.match(/line (\d+)/i);
636
+ return {
637
+ errorType: "syntax_error",
638
+ hint: lineMatch
639
+ ? `Syntax error on line ${lineMatch[1]}. Check for typos, missing colons, or indentation.`
640
+ : "Syntax error in code. Check for typos or missing brackets.",
641
+ };
642
+ }
643
+
644
+ // Timeout
645
+ if (/ETIMEDOUT|timed?\s*out/i.test(text) || exitCode === null) {
646
+ return {
647
+ errorType: "timeout",
648
+ hint: "Script timed out. Consider increasing timeout or optimizing the code.",
649
+ };
650
+ }
651
+
652
+ // Permission errors
653
+ if (/EACCES|Permission denied|PermissionError/i.test(text)) {
654
+ return {
655
+ errorType: "permission_error",
656
+ hint: "Permission denied. Try a different directory or run with appropriate permissions.",
657
+ };
658
+ }
659
+
660
+ // Generic runtime error
661
+ const lineMatch = text.match(/(?:line |:)(\d+)/);
662
+ return {
663
+ errorType: "runtime_error",
664
+ hint: lineMatch
665
+ ? `Runtime error near line ${lineMatch[1]}. Check the traceback above.`
666
+ : "Runtime error. Check stderr for details.",
667
+ };
668
+ }
669
+
670
+ /**
671
+ * Validate a package name for pip install (reject shell metacharacters).
672
+ * @param {string} name
673
+ * @returns {boolean}
674
+ */
675
+ export function isValidPackageName(name) {
676
+ return /^[a-zA-Z0-9_][a-zA-Z0-9._-]*$/.test(name) && name.length <= 100;
677
+ }
678
+
679
+ /**
680
+ * Execute code with auto pip-install, script persistence, and error classification.
681
+ */
682
+ async function _executeRunCode(args, cwd) {
683
+ const lang = args.language;
684
+ const code = args.code;
685
+ const timeoutSec = Math.min(Math.max(args.timeout || 60, 1), 300);
686
+ const persist = args.persist !== false; // default true
687
+
688
+ const extMap = { python: ".py", node: ".js", bash: ".sh" };
689
+ const ext = extMap[lang];
690
+ if (!ext) {
691
+ return {
692
+ error: `Unsupported language: ${lang}. Use python, node, or bash.`,
693
+ };
694
+ }
695
+
696
+ // Determine script path
697
+ let scriptPath;
698
+ if (persist) {
699
+ const scriptsDir = path.join(cwd, ".chainlesschain", "agent-scripts");
700
+ if (!fs.existsSync(scriptsDir)) {
701
+ fs.mkdirSync(scriptsDir, { recursive: true });
702
+ }
703
+ const timestamp = new Date()
704
+ .toISOString()
705
+ .replace(/[T:]/g, "-")
706
+ .replace(/\.\d+Z$/, "");
707
+ scriptPath = path.join(scriptsDir, `${timestamp}-${lang}${ext}`);
708
+ } else {
709
+ scriptPath = path.join(os.tmpdir(), `cc-agent-${Date.now()}${ext}`);
710
+ }
711
+
712
+ try {
713
+ fs.writeFileSync(scriptPath, code, "utf8");
714
+
715
+ // Determine interpreter
716
+ let interpreter;
717
+ if (lang === "python") {
718
+ const py = getCachedPython();
719
+ interpreter = py.found ? py.command : "python";
720
+ } else if (lang === "node") {
721
+ interpreter = "node";
722
+ } else {
723
+ interpreter = "bash";
724
+ }
725
+
726
+ const start = Date.now();
727
+ let output;
728
+ try {
729
+ output = execSync(`${interpreter} "${scriptPath}"`, {
730
+ cwd,
731
+ encoding: "utf8",
732
+ timeout: timeoutSec * 1000,
733
+ maxBuffer: 5 * 1024 * 1024,
734
+ });
735
+ } catch (err) {
736
+ const stderr = (err.stderr || "").toString();
737
+ const message = err.message || "";
738
+ const classified = classifyError(stderr, message, err.status, lang);
739
+
740
+ // Auto-install missing Python packages
741
+ if (lang === "python" && classified.errorType === "import_error") {
742
+ const modMatch = stderr.match(/No module named ['"]([^'"]+)['"]/);
743
+ if (modMatch) {
744
+ // Use top-level package name (e.g. "foo.bar" → "foo")
745
+ const packageName = modMatch[1].split(".")[0];
746
+
747
+ if (!isValidPackageName(packageName)) {
748
+ return {
749
+ error: `Invalid package name: "${packageName}"`,
750
+ ...classified,
751
+ language: lang,
752
+ scriptPath: persist ? scriptPath : undefined,
753
+ };
754
+ }
755
+
756
+ // Attempt pip install
757
+ try {
758
+ execSync(`${interpreter} -m pip install ${packageName}`, {
759
+ encoding: "utf-8",
760
+ timeout: 120000,
761
+ maxBuffer: 2 * 1024 * 1024,
762
+ stdio: ["pipe", "pipe", "pipe"],
763
+ });
764
+
765
+ // Retry execution
766
+ const retryStart = Date.now();
767
+ const retryOutput = execSync(`${interpreter} "${scriptPath}"`, {
768
+ cwd,
769
+ encoding: "utf8",
770
+ timeout: timeoutSec * 1000,
771
+ maxBuffer: 5 * 1024 * 1024,
772
+ });
773
+ const retryDuration = Date.now() - retryStart;
774
+
775
+ return {
776
+ success: true,
777
+ output: retryOutput.substring(0, 50000),
778
+ language: lang,
779
+ duration: `${retryDuration}ms`,
780
+ autoInstalled: [packageName],
781
+ scriptPath: persist ? scriptPath : undefined,
782
+ };
783
+ } catch (pipErr) {
784
+ return {
785
+ error: (stderr || message).substring(0, 5000),
786
+ stderr: stderr.substring(0, 5000),
787
+ exitCode: err.status,
788
+ language: lang,
789
+ ...classified,
790
+ hint: `Failed to auto-install "${packageName}". ${(pipErr.stderr || pipErr.message || "").substring(0, 500)}`,
791
+ scriptPath: persist ? scriptPath : undefined,
792
+ };
793
+ }
794
+ }
795
+ }
796
+
797
+ return {
798
+ error: (stderr || message).substring(0, 5000),
799
+ stderr: stderr.substring(0, 5000),
800
+ exitCode: err.status,
801
+ language: lang,
802
+ ...classified,
803
+ scriptPath: persist ? scriptPath : undefined,
804
+ };
805
+ }
806
+
807
+ const duration = Date.now() - start;
808
+ return {
809
+ success: true,
810
+ output: output.substring(0, 50000),
811
+ language: lang,
812
+ duration: `${duration}ms`,
813
+ scriptPath: persist ? scriptPath : undefined,
814
+ };
815
+ } finally {
816
+ // Only clean up if not persisting
817
+ if (!persist) {
818
+ try {
819
+ fs.unlinkSync(scriptPath);
820
+ } catch {
821
+ // Cleanup best-effort
822
+ }
823
+ }
824
+ }
825
+ }
826
+
827
+ // ─── LLM chat with tools ─────────────────────────────────────────────────
828
+
829
+ /**
830
+ * Send a chat completion request with tool definitions.
831
+ * Supports 8 providers: ollama, anthropic, openai, deepseek, dashscope, gemini, mistral, volcengine
832
+ *
833
+ * @param {Array} rawMessages
834
+ * @param {object} options
835
+ * @returns {Promise<object>} response with .message
836
+ */
837
+ export async function chatWithTools(rawMessages, options) {
838
+ const { provider, model, baseUrl, apiKey, contextEngine: ce } = options;
839
+
840
+ const lastUserMsg = [...rawMessages].reverse().find((m) => m.role === "user");
841
+ const messages = ce
842
+ ? ce.buildOptimizedMessages(rawMessages, {
843
+ userQuery: lastUserMsg?.content,
844
+ })
845
+ : rawMessages;
846
+
847
+ if (provider === "ollama") {
848
+ const response = await fetch(`${baseUrl}/api/chat`, {
849
+ method: "POST",
850
+ headers: { "Content-Type": "application/json" },
851
+ body: JSON.stringify({
852
+ model,
853
+ messages,
854
+ tools: AGENT_TOOLS,
855
+ stream: false,
856
+ }),
857
+ });
858
+ if (!response.ok) {
859
+ throw new Error(`Ollama error: ${response.status}`);
860
+ }
861
+ return await response.json();
862
+ }
863
+
864
+ if (provider === "anthropic") {
865
+ const key = apiKey || process.env.ANTHROPIC_API_KEY;
866
+ if (!key) throw new Error("ANTHROPIC_API_KEY required");
867
+
868
+ const systemMsgs = messages.filter((m) => m.role === "system");
869
+ const otherMsgs = messages.filter((m) => m.role !== "system");
870
+
871
+ const anthropicTools = AGENT_TOOLS.map((t) => ({
872
+ name: t.function.name,
873
+ description: t.function.description,
874
+ input_schema: t.function.parameters,
875
+ }));
876
+
877
+ const body = {
878
+ model: model || "claude-sonnet-4-20250514",
879
+ max_tokens: 8192,
880
+ messages: otherMsgs,
881
+ tools: anthropicTools,
882
+ };
883
+ if (systemMsgs.length > 0) {
884
+ body.system = systemMsgs.map((m) => m.content).join("\n");
885
+ }
886
+
887
+ const url =
888
+ baseUrl && baseUrl !== "http://localhost:11434"
889
+ ? baseUrl
890
+ : "https://api.anthropic.com/v1";
891
+
892
+ const response = await fetch(`${url}/messages`, {
893
+ method: "POST",
894
+ headers: {
895
+ "Content-Type": "application/json",
896
+ "x-api-key": key,
897
+ "anthropic-version": "2023-06-01",
898
+ },
899
+ body: JSON.stringify(body),
900
+ });
901
+
902
+ if (!response.ok) {
903
+ throw new Error(`Anthropic error: ${response.status}`);
904
+ }
905
+
906
+ const data = await response.json();
907
+ return _normalizeAnthropicResponse(data);
908
+ }
909
+
910
+ // OpenAI-compatible providers
911
+ const providerUrls = {
912
+ openai: "https://api.openai.com/v1",
913
+ deepseek: "https://api.deepseek.com/v1",
914
+ dashscope: "https://dashscope.aliyuncs.com/compatible-mode/v1",
915
+ mistral: "https://api.mistral.ai/v1",
916
+ gemini: "https://generativelanguage.googleapis.com/v1beta/openai",
917
+ volcengine: "https://ark.cn-beijing.volces.com/api/v3",
918
+ };
919
+
920
+ const providerApiKeyEnvs = {
921
+ openai: "OPENAI_API_KEY",
922
+ deepseek: "DEEPSEEK_API_KEY",
923
+ dashscope: "DASHSCOPE_API_KEY",
924
+ mistral: "MISTRAL_API_KEY",
925
+ gemini: "GEMINI_API_KEY",
926
+ volcengine: "VOLCENGINE_API_KEY",
927
+ };
928
+
929
+ const url =
930
+ baseUrl && baseUrl !== "http://localhost:11434"
931
+ ? baseUrl
932
+ : providerUrls[provider];
933
+
934
+ if (!url) {
935
+ throw new Error(
936
+ `Unsupported provider: ${provider}. Supported: ollama, anthropic, openai, deepseek, dashscope, mistral, gemini, volcengine`,
937
+ );
938
+ }
939
+
940
+ const envKey = providerApiKeyEnvs[provider] || "OPENAI_API_KEY";
941
+ const key = apiKey || process.env[envKey];
942
+ if (!key) throw new Error(`${envKey} required for provider ${provider}`);
943
+
944
+ const defaultModels = {
945
+ openai: "gpt-4o",
946
+ deepseek: "deepseek-chat",
947
+ dashscope: "qwen-turbo",
948
+ mistral: "mistral-large-latest",
949
+ gemini: "gemini-2.0-flash",
950
+ volcengine: "doubao-seed-1-6-251015",
951
+ };
952
+
953
+ const response = await fetch(`${url}/chat/completions`, {
954
+ method: "POST",
955
+ headers: {
956
+ "Content-Type": "application/json",
957
+ Authorization: `Bearer ${key}`,
958
+ },
959
+ body: JSON.stringify({
960
+ model: model || defaultModels[provider] || "gpt-4o-mini",
961
+ messages,
962
+ tools: AGENT_TOOLS,
963
+ }),
964
+ });
965
+
966
+ if (!response.ok) {
967
+ throw new Error(`${provider} API error: ${response.status}`);
968
+ }
969
+
970
+ const data = await response.json();
971
+ if (!data.choices || !data.choices[0]) {
972
+ throw new Error("Invalid API response: no choices returned");
973
+ }
974
+ const choice = data.choices[0];
975
+ return { message: choice.message };
976
+ }
977
+
978
+ function _normalizeAnthropicResponse(data) {
979
+ const content = data.content || [];
980
+ const textBlocks = content.filter((b) => b.type === "text");
981
+ const toolBlocks = content.filter((b) => b.type === "tool_use");
982
+
983
+ const message = {
984
+ role: "assistant",
985
+ content: textBlocks.map((b) => b.text).join("\n") || "",
986
+ };
987
+
988
+ if (toolBlocks.length > 0) {
989
+ message.tool_calls = toolBlocks.map((b) => ({
990
+ id: b.id,
991
+ type: "function",
992
+ function: {
993
+ name: b.name,
994
+ arguments: JSON.stringify(b.input),
995
+ },
996
+ }));
997
+ }
998
+
999
+ return { message };
1000
+ }
1001
+
1002
+ // ─── Agent loop (async generator) ─────────────────────────────────────────
1003
+
1004
+ /**
1005
+ * Async generator that drives the agentic tool-use loop.
1006
+ *
1007
+ * Yields events:
1008
+ * { type: "slot-filling", slot, question } — when asking user for missing info
1009
+ * { type: "tool-executing", tool, args }
1010
+ * { type: "tool-result", tool, result, error }
1011
+ * { type: "response-complete", content }
1012
+ *
1013
+ * @param {Array} messages - mutable messages array (will be appended to)
1014
+ * @param {object} options - provider, model, baseUrl, apiKey, contextEngine, hookDb, skillLoader, cwd, slotFiller, interaction
1015
+ */
1016
+ export async function* agentLoop(messages, options) {
1017
+ const MAX_ITERATIONS = 15;
1018
+ const toolContext = {
1019
+ hookDb: options.hookDb || null,
1020
+ skillLoader: options.skillLoader || _defaultSkillLoader,
1021
+ cwd: options.cwd || process.cwd(),
1022
+ };
1023
+
1024
+ // ── Slot-filling phase ──────────────────────────────────────────────
1025
+ // Before calling the LLM, check if the user's message matches a known
1026
+ // intent with missing required parameters. If so, interactively fill them
1027
+ // and append the gathered context to the user message.
1028
+ if (options.slotFiller && options.interaction) {
1029
+ const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
1030
+ if (lastUserMsg) {
1031
+ try {
1032
+ const { CLISlotFiller } = await import("./slot-filler.js");
1033
+ const intent = CLISlotFiller.detectIntent(lastUserMsg.content);
1034
+
1035
+ if (intent) {
1036
+ const requiredSlots = CLISlotFiller.getSlotDefinitions(
1037
+ intent.type,
1038
+ ).required;
1039
+ const missingSlots = requiredSlots.filter((s) => !intent.entities[s]);
1040
+
1041
+ if (missingSlots.length > 0) {
1042
+ const result = await options.slotFiller.fillSlots(intent, {
1043
+ cwd: options.cwd || process.cwd(),
1044
+ });
1045
+
1046
+ // Yield slot-filling events for each filled slot
1047
+ for (const slot of result.filledSlots) {
1048
+ yield {
1049
+ type: "slot-filling",
1050
+ slot,
1051
+ question: `Filled "${slot}" = "${result.entities[slot]}"`,
1052
+ };
1053
+ }
1054
+
1055
+ // Append gathered context to the user message so the LLM has full info
1056
+ if (result.filledSlots.length > 0) {
1057
+ const contextParts = Object.entries(result.entities)
1058
+ .filter(([, v]) => v)
1059
+ .map(([k, v]) => `${k}: ${v}`);
1060
+ lastUserMsg.content += `\n\n[Context — user provided: ${contextParts.join(", ")}]`;
1061
+ }
1062
+ }
1063
+ }
1064
+ } catch (_err) {
1065
+ // Slot-filling failure is non-critical — proceed to LLM
1066
+ }
1067
+ }
1068
+ }
1069
+
1070
+ for (let i = 0; i < MAX_ITERATIONS; i++) {
1071
+ const result = await chatWithTools(messages, options);
1072
+ const msg = result?.message;
1073
+
1074
+ if (!msg) {
1075
+ yield { type: "response-complete", content: "(No response from LLM)" };
1076
+ return;
1077
+ }
1078
+
1079
+ const toolCalls = msg.tool_calls;
1080
+
1081
+ if (!toolCalls || toolCalls.length === 0) {
1082
+ yield { type: "response-complete", content: msg.content || "" };
1083
+ return;
1084
+ }
1085
+
1086
+ // Add assistant message with tool calls
1087
+ messages.push(msg);
1088
+
1089
+ for (const call of toolCalls) {
1090
+ const fn = call.function;
1091
+ const toolName = fn.name;
1092
+ let toolArgs;
1093
+
1094
+ try {
1095
+ toolArgs =
1096
+ typeof fn.arguments === "string"
1097
+ ? JSON.parse(fn.arguments)
1098
+ : fn.arguments;
1099
+ } catch {
1100
+ toolArgs = {};
1101
+ }
1102
+
1103
+ yield { type: "tool-executing", tool: toolName, args: toolArgs };
1104
+
1105
+ let toolResult;
1106
+ let toolError = null;
1107
+ try {
1108
+ toolResult = await executeTool(toolName, toolArgs, toolContext);
1109
+ } catch (err) {
1110
+ toolResult = { error: err.message };
1111
+ toolError = err.message;
1112
+ }
1113
+
1114
+ yield {
1115
+ type: "tool-result",
1116
+ tool: toolName,
1117
+ result: toolResult,
1118
+ error: toolError,
1119
+ };
1120
+
1121
+ messages.push({
1122
+ role: "tool",
1123
+ content: JSON.stringify(toolResult).substring(0, 5000),
1124
+ tool_call_id: call.id,
1125
+ });
1126
+ }
1127
+ }
1128
+
1129
+ yield {
1130
+ type: "response-complete",
1131
+ content: "(Reached max tool call iterations)",
1132
+ };
1133
+ }
1134
+
1135
+ // ─── Format helpers ───────────────────────────────────────────────────────
1136
+
1137
+ export function formatToolArgs(name, args) {
1138
+ switch (name) {
1139
+ case "read_file":
1140
+ return args.path;
1141
+ case "write_file":
1142
+ return `${args.path} (${args.content?.length || 0} chars)`;
1143
+ case "edit_file":
1144
+ return args.path;
1145
+ case "run_shell":
1146
+ return args.command;
1147
+ case "search_files":
1148
+ return args.pattern;
1149
+ case "list_dir":
1150
+ return args.path || ".";
1151
+ case "run_skill":
1152
+ return `${args.skill_name}: ${(args.input || "").substring(0, 50)}`;
1153
+ case "list_skills":
1154
+ return args.category || args.query || "all";
1155
+ case "run_code":
1156
+ return `${args.language} (${(args.code || "").length} chars)`;
1157
+ default:
1158
+ return JSON.stringify(args).substring(0, 60);
1159
+ }
1160
+ }