protoagent 0.1.13 → 0.1.15
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/README.md +1 -4
- package/dist/App.js +77 -442
- package/dist/agentic-loop/errors.js +198 -0
- package/dist/agentic-loop/executor.js +108 -0
- package/dist/agentic-loop/stream.js +109 -0
- package/dist/agentic-loop.js +67 -593
- package/dist/components/ApprovalPrompt.js +18 -0
- package/dist/components/CommandFilter.js +19 -0
- package/dist/components/InlineSetup.js +33 -0
- package/dist/components/UsageDisplay.js +10 -0
- package/dist/config.js +52 -51
- package/dist/hooks/useAgentEventHandler.js +356 -0
- package/dist/mcp.js +3 -0
- package/dist/runtime-config.js +64 -33
- package/dist/skills.js +3 -1
- package/dist/sub-agent.js +11 -16
- package/dist/tools/bash.js +37 -11
- package/dist/tools/edit-file.js +8 -49
- package/dist/tools/read-file.js +3 -66
- package/dist/tools/search-files.js +70 -12
- package/dist/tools/webfetch.js +77 -62
- package/dist/tools/write-file.js +39 -3
- package/dist/utils/approval.js +2 -0
- package/dist/utils/compactor.js +2 -1
- package/dist/utils/cost-tracker.js +5 -2
- package/dist/utils/format-message.js +13 -0
- package/dist/utils/logger.js +16 -3
- package/dist/utils/path-suggestions.js +74 -0
- package/dist/utils/path-validation.js +2 -5
- package/dist/utils/tool-display.js +53 -0
- package/package.json +11 -4
- package/dist/components/CollapsibleBox.js +0 -27
- package/dist/components/ConfigDialog.js +0 -42
- package/dist/components/ConsolidatedToolMessage.js +0 -34
- package/dist/components/FormattedMessage.js +0 -170
package/dist/runtime-config.js
CHANGED
|
@@ -3,7 +3,49 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { parse, printParseErrorCode } from 'jsonc-parser';
|
|
6
|
+
import { z } from 'zod';
|
|
6
7
|
import { logger } from './utils/logger.js';
|
|
8
|
+
// ─── Zod Schemas for Runtime Validation ───
|
|
9
|
+
export const RuntimeModelConfigSchema = z.object({
|
|
10
|
+
name: z.string().optional(),
|
|
11
|
+
contextWindow: z.number().optional(),
|
|
12
|
+
inputPricePerMillion: z.number().optional(),
|
|
13
|
+
outputPricePerMillion: z.number().optional(),
|
|
14
|
+
cachedPricePerMillion: z.number().optional(),
|
|
15
|
+
defaultParams: z.record(z.unknown()).optional(),
|
|
16
|
+
});
|
|
17
|
+
export const RuntimeProviderConfigSchema = z.object({
|
|
18
|
+
name: z.string().optional(),
|
|
19
|
+
baseURL: z.string().optional(),
|
|
20
|
+
apiKey: z.string().optional(),
|
|
21
|
+
apiKeyEnvVar: z.string().optional(),
|
|
22
|
+
headers: z.record(z.string()).optional(),
|
|
23
|
+
defaultParams: z.record(z.unknown()).optional(),
|
|
24
|
+
models: z.record(RuntimeModelConfigSchema).optional(),
|
|
25
|
+
});
|
|
26
|
+
export const StdioServerConfigSchema = z.object({
|
|
27
|
+
type: z.literal('stdio'),
|
|
28
|
+
command: z.string(),
|
|
29
|
+
args: z.array(z.string()).optional(),
|
|
30
|
+
env: z.record(z.string()).optional(),
|
|
31
|
+
cwd: z.string().optional(),
|
|
32
|
+
enabled: z.boolean().optional(),
|
|
33
|
+
timeoutMs: z.number().optional(),
|
|
34
|
+
});
|
|
35
|
+
export const HttpServerConfigSchema = z.object({
|
|
36
|
+
type: z.literal('http'),
|
|
37
|
+
url: z.string(),
|
|
38
|
+
headers: z.record(z.string()).optional(),
|
|
39
|
+
enabled: z.boolean().optional(),
|
|
40
|
+
timeoutMs: z.number().optional(),
|
|
41
|
+
});
|
|
42
|
+
export const RuntimeMcpServerConfigSchema = z.union([StdioServerConfigSchema, HttpServerConfigSchema]);
|
|
43
|
+
export const RuntimeConfigFileSchema = z.object({
|
|
44
|
+
providers: z.record(z.any()).optional(),
|
|
45
|
+
mcp: z.object({
|
|
46
|
+
servers: z.record(z.any()).optional(),
|
|
47
|
+
}).optional(),
|
|
48
|
+
});
|
|
7
49
|
const RESERVED_DEFAULT_PARAM_KEYS = new Set([
|
|
8
50
|
'model',
|
|
9
51
|
'messages',
|
|
@@ -41,6 +83,10 @@ export function getActiveRuntimeConfigPath() {
|
|
|
41
83
|
function isPlainObject(value) {
|
|
42
84
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
43
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Replaces ${ENV_VAR} placeholders in a string with actual environment variable values.
|
|
88
|
+
* Logs a warning if the environment variable is not set (replaces with empty string).
|
|
89
|
+
*/
|
|
44
90
|
function interpolateString(value, sourcePath) {
|
|
45
91
|
return value.replace(/\$\{([A-Z0-9_]+)\}/gi, (_match, envVar) => {
|
|
46
92
|
const resolved = process.env[envVar];
|
|
@@ -51,6 +97,10 @@ function interpolateString(value, sourcePath) {
|
|
|
51
97
|
return resolved;
|
|
52
98
|
});
|
|
53
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Recursively interpolates environment variables in all string values within a config object.
|
|
102
|
+
* Handles nested objects and arrays. Filters out empty header values.
|
|
103
|
+
*/
|
|
54
104
|
function interpolateValue(value, sourcePath) {
|
|
55
105
|
if (typeof value === 'string') {
|
|
56
106
|
return interpolateString(value, sourcePath);
|
|
@@ -63,6 +113,7 @@ function interpolateValue(value, sourcePath) {
|
|
|
63
113
|
for (const [key, entry] of Object.entries(value)) {
|
|
64
114
|
const interpolated = interpolateValue(entry, sourcePath);
|
|
65
115
|
if (key === 'headers' && isPlainObject(interpolated)) {
|
|
116
|
+
// Filter out headers with empty values (from unset env vars)
|
|
66
117
|
const filtered = Object.fromEntries(Object.entries(interpolated).filter(([, headerValue]) => typeof headerValue !== 'string' || headerValue.length > 0));
|
|
67
118
|
next[key] = filtered;
|
|
68
119
|
continue;
|
|
@@ -73,6 +124,11 @@ function interpolateValue(value, sourcePath) {
|
|
|
73
124
|
}
|
|
74
125
|
return value;
|
|
75
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Removes reserved API parameters from provider and model defaultParams.
|
|
129
|
+
* Prevents users from accidentally overriding critical parameters like
|
|
130
|
+
* 'model', 'messages', 'tools' that are managed by the agentic loop.
|
|
131
|
+
*/
|
|
76
132
|
function sanitizeDefaultParamsInConfig(config) {
|
|
77
133
|
const nextProviders = Object.fromEntries(Object.entries(config.providers || {}).map(([providerId, provider]) => {
|
|
78
134
|
const providerDefaultParams = Object.fromEntries(Object.entries(provider.defaultParams || {}).filter(([key]) => {
|
|
@@ -112,37 +168,6 @@ function sanitizeDefaultParamsInConfig(config) {
|
|
|
112
168
|
providers: nextProviders,
|
|
113
169
|
};
|
|
114
170
|
}
|
|
115
|
-
function mergeRuntimeConfig(base, overlay) {
|
|
116
|
-
const mergedProviders = {
|
|
117
|
-
...(base.providers || {}),
|
|
118
|
-
};
|
|
119
|
-
for (const [providerId, providerConfig] of Object.entries(overlay.providers || {})) {
|
|
120
|
-
const currentProvider = mergedProviders[providerId] || {};
|
|
121
|
-
mergedProviders[providerId] = {
|
|
122
|
-
...currentProvider,
|
|
123
|
-
...providerConfig,
|
|
124
|
-
models: {
|
|
125
|
-
...(currentProvider.models || {}),
|
|
126
|
-
...(providerConfig.models || {}),
|
|
127
|
-
},
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
const mergedServers = {
|
|
131
|
-
...(base.mcp?.servers || {}),
|
|
132
|
-
};
|
|
133
|
-
for (const [serverName, serverConfig] of Object.entries(overlay.mcp?.servers || {})) {
|
|
134
|
-
const currentServer = mergedServers[serverName];
|
|
135
|
-
mergedServers[serverName] = currentServer && isPlainObject(currentServer)
|
|
136
|
-
? { ...currentServer, ...serverConfig }
|
|
137
|
-
: serverConfig;
|
|
138
|
-
}
|
|
139
|
-
return {
|
|
140
|
-
providers: mergedProviders,
|
|
141
|
-
mcp: {
|
|
142
|
-
servers: mergedServers,
|
|
143
|
-
},
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
171
|
async function readRuntimeConfigFile(configPath) {
|
|
147
172
|
try {
|
|
148
173
|
const content = await fs.readFile(configPath, 'utf8');
|
|
@@ -157,7 +182,13 @@ async function readRuntimeConfigFile(configPath) {
|
|
|
157
182
|
if (!isPlainObject(parsed)) {
|
|
158
183
|
throw new Error(`Failed to parse ${configPath}: top-level value must be an object`);
|
|
159
184
|
}
|
|
160
|
-
|
|
185
|
+
// Validate against zod schema for better error messages
|
|
186
|
+
const result = RuntimeConfigFileSchema.safeParse(parsed);
|
|
187
|
+
if (!result.success) {
|
|
188
|
+
const issues = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join(', ');
|
|
189
|
+
throw new Error(`Invalid runtime config in ${configPath}: ${issues}`);
|
|
190
|
+
}
|
|
191
|
+
return sanitizeDefaultParamsInConfig(interpolateValue(result.data, configPath));
|
|
161
192
|
}
|
|
162
193
|
catch (error) {
|
|
163
194
|
if (error?.code === 'ENOENT') {
|
|
@@ -176,7 +207,7 @@ export async function loadRuntimeConfig(forceReload = false) {
|
|
|
176
207
|
const fileConfig = await readRuntimeConfigFile(configPath);
|
|
177
208
|
if (fileConfig) {
|
|
178
209
|
logger.debug('Loaded runtime config', { path: configPath });
|
|
179
|
-
loaded =
|
|
210
|
+
loaded = fileConfig;
|
|
180
211
|
}
|
|
181
212
|
}
|
|
182
213
|
runtimeConfigCache = loaded;
|
package/dist/skills.js
CHANGED
|
@@ -184,7 +184,9 @@ async function listSkillResources(skillDir) {
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
|
-
|
|
187
|
+
for (const dir of ['scripts', 'references', 'assets']) {
|
|
188
|
+
await walk(dir);
|
|
189
|
+
}
|
|
188
190
|
return files.sort();
|
|
189
191
|
}
|
|
190
192
|
export async function activateSkill(skillName, options = {}) {
|
package/dist/sub-agent.js
CHANGED
|
@@ -12,6 +12,7 @@ import { handleToolCall, getAllTools } from './tools/index.js';
|
|
|
12
12
|
import { generateSystemPrompt } from './system-prompt.js';
|
|
13
13
|
import { logger } from './utils/logger.js';
|
|
14
14
|
import { clearTodos } from './tools/todo.js';
|
|
15
|
+
import { calculateCost } from './utils/cost-tracker.js';
|
|
15
16
|
export const subAgentTool = {
|
|
16
17
|
type: 'function',
|
|
17
18
|
function: {
|
|
@@ -62,7 +63,7 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
|
|
|
62
63
|
for (let i = 0; i < maxIterations; i++) {
|
|
63
64
|
// Check abort at the top of each iteration
|
|
64
65
|
if (abortSignal?.aborted) {
|
|
65
|
-
return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens,
|
|
66
|
+
return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
|
|
66
67
|
}
|
|
67
68
|
let assistantMessage;
|
|
68
69
|
let hasToolCalls = false;
|
|
@@ -99,7 +100,7 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
|
|
|
99
100
|
if (delta?.tool_calls) {
|
|
100
101
|
hasToolCalls = true;
|
|
101
102
|
for (const tc of delta.tool_calls) {
|
|
102
|
-
const idx = tc.index
|
|
103
|
+
const idx = tc.index ?? 0;
|
|
103
104
|
if (!assistantMessage.tool_calls[idx]) {
|
|
104
105
|
assistantMessage.tool_calls[idx] = {
|
|
105
106
|
id: '',
|
|
@@ -121,25 +122,19 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
|
|
|
121
122
|
// Accumulate usage for this iteration
|
|
122
123
|
const iterationInputTokens = actualUsage?.prompt_tokens || 0;
|
|
123
124
|
const iterationOutputTokens = actualUsage?.completion_tokens || 0;
|
|
125
|
+
const iterationCachedTokens = actualUsage?.prompt_tokens_details?.cached_tokens || 0;
|
|
124
126
|
totalInputTokens += iterationInputTokens;
|
|
125
127
|
totalOutputTokens += iterationOutputTokens;
|
|
126
|
-
// Calculate cost if pricing is available
|
|
128
|
+
// Calculate cost if pricing is available (handles cached token discount)
|
|
127
129
|
if (pricing && (iterationInputTokens > 0 || iterationOutputTokens > 0)) {
|
|
128
|
-
|
|
129
|
-
if (cachedTokens && cachedTokens > 0 && pricing.cachedPerToken != null) {
|
|
130
|
-
const uncachedTokens = iterationInputTokens - cachedTokens;
|
|
131
|
-
totalCost += uncachedTokens * pricing.inputPerToken + cachedTokens * pricing.cachedPerToken + iterationOutputTokens * pricing.outputPerToken;
|
|
132
|
-
}
|
|
133
|
-
else {
|
|
134
|
-
totalCost += iterationInputTokens * pricing.inputPerToken + iterationOutputTokens * pricing.outputPerToken;
|
|
135
|
-
}
|
|
130
|
+
totalCost += calculateCost(iterationInputTokens, iterationOutputTokens, pricing, iterationCachedTokens);
|
|
136
131
|
}
|
|
137
132
|
}
|
|
138
133
|
catch (err) {
|
|
139
134
|
// If aborted during streaming, return gracefully
|
|
140
135
|
if (abortSignal?.aborted || (err instanceof Error && (err.name === 'AbortError' || err.message === 'Operation aborted'))) {
|
|
141
136
|
logger.debug('Sub-agent aborted during streaming');
|
|
142
|
-
return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens,
|
|
137
|
+
return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
|
|
143
138
|
}
|
|
144
139
|
throw err;
|
|
145
140
|
}
|
|
@@ -177,7 +172,7 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
|
|
|
177
172
|
for (const toolCall of assistantMessage.tool_calls) {
|
|
178
173
|
// Check abort between tool calls
|
|
179
174
|
if (abortSignal?.aborted) {
|
|
180
|
-
return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens,
|
|
175
|
+
return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
|
|
181
176
|
}
|
|
182
177
|
const { name, arguments: argsStr } = toolCall.function;
|
|
183
178
|
let args;
|
|
@@ -216,15 +211,15 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
|
|
|
216
211
|
role: 'assistant',
|
|
217
212
|
content: message.content,
|
|
218
213
|
});
|
|
219
|
-
return { response: message.content, usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens,
|
|
214
|
+
return { response: message.content, usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
|
|
220
215
|
}
|
|
221
216
|
// The model produced an empty text response (e.g. it only called tools
|
|
222
217
|
// and issued no final summary). Log it and return a sentinel so the
|
|
223
218
|
// parent agent knows the sub-agent finished but had nothing to say.
|
|
224
219
|
logger.debug('Sub-agent returned empty content', { iteration: i });
|
|
225
|
-
return { response: '(sub-agent completed with no response)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens,
|
|
220
|
+
return { response: '(sub-agent completed with no response)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
|
|
226
221
|
}
|
|
227
|
-
return { response: '(sub-agent reached iteration limit)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens,
|
|
222
|
+
return { response: '(sub-agent reached iteration limit)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, totalTokens: totalInputTokens + totalOutputTokens, estimatedCost: totalCost } };
|
|
228
223
|
}
|
|
229
224
|
finally {
|
|
230
225
|
op.end();
|
package/dist/tools/bash.js
CHANGED
|
@@ -27,7 +27,9 @@ export const bashTool = {
|
|
|
27
27
|
},
|
|
28
28
|
},
|
|
29
29
|
};
|
|
30
|
-
// Hard-blocked commands — these CANNOT be run, even with --dangerously-skip-permissions
|
|
30
|
+
// Security: Hard-blocked commands — these CANNOT be run, even with --dangerously-skip-permissions
|
|
31
|
+
// Naive approach: Allow any command with user approval
|
|
32
|
+
// Risk: Some commands are too destructive even with approval (rm -rf /, mkfs)
|
|
31
33
|
const DANGEROUS_PATTERNS = [
|
|
32
34
|
'rm -rf /',
|
|
33
35
|
'sudo',
|
|
@@ -37,16 +39,27 @@ const DANGEROUS_PATTERNS = [
|
|
|
37
39
|
'mkfs',
|
|
38
40
|
'fdisk',
|
|
39
41
|
'format c:',
|
|
42
|
+
'> /dev/sda', // Disk overwrite
|
|
43
|
+
'of=/dev/sda', // dd to disk
|
|
44
|
+
':(){ :|:& };:', // Fork bomb
|
|
40
45
|
];
|
|
41
|
-
//
|
|
46
|
+
// Security: Allowlist approach for safe commands
|
|
47
|
+
// Naive approach: Blocklist of dangerous patterns - easily bypassed
|
|
48
|
+
// Bypass examples: 's\udo', '$(which sudo)', '/bin/rm', 'sh -c "rm -rf /"'
|
|
49
|
+
// Fix: Allowlist - only these specific commands run without approval
|
|
42
50
|
const SAFE_COMMANDS = [
|
|
43
|
-
'pwd', 'whoami', 'date',
|
|
44
|
-
'git status', 'git log', 'git diff', 'git branch', 'git show', 'git remote',
|
|
51
|
+
'pwd', 'whoami', 'date', 'uname', 'uptime',
|
|
52
|
+
'git status', 'git log', 'git diff', 'git branch', 'git show', 'git remote -v',
|
|
45
53
|
'npm list', 'npm ls', 'yarn list',
|
|
46
54
|
'node --version', 'npm --version', 'python --version', 'python3 --version',
|
|
55
|
+
'which', 'type',
|
|
47
56
|
];
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
// Security: Stricter shell control pattern to catch bypass attempts
|
|
58
|
+
// Naive approach: Simple regex misses many bypasses
|
|
59
|
+
// Bypasses: Newlines, encoded chars, quoted operators, backticks
|
|
60
|
+
const SHELL_CONTROL_PATTERN = /[;&|<>()`$\[\]{}!]/;
|
|
61
|
+
// Commands that read files - require path validation
|
|
62
|
+
const FILE_READING_COMMANDS = new Set(['cat', 'head', 'tail', 'grep', 'rg', 'find', 'awk', 'sed', 'sort', 'uniq', 'cut', 'wc', 'tree', 'file', 'dir', 'ls', 'echo']);
|
|
50
63
|
function isDangerous(command) {
|
|
51
64
|
const lower = command.toLowerCase().trim();
|
|
52
65
|
return DANGEROUS_PATTERNS.some((p) => lower.includes(p));
|
|
@@ -95,24 +108,37 @@ async function isSafe(command) {
|
|
|
95
108
|
if (!trimmed || hasShellControlOperators(trimmed)) {
|
|
96
109
|
return false;
|
|
97
110
|
}
|
|
111
|
+
// Security: Reject commands with newlines (bypass technique)
|
|
112
|
+
if (trimmed.includes('\n') || trimmed.includes('\r')) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
// Security: Reject escape sequences that could hide shell operators
|
|
116
|
+
if (/\\[;&|<>()`$]/.test(trimmed)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
98
119
|
const tokens = tokenizeCommand(trimmed);
|
|
99
120
|
if (!tokens) {
|
|
100
121
|
return false;
|
|
101
122
|
}
|
|
102
123
|
const firstWord = trimmed.split(/\s+/)[0];
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
124
|
+
// Security: File-reading commands need path validation
|
|
125
|
+
// They can read any file in working directory but not outside
|
|
126
|
+
const needsPathValidation = FILE_READING_COMMANDS.has(firstWord);
|
|
127
|
+
// Check against allowlist of safe commands
|
|
106
128
|
const matchedSafeCommand = SAFE_COMMANDS.some((safe) => {
|
|
107
129
|
if (safe.includes(' ')) {
|
|
108
130
|
return trimmed === safe || trimmed.startsWith(`${safe} `);
|
|
109
131
|
}
|
|
110
132
|
return firstWord === safe;
|
|
111
133
|
});
|
|
112
|
-
if (!matchedSafeCommand) {
|
|
134
|
+
if (!matchedSafeCommand && !needsPathValidation) {
|
|
113
135
|
return false;
|
|
114
136
|
}
|
|
115
|
-
|
|
137
|
+
// Security: Always validate paths for file-reading commands
|
|
138
|
+
if (needsPathValidation) {
|
|
139
|
+
return validateCommandPaths(tokens);
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
116
142
|
}
|
|
117
143
|
export async function runBash(command, timeoutMs = 30_000, sessionId, abortSignal) {
|
|
118
144
|
// Layer 1: hard block
|
package/dist/tools/edit-file.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'node:fs/promises';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import { validatePath
|
|
10
|
+
import { validatePath } from '../utils/path-validation.js';
|
|
11
|
+
import { findSimilarPaths } from '../utils/path-suggestions.js';
|
|
11
12
|
import { requestApproval } from '../utils/approval.js';
|
|
12
13
|
import { checkReadBefore, recordRead } from '../utils/file-time.js';
|
|
13
14
|
export const editFileTool = {
|
|
@@ -32,54 +33,6 @@ export const editFileTool = {
|
|
|
32
33
|
},
|
|
33
34
|
},
|
|
34
35
|
};
|
|
35
|
-
// ─── Path suggestion helper (mirrors read_file behaviour) ───
|
|
36
|
-
async function findSimilarPaths(requestedPath) {
|
|
37
|
-
const cwd = getWorkingDirectory();
|
|
38
|
-
const segments = requestedPath.split('/').filter(Boolean);
|
|
39
|
-
const MAX_DEPTH = 6;
|
|
40
|
-
const MAX_ENTRIES = 200;
|
|
41
|
-
const MAX_SUGGESTIONS = 3;
|
|
42
|
-
const candidates = [];
|
|
43
|
-
async function walkSegments(dir, segIndex, currentPath) {
|
|
44
|
-
if (segIndex >= segments.length || segIndex >= MAX_DEPTH || candidates.length >= MAX_SUGGESTIONS)
|
|
45
|
-
return;
|
|
46
|
-
const targetSegment = segments[segIndex].toLowerCase();
|
|
47
|
-
let entries;
|
|
48
|
-
try {
|
|
49
|
-
entries = (await fs.readdir(dir, { withFileTypes: true })).slice(0, MAX_ENTRIES).map(e => e.name);
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
const isLastSegment = segIndex === segments.length - 1;
|
|
55
|
-
for (const entry of entries) {
|
|
56
|
-
if (candidates.length >= MAX_SUGGESTIONS)
|
|
57
|
-
break;
|
|
58
|
-
const entryLower = entry.toLowerCase();
|
|
59
|
-
if (!entryLower.includes(targetSegment) && !targetSegment.includes(entryLower))
|
|
60
|
-
continue;
|
|
61
|
-
const entryPath = path.join(currentPath, entry);
|
|
62
|
-
const fullPath = path.join(dir, entry);
|
|
63
|
-
if (isLastSegment) {
|
|
64
|
-
try {
|
|
65
|
-
await fs.stat(fullPath);
|
|
66
|
-
candidates.push(entryPath);
|
|
67
|
-
}
|
|
68
|
-
catch { /* skip */ }
|
|
69
|
-
}
|
|
70
|
-
else {
|
|
71
|
-
try {
|
|
72
|
-
const stat = await fs.stat(fullPath);
|
|
73
|
-
if (stat.isDirectory())
|
|
74
|
-
await walkSegments(fullPath, segIndex + 1, entryPath);
|
|
75
|
-
}
|
|
76
|
-
catch { /* skip */ }
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
await walkSegments(cwd, 0, '');
|
|
81
|
-
return candidates;
|
|
82
|
-
}
|
|
83
36
|
/** Strategy 1: Exact verbatim match (current behavior). */
|
|
84
37
|
const exactReplacer = {
|
|
85
38
|
name: 'exact',
|
|
@@ -314,6 +267,12 @@ export async function editFile(filePath, oldString, newString, expectedReplaceme
|
|
|
314
267
|
if (staleError)
|
|
315
268
|
return staleError;
|
|
316
269
|
}
|
|
270
|
+
// Check file size before reading to avoid OOM on huge files
|
|
271
|
+
const stat = await fs.stat(validated);
|
|
272
|
+
const MAX_EDIT_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
|
273
|
+
if (stat.size > MAX_EDIT_FILE_SIZE) {
|
|
274
|
+
return `Error: file is too large to edit (${(stat.size / 1024 / 1024).toFixed(1)} MB, limit is ${MAX_EDIT_FILE_SIZE / 1024 / 1024} MB).`;
|
|
275
|
+
}
|
|
317
276
|
const content = await fs.readFile(validated, 'utf8');
|
|
318
277
|
// Use fuzzy match cascade
|
|
319
278
|
const match = findWithCascade(content, oldString, expectedReplacements);
|
package/dist/tools/read-file.js
CHANGED
|
@@ -4,17 +4,16 @@
|
|
|
4
4
|
* When a file is not found, suggests similar paths to help the model
|
|
5
5
|
* recover from typos without repeated failed attempts.
|
|
6
6
|
*/
|
|
7
|
-
import fs from 'node:fs/promises';
|
|
8
7
|
import { createReadStream } from 'node:fs';
|
|
9
8
|
import readline from 'node:readline';
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
9
|
+
import { validatePath } from '../utils/path-validation.js';
|
|
10
|
+
import { findSimilarPaths } from '../utils/path-suggestions.js';
|
|
12
11
|
import { recordRead } from '../utils/file-time.js';
|
|
13
12
|
export const readFileTool = {
|
|
14
13
|
type: 'function',
|
|
15
14
|
function: {
|
|
16
15
|
name: 'read_file',
|
|
17
|
-
description: 'Read the contents of a file.
|
|
16
|
+
description: 'Read the contents of a file. Use offset and limit to read specific sections of large files.',
|
|
18
17
|
parameters: {
|
|
19
18
|
type: 'object',
|
|
20
19
|
properties: {
|
|
@@ -26,68 +25,6 @@ export const readFileTool = {
|
|
|
26
25
|
},
|
|
27
26
|
},
|
|
28
27
|
};
|
|
29
|
-
/**
|
|
30
|
-
* Find similar paths when a requested file doesn't exist.
|
|
31
|
-
* Walks from the repo root, matching segments case-insensitively.
|
|
32
|
-
*/
|
|
33
|
-
async function findSimilarPaths(requestedPath) {
|
|
34
|
-
const cwd = getWorkingDirectory();
|
|
35
|
-
const segments = requestedPath.split('/').filter(Boolean);
|
|
36
|
-
const MAX_DEPTH = 6;
|
|
37
|
-
const MAX_ENTRIES = 200;
|
|
38
|
-
const MAX_SUGGESTIONS = 3;
|
|
39
|
-
const candidates = [];
|
|
40
|
-
async function walkSegments(dir, segIndex, currentPath) {
|
|
41
|
-
if (segIndex >= segments.length || segIndex >= MAX_DEPTH || candidates.length >= MAX_SUGGESTIONS)
|
|
42
|
-
return;
|
|
43
|
-
const targetSegment = segments[segIndex].toLowerCase();
|
|
44
|
-
let entries;
|
|
45
|
-
try {
|
|
46
|
-
const dirEntries = await fs.readdir(dir, { withFileTypes: true });
|
|
47
|
-
entries = dirEntries
|
|
48
|
-
.slice(0, MAX_ENTRIES)
|
|
49
|
-
.map(e => e.name);
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
const isLastSegment = segIndex === segments.length - 1;
|
|
55
|
-
for (const entry of entries) {
|
|
56
|
-
if (candidates.length >= MAX_SUGGESTIONS)
|
|
57
|
-
break;
|
|
58
|
-
const entryLower = entry.toLowerCase();
|
|
59
|
-
// Match if entry contains the target segment as a substring (case-insensitive)
|
|
60
|
-
if (!entryLower.includes(targetSegment) && !targetSegment.includes(entryLower))
|
|
61
|
-
continue;
|
|
62
|
-
const entryPath = path.join(currentPath, entry);
|
|
63
|
-
const fullPath = path.join(dir, entry);
|
|
64
|
-
if (isLastSegment) {
|
|
65
|
-
// Check if this file/dir actually exists
|
|
66
|
-
try {
|
|
67
|
-
await fs.stat(fullPath);
|
|
68
|
-
candidates.push(entryPath);
|
|
69
|
-
}
|
|
70
|
-
catch {
|
|
71
|
-
// skip
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
else {
|
|
75
|
-
// Continue walking deeper
|
|
76
|
-
try {
|
|
77
|
-
const stat = await fs.stat(fullPath);
|
|
78
|
-
if (stat.isDirectory()) {
|
|
79
|
-
await walkSegments(fullPath, segIndex + 1, entryPath);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
// skip
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
await walkSegments(cwd, 0, '');
|
|
89
|
-
return candidates;
|
|
90
|
-
}
|
|
91
28
|
export async function readFile(filePath, offset = 0, limit = 2000, sessionId) {
|
|
92
29
|
let validated;
|
|
93
30
|
try {
|