longer-agent 0.1.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.
- package/LICENSE +21 -0
- package/README.md +227 -0
- package/README.zh-CN.md +227 -0
- package/agent_templates/executor/agent.yaml +22 -0
- package/agent_templates/executor/system_prompt.md +17 -0
- package/agent_templates/explorer/agent.yaml +13 -0
- package/agent_templates/explorer/system_prompt.md +19 -0
- package/agent_templates/main/agent.yaml +7 -0
- package/agent_templates/main/system_prompt.md +45 -0
- package/configExample.yaml +83 -0
- package/dist/agents/agent.d.ts +79 -0
- package/dist/agents/agent.d.ts.map +1 -0
- package/dist/agents/agent.js +156 -0
- package/dist/agents/agent.js.map +1 -0
- package/dist/agents/tool-loop.d.ts +140 -0
- package/dist/agents/tool-loop.d.ts.map +1 -0
- package/dist/agents/tool-loop.js +465 -0
- package/dist/agents/tool-loop.js.map +1 -0
- package/dist/ask.d.ts +81 -0
- package/dist/ask.d.ts.map +1 -0
- package/dist/ask.js +34 -0
- package/dist/ask.js.map +1 -0
- package/dist/auth/openai-oauth.d.ts +66 -0
- package/dist/auth/openai-oauth.d.ts.map +1 -0
- package/dist/auth/openai-oauth.js +640 -0
- package/dist/auth/openai-oauth.js.map +1 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +254 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands.d.ts +118 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +862 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +130 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +648 -0
- package/dist/config.js.map +1 -0
- package/dist/context-rendering.d.ts +69 -0
- package/dist/context-rendering.d.ts.map +1 -0
- package/dist/context-rendering.js +250 -0
- package/dist/context-rendering.js.map +1 -0
- package/dist/document-projection.d.ts +12 -0
- package/dist/document-projection.d.ts.map +1 -0
- package/dist/document-projection.js +75 -0
- package/dist/document-projection.js.map +1 -0
- package/dist/ephemeral-log.d.ts +15 -0
- package/dist/ephemeral-log.d.ts.map +1 -0
- package/dist/ephemeral-log.js +173 -0
- package/dist/ephemeral-log.js.map +1 -0
- package/dist/file-attach.d.ts +89 -0
- package/dist/file-attach.d.ts.map +1 -0
- package/dist/file-attach.js +571 -0
- package/dist/file-attach.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/init-wizard.d.ts +13 -0
- package/dist/init-wizard.d.ts.map +1 -0
- package/dist/init-wizard.js +328 -0
- package/dist/init-wizard.js.map +1 -0
- package/dist/log-entry.d.ts +104 -0
- package/dist/log-entry.d.ts.map +1 -0
- package/dist/log-entry.js +292 -0
- package/dist/log-entry.js.map +1 -0
- package/dist/log-projection.d.ts +73 -0
- package/dist/log-projection.d.ts.map +1 -0
- package/dist/log-projection.js +651 -0
- package/dist/log-projection.js.map +1 -0
- package/dist/mcp-client.d.ts +55 -0
- package/dist/mcp-client.d.ts.map +1 -0
- package/dist/mcp-client.js +402 -0
- package/dist/mcp-client.js.map +1 -0
- package/dist/model-selection.d.ts +16 -0
- package/dist/model-selection.d.ts.map +1 -0
- package/dist/model-selection.js +181 -0
- package/dist/model-selection.js.map +1 -0
- package/dist/network-retry.d.ts +38 -0
- package/dist/network-retry.d.ts.map +1 -0
- package/dist/network-retry.js +140 -0
- package/dist/network-retry.js.map +1 -0
- package/dist/persistence.d.ts +104 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +644 -0
- package/dist/persistence.js.map +1 -0
- package/dist/primitives/context.d.ts +29 -0
- package/dist/primitives/context.d.ts.map +1 -0
- package/dist/primitives/context.js +85 -0
- package/dist/primitives/context.js.map +1 -0
- package/dist/progress.d.ts +51 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +229 -0
- package/dist/progress.js.map +1 -0
- package/dist/provider-presets.d.ts +34 -0
- package/dist/provider-presets.d.ts.map +1 -0
- package/dist/provider-presets.js +181 -0
- package/dist/provider-presets.js.map +1 -0
- package/dist/providers/anthropic.d.ts +32 -0
- package/dist/providers/anthropic.d.ts.map +1 -0
- package/dist/providers/anthropic.js +450 -0
- package/dist/providers/anthropic.js.map +1 -0
- package/dist/providers/base.d.ts +135 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/base.js +104 -0
- package/dist/providers/base.js.map +1 -0
- package/dist/providers/glm.d.ts +18 -0
- package/dist/providers/glm.d.ts.map +1 -0
- package/dist/providers/glm.js +59 -0
- package/dist/providers/glm.js.map +1 -0
- package/dist/providers/kimi.d.ts +23 -0
- package/dist/providers/kimi.d.ts.map +1 -0
- package/dist/providers/kimi.js +89 -0
- package/dist/providers/kimi.js.map +1 -0
- package/dist/providers/minimax.d.ts +20 -0
- package/dist/providers/minimax.d.ts.map +1 -0
- package/dist/providers/minimax.js +192 -0
- package/dist/providers/minimax.js.map +1 -0
- package/dist/providers/openai-chat.d.ts +33 -0
- package/dist/providers/openai-chat.d.ts.map +1 -0
- package/dist/providers/openai-chat.js +543 -0
- package/dist/providers/openai-chat.js.map +1 -0
- package/dist/providers/openai-responses.d.ts +26 -0
- package/dist/providers/openai-responses.d.ts.map +1 -0
- package/dist/providers/openai-responses.js +443 -0
- package/dist/providers/openai-responses.js.map +1 -0
- package/dist/providers/openrouter.d.ts +24 -0
- package/dist/providers/openrouter.d.ts.map +1 -0
- package/dist/providers/openrouter.js +177 -0
- package/dist/providers/openrouter.js.map +1 -0
- package/dist/providers/registry.d.ts +7 -0
- package/dist/providers/registry.d.ts.map +1 -0
- package/dist/providers/registry.js +38 -0
- package/dist/providers/registry.js.map +1 -0
- package/dist/security/path.d.ts +51 -0
- package/dist/security/path.d.ts.map +1 -0
- package/dist/security/path.js +187 -0
- package/dist/security/path.js.map +1 -0
- package/dist/security/sensitive-files.d.ts +3 -0
- package/dist/security/sensitive-files.d.ts.map +1 -0
- package/dist/security/sensitive-files.js +41 -0
- package/dist/security/sensitive-files.js.map +1 -0
- package/dist/session.d.ts +446 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +4595 -0
- package/dist/session.js.map +1 -0
- package/dist/settings.d.ts +46 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +134 -0
- package/dist/settings.js.map +1 -0
- package/dist/show-context.d.ts +35 -0
- package/dist/show-context.d.ts.map +1 -0
- package/dist/show-context.js +320 -0
- package/dist/show-context.js.map +1 -0
- package/dist/skills/loader.d.ts +49 -0
- package/dist/skills/loader.d.ts.map +1 -0
- package/dist/skills/loader.js +166 -0
- package/dist/skills/loader.js.map +1 -0
- package/dist/summarize-context.d.ts +29 -0
- package/dist/summarize-context.d.ts.map +1 -0
- package/dist/summarize-context.js +247 -0
- package/dist/summarize-context.js.map +1 -0
- package/dist/templates/loader.d.ts +104 -0
- package/dist/templates/loader.d.ts.map +1 -0
- package/dist/templates/loader.js +514 -0
- package/dist/templates/loader.js.map +1 -0
- package/dist/tools/basic.d.ts +29 -0
- package/dist/tools/basic.d.ts.map +1 -0
- package/dist/tools/basic.js +2079 -0
- package/dist/tools/basic.js.map +1 -0
- package/dist/tools/comm.d.ts +17 -0
- package/dist/tools/comm.d.ts.map +1 -0
- package/dist/tools/comm.js +192 -0
- package/dist/tools/comm.js.map +1 -0
- package/dist/tools/web-fetch.d.ts +11 -0
- package/dist/tools/web-fetch.d.ts.map +1 -0
- package/dist/tools/web-fetch.js +237 -0
- package/dist/tools/web-fetch.js.map +1 -0
- package/dist/tools/web-search.d.ts +24 -0
- package/dist/tools/web-search.d.ts.map +1 -0
- package/dist/tools/web-search.js +51 -0
- package/dist/tools/web-search.js.map +1 -0
- package/dist/tui/app.d.ts +35 -0
- package/dist/tui/app.d.ts.map +1 -0
- package/dist/tui/app.js +1042 -0
- package/dist/tui/app.js.map +1 -0
- package/dist/tui/checkbox-picker.d.ts +35 -0
- package/dist/tui/checkbox-picker.d.ts.map +1 -0
- package/dist/tui/checkbox-picker.js +85 -0
- package/dist/tui/checkbox-picker.js.map +1 -0
- package/dist/tui/command-picker.d.ts +31 -0
- package/dist/tui/command-picker.d.ts.map +1 -0
- package/dist/tui/command-picker.js +113 -0
- package/dist/tui/command-picker.js.map +1 -0
- package/dist/tui/components/ask-panel.d.ts +21 -0
- package/dist/tui/components/ask-panel.d.ts.map +1 -0
- package/dist/tui/components/ask-panel.js +81 -0
- package/dist/tui/components/ask-panel.js.map +1 -0
- package/dist/tui/components/conversation-panel.d.ts +68 -0
- package/dist/tui/components/conversation-panel.d.ts.map +1 -0
- package/dist/tui/components/conversation-panel.js +611 -0
- package/dist/tui/components/conversation-panel.js.map +1 -0
- package/dist/tui/components/input-panel.d.ts +27 -0
- package/dist/tui/components/input-panel.d.ts.map +1 -0
- package/dist/tui/components/input-panel.js +725 -0
- package/dist/tui/components/input-panel.js.map +1 -0
- package/dist/tui/components/logo-panel.d.ts +14 -0
- package/dist/tui/components/logo-panel.d.ts.map +1 -0
- package/dist/tui/components/logo-panel.js +37 -0
- package/dist/tui/components/logo-panel.js.map +1 -0
- package/dist/tui/components/plan-panel.d.ts +10 -0
- package/dist/tui/components/plan-panel.d.ts.map +1 -0
- package/dist/tui/components/plan-panel.js +8 -0
- package/dist/tui/components/plan-panel.js.map +1 -0
- package/dist/tui/components/status-bar.d.ts +24 -0
- package/dist/tui/components/status-bar.d.ts.map +1 -0
- package/dist/tui/components/status-bar.js +80 -0
- package/dist/tui/components/status-bar.js.map +1 -0
- package/dist/tui/input/editor-state.d.ts +22 -0
- package/dist/tui/input/editor-state.d.ts.map +1 -0
- package/dist/tui/input/editor-state.js +157 -0
- package/dist/tui/input/editor-state.js.map +1 -0
- package/dist/tui/input/keymap.d.ts +3 -0
- package/dist/tui/input/keymap.d.ts.map +1 -0
- package/dist/tui/input/keymap.js +72 -0
- package/dist/tui/input/keymap.js.map +1 -0
- package/dist/tui/input/paste-slots.d.ts +17 -0
- package/dist/tui/input/paste-slots.d.ts.map +1 -0
- package/dist/tui/input/paste-slots.js +46 -0
- package/dist/tui/input/paste-slots.js.map +1 -0
- package/dist/tui/input/paste.d.ts +15 -0
- package/dist/tui/input/paste.d.ts.map +1 -0
- package/dist/tui/input/paste.js +35 -0
- package/dist/tui/input/paste.js.map +1 -0
- package/dist/tui/input/protocol.d.ts +9 -0
- package/dist/tui/input/protocol.d.ts.map +1 -0
- package/dist/tui/input/protocol.js +387 -0
- package/dist/tui/input/protocol.js.map +1 -0
- package/dist/tui/input/sanitize.d.ts +6 -0
- package/dist/tui/input/sanitize.d.ts.map +1 -0
- package/dist/tui/input/sanitize.js +20 -0
- package/dist/tui/input/sanitize.js.map +1 -0
- package/dist/tui/input/types.d.ts +18 -0
- package/dist/tui/input/types.d.ts.map +1 -0
- package/dist/tui/input/types.js +2 -0
- package/dist/tui/input/types.js.map +1 -0
- package/dist/tui/launch.d.ts +23 -0
- package/dist/tui/launch.d.ts.map +1 -0
- package/dist/tui/launch.js +104 -0
- package/dist/tui/launch.js.map +1 -0
- package/dist/tui/theme.d.ts +20 -0
- package/dist/tui/theme.d.ts.map +1 -0
- package/dist/tui/theme.js +29 -0
- package/dist/tui/theme.js.map +1 -0
- package/dist/tui/types.d.ts +136 -0
- package/dist/tui/types.d.ts.map +1 -0
- package/dist/tui/types.js +9 -0
- package/dist/tui/types.js.map +1 -0
- package/package.json +76 -0
- package/prompts/sections/agents_md.md +23 -0
- package/prompts/sections/important_log.md +16 -0
- package/prompts/sections/system_mechanisms.md +18 -0
- package/prompts/tools/apply_patch.md +31 -0
- package/prompts/tools/ask.md +18 -0
- package/prompts/tools/bash.md +13 -0
- package/prompts/tools/bash_background.md +9 -0
- package/prompts/tools/bash_output.md +9 -0
- package/prompts/tools/check_status.md +3 -0
- package/prompts/tools/diff.md +5 -0
- package/prompts/tools/edit_file.md +11 -0
- package/prompts/tools/glob.md +7 -0
- package/prompts/tools/grep.md +20 -0
- package/prompts/tools/kill_agent.md +3 -0
- package/prompts/tools/kill_shell.md +5 -0
- package/prompts/tools/list_dir.md +5 -0
- package/prompts/tools/plan.md +252 -0
- package/prompts/tools/read_file.md +9 -0
- package/prompts/tools/show_context.md +12 -0
- package/prompts/tools/skill.md +7 -0
- package/prompts/tools/spawn_agent.md +195 -0
- package/prompts/tools/summarize_context.md +122 -0
- package/prompts/tools/test.md +5 -0
- package/prompts/tools/wait.md +17 -0
- package/prompts/tools/web_fetch.md +9 -0
- package/prompts/tools/web_search.md +5 -0
- package/prompts/tools/write_file.md +11 -0
- package/skills/.staging/.gitkeep +0 -0
- package/skills/explain-code/SKILL.md +15 -0
- package/skills/skill-manager/SKILL.md +83 -0
|
@@ -0,0 +1,2079 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in tool definitions and executors.
|
|
3
|
+
*
|
|
4
|
+
* 15 tools: read_file, list_dir, glob, grep, edit_file, write_file,
|
|
5
|
+
* apply_patch, bash, bash_background, bash_output, kill_shell,
|
|
6
|
+
* diff, test, web_search, web_fetch.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import { existsSync, statSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { ToolResult } from "../providers/base.js";
|
|
14
|
+
import { safePath, SafePathError, } from "../security/path.js";
|
|
15
|
+
import { getSensitiveFileReadReason } from "../security/sensitive-files.js";
|
|
16
|
+
import { WEB_SEARCH, toolBuiltinWebSearchPassthrough, } from "./web-search.js";
|
|
17
|
+
import { WEB_FETCH, toolWebFetch } from "./web-fetch.js";
|
|
18
|
+
import { isProjectedDocumentPath, loadProjectedDocumentView, projectedDocumentLabel, } from "../document-projection.js";
|
|
19
|
+
import { classifyFile, IMAGE_MEDIA_TYPES } from "../file-attach.js";
|
|
20
|
+
// ------------------------------------------------------------------
|
|
21
|
+
// Bash safety limits
|
|
22
|
+
// ------------------------------------------------------------------
|
|
23
|
+
const BASH_MAX_TIMEOUT = 600; // 10 minutes hard cap (seconds)
|
|
24
|
+
const BASH_DEFAULT_TIMEOUT = 60;
|
|
25
|
+
const BASH_MAX_OUTPUT_CHARS = 200_000; // ~200 KB text cap per stream
|
|
26
|
+
const BASH_TIMEOUT_KILL_SIGNAL = "SIGKILL";
|
|
27
|
+
const BASH_ENV_ALLOWLIST = new Set([
|
|
28
|
+
"PATH",
|
|
29
|
+
"HOME",
|
|
30
|
+
"SHELL",
|
|
31
|
+
"TERM",
|
|
32
|
+
"COLORTERM",
|
|
33
|
+
"LANG",
|
|
34
|
+
"LC_ALL",
|
|
35
|
+
"LC_CTYPE",
|
|
36
|
+
"LC_MESSAGES",
|
|
37
|
+
"TMPDIR",
|
|
38
|
+
"TMP",
|
|
39
|
+
"TEMP",
|
|
40
|
+
"PWD",
|
|
41
|
+
"USER",
|
|
42
|
+
"LOGNAME",
|
|
43
|
+
"TZ",
|
|
44
|
+
"NO_COLOR",
|
|
45
|
+
"FORCE_COLOR",
|
|
46
|
+
"CI",
|
|
47
|
+
"XDG_RUNTIME_DIR",
|
|
48
|
+
"XDG_CONFIG_HOME",
|
|
49
|
+
"XDG_CACHE_HOME",
|
|
50
|
+
"XDG_DATA_HOME",
|
|
51
|
+
]);
|
|
52
|
+
// ------------------------------------------------------------------
|
|
53
|
+
// Read limits
|
|
54
|
+
// ------------------------------------------------------------------
|
|
55
|
+
const READ_MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
56
|
+
const READ_MAX_LINES = 1000;
|
|
57
|
+
const READ_MAX_CHARS = 50_000;
|
|
58
|
+
const READ_MAX_IMAGE_SIZE = 20 * 1024 * 1024; // 20 MB limit for images
|
|
59
|
+
// ------------------------------------------------------------------
|
|
60
|
+
// Search safety limits
|
|
61
|
+
// ------------------------------------------------------------------
|
|
62
|
+
const SEARCH_MAX_RESULTS = 50;
|
|
63
|
+
const SEARCH_MAX_DEPTH = 6;
|
|
64
|
+
const SEARCH_MAX_FILES = 2_000;
|
|
65
|
+
const SEARCH_MAX_FILE_SIZE = 1 * 1024 * 1024; // 1 MB per file
|
|
66
|
+
const SEARCH_MAX_TOTAL_BYTES = 8 * 1024 * 1024; // 8 MB total scanned text
|
|
67
|
+
const SEARCH_MAX_PATTERN_LENGTH = 300;
|
|
68
|
+
const SEARCH_MAX_DURATION_MS = 2_000;
|
|
69
|
+
// ------------------------------------------------------------------
|
|
70
|
+
// File write safety (Phase 5)
|
|
71
|
+
// ------------------------------------------------------------------
|
|
72
|
+
const FILE_WRITE_LOCKS = new Map();
|
|
73
|
+
// ======================================================================
|
|
74
|
+
// Tool definitions (provider-agnostic JSON Schema)
|
|
75
|
+
// ======================================================================
|
|
76
|
+
const READ = {
|
|
77
|
+
name: "read_file",
|
|
78
|
+
description: "Read the contents of a text file (max 50 MB). " +
|
|
79
|
+
"Some document formats such as PDF, DOCX, and XLSX are returned as an auto-extracted Markdown view of the original file. " +
|
|
80
|
+
"Returns line window plus file metadata (including mtime_ms) for optional optimistic concurrency checks. " +
|
|
81
|
+
"Each call returns at most 1000 lines and 50000 characters. " +
|
|
82
|
+
"If the file exceeds these limits, the output is truncated with a notice. " +
|
|
83
|
+
"Use start_line / end_line to navigate large files in multiple calls. " +
|
|
84
|
+
"If both are omitted, reads from the beginning up to the limit.",
|
|
85
|
+
parameters: {
|
|
86
|
+
type: "object",
|
|
87
|
+
properties: {
|
|
88
|
+
path: {
|
|
89
|
+
type: "string",
|
|
90
|
+
description: "Absolute or relative file path",
|
|
91
|
+
},
|
|
92
|
+
start_line: {
|
|
93
|
+
type: "integer",
|
|
94
|
+
description: "First line to read (1-indexed, inclusive). Defaults to 1.",
|
|
95
|
+
},
|
|
96
|
+
end_line: {
|
|
97
|
+
type: "integer",
|
|
98
|
+
description: "Last line to read (1-indexed, inclusive). " +
|
|
99
|
+
"Use -1 to read to the end of the file.",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
required: ["path"],
|
|
103
|
+
},
|
|
104
|
+
summaryTemplate: "{agent} is reading {path}",
|
|
105
|
+
};
|
|
106
|
+
const LIST = {
|
|
107
|
+
name: "list_dir",
|
|
108
|
+
description: "List files and directories. Returns a tree up to 2 levels deep.",
|
|
109
|
+
parameters: {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: {
|
|
112
|
+
path: {
|
|
113
|
+
type: "string",
|
|
114
|
+
description: "Directory path (default: current directory)",
|
|
115
|
+
default: ".",
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
required: [],
|
|
119
|
+
},
|
|
120
|
+
summaryTemplate: "{agent} is listing {path}",
|
|
121
|
+
};
|
|
122
|
+
const EDIT = {
|
|
123
|
+
name: "edit_file",
|
|
124
|
+
description: "Apply a minimal patch to an existing file by replacing a unique string with a new string. The old_str must appear exactly once in the file.",
|
|
125
|
+
parameters: {
|
|
126
|
+
type: "object",
|
|
127
|
+
properties: {
|
|
128
|
+
path: { type: "string", description: "File path to edit" },
|
|
129
|
+
old_str: {
|
|
130
|
+
type: "string",
|
|
131
|
+
description: "Exact string to find (must be unique in the file)",
|
|
132
|
+
},
|
|
133
|
+
new_str: { type: "string", description: "Replacement string" },
|
|
134
|
+
expected_mtime_ms: {
|
|
135
|
+
type: "integer",
|
|
136
|
+
description: "Optional optimistic concurrency guard. " +
|
|
137
|
+
"If provided, edit is rejected when the file mtime differs (milliseconds since epoch).",
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
required: ["path", "old_str", "new_str"],
|
|
141
|
+
},
|
|
142
|
+
summaryTemplate: "{agent} is editing {path}",
|
|
143
|
+
};
|
|
144
|
+
const WRITE = {
|
|
145
|
+
name: "write_file",
|
|
146
|
+
description: "Create or overwrite a file with the given content. Parent directories are created automatically.",
|
|
147
|
+
parameters: {
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: {
|
|
150
|
+
path: { type: "string", description: "File path to write" },
|
|
151
|
+
content: { type: "string", description: "Full file content" },
|
|
152
|
+
expected_mtime_ms: {
|
|
153
|
+
type: "integer",
|
|
154
|
+
description: "Optional optimistic concurrency guard for overwrites. " +
|
|
155
|
+
"If provided, write is rejected when the existing file mtime differs (milliseconds since epoch).",
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
required: ["path", "content"],
|
|
159
|
+
},
|
|
160
|
+
summaryTemplate: "{agent} is writing to {path}",
|
|
161
|
+
};
|
|
162
|
+
const APPLY_PATCH = {
|
|
163
|
+
name: "apply_patch",
|
|
164
|
+
description: "Apply a structured multi-file patch. " +
|
|
165
|
+
"Use for multi-hunk edits, appending to large files, and coordinated file changes. " +
|
|
166
|
+
"Patch syntax uses explicit markers such as '*** Begin Patch', " +
|
|
167
|
+
"'*** Update File:', '*** Append File:', '*** Add File:', '*** Delete File:', and '*** End Patch'.",
|
|
168
|
+
parameters: {
|
|
169
|
+
type: "object",
|
|
170
|
+
properties: {
|
|
171
|
+
patch: {
|
|
172
|
+
type: "string",
|
|
173
|
+
description: "Full patch text. Example:\n" +
|
|
174
|
+
"*** Begin Patch\n" +
|
|
175
|
+
"*** Update File: src/app.ts\n" +
|
|
176
|
+
"@@\n" +
|
|
177
|
+
"-old line\n" +
|
|
178
|
+
"+new line\n" +
|
|
179
|
+
"*** End Patch",
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
required: ["patch"],
|
|
183
|
+
},
|
|
184
|
+
summaryTemplate: "{agent} is applying a patch",
|
|
185
|
+
};
|
|
186
|
+
const BASH = {
|
|
187
|
+
name: "bash",
|
|
188
|
+
description: "Execute a shell command and return stdout, stderr, and exit code.",
|
|
189
|
+
parameters: {
|
|
190
|
+
type: "object",
|
|
191
|
+
properties: {
|
|
192
|
+
command: { type: "string", description: "Shell command to execute" },
|
|
193
|
+
timeout: {
|
|
194
|
+
type: "integer",
|
|
195
|
+
description: `Timeout in seconds (default: ${BASH_DEFAULT_TIMEOUT}, max: ${BASH_MAX_TIMEOUT})`,
|
|
196
|
+
default: BASH_DEFAULT_TIMEOUT,
|
|
197
|
+
},
|
|
198
|
+
cwd: {
|
|
199
|
+
type: "string",
|
|
200
|
+
description: "Working directory for the command (default: current directory)",
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
required: ["command"],
|
|
204
|
+
},
|
|
205
|
+
summaryTemplate: "{agent} is running a shell command",
|
|
206
|
+
};
|
|
207
|
+
const DIFF = {
|
|
208
|
+
name: "diff",
|
|
209
|
+
description: "Show unified diff between two files, or between a file's current content and provided new content.",
|
|
210
|
+
parameters: {
|
|
211
|
+
type: "object",
|
|
212
|
+
properties: {
|
|
213
|
+
file_a: { type: "string", description: "Path to first file" },
|
|
214
|
+
file_b: {
|
|
215
|
+
type: "string",
|
|
216
|
+
description: "Path to second file (optional if content_b is given)",
|
|
217
|
+
default: "",
|
|
218
|
+
},
|
|
219
|
+
content_b: {
|
|
220
|
+
type: "string",
|
|
221
|
+
description: "Content to compare against file_a (optional if file_b is given)",
|
|
222
|
+
default: "",
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
required: ["file_a"],
|
|
226
|
+
},
|
|
227
|
+
summaryTemplate: "{agent} is comparing {file_a}",
|
|
228
|
+
};
|
|
229
|
+
const TEST = {
|
|
230
|
+
name: "test",
|
|
231
|
+
description: "Run a test command (e.g. pytest, unittest) and return the result.",
|
|
232
|
+
parameters: {
|
|
233
|
+
type: "object",
|
|
234
|
+
properties: {
|
|
235
|
+
command: {
|
|
236
|
+
type: "string",
|
|
237
|
+
description: "Test command to run (default: 'python -m pytest')",
|
|
238
|
+
default: "python -m pytest",
|
|
239
|
+
},
|
|
240
|
+
timeout: {
|
|
241
|
+
type: "integer",
|
|
242
|
+
description: "Timeout in seconds (default: 60)",
|
|
243
|
+
default: 60,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
required: [],
|
|
247
|
+
},
|
|
248
|
+
summaryTemplate: "{agent} is running tests",
|
|
249
|
+
};
|
|
250
|
+
// ------------------------------------------------------------------
|
|
251
|
+
// Glob tool
|
|
252
|
+
// ------------------------------------------------------------------
|
|
253
|
+
const GLOB_MAX_RESULTS = 200;
|
|
254
|
+
const GLOB_MAX_FILES_SCANNED = 10_000;
|
|
255
|
+
const GLOB_MAX_DEPTH = 10;
|
|
256
|
+
const GLOB = {
|
|
257
|
+
name: "glob",
|
|
258
|
+
description: "Find files by name pattern. Returns matching paths sorted by modification time.",
|
|
259
|
+
parameters: {
|
|
260
|
+
type: "object",
|
|
261
|
+
properties: {
|
|
262
|
+
pattern: {
|
|
263
|
+
type: "string",
|
|
264
|
+
description: "Glob pattern to match (e.g. \"**/*.ts\", \"src/**/*.test.tsx\")",
|
|
265
|
+
},
|
|
266
|
+
path: {
|
|
267
|
+
type: "string",
|
|
268
|
+
description: "Directory to search in (default: current directory)",
|
|
269
|
+
default: ".",
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
required: ["pattern"],
|
|
273
|
+
},
|
|
274
|
+
summaryTemplate: "{agent} is finding files matching '{pattern}'",
|
|
275
|
+
};
|
|
276
|
+
// ------------------------------------------------------------------
|
|
277
|
+
// Grep tool (enhanced search)
|
|
278
|
+
// ------------------------------------------------------------------
|
|
279
|
+
const GREP = {
|
|
280
|
+
name: "grep",
|
|
281
|
+
description: "Search file contents using regex. Supports context lines, glob filtering, and multiple output modes.",
|
|
282
|
+
parameters: {
|
|
283
|
+
type: "object",
|
|
284
|
+
properties: {
|
|
285
|
+
pattern: {
|
|
286
|
+
type: "string",
|
|
287
|
+
description: "Regex pattern to search for",
|
|
288
|
+
},
|
|
289
|
+
path: {
|
|
290
|
+
type: "string",
|
|
291
|
+
description: "Directory or file to search in (default: current directory)",
|
|
292
|
+
default: ".",
|
|
293
|
+
},
|
|
294
|
+
glob: {
|
|
295
|
+
type: "string",
|
|
296
|
+
description: "Glob pattern to filter files (e.g. \"*.ts\", \"*.{ts,tsx}\")",
|
|
297
|
+
},
|
|
298
|
+
type: {
|
|
299
|
+
type: "string",
|
|
300
|
+
description: "File type filter by extension (e.g. \"js\", \"py\", \"ts\")",
|
|
301
|
+
},
|
|
302
|
+
output_mode: {
|
|
303
|
+
type: "string",
|
|
304
|
+
enum: ["content", "files_with_matches", "count"],
|
|
305
|
+
description: "Output mode: \"content\" (matching lines with context), " +
|
|
306
|
+
"\"files_with_matches\" (file paths only, default), " +
|
|
307
|
+
"\"count\" (match counts per file)",
|
|
308
|
+
},
|
|
309
|
+
"-A": {
|
|
310
|
+
type: "integer",
|
|
311
|
+
description: "Lines to show after each match (content mode only)",
|
|
312
|
+
},
|
|
313
|
+
"-B": {
|
|
314
|
+
type: "integer",
|
|
315
|
+
description: "Lines to show before each match (content mode only)",
|
|
316
|
+
},
|
|
317
|
+
"-C": {
|
|
318
|
+
type: "integer",
|
|
319
|
+
description: "Lines to show before and after each match (content mode only)",
|
|
320
|
+
},
|
|
321
|
+
"-i": {
|
|
322
|
+
type: "boolean",
|
|
323
|
+
description: "Case insensitive search",
|
|
324
|
+
},
|
|
325
|
+
"-n": {
|
|
326
|
+
type: "boolean",
|
|
327
|
+
description: "Show line numbers (default true for content mode)",
|
|
328
|
+
},
|
|
329
|
+
head_limit: {
|
|
330
|
+
type: "integer",
|
|
331
|
+
description: "Limit output to first N entries",
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
required: ["pattern"],
|
|
335
|
+
},
|
|
336
|
+
summaryTemplate: "{agent} is searching for '{pattern}'",
|
|
337
|
+
};
|
|
338
|
+
// ------------------------------------------------------------------
|
|
339
|
+
// Background shell tools (tracked by Session)
|
|
340
|
+
// ------------------------------------------------------------------
|
|
341
|
+
export const BASH_BACKGROUND_TOOL = {
|
|
342
|
+
name: "bash_background",
|
|
343
|
+
description: "Start a background shell command tracked by the Session. " +
|
|
344
|
+
"Use for dev servers, watchers, and long-running commands whose output you want to inspect later.",
|
|
345
|
+
parameters: {
|
|
346
|
+
type: "object",
|
|
347
|
+
properties: {
|
|
348
|
+
command: { type: "string", description: "Shell command to execute in the background." },
|
|
349
|
+
cwd: { type: "string", description: "Optional working directory for the command." },
|
|
350
|
+
id: {
|
|
351
|
+
type: "string",
|
|
352
|
+
description: "Optional stable shell ID. If omitted, the Session generates one.",
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
required: ["command"],
|
|
356
|
+
},
|
|
357
|
+
summaryTemplate: "{agent} is starting a background shell",
|
|
358
|
+
};
|
|
359
|
+
export const BASH_OUTPUT_TOOL = {
|
|
360
|
+
name: "bash_output",
|
|
361
|
+
description: "Read output from a tracked background shell. " +
|
|
362
|
+
"By default, returns unread output since the last bash_output call for that shell. " +
|
|
363
|
+
"Use tail_lines to inspect recent output without advancing the unread cursor.",
|
|
364
|
+
parameters: {
|
|
365
|
+
type: "object",
|
|
366
|
+
properties: {
|
|
367
|
+
id: { type: "string", description: "Tracked shell ID." },
|
|
368
|
+
tail_lines: {
|
|
369
|
+
type: "integer",
|
|
370
|
+
description: "Optional: return the last N lines without advancing unread state.",
|
|
371
|
+
},
|
|
372
|
+
max_chars: {
|
|
373
|
+
type: "integer",
|
|
374
|
+
description: "Optional max characters to return (default 8000).",
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
required: ["id"],
|
|
378
|
+
},
|
|
379
|
+
summaryTemplate: "{agent} is reading background shell output",
|
|
380
|
+
};
|
|
381
|
+
export const KILL_SHELL_TOOL = {
|
|
382
|
+
name: "kill_shell",
|
|
383
|
+
description: "Terminate one or more tracked background shells. " +
|
|
384
|
+
"Use when a watcher or dev server is no longer needed, or a command is stuck.",
|
|
385
|
+
parameters: {
|
|
386
|
+
type: "object",
|
|
387
|
+
properties: {
|
|
388
|
+
ids: {
|
|
389
|
+
type: "array",
|
|
390
|
+
items: { type: "string" },
|
|
391
|
+
description: "Tracked shell IDs to terminate.",
|
|
392
|
+
},
|
|
393
|
+
signal: {
|
|
394
|
+
type: "string",
|
|
395
|
+
description: "Optional signal name (default TERM).",
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
required: ["ids"],
|
|
399
|
+
},
|
|
400
|
+
summaryTemplate: "{agent} is terminating background shells",
|
|
401
|
+
};
|
|
402
|
+
// ------------------------------------------------------------------
|
|
403
|
+
// Exports: tool lists
|
|
404
|
+
// ------------------------------------------------------------------
|
|
405
|
+
export const BASIC_TOOLS = [
|
|
406
|
+
READ,
|
|
407
|
+
LIST,
|
|
408
|
+
GLOB,
|
|
409
|
+
GREP,
|
|
410
|
+
EDIT,
|
|
411
|
+
WRITE,
|
|
412
|
+
APPLY_PATCH,
|
|
413
|
+
BASH,
|
|
414
|
+
BASH_BACKGROUND_TOOL,
|
|
415
|
+
BASH_OUTPUT_TOOL,
|
|
416
|
+
KILL_SHELL_TOOL,
|
|
417
|
+
DIFF,
|
|
418
|
+
TEST,
|
|
419
|
+
WEB_SEARCH,
|
|
420
|
+
WEB_FETCH,
|
|
421
|
+
];
|
|
422
|
+
export const BASIC_TOOLS_MAP = Object.fromEntries(BASIC_TOOLS.map((t) => [t.name, t]));
|
|
423
|
+
// ======================================================================
|
|
424
|
+
// Tool executors
|
|
425
|
+
// ======================================================================
|
|
426
|
+
// ------------------------------------------------------------------
|
|
427
|
+
// read_file
|
|
428
|
+
// ------------------------------------------------------------------
|
|
429
|
+
async function toolReadFile(filePath, startLine, endLine, artifactsDir, supportsMultimodal) {
|
|
430
|
+
const sensitiveReason = getSensitiveFileReadReason(filePath);
|
|
431
|
+
if (sensitiveReason) {
|
|
432
|
+
return `ERROR: Access to sensitive file is blocked by default: ${filePath} (${sensitiveReason}).`;
|
|
433
|
+
}
|
|
434
|
+
if (!existsSync(filePath)) {
|
|
435
|
+
return `ERROR: File not found: ${filePath}`;
|
|
436
|
+
}
|
|
437
|
+
let stat;
|
|
438
|
+
try {
|
|
439
|
+
stat = statSync(filePath);
|
|
440
|
+
}
|
|
441
|
+
catch (e) {
|
|
442
|
+
return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
|
|
443
|
+
}
|
|
444
|
+
if (!stat.isFile()) {
|
|
445
|
+
return `ERROR: Not a file: ${filePath}`;
|
|
446
|
+
}
|
|
447
|
+
// --- Image file handling ---
|
|
448
|
+
const [isImage] = classifyFile(filePath);
|
|
449
|
+
if (isImage) {
|
|
450
|
+
if (!supportsMultimodal) {
|
|
451
|
+
return `ERROR: Cannot read image file: current model does not support multimodal input. File: ${filePath}`;
|
|
452
|
+
}
|
|
453
|
+
if (stat.size > READ_MAX_IMAGE_SIZE) {
|
|
454
|
+
const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
|
|
455
|
+
return `ERROR: Image too large (${sizeMB} MB, limit ${READ_MAX_IMAGE_SIZE / 1024 / 1024} MB).`;
|
|
456
|
+
}
|
|
457
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
458
|
+
const mediaType = IMAGE_MEDIA_TYPES[ext] ?? "application/octet-stream";
|
|
459
|
+
try {
|
|
460
|
+
const raw = readFileSync(filePath);
|
|
461
|
+
const b64Data = raw.toString("base64");
|
|
462
|
+
const sizeFmt = stat.size < 1024
|
|
463
|
+
? `${stat.size} B`
|
|
464
|
+
: stat.size < 1024 * 1024
|
|
465
|
+
? `${(stat.size / 1024).toFixed(1)} KB`
|
|
466
|
+
: `${(stat.size / (1024 * 1024)).toFixed(1)} MB`;
|
|
467
|
+
const description = `[Image: ${path.basename(filePath)} | ${mediaType} | ${sizeFmt}]`;
|
|
468
|
+
return new ToolResult({
|
|
469
|
+
content: description,
|
|
470
|
+
contentBlocks: [
|
|
471
|
+
{ type: "text", text: description },
|
|
472
|
+
{
|
|
473
|
+
type: "image",
|
|
474
|
+
source: {
|
|
475
|
+
type: "base64",
|
|
476
|
+
media_type: mediaType,
|
|
477
|
+
data: b64Data,
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
],
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
catch (e) {
|
|
484
|
+
return `ERROR: Failed to read image: ${e instanceof Error ? e.message : String(e)}`;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (stat.size > READ_MAX_FILE_SIZE) {
|
|
488
|
+
const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
|
|
489
|
+
return `ERROR: File too large (${sizeMB} MB, limit ${READ_MAX_FILE_SIZE / 1024 / 1024} MB).`;
|
|
490
|
+
}
|
|
491
|
+
const isProjectedDocument = isProjectedDocumentPath(filePath);
|
|
492
|
+
let text;
|
|
493
|
+
let mtimeMs = Math.trunc(stat.mtimeMs);
|
|
494
|
+
let sizeBytes = stat.size;
|
|
495
|
+
let headerPrefix = "";
|
|
496
|
+
try {
|
|
497
|
+
if (isProjectedDocument) {
|
|
498
|
+
const view = await loadProjectedDocumentView(filePath, artifactsDir);
|
|
499
|
+
text = view.text;
|
|
500
|
+
mtimeMs = view.mtimeMs;
|
|
501
|
+
sizeBytes = view.sizeBytes;
|
|
502
|
+
headerPrefix =
|
|
503
|
+
`[Auto-extracted Markdown view of ${path.basename(filePath)} (${projectedDocumentLabel(filePath)} source) | ` +
|
|
504
|
+
`original_path=${filePath}]` + "\n";
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
text = readFileSync(filePath, { encoding: "utf-8" });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
catch (e) {
|
|
511
|
+
return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
|
|
512
|
+
}
|
|
513
|
+
const lines = text.split(/\r?\n/);
|
|
514
|
+
// Keep trailing newline semantics: if file ends with \n the last split
|
|
515
|
+
// element is "" but that represents "no extra line".
|
|
516
|
+
const total = lines.length;
|
|
517
|
+
let start = startLine ?? 1;
|
|
518
|
+
let end = endLine == null || endLine === -1 ? total : endLine;
|
|
519
|
+
if (start < 1)
|
|
520
|
+
return `ERROR: start_line must be >= 1, got ${start}.`;
|
|
521
|
+
if (start > total)
|
|
522
|
+
return `ERROR: start_line ${start} exceeds total lines (${total}).`;
|
|
523
|
+
if (end > total)
|
|
524
|
+
end = total;
|
|
525
|
+
if (end < start)
|
|
526
|
+
return `ERROR: end_line (${end}) < start_line (${start}).`;
|
|
527
|
+
// Apply line limit
|
|
528
|
+
if (end - start + 1 > READ_MAX_LINES) {
|
|
529
|
+
end = start + READ_MAX_LINES - 1;
|
|
530
|
+
}
|
|
531
|
+
let selected = lines.slice(start - 1, end);
|
|
532
|
+
// Apply character limit
|
|
533
|
+
let charCount = 0;
|
|
534
|
+
let truncatedAtLine = null;
|
|
535
|
+
for (let i = 0; i < selected.length; i++) {
|
|
536
|
+
charCount += selected[i].length + 1; // +1 for newline
|
|
537
|
+
if (charCount > READ_MAX_CHARS) {
|
|
538
|
+
selected = selected.slice(0, i);
|
|
539
|
+
truncatedAtLine = start + i; // 1-indexed line that exceeded the limit
|
|
540
|
+
end = start + i - 1; // last fully included line
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
let result = headerPrefix +
|
|
545
|
+
`[Lines ${start}-${end} of ${total} | mtime_ms=${mtimeMs} | size_bytes=${sizeBytes}]\n` +
|
|
546
|
+
selected.join("\n");
|
|
547
|
+
if (truncatedAtLine !== null) {
|
|
548
|
+
result +=
|
|
549
|
+
`\n\n[WARNING: Reached ${READ_MAX_CHARS.toLocaleString()} character limit at line ` +
|
|
550
|
+
`${truncatedAtLine}. Showing lines ${start}-${end} ` +
|
|
551
|
+
`(${end - start + 1} complete lines). ` +
|
|
552
|
+
`Use start_line=${end + 1} to continue reading${isProjectedDocument ? " the extracted Markdown view of the same source path" : ""}.]`;
|
|
553
|
+
}
|
|
554
|
+
else if (end < total) {
|
|
555
|
+
result +=
|
|
556
|
+
`\n\n[Output truncated at ${READ_MAX_LINES} lines. ` +
|
|
557
|
+
`Use start_line=${end + 1} to continue reading${isProjectedDocument ? " the extracted Markdown view of the same source path" : ""}.]`;
|
|
558
|
+
}
|
|
559
|
+
return result;
|
|
560
|
+
}
|
|
561
|
+
// ------------------------------------------------------------------
|
|
562
|
+
// list_dir
|
|
563
|
+
// ------------------------------------------------------------------
|
|
564
|
+
function toolListDir(dirPath = ".") {
|
|
565
|
+
if (!existsSync(dirPath)) {
|
|
566
|
+
return `ERROR: Directory not found: ${dirPath}`;
|
|
567
|
+
}
|
|
568
|
+
const stat = statSync(dirPath);
|
|
569
|
+
if (!stat.isDirectory()) {
|
|
570
|
+
return `ERROR: Not a directory: ${dirPath}`;
|
|
571
|
+
}
|
|
572
|
+
const lines = [];
|
|
573
|
+
function walk(dir, prefix, depth) {
|
|
574
|
+
if (depth > 2)
|
|
575
|
+
return;
|
|
576
|
+
let entries;
|
|
577
|
+
try {
|
|
578
|
+
entries = readdirSync(dir);
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
// Sort: directories first, then files, alphabetical
|
|
584
|
+
const withStats = entries
|
|
585
|
+
.filter((name) => !name.startsWith(".") &&
|
|
586
|
+
name !== "node_modules" &&
|
|
587
|
+
name !== "__pycache__")
|
|
588
|
+
.map((name) => {
|
|
589
|
+
const full = path.join(dir, name);
|
|
590
|
+
let isDir = false;
|
|
591
|
+
try {
|
|
592
|
+
isDir = statSync(full).isDirectory();
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
// skip inaccessible
|
|
596
|
+
}
|
|
597
|
+
return { name, full, isDir };
|
|
598
|
+
})
|
|
599
|
+
.sort((a, b) => {
|
|
600
|
+
if (a.isDir !== b.isDir)
|
|
601
|
+
return a.isDir ? -1 : 1;
|
|
602
|
+
return a.name.localeCompare(b.name);
|
|
603
|
+
});
|
|
604
|
+
for (const entry of withStats) {
|
|
605
|
+
const marker = entry.isDir ? "[DIR] " : "";
|
|
606
|
+
lines.push(`${prefix}${marker}${entry.name}`);
|
|
607
|
+
if (entry.isDir) {
|
|
608
|
+
walk(entry.full, prefix + " ", depth + 1);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
walk(dirPath, "", 0);
|
|
613
|
+
return lines.length > 0 ? lines.join("\n") : "(empty directory)";
|
|
614
|
+
}
|
|
615
|
+
class FileVersionConflictError extends Error {
|
|
616
|
+
constructor(message) {
|
|
617
|
+
super(message);
|
|
618
|
+
this.name = "FileVersionConflictError";
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
function getFileVersionSnapshot(filePath) {
|
|
622
|
+
if (!existsSync(filePath))
|
|
623
|
+
return { exists: false };
|
|
624
|
+
const st = statSync(filePath);
|
|
625
|
+
return {
|
|
626
|
+
exists: true,
|
|
627
|
+
mtimeMs: Math.trunc(st.mtimeMs),
|
|
628
|
+
size: st.size,
|
|
629
|
+
ino: typeof st.ino === "number" ? st.ino : undefined,
|
|
630
|
+
dev: typeof st.dev === "number" ? st.dev : undefined,
|
|
631
|
+
mode: st.mode,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
function sameFileVersion(a, b) {
|
|
635
|
+
if (a.exists !== b.exists)
|
|
636
|
+
return false;
|
|
637
|
+
if (!a.exists && !b.exists)
|
|
638
|
+
return true;
|
|
639
|
+
return (a.mtimeMs === b.mtimeMs &&
|
|
640
|
+
a.size === b.size &&
|
|
641
|
+
a.ino === b.ino &&
|
|
642
|
+
a.dev === b.dev);
|
|
643
|
+
}
|
|
644
|
+
function validateExpectedMtime(filePath, expectedMtimeMs, current) {
|
|
645
|
+
if (expectedMtimeMs == null)
|
|
646
|
+
return;
|
|
647
|
+
if (!current.exists) {
|
|
648
|
+
throw new FileVersionConflictError(`File changed since last read (mtime conflict): ${filePath} (file does not exist).`);
|
|
649
|
+
}
|
|
650
|
+
if (current.mtimeMs !== expectedMtimeMs) {
|
|
651
|
+
throw new FileVersionConflictError(`File changed since last read (mtime conflict): ${filePath} ` +
|
|
652
|
+
`(expected ${expectedMtimeMs}, current ${current.mtimeMs}).`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
function fileWriteLockKey(filePath) {
|
|
656
|
+
try {
|
|
657
|
+
return realpathSync(filePath);
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
return path.resolve(filePath);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
async function withFileWriteLock(filePath, fn) {
|
|
664
|
+
const key = fileWriteLockKey(filePath);
|
|
665
|
+
const previous = FILE_WRITE_LOCKS.get(key) ?? Promise.resolve();
|
|
666
|
+
let release;
|
|
667
|
+
const current = new Promise((resolve) => {
|
|
668
|
+
release = resolve;
|
|
669
|
+
});
|
|
670
|
+
const chain = previous.then(() => current);
|
|
671
|
+
FILE_WRITE_LOCKS.set(key, chain);
|
|
672
|
+
await previous;
|
|
673
|
+
try {
|
|
674
|
+
return await fn();
|
|
675
|
+
}
|
|
676
|
+
finally {
|
|
677
|
+
release();
|
|
678
|
+
if (FILE_WRITE_LOCKS.get(key) === chain) {
|
|
679
|
+
FILE_WRITE_LOCKS.delete(key);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
// ------------------------------------------------------------------
|
|
684
|
+
// edit_file
|
|
685
|
+
// ------------------------------------------------------------------
|
|
686
|
+
async function toolEditFile(filePath, oldStr, newStr, expectedMtimeMs) {
|
|
687
|
+
return withFileWriteLock(filePath, async () => {
|
|
688
|
+
if (!existsSync(filePath)) {
|
|
689
|
+
return `ERROR: File not found: ${filePath}`;
|
|
690
|
+
}
|
|
691
|
+
let initialVersion;
|
|
692
|
+
try {
|
|
693
|
+
initialVersion = getFileVersionSnapshot(filePath);
|
|
694
|
+
validateExpectedMtime(filePath, expectedMtimeMs, initialVersion);
|
|
695
|
+
}
|
|
696
|
+
catch (e) {
|
|
697
|
+
return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
|
|
698
|
+
}
|
|
699
|
+
let content;
|
|
700
|
+
try {
|
|
701
|
+
content = readFileSync(filePath, { encoding: "utf-8" });
|
|
702
|
+
}
|
|
703
|
+
catch (e) {
|
|
704
|
+
return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
|
|
705
|
+
}
|
|
706
|
+
const count = content.split(oldStr).length - 1;
|
|
707
|
+
if (count === 0) {
|
|
708
|
+
return "ERROR: old_str not found in file.";
|
|
709
|
+
}
|
|
710
|
+
if (count > 1) {
|
|
711
|
+
return `ERROR: old_str appears ${count} times (must be unique).`;
|
|
712
|
+
}
|
|
713
|
+
const newContent = content.replace(oldStr, newStr);
|
|
714
|
+
const diffPreview = buildUnifiedDiffPreview(simpleUnifiedDiff(content.split("\n"), newContent.split("\n"), filePath, filePath));
|
|
715
|
+
try {
|
|
716
|
+
await atomicWriteTextFile(filePath, newContent, initialVersion.mode, initialVersion);
|
|
717
|
+
}
|
|
718
|
+
catch (e) {
|
|
719
|
+
return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
|
|
720
|
+
}
|
|
721
|
+
return new ToolResult({
|
|
722
|
+
content: "OK: File edited successfully.",
|
|
723
|
+
metadata: {
|
|
724
|
+
path: filePath,
|
|
725
|
+
tui_preview: {
|
|
726
|
+
kind: "diff",
|
|
727
|
+
text: diffPreview.text,
|
|
728
|
+
truncated: diffPreview.truncated,
|
|
729
|
+
},
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
// ------------------------------------------------------------------
|
|
735
|
+
// write_file
|
|
736
|
+
// ------------------------------------------------------------------
|
|
737
|
+
async function toolWriteFile(filePath, content, expectedMtimeMs) {
|
|
738
|
+
return withFileWriteLock(filePath, async () => {
|
|
739
|
+
try {
|
|
740
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
741
|
+
const initialVersion = getFileVersionSnapshot(filePath);
|
|
742
|
+
validateExpectedMtime(filePath, expectedMtimeMs, initialVersion);
|
|
743
|
+
const mode = initialVersion.mode;
|
|
744
|
+
const before = initialVersion.exists
|
|
745
|
+
? readFileSync(filePath, { encoding: "utf-8" })
|
|
746
|
+
: "";
|
|
747
|
+
const beforeLines = before.length > 0 ? before.split("\n") : [];
|
|
748
|
+
const afterLines = content.length > 0 ? content.split("\n") : [];
|
|
749
|
+
const diffPreview = buildUnifiedDiffPreview(simpleUnifiedDiff(beforeLines, afterLines, filePath, filePath));
|
|
750
|
+
await atomicWriteTextFile(filePath, content, mode, initialVersion);
|
|
751
|
+
return new ToolResult({
|
|
752
|
+
content: `OK: Wrote ${content.length} characters to ${filePath}`,
|
|
753
|
+
metadata: {
|
|
754
|
+
path: filePath,
|
|
755
|
+
tui_preview: {
|
|
756
|
+
kind: "diff",
|
|
757
|
+
text: diffPreview.text,
|
|
758
|
+
truncated: diffPreview.truncated,
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
catch (e) {
|
|
764
|
+
return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
async function atomicWriteTextFile(filePath, content, mode, expectedVersion) {
|
|
769
|
+
const dir = path.dirname(filePath);
|
|
770
|
+
const base = path.basename(filePath);
|
|
771
|
+
const tmpPath = path.join(dir, `.${base}.tmp-${process.pid}-${randomUUID()}`);
|
|
772
|
+
let tmpExists = false;
|
|
773
|
+
try {
|
|
774
|
+
await fs.writeFile(tmpPath, content, { encoding: "utf-8" });
|
|
775
|
+
tmpExists = true;
|
|
776
|
+
if (mode !== undefined) {
|
|
777
|
+
try {
|
|
778
|
+
await fs.chmod(tmpPath, mode);
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
// Best-effort permission preservation
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (expectedVersion) {
|
|
785
|
+
const currentVersion = getFileVersionSnapshot(filePath);
|
|
786
|
+
if (!sameFileVersion(expectedVersion, currentVersion)) {
|
|
787
|
+
throw new FileVersionConflictError(`File changed during write (mtime conflict): ${filePath}. Please re-read and retry.`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
await fs.rename(tmpPath, filePath);
|
|
791
|
+
tmpExists = false;
|
|
792
|
+
}
|
|
793
|
+
finally {
|
|
794
|
+
if (tmpExists) {
|
|
795
|
+
try {
|
|
796
|
+
await fs.unlink(tmpPath);
|
|
797
|
+
}
|
|
798
|
+
catch {
|
|
799
|
+
// ignore cleanup failure
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
function parsePatchBodyLines(lines, startIdx) {
|
|
805
|
+
const contentLines = [];
|
|
806
|
+
let i = startIdx;
|
|
807
|
+
while (i < lines.length && !lines[i].startsWith("***")) {
|
|
808
|
+
const line = lines[i];
|
|
809
|
+
if (line.startsWith("+")) {
|
|
810
|
+
contentLines.push(line.slice(1));
|
|
811
|
+
}
|
|
812
|
+
else if (line.trim() !== "") {
|
|
813
|
+
throw new Error(`Invalid patch line in add/append block: '${line}'`);
|
|
814
|
+
}
|
|
815
|
+
i += 1;
|
|
816
|
+
}
|
|
817
|
+
return { contents: contentLines.join("\n"), nextIdx: i };
|
|
818
|
+
}
|
|
819
|
+
function parsePatchUpdateChunks(lines, startIdx) {
|
|
820
|
+
const chunks = [];
|
|
821
|
+
let i = startIdx;
|
|
822
|
+
while (i < lines.length && !lines[i].startsWith("***")) {
|
|
823
|
+
if (!lines[i].startsWith("@@")) {
|
|
824
|
+
if (!lines[i].trim()) {
|
|
825
|
+
i += 1;
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
throw new Error(`Invalid patch chunk header: '${lines[i]}'`);
|
|
829
|
+
}
|
|
830
|
+
const changeContext = lines[i].slice(2).trim() || undefined;
|
|
831
|
+
i += 1;
|
|
832
|
+
const oldLines = [];
|
|
833
|
+
const newLines = [];
|
|
834
|
+
while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
|
|
835
|
+
const line = lines[i];
|
|
836
|
+
if (line.startsWith(" ")) {
|
|
837
|
+
const content = line.slice(1);
|
|
838
|
+
oldLines.push(content);
|
|
839
|
+
newLines.push(content);
|
|
840
|
+
}
|
|
841
|
+
else if (line.startsWith("-")) {
|
|
842
|
+
oldLines.push(line.slice(1));
|
|
843
|
+
}
|
|
844
|
+
else if (line.startsWith("+")) {
|
|
845
|
+
newLines.push(line.slice(1));
|
|
846
|
+
}
|
|
847
|
+
else if (line.trim() !== "") {
|
|
848
|
+
throw new Error(`Invalid patch change line: '${line}'`);
|
|
849
|
+
}
|
|
850
|
+
i += 1;
|
|
851
|
+
}
|
|
852
|
+
if (oldLines.length === 0 && newLines.length === 0) {
|
|
853
|
+
throw new Error("Empty update chunk is not allowed.");
|
|
854
|
+
}
|
|
855
|
+
chunks.push({ oldLines, newLines, changeContext });
|
|
856
|
+
}
|
|
857
|
+
return { chunks, nextIdx: i };
|
|
858
|
+
}
|
|
859
|
+
function parseApplyPatchText(patchText) {
|
|
860
|
+
const trimmed = patchText.trim();
|
|
861
|
+
if (!trimmed)
|
|
862
|
+
throw new Error("patchText is required.");
|
|
863
|
+
const lines = trimmed.split("\n");
|
|
864
|
+
const beginIdx = lines.findIndex((line) => line.trim() === "*** Begin Patch");
|
|
865
|
+
const endIdx = lines.findIndex((line) => line.trim() === "*** End Patch");
|
|
866
|
+
if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) {
|
|
867
|
+
throw new Error("Invalid patch format: missing Begin/End markers.");
|
|
868
|
+
}
|
|
869
|
+
const ops = [];
|
|
870
|
+
let i = beginIdx + 1;
|
|
871
|
+
while (i < endIdx) {
|
|
872
|
+
const line = lines[i];
|
|
873
|
+
if (!line.trim()) {
|
|
874
|
+
i += 1;
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (line.startsWith("*** Add File:")) {
|
|
878
|
+
const requestedPath = line.slice("*** Add File:".length).trim();
|
|
879
|
+
if (!requestedPath)
|
|
880
|
+
throw new Error("Add File is missing a path.");
|
|
881
|
+
const { contents, nextIdx } = parsePatchBodyLines(lines, i + 1);
|
|
882
|
+
ops.push({ type: "add", path: requestedPath, contents });
|
|
883
|
+
i = nextIdx;
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
if (line.startsWith("*** Append File:")) {
|
|
887
|
+
const requestedPath = line.slice("*** Append File:".length).trim();
|
|
888
|
+
if (!requestedPath)
|
|
889
|
+
throw new Error("Append File is missing a path.");
|
|
890
|
+
const { contents, nextIdx } = parsePatchBodyLines(lines, i + 1);
|
|
891
|
+
ops.push({ type: "append", path: requestedPath, contents });
|
|
892
|
+
i = nextIdx;
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (line.startsWith("*** Delete File:")) {
|
|
896
|
+
const requestedPath = line.slice("*** Delete File:".length).trim();
|
|
897
|
+
if (!requestedPath)
|
|
898
|
+
throw new Error("Delete File is missing a path.");
|
|
899
|
+
ops.push({ type: "delete", path: requestedPath });
|
|
900
|
+
i += 1;
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
if (line.startsWith("*** Update File:")) {
|
|
904
|
+
const requestedPath = line.slice("*** Update File:".length).trim();
|
|
905
|
+
if (!requestedPath)
|
|
906
|
+
throw new Error("Update File is missing a path.");
|
|
907
|
+
const { chunks, nextIdx } = parsePatchUpdateChunks(lines, i + 1);
|
|
908
|
+
if (!chunks.length) {
|
|
909
|
+
throw new Error(`Update File '${requestedPath}' does not contain any chunks.`);
|
|
910
|
+
}
|
|
911
|
+
ops.push({ type: "update", path: requestedPath, chunks });
|
|
912
|
+
i = nextIdx;
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
throw new Error(`Invalid patch directive: '${line}'`);
|
|
916
|
+
}
|
|
917
|
+
if (!ops.length) {
|
|
918
|
+
throw new Error("patch rejected: empty patch");
|
|
919
|
+
}
|
|
920
|
+
return ops;
|
|
921
|
+
}
|
|
922
|
+
function seekSequence(lines, needle, startIdx) {
|
|
923
|
+
if (needle.length === 0)
|
|
924
|
+
return startIdx;
|
|
925
|
+
outer: for (let i = Math.max(0, startIdx); i <= lines.length - needle.length; i += 1) {
|
|
926
|
+
for (let j = 0; j < needle.length; j += 1) {
|
|
927
|
+
if (lines[i + j] !== needle[j])
|
|
928
|
+
continue outer;
|
|
929
|
+
}
|
|
930
|
+
return i;
|
|
931
|
+
}
|
|
932
|
+
return -1;
|
|
933
|
+
}
|
|
934
|
+
function applyUpdateChunksToContent(filePath, originalContent, chunks) {
|
|
935
|
+
let lines = originalContent.split("\n");
|
|
936
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
937
|
+
lines = lines.slice(0, -1);
|
|
938
|
+
}
|
|
939
|
+
let cursor = 0;
|
|
940
|
+
for (const chunk of chunks) {
|
|
941
|
+
const oldSeq = chunk.oldLines;
|
|
942
|
+
let matchIdx = -1;
|
|
943
|
+
if (chunk.changeContext) {
|
|
944
|
+
for (let i = cursor; i < lines.length; i += 1) {
|
|
945
|
+
if (lines[i] !== chunk.changeContext)
|
|
946
|
+
continue;
|
|
947
|
+
const candidate = seekSequence(lines, oldSeq, i);
|
|
948
|
+
if (candidate !== -1) {
|
|
949
|
+
matchIdx = candidate;
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
else {
|
|
955
|
+
matchIdx = seekSequence(lines, oldSeq, cursor);
|
|
956
|
+
if (matchIdx === -1) {
|
|
957
|
+
matchIdx = seekSequence(lines, oldSeq, 0);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
if (matchIdx === -1) {
|
|
961
|
+
const detail = chunk.changeContext
|
|
962
|
+
? `context '${chunk.changeContext}'`
|
|
963
|
+
: oldSeq.length > 0
|
|
964
|
+
? `sequence '${oldSeq[0]}'`
|
|
965
|
+
: "target location";
|
|
966
|
+
throw new Error(`Failed to match patch chunk in ${filePath}: ${detail}`);
|
|
967
|
+
}
|
|
968
|
+
lines.splice(matchIdx, oldSeq.length, ...chunk.newLines);
|
|
969
|
+
cursor = matchIdx + chunk.newLines.length;
|
|
970
|
+
}
|
|
971
|
+
let nextContent = lines.join("\n");
|
|
972
|
+
if (nextContent && !nextContent.endsWith("\n")) {
|
|
973
|
+
nextContent += "\n";
|
|
974
|
+
}
|
|
975
|
+
return nextContent;
|
|
976
|
+
}
|
|
977
|
+
function displayRelativePath(root, filePath) {
|
|
978
|
+
const rel = path.relative(root, filePath) || path.basename(filePath);
|
|
979
|
+
return rel.split(path.sep).join("/");
|
|
980
|
+
}
|
|
981
|
+
async function toolApplyPatch(patchText, ctx) {
|
|
982
|
+
const ops = parseApplyPatchText(patchText);
|
|
983
|
+
const root = toolRoot(ctx);
|
|
984
|
+
const prepared = [];
|
|
985
|
+
for (const op of ops) {
|
|
986
|
+
if (op.type === "add") {
|
|
987
|
+
const filePath = scopedPath(op.path, "write", ctx, { allowCreate: true, expectFile: true });
|
|
988
|
+
if (existsSync(filePath)) {
|
|
989
|
+
throw new Error(`apply_patch verification failed: File already exists: ${displayRelativePath(root, filePath)}`);
|
|
990
|
+
}
|
|
991
|
+
prepared.push({
|
|
992
|
+
type: "add",
|
|
993
|
+
requestedPath: op.path,
|
|
994
|
+
filePath,
|
|
995
|
+
before: "",
|
|
996
|
+
after: op.contents,
|
|
997
|
+
});
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
if (op.type === "append") {
|
|
1001
|
+
const filePath = scopedPath(op.path, "write", ctx, { mustExist: true, expectFile: true });
|
|
1002
|
+
const before = readFileSync(filePath, "utf-8");
|
|
1003
|
+
const separator = before.length > 0 && !before.endsWith("\n") ? "\n" : "";
|
|
1004
|
+
let after = before + separator + op.contents;
|
|
1005
|
+
if (after && !after.endsWith("\n"))
|
|
1006
|
+
after += "\n";
|
|
1007
|
+
prepared.push({
|
|
1008
|
+
type: "append",
|
|
1009
|
+
requestedPath: op.path,
|
|
1010
|
+
filePath,
|
|
1011
|
+
before,
|
|
1012
|
+
after,
|
|
1013
|
+
mode: getFileVersionSnapshot(filePath).mode,
|
|
1014
|
+
});
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
if (op.type === "delete") {
|
|
1018
|
+
const filePath = scopedPath(op.path, "write", ctx, { mustExist: true, expectFile: true });
|
|
1019
|
+
prepared.push({
|
|
1020
|
+
type: "delete",
|
|
1021
|
+
requestedPath: op.path,
|
|
1022
|
+
filePath,
|
|
1023
|
+
before: readFileSync(filePath, "utf-8"),
|
|
1024
|
+
after: null,
|
|
1025
|
+
mode: getFileVersionSnapshot(filePath).mode,
|
|
1026
|
+
});
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
const filePath = scopedPath(op.path, "write", ctx, { mustExist: true, expectFile: true });
|
|
1030
|
+
const before = readFileSync(filePath, "utf-8");
|
|
1031
|
+
const after = applyUpdateChunksToContent(filePath, before, op.chunks);
|
|
1032
|
+
prepared.push({
|
|
1033
|
+
type: "update",
|
|
1034
|
+
requestedPath: op.path,
|
|
1035
|
+
filePath,
|
|
1036
|
+
before,
|
|
1037
|
+
after,
|
|
1038
|
+
mode: getFileVersionSnapshot(filePath).mode,
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
const previewDiffs = [];
|
|
1042
|
+
for (const change of prepared) {
|
|
1043
|
+
const beforeLines = change.before.split("\n");
|
|
1044
|
+
const afterLines = (change.after ?? "").split("\n");
|
|
1045
|
+
previewDiffs.push(simpleUnifiedDiff(beforeLines, afterLines, change.filePath, change.filePath));
|
|
1046
|
+
}
|
|
1047
|
+
const diffPreview = buildUnifiedDiffPreview(previewDiffs.join("\n"));
|
|
1048
|
+
for (const change of prepared) {
|
|
1049
|
+
if (change.after === null) {
|
|
1050
|
+
await fs.unlink(change.filePath);
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
await fs.mkdir(path.dirname(change.filePath), { recursive: true });
|
|
1054
|
+
const expectedVersion = change.type === "add"
|
|
1055
|
+
? undefined
|
|
1056
|
+
: getFileVersionSnapshot(change.filePath);
|
|
1057
|
+
await atomicWriteTextFile(change.filePath, change.after, change.mode, expectedVersion);
|
|
1058
|
+
}
|
|
1059
|
+
const lines = ["Success. Updated the following files:"];
|
|
1060
|
+
for (const change of prepared) {
|
|
1061
|
+
const kind = change.type === "add"
|
|
1062
|
+
? "A"
|
|
1063
|
+
: change.type === "delete"
|
|
1064
|
+
? "D"
|
|
1065
|
+
: "M";
|
|
1066
|
+
lines.push(`${kind} ${displayRelativePath(root, change.filePath)}`);
|
|
1067
|
+
}
|
|
1068
|
+
return new ToolResult({
|
|
1069
|
+
content: lines.join("\n"),
|
|
1070
|
+
metadata: {
|
|
1071
|
+
paths: prepared.map((change) => change.filePath),
|
|
1072
|
+
tui_preview: {
|
|
1073
|
+
kind: "diff",
|
|
1074
|
+
text: diffPreview.text,
|
|
1075
|
+
truncated: diffPreview.truncated,
|
|
1076
|
+
},
|
|
1077
|
+
},
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
// ------------------------------------------------------------------
|
|
1081
|
+
// bash
|
|
1082
|
+
// ------------------------------------------------------------------
|
|
1083
|
+
function truncateOutput(text, limit) {
|
|
1084
|
+
if (text.length <= limit)
|
|
1085
|
+
return text;
|
|
1086
|
+
const half = Math.floor(limit / 2);
|
|
1087
|
+
const omitted = text.length - limit;
|
|
1088
|
+
return (text.slice(0, half) +
|
|
1089
|
+
`\n\n... [truncated ${omitted.toLocaleString()} chars] ...\n\n` +
|
|
1090
|
+
text.slice(-half));
|
|
1091
|
+
}
|
|
1092
|
+
export function buildBashEnv() {
|
|
1093
|
+
const env = {};
|
|
1094
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
1095
|
+
if (value == null)
|
|
1096
|
+
continue;
|
|
1097
|
+
if (BASH_ENV_ALLOWLIST.has(key) || key.startsWith("LC_")) {
|
|
1098
|
+
env[key] = value;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
// Keep a usable PATH even if parent PATH is missing.
|
|
1102
|
+
if (!env["PATH"]) {
|
|
1103
|
+
env["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin";
|
|
1104
|
+
}
|
|
1105
|
+
return env;
|
|
1106
|
+
}
|
|
1107
|
+
function toolBash(command, timeout = BASH_DEFAULT_TIMEOUT, cwd = "") {
|
|
1108
|
+
// Enforce timeout bounds
|
|
1109
|
+
if (typeof timeout !== "number" || timeout < 1) {
|
|
1110
|
+
timeout = BASH_DEFAULT_TIMEOUT;
|
|
1111
|
+
}
|
|
1112
|
+
timeout = Math.min(timeout, BASH_MAX_TIMEOUT);
|
|
1113
|
+
// Resolve working directory
|
|
1114
|
+
let runCwd;
|
|
1115
|
+
if (cwd) {
|
|
1116
|
+
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
|
1117
|
+
return `ERROR: Working directory does not exist or is not a directory: ${cwd}`;
|
|
1118
|
+
}
|
|
1119
|
+
runCwd = cwd;
|
|
1120
|
+
}
|
|
1121
|
+
const result = spawnSync("sh", ["-c", command], {
|
|
1122
|
+
cwd: runCwd,
|
|
1123
|
+
timeout: timeout * 1000,
|
|
1124
|
+
encoding: "utf-8",
|
|
1125
|
+
maxBuffer: 10 * 1024 * 1024, // 10 MB buffer
|
|
1126
|
+
env: buildBashEnv(),
|
|
1127
|
+
killSignal: BASH_TIMEOUT_KILL_SIGNAL,
|
|
1128
|
+
});
|
|
1129
|
+
if (result.error) {
|
|
1130
|
+
if (result.error.code === "ETIMEDOUT" ||
|
|
1131
|
+
result.signal === "SIGTERM" ||
|
|
1132
|
+
result.signal === BASH_TIMEOUT_KILL_SIGNAL) {
|
|
1133
|
+
return (`ERROR: Command timed out after ${timeout}s (max allowed: ${BASH_MAX_TIMEOUT}s). ` +
|
|
1134
|
+
`Shell process was terminated (${BASH_TIMEOUT_KILL_SIGNAL}); child-process tree termination is best-effort.`);
|
|
1135
|
+
}
|
|
1136
|
+
return `ERROR: ${result.error.message}`;
|
|
1137
|
+
}
|
|
1138
|
+
const parts = [];
|
|
1139
|
+
if (result.stdout) {
|
|
1140
|
+
parts.push(`STDOUT:\n${truncateOutput(result.stdout, BASH_MAX_OUTPUT_CHARS)}`);
|
|
1141
|
+
}
|
|
1142
|
+
if (result.stderr) {
|
|
1143
|
+
parts.push(`STDERR:\n${truncateOutput(result.stderr, BASH_MAX_OUTPUT_CHARS)}`);
|
|
1144
|
+
}
|
|
1145
|
+
parts.push(`EXIT CODE: ${result.status ?? 1}`);
|
|
1146
|
+
return parts.join("\n");
|
|
1147
|
+
}
|
|
1148
|
+
// ------------------------------------------------------------------
|
|
1149
|
+
// diff
|
|
1150
|
+
// ------------------------------------------------------------------
|
|
1151
|
+
function toolDiff(fileA, fileB = "", contentB = "", contentBProvided = false) {
|
|
1152
|
+
const sensitiveA = getSensitiveFileReadReason(fileA);
|
|
1153
|
+
if (sensitiveA) {
|
|
1154
|
+
return `ERROR: Access to sensitive file is blocked by default: ${fileA} (${sensitiveA}).`;
|
|
1155
|
+
}
|
|
1156
|
+
if (!existsSync(fileA)) {
|
|
1157
|
+
return `ERROR: File not found: ${fileA}`;
|
|
1158
|
+
}
|
|
1159
|
+
let linesA;
|
|
1160
|
+
try {
|
|
1161
|
+
linesA = readFileSync(fileA, { encoding: "utf-8" }).split("\n");
|
|
1162
|
+
}
|
|
1163
|
+
catch (e) {
|
|
1164
|
+
return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
|
|
1165
|
+
}
|
|
1166
|
+
let linesB;
|
|
1167
|
+
let labelB;
|
|
1168
|
+
if (contentBProvided) {
|
|
1169
|
+
linesB = contentB.split("\n");
|
|
1170
|
+
labelB = "(provided content)";
|
|
1171
|
+
}
|
|
1172
|
+
else if (fileB) {
|
|
1173
|
+
const sensitiveB = getSensitiveFileReadReason(fileB);
|
|
1174
|
+
if (sensitiveB) {
|
|
1175
|
+
return `ERROR: Access to sensitive file is blocked by default: ${fileB} (${sensitiveB}).`;
|
|
1176
|
+
}
|
|
1177
|
+
if (!existsSync(fileB)) {
|
|
1178
|
+
return `ERROR: File not found: ${fileB}`;
|
|
1179
|
+
}
|
|
1180
|
+
try {
|
|
1181
|
+
linesB = readFileSync(fileB, { encoding: "utf-8" }).split("\n");
|
|
1182
|
+
}
|
|
1183
|
+
catch (e) {
|
|
1184
|
+
return `ERROR: ${e instanceof Error ? e.message : String(e)}`;
|
|
1185
|
+
}
|
|
1186
|
+
labelB = fileB;
|
|
1187
|
+
}
|
|
1188
|
+
else {
|
|
1189
|
+
return "ERROR: Provide either file_b or content_b.";
|
|
1190
|
+
}
|
|
1191
|
+
// Simple unified diff implementation
|
|
1192
|
+
const result = simpleUnifiedDiff(linesA, linesB, fileA, labelB);
|
|
1193
|
+
return result || "No differences found.";
|
|
1194
|
+
}
|
|
1195
|
+
function buildUnifiedDiffPreview(diff, maxLines = 80, maxChars = 8_000) {
|
|
1196
|
+
if (!diff) {
|
|
1197
|
+
return { text: "(No textual changes.)", truncated: false };
|
|
1198
|
+
}
|
|
1199
|
+
const parsedLines = [];
|
|
1200
|
+
let oldLine = 0;
|
|
1201
|
+
let newLine = 0;
|
|
1202
|
+
for (const raw of diff.split("\n")) {
|
|
1203
|
+
if (raw.startsWith("@@")) {
|
|
1204
|
+
const match = raw.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
1205
|
+
if (match) {
|
|
1206
|
+
oldLine = parseInt(match[1], 10);
|
|
1207
|
+
newLine = parseInt(match[2], 10);
|
|
1208
|
+
}
|
|
1209
|
+
parsedLines.push({ raw });
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
if (raw.startsWith("--- ") || raw.startsWith("+++ ")) {
|
|
1213
|
+
parsedLines.push({ raw });
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
if (raw.startsWith("-")) {
|
|
1217
|
+
parsedLines.push({ raw, oldLine });
|
|
1218
|
+
oldLine += 1;
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
if (raw.startsWith("+")) {
|
|
1222
|
+
parsedLines.push({ raw, newLine });
|
|
1223
|
+
newLine += 1;
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
if (raw.startsWith(" ")) {
|
|
1227
|
+
parsedLines.push({ raw, oldLine, newLine });
|
|
1228
|
+
oldLine += 1;
|
|
1229
|
+
newLine += 1;
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
parsedLines.push({ raw });
|
|
1233
|
+
}
|
|
1234
|
+
const displayLineFor = (line) => {
|
|
1235
|
+
if (line.raw.startsWith("-"))
|
|
1236
|
+
return line.oldLine;
|
|
1237
|
+
if (line.raw.startsWith("+"))
|
|
1238
|
+
return line.newLine;
|
|
1239
|
+
if (line.raw.startsWith(" "))
|
|
1240
|
+
return line.newLine;
|
|
1241
|
+
return undefined;
|
|
1242
|
+
};
|
|
1243
|
+
const maxLineNumber = parsedLines.reduce((max, line) => {
|
|
1244
|
+
return Math.max(max, displayLineFor(line) ?? 0);
|
|
1245
|
+
}, 0);
|
|
1246
|
+
const numberWidth = Math.max(String(maxLineNumber || 0).length, 2);
|
|
1247
|
+
const formatLine = (line) => {
|
|
1248
|
+
const displayLine = displayLineFor(line);
|
|
1249
|
+
const lineCol = displayLine == null ? "".padStart(numberWidth, " ") : String(displayLine).padStart(numberWidth, " ");
|
|
1250
|
+
return `${lineCol} | ${line.raw}`;
|
|
1251
|
+
};
|
|
1252
|
+
let previewLines = parsedLines;
|
|
1253
|
+
let truncated = false;
|
|
1254
|
+
if (previewLines.length > 60) {
|
|
1255
|
+
const omitted = previewLines.length - 50;
|
|
1256
|
+
previewLines = [
|
|
1257
|
+
...previewLines.slice(0, 25),
|
|
1258
|
+
{ raw: `... [${omitted} diff lines omitted] ...` },
|
|
1259
|
+
...previewLines.slice(-25),
|
|
1260
|
+
];
|
|
1261
|
+
truncated = true;
|
|
1262
|
+
}
|
|
1263
|
+
if (previewLines.length > maxLines) {
|
|
1264
|
+
previewLines = previewLines.slice(0, maxLines);
|
|
1265
|
+
truncated = true;
|
|
1266
|
+
}
|
|
1267
|
+
let text = previewLines.map(formatLine).join("\n");
|
|
1268
|
+
if (text.length > maxChars) {
|
|
1269
|
+
text = text.slice(0, maxChars);
|
|
1270
|
+
const lastNewline = text.lastIndexOf("\n");
|
|
1271
|
+
if (lastNewline !== -1) {
|
|
1272
|
+
text = text.slice(0, lastNewline);
|
|
1273
|
+
}
|
|
1274
|
+
truncated = true;
|
|
1275
|
+
}
|
|
1276
|
+
if (truncated && !text.includes("diff preview truncated")) {
|
|
1277
|
+
text += `\n${"".padStart(numberWidth)} | ... [diff preview truncated]`;
|
|
1278
|
+
}
|
|
1279
|
+
return { text, truncated };
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Minimal unified diff: generates a unified diff string from two line arrays.
|
|
1283
|
+
*/
|
|
1284
|
+
function simpleUnifiedDiff(a, b, labelA, labelB) {
|
|
1285
|
+
// Use a simple LCS-based approach
|
|
1286
|
+
const n = a.length;
|
|
1287
|
+
const m = b.length;
|
|
1288
|
+
// For very large files, fall back to a simpler comparison
|
|
1289
|
+
if (n * m > 10_000_000) {
|
|
1290
|
+
// Too large for full LCS, just show stats
|
|
1291
|
+
return (`--- ${labelA}\n+++ ${labelB}\n` +
|
|
1292
|
+
`(Files differ: ${n} lines vs ${m} lines, diff too large to compute)`);
|
|
1293
|
+
}
|
|
1294
|
+
// Build LCS table
|
|
1295
|
+
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
1296
|
+
for (let i = 1; i <= n; i++) {
|
|
1297
|
+
for (let j = 1; j <= m; j++) {
|
|
1298
|
+
if (a[i - 1] === b[j - 1]) {
|
|
1299
|
+
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
1300
|
+
}
|
|
1301
|
+
else {
|
|
1302
|
+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
// Backtrack to find edit script
|
|
1307
|
+
const ops = [];
|
|
1308
|
+
let i = n;
|
|
1309
|
+
let j = m;
|
|
1310
|
+
while (i > 0 || j > 0) {
|
|
1311
|
+
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
|
1312
|
+
ops.push({ type: "equal", line: a[i - 1] });
|
|
1313
|
+
i--;
|
|
1314
|
+
j--;
|
|
1315
|
+
}
|
|
1316
|
+
else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
1317
|
+
ops.push({ type: "insert", line: b[j - 1] });
|
|
1318
|
+
j--;
|
|
1319
|
+
}
|
|
1320
|
+
else {
|
|
1321
|
+
ops.push({ type: "delete", line: a[i - 1] });
|
|
1322
|
+
i--;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
ops.reverse();
|
|
1326
|
+
// Group into hunks with context
|
|
1327
|
+
const contextLines = 3;
|
|
1328
|
+
const hunks = [];
|
|
1329
|
+
let hunkStart = -1;
|
|
1330
|
+
let hunkLines = [];
|
|
1331
|
+
let aLine = 0;
|
|
1332
|
+
let bLine = 0;
|
|
1333
|
+
let aStart = 0;
|
|
1334
|
+
let bStart = 0;
|
|
1335
|
+
let aCount = 0;
|
|
1336
|
+
let bCount = 0;
|
|
1337
|
+
let lastChangeIdx = -contextLines - 1;
|
|
1338
|
+
function flushHunk() {
|
|
1339
|
+
if (hunkLines.length > 0) {
|
|
1340
|
+
hunks.push(`@@ -${aStart + 1},${aCount} +${bStart + 1},${bCount} @@\n` +
|
|
1341
|
+
hunkLines.join("\n"));
|
|
1342
|
+
hunkLines = [];
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
for (let idx = 0; idx < ops.length; idx++) {
|
|
1346
|
+
const op = ops[idx];
|
|
1347
|
+
const isChange = op.type !== "equal";
|
|
1348
|
+
if (isChange) {
|
|
1349
|
+
if (hunkStart === -1 || idx - lastChangeIdx > contextLines * 2) {
|
|
1350
|
+
// Start a new hunk
|
|
1351
|
+
flushHunk();
|
|
1352
|
+
hunkStart = idx;
|
|
1353
|
+
aStart = aLine;
|
|
1354
|
+
bStart = bLine;
|
|
1355
|
+
aCount = 0;
|
|
1356
|
+
bCount = 0;
|
|
1357
|
+
// Add leading context
|
|
1358
|
+
const ctxStart = Math.max(0, idx - contextLines);
|
|
1359
|
+
// We need to recount from ctxStart -- but for simplicity, just
|
|
1360
|
+
// include context from current position
|
|
1361
|
+
}
|
|
1362
|
+
lastChangeIdx = idx;
|
|
1363
|
+
}
|
|
1364
|
+
if (hunkStart !== -1 && idx - lastChangeIdx <= contextLines) {
|
|
1365
|
+
if (op.type === "equal") {
|
|
1366
|
+
hunkLines.push(` ${op.line}`);
|
|
1367
|
+
aCount++;
|
|
1368
|
+
bCount++;
|
|
1369
|
+
}
|
|
1370
|
+
else if (op.type === "delete") {
|
|
1371
|
+
hunkLines.push(`-${op.line}`);
|
|
1372
|
+
aCount++;
|
|
1373
|
+
}
|
|
1374
|
+
else {
|
|
1375
|
+
hunkLines.push(`+${op.line}`);
|
|
1376
|
+
bCount++;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
if (op.type === "equal" || op.type === "delete")
|
|
1380
|
+
aLine++;
|
|
1381
|
+
if (op.type === "equal" || op.type === "insert")
|
|
1382
|
+
bLine++;
|
|
1383
|
+
}
|
|
1384
|
+
flushHunk();
|
|
1385
|
+
if (hunks.length === 0)
|
|
1386
|
+
return "";
|
|
1387
|
+
return `--- ${labelA}\n+++ ${labelB}\n${hunks.join("\n")}`;
|
|
1388
|
+
}
|
|
1389
|
+
// ------------------------------------------------------------------
|
|
1390
|
+
// test
|
|
1391
|
+
// ------------------------------------------------------------------
|
|
1392
|
+
function toolTest(command = "python -m pytest", timeout = 60) {
|
|
1393
|
+
return toolBash(command, timeout);
|
|
1394
|
+
}
|
|
1395
|
+
class ToolArgValidationError extends Error {
|
|
1396
|
+
toolName;
|
|
1397
|
+
field;
|
|
1398
|
+
constructor(toolName, field, message) {
|
|
1399
|
+
super(message);
|
|
1400
|
+
this.name = "ToolArgValidationError";
|
|
1401
|
+
this.toolName = toolName;
|
|
1402
|
+
this.field = field;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
function toolRoot(ctx) {
|
|
1406
|
+
return path.resolve(ctx?.projectRoot ?? process.cwd());
|
|
1407
|
+
}
|
|
1408
|
+
function formatToolError(toolName, err) {
|
|
1409
|
+
if (err instanceof ToolArgValidationError) {
|
|
1410
|
+
return `ERROR: Invalid arguments for ${toolName}: ${err.message}`;
|
|
1411
|
+
}
|
|
1412
|
+
if (err instanceof SafePathError) {
|
|
1413
|
+
const p = err.details.resolvedPath || err.details.requestedPath;
|
|
1414
|
+
switch (err.code) {
|
|
1415
|
+
case "PATH_OUTSIDE_SCOPE":
|
|
1416
|
+
return `ERROR: ${toolName} path is outside the project root boundary: ${err.details.requestedPath}`;
|
|
1417
|
+
case "PATH_SYMLINK_ESCAPES_SCOPE":
|
|
1418
|
+
return `ERROR: ${toolName} path escapes the project root via a symbolic link: ${err.details.requestedPath}`;
|
|
1419
|
+
case "PATH_NOT_FOUND":
|
|
1420
|
+
return `ERROR: Path not found: ${p}`;
|
|
1421
|
+
case "PATH_NOT_FILE":
|
|
1422
|
+
return `ERROR: Not a file: ${p}`;
|
|
1423
|
+
case "PATH_NOT_DIRECTORY":
|
|
1424
|
+
return `ERROR: Not a directory: ${p}`;
|
|
1425
|
+
case "PATH_INVALID_INPUT":
|
|
1426
|
+
return `ERROR: ${err.message}`;
|
|
1427
|
+
default:
|
|
1428
|
+
return `ERROR: ${err.message}`;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
return `ERROR: ${err instanceof Error ? err.message : String(err)}`;
|
|
1432
|
+
}
|
|
1433
|
+
function expectArgsObject(toolName, args) {
|
|
1434
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
|
1435
|
+
throw new ToolArgValidationError(toolName, "(root)", "arguments must be an object.");
|
|
1436
|
+
}
|
|
1437
|
+
return args;
|
|
1438
|
+
}
|
|
1439
|
+
function requiredStringArg(toolName, args, key, opts) {
|
|
1440
|
+
const v = args[key];
|
|
1441
|
+
if (typeof v !== "string") {
|
|
1442
|
+
throw new ToolArgValidationError(toolName, key, `'${key}' must be a string.`);
|
|
1443
|
+
}
|
|
1444
|
+
if (opts?.nonEmpty && !v.trim()) {
|
|
1445
|
+
throw new ToolArgValidationError(toolName, key, `'${key}' must be a non-empty string.`);
|
|
1446
|
+
}
|
|
1447
|
+
if (opts?.maxLen !== undefined && v.length > opts.maxLen) {
|
|
1448
|
+
throw new ToolArgValidationError(toolName, key, `'${key}' exceeds max length (${opts.maxLen}).`);
|
|
1449
|
+
}
|
|
1450
|
+
return v;
|
|
1451
|
+
}
|
|
1452
|
+
function optionalStringArg(toolName, args, key, fallback) {
|
|
1453
|
+
const v = args[key];
|
|
1454
|
+
if (v == null)
|
|
1455
|
+
return fallback;
|
|
1456
|
+
if (typeof v !== "string") {
|
|
1457
|
+
throw new ToolArgValidationError(toolName, key, `'${key}' must be a string.`);
|
|
1458
|
+
}
|
|
1459
|
+
return v;
|
|
1460
|
+
}
|
|
1461
|
+
function optionalIntegerArg(toolName, args, key) {
|
|
1462
|
+
const v = args[key];
|
|
1463
|
+
if (v == null)
|
|
1464
|
+
return undefined;
|
|
1465
|
+
if (typeof v !== "number" || !Number.isFinite(v) || !Number.isInteger(v)) {
|
|
1466
|
+
throw new ToolArgValidationError(toolName, key, `'${key}' must be an integer.`);
|
|
1467
|
+
}
|
|
1468
|
+
return v;
|
|
1469
|
+
}
|
|
1470
|
+
function scopedPath(requestedPath, accessKind, ctx, opts) {
|
|
1471
|
+
const baseDir = toolRoot(ctx);
|
|
1472
|
+
const attempt = (scopeBaseDir) => safePath({
|
|
1473
|
+
baseDir: scopeBaseDir,
|
|
1474
|
+
requestedPath,
|
|
1475
|
+
cwd: baseDir,
|
|
1476
|
+
accessKind,
|
|
1477
|
+
mustExist: opts.mustExist,
|
|
1478
|
+
allowCreate: opts.allowCreate,
|
|
1479
|
+
expectFile: opts.expectFile,
|
|
1480
|
+
expectDirectory: opts.expectDirectory,
|
|
1481
|
+
}).safePath;
|
|
1482
|
+
try {
|
|
1483
|
+
return attempt(baseDir);
|
|
1484
|
+
}
|
|
1485
|
+
catch (err) {
|
|
1486
|
+
if (!(err instanceof SafePathError))
|
|
1487
|
+
throw err;
|
|
1488
|
+
if (err.code !== "PATH_OUTSIDE_SCOPE" && err.code !== "PATH_SYMLINK_ESCAPES_SCOPE") {
|
|
1489
|
+
throw err;
|
|
1490
|
+
}
|
|
1491
|
+
const allowlist = ctx?.externalPathAllowlist ?? [];
|
|
1492
|
+
for (const allowedRoot of allowlist) {
|
|
1493
|
+
try {
|
|
1494
|
+
return attempt(allowedRoot);
|
|
1495
|
+
}
|
|
1496
|
+
catch (inner) {
|
|
1497
|
+
if (inner instanceof SafePathError &&
|
|
1498
|
+
(inner.code === "PATH_OUTSIDE_SCOPE" || inner.code === "PATH_SYMLINK_ESCAPES_SCOPE")) {
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1501
|
+
throw inner;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
throw err;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
// ------------------------------------------------------------------
|
|
1508
|
+
// glob executor
|
|
1509
|
+
// ------------------------------------------------------------------
|
|
1510
|
+
/**
|
|
1511
|
+
* Convert a simple glob pattern to a RegExp.
|
|
1512
|
+
* Supports: `*` (any non-slash), `**` (any including slash), `?` (single char),
|
|
1513
|
+
* `{a,b}` (alternatives), and literal characters.
|
|
1514
|
+
*/
|
|
1515
|
+
function globToRegex(pattern) {
|
|
1516
|
+
let re = "^";
|
|
1517
|
+
let i = 0;
|
|
1518
|
+
while (i < pattern.length) {
|
|
1519
|
+
const ch = pattern[i];
|
|
1520
|
+
if (ch === "*") {
|
|
1521
|
+
if (pattern[i + 1] === "*") {
|
|
1522
|
+
// ** matches anything including slashes
|
|
1523
|
+
if (pattern[i + 2] === "/") {
|
|
1524
|
+
re += "(?:.*/)?"; // **/ matches zero or more directories
|
|
1525
|
+
i += 3;
|
|
1526
|
+
}
|
|
1527
|
+
else {
|
|
1528
|
+
re += ".*";
|
|
1529
|
+
i += 2;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
else {
|
|
1533
|
+
re += "[^/]*";
|
|
1534
|
+
i++;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
else if (ch === "?") {
|
|
1538
|
+
re += "[^/]";
|
|
1539
|
+
i++;
|
|
1540
|
+
}
|
|
1541
|
+
else if (ch === "{") {
|
|
1542
|
+
const close = pattern.indexOf("}", i);
|
|
1543
|
+
if (close > i) {
|
|
1544
|
+
const alts = pattern.slice(i + 1, close).split(",").map(a => a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
1545
|
+
re += `(?:${alts})`;
|
|
1546
|
+
i = close + 1;
|
|
1547
|
+
}
|
|
1548
|
+
else {
|
|
1549
|
+
re += "\\{";
|
|
1550
|
+
i++;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
else if (".+^$|()[]\\".includes(ch)) {
|
|
1554
|
+
re += "\\" + ch;
|
|
1555
|
+
i++;
|
|
1556
|
+
}
|
|
1557
|
+
else {
|
|
1558
|
+
re += ch;
|
|
1559
|
+
i++;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
re += "$";
|
|
1563
|
+
return new RegExp(re);
|
|
1564
|
+
}
|
|
1565
|
+
const GLOB_SKIP_DIRS = new Set([
|
|
1566
|
+
".git", "node_modules", "__pycache__", ".next", ".nuxt",
|
|
1567
|
+
"dist", ".tox", ".mypy_cache", ".pytest_cache", ".venv", "venv",
|
|
1568
|
+
]);
|
|
1569
|
+
function toolGlob(pattern, searchPath) {
|
|
1570
|
+
if (!existsSync(searchPath)) {
|
|
1571
|
+
return `ERROR: Path not found: ${searchPath}`;
|
|
1572
|
+
}
|
|
1573
|
+
const regex = globToRegex(pattern);
|
|
1574
|
+
const results = [];
|
|
1575
|
+
let filesScanned = 0;
|
|
1576
|
+
function walk(dir, depth, relPrefix) {
|
|
1577
|
+
if (depth > GLOB_MAX_DEPTH)
|
|
1578
|
+
return;
|
|
1579
|
+
if (results.length >= GLOB_MAX_RESULTS)
|
|
1580
|
+
return;
|
|
1581
|
+
if (filesScanned >= GLOB_MAX_FILES_SCANNED)
|
|
1582
|
+
return;
|
|
1583
|
+
let entries;
|
|
1584
|
+
try {
|
|
1585
|
+
entries = readdirSync(dir);
|
|
1586
|
+
}
|
|
1587
|
+
catch {
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
for (const name of entries) {
|
|
1591
|
+
if (results.length >= GLOB_MAX_RESULTS)
|
|
1592
|
+
return;
|
|
1593
|
+
if (filesScanned >= GLOB_MAX_FILES_SCANNED)
|
|
1594
|
+
return;
|
|
1595
|
+
if (GLOB_SKIP_DIRS.has(name))
|
|
1596
|
+
continue;
|
|
1597
|
+
if (name.startsWith(".") && name !== ".")
|
|
1598
|
+
continue;
|
|
1599
|
+
const full = path.join(dir, name);
|
|
1600
|
+
const rel = relPrefix ? relPrefix + "/" + name : name;
|
|
1601
|
+
let stat;
|
|
1602
|
+
try {
|
|
1603
|
+
stat = statSync(full);
|
|
1604
|
+
}
|
|
1605
|
+
catch {
|
|
1606
|
+
continue;
|
|
1607
|
+
}
|
|
1608
|
+
if (stat.isDirectory()) {
|
|
1609
|
+
walk(full, depth + 1, rel);
|
|
1610
|
+
}
|
|
1611
|
+
else if (stat.isFile()) {
|
|
1612
|
+
filesScanned++;
|
|
1613
|
+
if (regex.test(rel)) {
|
|
1614
|
+
results.push({ path: full, mtime: stat.mtimeMs });
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
walk(searchPath, 0, "");
|
|
1620
|
+
if (results.length === 0) {
|
|
1621
|
+
return "No files found matching the pattern.";
|
|
1622
|
+
}
|
|
1623
|
+
// Sort by mtime descending (most recently modified first)
|
|
1624
|
+
results.sort((a, b) => b.mtime - a.mtime);
|
|
1625
|
+
const lines = results.map((r) => r.path);
|
|
1626
|
+
let output = lines.join("\n");
|
|
1627
|
+
if (results.length >= GLOB_MAX_RESULTS) {
|
|
1628
|
+
output += `\n... (truncated at ${GLOB_MAX_RESULTS} results)`;
|
|
1629
|
+
}
|
|
1630
|
+
return output;
|
|
1631
|
+
}
|
|
1632
|
+
/** Check if a filename matches a simple glob pattern (e.g. "*.ts", "*.{ts,tsx}") */
|
|
1633
|
+
function matchFileGlob(filename, globPattern) {
|
|
1634
|
+
const regex = globToRegex(globPattern);
|
|
1635
|
+
return regex.test(filename);
|
|
1636
|
+
}
|
|
1637
|
+
/** Check if file extension matches a type filter */
|
|
1638
|
+
function matchFileType(filename, typeFilter) {
|
|
1639
|
+
const ext = path.extname(filename).slice(1).toLowerCase();
|
|
1640
|
+
return ext === typeFilter.toLowerCase();
|
|
1641
|
+
}
|
|
1642
|
+
function toolGrep(pattern, searchPath, options) {
|
|
1643
|
+
if (!existsSync(searchPath)) {
|
|
1644
|
+
return `ERROR: Path not found: ${searchPath}`;
|
|
1645
|
+
}
|
|
1646
|
+
if (!pattern) {
|
|
1647
|
+
return "ERROR: pattern must be a non-empty string.";
|
|
1648
|
+
}
|
|
1649
|
+
if (pattern.length > SEARCH_MAX_PATTERN_LENGTH) {
|
|
1650
|
+
return (`ERROR: Regex pattern too long (${pattern.length} chars, ` +
|
|
1651
|
+
`limit ${SEARCH_MAX_PATTERN_LENGTH}).`);
|
|
1652
|
+
}
|
|
1653
|
+
// Catastrophic backtracking check
|
|
1654
|
+
if (/(^|[^\\])\((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*\)[+*{]/.test(pattern)) {
|
|
1655
|
+
return "ERROR: Regex appears too complex/risky (nested quantified group).";
|
|
1656
|
+
}
|
|
1657
|
+
let regex;
|
|
1658
|
+
try {
|
|
1659
|
+
const flags = options.caseInsensitive ? "i" : "";
|
|
1660
|
+
regex = new RegExp(pattern, flags);
|
|
1661
|
+
}
|
|
1662
|
+
catch (e) {
|
|
1663
|
+
return `ERROR: Invalid regex: ${e instanceof Error ? e.message : String(e)}`;
|
|
1664
|
+
}
|
|
1665
|
+
const startedAt = Date.now();
|
|
1666
|
+
const stats = {
|
|
1667
|
+
filesScanned: 0,
|
|
1668
|
+
bytesScanned: 0,
|
|
1669
|
+
skippedLargeFiles: 0,
|
|
1670
|
+
skippedSensitiveFiles: 0,
|
|
1671
|
+
depthLimitHits: 0,
|
|
1672
|
+
maxFilesHit: false,
|
|
1673
|
+
maxBytesHit: false,
|
|
1674
|
+
timeoutHit: false,
|
|
1675
|
+
};
|
|
1676
|
+
// Results storage depends on output mode
|
|
1677
|
+
const fileMatches = [];
|
|
1678
|
+
let totalEntries = 0;
|
|
1679
|
+
function shouldStop() {
|
|
1680
|
+
if (options.headLimit > 0 && totalEntries >= options.headLimit)
|
|
1681
|
+
return true;
|
|
1682
|
+
if (stats.maxFilesHit || stats.maxBytesHit || stats.timeoutHit)
|
|
1683
|
+
return true;
|
|
1684
|
+
if (Date.now() - startedAt > SEARCH_MAX_DURATION_MS) {
|
|
1685
|
+
stats.timeoutHit = true;
|
|
1686
|
+
return true;
|
|
1687
|
+
}
|
|
1688
|
+
return false;
|
|
1689
|
+
}
|
|
1690
|
+
function shouldIncludeFile(filename) {
|
|
1691
|
+
if (options.glob && !matchFileGlob(filename, options.glob))
|
|
1692
|
+
return false;
|
|
1693
|
+
if (options.fileType && !matchFileType(filename, options.fileType))
|
|
1694
|
+
return false;
|
|
1695
|
+
return true;
|
|
1696
|
+
}
|
|
1697
|
+
function processFile(filePath) {
|
|
1698
|
+
let raw;
|
|
1699
|
+
try {
|
|
1700
|
+
raw = readFileSync(filePath);
|
|
1701
|
+
}
|
|
1702
|
+
catch {
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
// Skip binary files
|
|
1706
|
+
const header = raw.subarray(0, 8192);
|
|
1707
|
+
if (header.includes(0))
|
|
1708
|
+
return;
|
|
1709
|
+
const text = raw.toString("utf-8");
|
|
1710
|
+
const lines = text.split("\n");
|
|
1711
|
+
const matchingLines = [];
|
|
1712
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1713
|
+
if (regex.global || regex.sticky)
|
|
1714
|
+
regex.lastIndex = 0;
|
|
1715
|
+
if (regex.test(lines[i])) {
|
|
1716
|
+
matchingLines.push({ line: i + 1, text: lines[i].trimEnd() });
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
if (matchingLines.length > 0) {
|
|
1720
|
+
fileMatches.push({
|
|
1721
|
+
file: filePath,
|
|
1722
|
+
matches: matchingLines,
|
|
1723
|
+
count: matchingLines.length,
|
|
1724
|
+
});
|
|
1725
|
+
totalEntries++;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
function walkForGrep(dir, depth) {
|
|
1729
|
+
if (shouldStop())
|
|
1730
|
+
return;
|
|
1731
|
+
if (depth > SEARCH_MAX_DEPTH) {
|
|
1732
|
+
stats.depthLimitHits += 1;
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
let entries;
|
|
1736
|
+
try {
|
|
1737
|
+
entries = readdirSync(dir);
|
|
1738
|
+
}
|
|
1739
|
+
catch {
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
for (const name of entries) {
|
|
1743
|
+
if (shouldStop())
|
|
1744
|
+
return;
|
|
1745
|
+
if (name.startsWith(".") || name === "__pycache__" || name === "node_modules")
|
|
1746
|
+
continue;
|
|
1747
|
+
const full = path.join(dir, name);
|
|
1748
|
+
let stat;
|
|
1749
|
+
try {
|
|
1750
|
+
stat = statSync(full);
|
|
1751
|
+
}
|
|
1752
|
+
catch {
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
if (stat.isDirectory()) {
|
|
1756
|
+
walkForGrep(full, depth + 1);
|
|
1757
|
+
}
|
|
1758
|
+
else if (stat.isFile()) {
|
|
1759
|
+
if (!shouldIncludeFile(name))
|
|
1760
|
+
continue;
|
|
1761
|
+
if (getSensitiveFileReadReason(full)) {
|
|
1762
|
+
stats.skippedSensitiveFiles += 1;
|
|
1763
|
+
continue;
|
|
1764
|
+
}
|
|
1765
|
+
if (stats.filesScanned >= SEARCH_MAX_FILES) {
|
|
1766
|
+
stats.maxFilesHit = true;
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
stats.filesScanned += 1;
|
|
1770
|
+
if (stat.size > SEARCH_MAX_FILE_SIZE) {
|
|
1771
|
+
stats.skippedLargeFiles += 1;
|
|
1772
|
+
continue;
|
|
1773
|
+
}
|
|
1774
|
+
if (stats.bytesScanned + stat.size > SEARCH_MAX_TOTAL_BYTES) {
|
|
1775
|
+
stats.maxBytesHit = true;
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
stats.bytesScanned += stat.size;
|
|
1779
|
+
processFile(full);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
// Handle single file path
|
|
1784
|
+
const pathStat = statSync(searchPath);
|
|
1785
|
+
if (pathStat.isFile()) {
|
|
1786
|
+
if (shouldIncludeFile(path.basename(searchPath))) {
|
|
1787
|
+
processFile(searchPath);
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
else {
|
|
1791
|
+
walkForGrep(searchPath, 0);
|
|
1792
|
+
}
|
|
1793
|
+
// Format output based on mode
|
|
1794
|
+
let output = "";
|
|
1795
|
+
const { outputMode } = options;
|
|
1796
|
+
if (fileMatches.length === 0) {
|
|
1797
|
+
output = "No matches found.";
|
|
1798
|
+
}
|
|
1799
|
+
else if (outputMode === "files_with_matches") {
|
|
1800
|
+
const lines = fileMatches.map((f) => f.file);
|
|
1801
|
+
output = lines.join("\n");
|
|
1802
|
+
}
|
|
1803
|
+
else if (outputMode === "count") {
|
|
1804
|
+
const lines = fileMatches.map((f) => `${f.file}:${f.count}`);
|
|
1805
|
+
output = lines.join("\n");
|
|
1806
|
+
}
|
|
1807
|
+
else {
|
|
1808
|
+
// content mode — show matching lines with optional context
|
|
1809
|
+
const parts = [];
|
|
1810
|
+
const beforeCtx = options.beforeContext;
|
|
1811
|
+
const afterCtx = options.afterContext;
|
|
1812
|
+
const showNumbers = options.showLineNumbers;
|
|
1813
|
+
for (const fm of fileMatches) {
|
|
1814
|
+
if (options.headLimit > 0 && parts.length >= options.headLimit)
|
|
1815
|
+
break;
|
|
1816
|
+
if (beforeCtx > 0 || afterCtx > 0) {
|
|
1817
|
+
// Need to re-read file for context lines
|
|
1818
|
+
let fileLines;
|
|
1819
|
+
try {
|
|
1820
|
+
fileLines = readFileSync(fm.file, "utf-8").split("\n");
|
|
1821
|
+
}
|
|
1822
|
+
catch {
|
|
1823
|
+
continue;
|
|
1824
|
+
}
|
|
1825
|
+
for (const m of fm.matches) {
|
|
1826
|
+
if (options.headLimit > 0 && parts.length >= options.headLimit)
|
|
1827
|
+
break;
|
|
1828
|
+
const startL = Math.max(0, m.line - 1 - beforeCtx);
|
|
1829
|
+
const endL = Math.min(fileLines.length, m.line + afterCtx);
|
|
1830
|
+
for (let li = startL; li < endL; li++) {
|
|
1831
|
+
const isMatch = li === m.line - 1;
|
|
1832
|
+
const prefix = isMatch ? ">" : " ";
|
|
1833
|
+
const lineText = fileLines[li].trimEnd();
|
|
1834
|
+
if (showNumbers) {
|
|
1835
|
+
parts.push(`${fm.file}:${li + 1}:${prefix} ${lineText}`);
|
|
1836
|
+
}
|
|
1837
|
+
else {
|
|
1838
|
+
parts.push(`${fm.file}:${prefix} ${lineText}`);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
parts.push("--");
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
else {
|
|
1845
|
+
// No context — just matching lines
|
|
1846
|
+
for (const m of fm.matches) {
|
|
1847
|
+
if (options.headLimit > 0 && parts.length >= options.headLimit)
|
|
1848
|
+
break;
|
|
1849
|
+
if (showNumbers) {
|
|
1850
|
+
parts.push(`${fm.file}:${m.line}: ${m.text}`);
|
|
1851
|
+
}
|
|
1852
|
+
else {
|
|
1853
|
+
parts.push(`${fm.file}: ${m.text}`);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
output = parts.join("\n");
|
|
1859
|
+
}
|
|
1860
|
+
// Append notices
|
|
1861
|
+
const notices = [];
|
|
1862
|
+
if (stats.skippedLargeFiles > 0) {
|
|
1863
|
+
notices.push(`Skipped ${stats.skippedLargeFiles} large file(s) over ${Math.round(SEARCH_MAX_FILE_SIZE / 1024)} KB.`);
|
|
1864
|
+
}
|
|
1865
|
+
if (stats.skippedSensitiveFiles > 0) {
|
|
1866
|
+
notices.push(`Skipped ${stats.skippedSensitiveFiles} sensitive file(s).`);
|
|
1867
|
+
}
|
|
1868
|
+
if (stats.depthLimitHits > 0) {
|
|
1869
|
+
notices.push(`Depth limit reached in ${stats.depthLimitHits} director${stats.depthLimitHits === 1 ? "y" : "ies"} (max depth ${SEARCH_MAX_DEPTH}).`);
|
|
1870
|
+
}
|
|
1871
|
+
if (stats.maxFilesHit) {
|
|
1872
|
+
notices.push(`Stopped after scanning ${SEARCH_MAX_FILES} files.`);
|
|
1873
|
+
}
|
|
1874
|
+
if (stats.maxBytesHit) {
|
|
1875
|
+
notices.push(`Stopped after scanning ${Math.round(SEARCH_MAX_TOTAL_BYTES / 1024 / 1024)} MB.`);
|
|
1876
|
+
}
|
|
1877
|
+
if (stats.timeoutHit) {
|
|
1878
|
+
notices.push(`Stopped after ${SEARCH_MAX_DURATION_MS}ms time limit.`);
|
|
1879
|
+
}
|
|
1880
|
+
if (notices.length > 0) {
|
|
1881
|
+
output += "\n\n[Search notices]\n" + notices.map((n) => `- ${n}`).join("\n");
|
|
1882
|
+
}
|
|
1883
|
+
return output;
|
|
1884
|
+
}
|
|
1885
|
+
function createDispatch(ctx) {
|
|
1886
|
+
return {
|
|
1887
|
+
read_file: (args) => {
|
|
1888
|
+
try {
|
|
1889
|
+
const a = expectArgsObject("read_file", args);
|
|
1890
|
+
const requestedPath = requiredStringArg("read_file", a, "path", { nonEmpty: true });
|
|
1891
|
+
const startLine = optionalIntegerArg("read_file", a, "start_line");
|
|
1892
|
+
const endLine = optionalIntegerArg("read_file", a, "end_line");
|
|
1893
|
+
const filePath = scopedPath(requestedPath, "read", ctx, { mustExist: true, expectFile: true });
|
|
1894
|
+
return toolReadFile(filePath, startLine, endLine, ctx?.sessionArtifactsDir, ctx?.supportsMultimodal);
|
|
1895
|
+
}
|
|
1896
|
+
catch (e) {
|
|
1897
|
+
return formatToolError("read_file", e);
|
|
1898
|
+
}
|
|
1899
|
+
},
|
|
1900
|
+
list_dir: (args) => {
|
|
1901
|
+
try {
|
|
1902
|
+
const a = expectArgsObject("list_dir", args);
|
|
1903
|
+
const requestedPath = optionalStringArg("list_dir", a, "path", ".");
|
|
1904
|
+
const dirPath = scopedPath(requestedPath, "list", ctx, { mustExist: true, expectDirectory: true });
|
|
1905
|
+
return toolListDir(dirPath);
|
|
1906
|
+
}
|
|
1907
|
+
catch (e) {
|
|
1908
|
+
return formatToolError("list_dir", e);
|
|
1909
|
+
}
|
|
1910
|
+
},
|
|
1911
|
+
edit_file: (args) => {
|
|
1912
|
+
try {
|
|
1913
|
+
const a = expectArgsObject("edit_file", args);
|
|
1914
|
+
const requestedPath = requiredStringArg("edit_file", a, "path", { nonEmpty: true });
|
|
1915
|
+
const oldStr = requiredStringArg("edit_file", a, "old_str", { nonEmpty: true });
|
|
1916
|
+
const newStr = requiredStringArg("edit_file", a, "new_str");
|
|
1917
|
+
const expectedMtimeMs = optionalIntegerArg("edit_file", a, "expected_mtime_ms");
|
|
1918
|
+
const filePath = scopedPath(requestedPath, "write", ctx, { mustExist: true, expectFile: true });
|
|
1919
|
+
return toolEditFile(filePath, oldStr, newStr, expectedMtimeMs);
|
|
1920
|
+
}
|
|
1921
|
+
catch (e) {
|
|
1922
|
+
return formatToolError("edit_file", e);
|
|
1923
|
+
}
|
|
1924
|
+
},
|
|
1925
|
+
write_file: (args) => {
|
|
1926
|
+
try {
|
|
1927
|
+
const a = expectArgsObject("write_file", args);
|
|
1928
|
+
const requestedPath = requiredStringArg("write_file", a, "path", { nonEmpty: true });
|
|
1929
|
+
const content = requiredStringArg("write_file", a, "content");
|
|
1930
|
+
const expectedMtimeMs = optionalIntegerArg("write_file", a, "expected_mtime_ms");
|
|
1931
|
+
const filePath = scopedPath(requestedPath, "write", ctx, { allowCreate: true, expectFile: true });
|
|
1932
|
+
return toolWriteFile(filePath, content, expectedMtimeMs);
|
|
1933
|
+
}
|
|
1934
|
+
catch (e) {
|
|
1935
|
+
return formatToolError("write_file", e);
|
|
1936
|
+
}
|
|
1937
|
+
},
|
|
1938
|
+
apply_patch: async (args) => {
|
|
1939
|
+
try {
|
|
1940
|
+
const a = expectArgsObject("apply_patch", args);
|
|
1941
|
+
const patch = requiredStringArg("apply_patch", a, "patch", { nonEmpty: true, maxLen: 200_000 });
|
|
1942
|
+
return await toolApplyPatch(patch, ctx);
|
|
1943
|
+
}
|
|
1944
|
+
catch (e) {
|
|
1945
|
+
if (e instanceof ToolArgValidationError) {
|
|
1946
|
+
return formatToolError("apply_patch", e);
|
|
1947
|
+
}
|
|
1948
|
+
return `ERROR: apply_patch verification failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
1949
|
+
}
|
|
1950
|
+
},
|
|
1951
|
+
bash: (args) => {
|
|
1952
|
+
try {
|
|
1953
|
+
const a = expectArgsObject("bash", args);
|
|
1954
|
+
const command = requiredStringArg("bash", a, "command", { nonEmpty: true, maxLen: 20_000 });
|
|
1955
|
+
const timeout = optionalIntegerArg("bash", a, "timeout");
|
|
1956
|
+
const cwdArg = optionalStringArg("bash", a, "cwd", "");
|
|
1957
|
+
let cwd = "";
|
|
1958
|
+
if (cwdArg.trim()) {
|
|
1959
|
+
cwd = scopedPath(cwdArg, "list", ctx, { mustExist: true, expectDirectory: true });
|
|
1960
|
+
}
|
|
1961
|
+
return toolBash(command, timeout ?? BASH_DEFAULT_TIMEOUT, cwd);
|
|
1962
|
+
}
|
|
1963
|
+
catch (e) {
|
|
1964
|
+
return formatToolError("bash", e);
|
|
1965
|
+
}
|
|
1966
|
+
},
|
|
1967
|
+
diff: (args) => {
|
|
1968
|
+
try {
|
|
1969
|
+
const a = expectArgsObject("diff", args);
|
|
1970
|
+
const fileAArg = requiredStringArg("diff", a, "file_a", { nonEmpty: true });
|
|
1971
|
+
const rawFileB = optionalStringArg("diff", a, "file_b", "");
|
|
1972
|
+
const hasContentB = Object.prototype.hasOwnProperty.call(a, "content_b");
|
|
1973
|
+
const contentB = optionalStringArg("diff", a, "content_b", "");
|
|
1974
|
+
const fileA = scopedPath(fileAArg, "diff", ctx, { mustExist: true, expectFile: true });
|
|
1975
|
+
let fileB = "";
|
|
1976
|
+
if (!hasContentB && rawFileB) {
|
|
1977
|
+
fileB = scopedPath(rawFileB, "diff", ctx, { mustExist: true, expectFile: true });
|
|
1978
|
+
}
|
|
1979
|
+
else {
|
|
1980
|
+
fileB = rawFileB;
|
|
1981
|
+
}
|
|
1982
|
+
return toolDiff(fileA, fileB, contentB, hasContentB);
|
|
1983
|
+
}
|
|
1984
|
+
catch (e) {
|
|
1985
|
+
return formatToolError("diff", e);
|
|
1986
|
+
}
|
|
1987
|
+
},
|
|
1988
|
+
test: (args) => {
|
|
1989
|
+
try {
|
|
1990
|
+
const a = expectArgsObject("test", args);
|
|
1991
|
+
const command = optionalStringArg("test", a, "command", "python -m pytest");
|
|
1992
|
+
const timeout = optionalIntegerArg("test", a, "timeout");
|
|
1993
|
+
return toolTest(command, timeout ?? 60);
|
|
1994
|
+
}
|
|
1995
|
+
catch (e) {
|
|
1996
|
+
return formatToolError("test", e);
|
|
1997
|
+
}
|
|
1998
|
+
},
|
|
1999
|
+
glob: (args) => {
|
|
2000
|
+
try {
|
|
2001
|
+
const a = expectArgsObject("glob", args);
|
|
2002
|
+
const pattern = requiredStringArg("glob", a, "pattern", { nonEmpty: true });
|
|
2003
|
+
const requestedPath = optionalStringArg("glob", a, "path", ".");
|
|
2004
|
+
const globPath = scopedPath(requestedPath, "search", ctx, { mustExist: true, expectDirectory: true });
|
|
2005
|
+
return toolGlob(pattern, globPath);
|
|
2006
|
+
}
|
|
2007
|
+
catch (e) {
|
|
2008
|
+
return formatToolError("glob", e);
|
|
2009
|
+
}
|
|
2010
|
+
},
|
|
2011
|
+
grep: (args) => {
|
|
2012
|
+
try {
|
|
2013
|
+
const a = expectArgsObject("grep", args);
|
|
2014
|
+
const pattern = requiredStringArg("grep", a, "pattern", { nonEmpty: true, maxLen: SEARCH_MAX_PATTERN_LENGTH });
|
|
2015
|
+
const requestedPath = optionalStringArg("grep", a, "path", ".");
|
|
2016
|
+
const searchPath = scopedPath(requestedPath, "search", ctx, { mustExist: true });
|
|
2017
|
+
const globFilter = optionalStringArg("grep", a, "glob", "");
|
|
2018
|
+
const fileType = optionalStringArg("grep", a, "type", "");
|
|
2019
|
+
const outputMode = optionalStringArg("grep", a, "output_mode", "files_with_matches");
|
|
2020
|
+
const afterCtx = optionalIntegerArg("grep", a, "-A") ?? 0;
|
|
2021
|
+
const beforeCtx = optionalIntegerArg("grep", a, "-B") ?? 0;
|
|
2022
|
+
const contextCtx = optionalIntegerArg("grep", a, "-C") ?? 0;
|
|
2023
|
+
const caseInsensitive = a["-i"] === true;
|
|
2024
|
+
const showLineNumbers = a["-n"] !== false; // default true
|
|
2025
|
+
const headLimit = optionalIntegerArg("grep", a, "head_limit") ?? 0;
|
|
2026
|
+
return toolGrep(pattern, searchPath, {
|
|
2027
|
+
glob: globFilter || undefined,
|
|
2028
|
+
fileType: fileType || undefined,
|
|
2029
|
+
outputMode,
|
|
2030
|
+
afterContext: contextCtx > 0 ? contextCtx : afterCtx,
|
|
2031
|
+
beforeContext: contextCtx > 0 ? contextCtx : beforeCtx,
|
|
2032
|
+
caseInsensitive,
|
|
2033
|
+
showLineNumbers,
|
|
2034
|
+
headLimit,
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
catch (e) {
|
|
2038
|
+
return formatToolError("grep", e);
|
|
2039
|
+
}
|
|
2040
|
+
},
|
|
2041
|
+
web_fetch: async (args) => {
|
|
2042
|
+
try {
|
|
2043
|
+
const a = expectArgsObject("web_fetch", args);
|
|
2044
|
+
const url = requiredStringArg("web_fetch", a, "url", { nonEmpty: true });
|
|
2045
|
+
const prompt = optionalStringArg("web_fetch", a, "prompt", "");
|
|
2046
|
+
return toolWebFetch(url, prompt || undefined);
|
|
2047
|
+
}
|
|
2048
|
+
catch (e) {
|
|
2049
|
+
return formatToolError("web_fetch", e);
|
|
2050
|
+
}
|
|
2051
|
+
},
|
|
2052
|
+
$web_search: (args) => toolBuiltinWebSearchPassthrough(args),
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
/**
|
|
2056
|
+
* Execute a tool by name and return a `ToolResult`.
|
|
2057
|
+
*
|
|
2058
|
+
* Tool functions may return either a plain `string` (wrapped automatically)
|
|
2059
|
+
* or a `ToolResult` with optional action hints, tags, and metadata.
|
|
2060
|
+
*/
|
|
2061
|
+
export async function executeTool(name, args, ctx) {
|
|
2062
|
+
const fn = createDispatch(ctx)[name];
|
|
2063
|
+
if (!fn) {
|
|
2064
|
+
return new ToolResult({ content: `ERROR: Unknown tool '${name}'` });
|
|
2065
|
+
}
|
|
2066
|
+
try {
|
|
2067
|
+
const raw = await fn(args);
|
|
2068
|
+
if (raw instanceof ToolResult) {
|
|
2069
|
+
return raw;
|
|
2070
|
+
}
|
|
2071
|
+
return new ToolResult({ content: raw });
|
|
2072
|
+
}
|
|
2073
|
+
catch (e) {
|
|
2074
|
+
return new ToolResult({
|
|
2075
|
+
content: `ERROR executing ${name}: ${e instanceof Error ? e.message : String(e)}`,
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
//# sourceMappingURL=basic.js.map
|