protoagent 0.0.5 → 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/README.md +99 -19
- package/dist/App.js +602 -0
- package/dist/agentic-loop.js +492 -525
- package/dist/cli.js +39 -0
- package/dist/components/CollapsibleBox.js +26 -0
- package/dist/components/ConfigDialog.js +40 -0
- package/dist/components/ConsolidatedToolMessage.js +41 -0
- package/dist/components/FormattedMessage.js +93 -0
- package/dist/components/Table.js +275 -0
- package/dist/config.js +171 -0
- package/dist/mcp.js +170 -0
- package/dist/providers.js +137 -0
- package/dist/sessions.js +161 -0
- package/dist/skills.js +229 -0
- package/dist/sub-agent.js +103 -0
- package/dist/system-prompt.js +131 -0
- package/dist/tools/bash.js +178 -0
- package/dist/tools/edit-file.js +65 -171
- package/dist/tools/index.js +79 -134
- package/dist/tools/list-directory.js +20 -73
- package/dist/tools/read-file.js +57 -101
- package/dist/tools/search-files.js +74 -162
- package/dist/tools/todo.js +57 -140
- package/dist/tools/webfetch.js +310 -0
- package/dist/tools/write-file.js +44 -135
- package/dist/utils/approval.js +69 -0
- package/dist/utils/compactor.js +87 -0
- package/dist/utils/cost-tracker.js +26 -81
- package/dist/utils/format-message.js +26 -0
- package/dist/utils/logger.js +101 -307
- package/dist/utils/path-validation.js +74 -0
- package/package.json +45 -51
- package/LICENSE +0 -21
- package/dist/config/client.js +0 -315
- package/dist/config/commands.js +0 -223
- package/dist/config/manager.js +0 -117
- package/dist/config/mcp-commands.js +0 -266
- package/dist/config/mcp-manager.js +0 -240
- package/dist/config/mcp-types.js +0 -28
- package/dist/config/providers.js +0 -229
- package/dist/config/setup.js +0 -209
- package/dist/config/system-prompt.js +0 -397
- package/dist/config/types.js +0 -4
- package/dist/index.js +0 -229
- package/dist/tools/create-directory.js +0 -76
- package/dist/tools/directory-operations.js +0 -195
- package/dist/tools/file-operations.js +0 -211
- package/dist/tools/run-shell-command.js +0 -746
- package/dist/tools/search-operations.js +0 -179
- package/dist/tools/shell-operations.js +0 -342
- package/dist/tools/task-complete.js +0 -26
- package/dist/tools/view-directory-tree.js +0 -125
- package/dist/tools.js +0 -2
- package/dist/utils/conversation-compactor.js +0 -140
- package/dist/utils/enhanced-prompt.js +0 -23
- package/dist/utils/file-operations-approval.js +0 -373
- package/dist/utils/interrupt-handler.js +0 -127
- package/dist/utils/user-cancellation.js +0 -34
package/dist/skills.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
import { registerDynamicHandler, registerDynamicTool, unregisterDynamicHandler, unregisterDynamicTool, } from './tools/index.js';
|
|
6
|
+
import { setAllowedPathRoots } from './utils/path-validation.js';
|
|
7
|
+
import { logger } from './utils/logger.js';
|
|
8
|
+
const ACTIVATE_SKILL_TOOL_NAME = 'activate_skill';
|
|
9
|
+
const VALID_SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
10
|
+
const MAX_RESOURCE_FILES = 200;
|
|
11
|
+
function getSkillRoots(options = {}) {
|
|
12
|
+
const cwd = options.cwd ?? process.cwd();
|
|
13
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
14
|
+
return [
|
|
15
|
+
{ dir: path.join(homeDir, '.agents', 'skills'), source: 'user' },
|
|
16
|
+
{ dir: path.join(homeDir, '.protoagent', 'skills'), source: 'user' },
|
|
17
|
+
{ dir: path.join(homeDir, '.config', 'protoagent', 'skills'), source: 'user' },
|
|
18
|
+
{ dir: path.join(cwd, '.agents', 'skills'), source: 'project' },
|
|
19
|
+
{ dir: path.join(cwd, '.protoagent', 'skills'), source: 'project' },
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
function parseFrontmatter(rawContent) {
|
|
23
|
+
const match = rawContent.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
24
|
+
if (!match) {
|
|
25
|
+
throw new Error('SKILL.md must begin with YAML frontmatter delimited by --- lines.');
|
|
26
|
+
}
|
|
27
|
+
const document = YAML.parse(match[1]);
|
|
28
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)) {
|
|
29
|
+
throw new Error('Frontmatter must parse to an object.');
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
frontmatter: document,
|
|
33
|
+
body: match[2].trim(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function isValidSkillName(name) {
|
|
37
|
+
return name.length >= 1 && name.length <= 64 && VALID_SKILL_NAME.test(name);
|
|
38
|
+
}
|
|
39
|
+
function normalizeMetadata(value) {
|
|
40
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
41
|
+
return undefined;
|
|
42
|
+
const entries = Object.entries(value).filter(([, entryValue]) => typeof entryValue === 'string');
|
|
43
|
+
if (entries.length === 0)
|
|
44
|
+
return undefined;
|
|
45
|
+
return Object.fromEntries(entries);
|
|
46
|
+
}
|
|
47
|
+
function validateSkill(parsed, skillDir, source, location) {
|
|
48
|
+
const name = typeof parsed.frontmatter.name === 'string' ? parsed.frontmatter.name.trim() : '';
|
|
49
|
+
const description = typeof parsed.frontmatter.description === 'string'
|
|
50
|
+
? parsed.frontmatter.description.trim()
|
|
51
|
+
: '';
|
|
52
|
+
const compatibility = typeof parsed.frontmatter.compatibility === 'string'
|
|
53
|
+
? parsed.frontmatter.compatibility.trim()
|
|
54
|
+
: undefined;
|
|
55
|
+
const license = typeof parsed.frontmatter.license === 'string'
|
|
56
|
+
? parsed.frontmatter.license.trim()
|
|
57
|
+
: undefined;
|
|
58
|
+
const allowedToolsValue = typeof parsed.frontmatter['allowed-tools'] === 'string'
|
|
59
|
+
? parsed.frontmatter['allowed-tools'].trim()
|
|
60
|
+
: undefined;
|
|
61
|
+
if (!isValidSkillName(name)) {
|
|
62
|
+
throw new Error(`Skill name "${name}" is invalid.`);
|
|
63
|
+
}
|
|
64
|
+
if (path.basename(skillDir) !== name) {
|
|
65
|
+
throw new Error(`Skill name "${name}" must match directory name "${path.basename(skillDir)}".`);
|
|
66
|
+
}
|
|
67
|
+
if (!description || description.length > 1024) {
|
|
68
|
+
throw new Error('Skill description is required and must be 1-1024 characters.');
|
|
69
|
+
}
|
|
70
|
+
if (compatibility !== undefined && (compatibility.length === 0 || compatibility.length > 500)) {
|
|
71
|
+
throw new Error('Skill compatibility must be 1-500 characters when provided.');
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
name,
|
|
75
|
+
description,
|
|
76
|
+
source,
|
|
77
|
+
location,
|
|
78
|
+
skillDir,
|
|
79
|
+
body: parsed.body,
|
|
80
|
+
compatibility,
|
|
81
|
+
license,
|
|
82
|
+
metadata: normalizeMetadata(parsed.frontmatter.metadata),
|
|
83
|
+
allowedTools: allowedToolsValue ? allowedToolsValue.split(/\s+/).filter(Boolean) : undefined,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async function loadSkillFromDirectory(skillDir, source) {
|
|
87
|
+
const location = path.join(skillDir, 'SKILL.md');
|
|
88
|
+
try {
|
|
89
|
+
const rawContent = await fs.readFile(location, 'utf8');
|
|
90
|
+
const parsed = parseFrontmatter(rawContent);
|
|
91
|
+
const skill = validateSkill(parsed, skillDir, source, location);
|
|
92
|
+
logger.debug(`Loaded skill: ${skill.name} (${source})`, { location });
|
|
93
|
+
return skill;
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
logger.warn(`Skipping invalid skill at ${location}`, {
|
|
97
|
+
error: error instanceof Error ? error.message : String(error),
|
|
98
|
+
});
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function discoverSkillsInRoot(root) {
|
|
103
|
+
let entries = [];
|
|
104
|
+
try {
|
|
105
|
+
entries = await fs.readdir(root.dir, { withFileTypes: true });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
const loaded = await Promise.all(entries
|
|
111
|
+
.filter((entry) => entry.isDirectory())
|
|
112
|
+
.map((entry) => loadSkillFromDirectory(path.join(root.dir, entry.name), root.source)));
|
|
113
|
+
return loaded.filter((skill) => skill !== null);
|
|
114
|
+
}
|
|
115
|
+
export async function loadSkills(options = {}) {
|
|
116
|
+
const roots = getSkillRoots(options);
|
|
117
|
+
const merged = new Map();
|
|
118
|
+
for (const root of roots) {
|
|
119
|
+
const skills = await discoverSkillsInRoot(root);
|
|
120
|
+
for (const skill of skills) {
|
|
121
|
+
if (merged.has(skill.name)) {
|
|
122
|
+
logger.warn(`Skill collision detected for "${skill.name}". Using ${skill.source}-level version.`, {
|
|
123
|
+
location: skill.location,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
merged.set(skill.name, skill);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return Array.from(merged.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
130
|
+
}
|
|
131
|
+
function escapeXml(value) {
|
|
132
|
+
return value
|
|
133
|
+
.replace(/&/g, '&')
|
|
134
|
+
.replace(/</g, '<')
|
|
135
|
+
.replace(/>/g, '>')
|
|
136
|
+
.replace(/"/g, '"')
|
|
137
|
+
.replace(/'/g, ''');
|
|
138
|
+
}
|
|
139
|
+
export function buildSkillsCatalogSection(skills) {
|
|
140
|
+
if (skills.length === 0)
|
|
141
|
+
return '';
|
|
142
|
+
const catalog = skills
|
|
143
|
+
.map((skill) => [
|
|
144
|
+
' <skill>',
|
|
145
|
+
` <name>${escapeXml(skill.name)}</name>`,
|
|
146
|
+
` <description>${escapeXml(skill.description)}</description>`,
|
|
147
|
+
` <location>${escapeXml(skill.location)}</location>`,
|
|
148
|
+
' </skill>',
|
|
149
|
+
].join('\n'))
|
|
150
|
+
.join('\n');
|
|
151
|
+
return `AVAILABLE SKILLS
|
|
152
|
+
|
|
153
|
+
The following skills provide specialized instructions for specific tasks.
|
|
154
|
+
When a task matches a skill's description, call the ${ACTIVATE_SKILL_TOOL_NAME} tool with the skill's name before proceeding.
|
|
155
|
+
After activation, treat paths listed in the skill output as readable resources and resolve relative paths against the skill directory.
|
|
156
|
+
|
|
157
|
+
<available_skills>
|
|
158
|
+
${catalog}
|
|
159
|
+
</available_skills>`;
|
|
160
|
+
}
|
|
161
|
+
async function listSkillResources(skillDir) {
|
|
162
|
+
const files = [];
|
|
163
|
+
async function walk(relativeDir) {
|
|
164
|
+
if (files.length >= MAX_RESOURCE_FILES)
|
|
165
|
+
return;
|
|
166
|
+
const absoluteDir = path.join(skillDir, relativeDir);
|
|
167
|
+
let entries = [];
|
|
168
|
+
try {
|
|
169
|
+
entries = await fs.readdir(absoluteDir, { withFileTypes: true });
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
if (files.length >= MAX_RESOURCE_FILES)
|
|
176
|
+
return;
|
|
177
|
+
const nextRelative = path.join(relativeDir, entry.name);
|
|
178
|
+
if (entry.isDirectory()) {
|
|
179
|
+
await walk(nextRelative);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
files.push(nextRelative.split(path.sep).join('/'));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
await Promise.all(['scripts', 'references', 'assets'].map((dir) => walk(dir)));
|
|
187
|
+
return files.sort();
|
|
188
|
+
}
|
|
189
|
+
export async function activateSkill(skillName, options = {}) {
|
|
190
|
+
const skills = await loadSkills(options);
|
|
191
|
+
const skill = skills.find((entry) => entry.name === skillName);
|
|
192
|
+
if (!skill) {
|
|
193
|
+
return `Error: Unknown skill "${skillName}".`;
|
|
194
|
+
}
|
|
195
|
+
const resources = await listSkillResources(skill.skillDir);
|
|
196
|
+
const resourcesBlock = resources.length > 0
|
|
197
|
+
? `<skill_resources>\n${resources.map((resource) => ` <file>${escapeXml(resource)}</file>`).join('\n')}\n</skill_resources>`
|
|
198
|
+
: '<skill_resources />';
|
|
199
|
+
return `<skill_content name="${escapeXml(skill.name)}">\n${skill.body}\n\nSkill directory: ${escapeXml(skill.skillDir)}\nRelative paths in this skill are relative to the skill directory. Use absolute paths in tool calls when needed.\n\n${resourcesBlock}\n</skill_content>`;
|
|
200
|
+
}
|
|
201
|
+
export async function initializeSkillsSupport(options = {}) {
|
|
202
|
+
const skills = await loadSkills(options);
|
|
203
|
+
await setAllowedPathRoots(skills.map((skill) => skill.skillDir));
|
|
204
|
+
if (skills.length === 0) {
|
|
205
|
+
unregisterDynamicTool(ACTIVATE_SKILL_TOOL_NAME);
|
|
206
|
+
unregisterDynamicHandler(ACTIVATE_SKILL_TOOL_NAME);
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
registerDynamicTool({
|
|
210
|
+
type: 'function',
|
|
211
|
+
function: {
|
|
212
|
+
name: ACTIVATE_SKILL_TOOL_NAME,
|
|
213
|
+
description: 'Load the full instructions for a discovered skill so you can follow it for the current task.',
|
|
214
|
+
parameters: {
|
|
215
|
+
type: 'object',
|
|
216
|
+
properties: {
|
|
217
|
+
name: {
|
|
218
|
+
type: 'string',
|
|
219
|
+
enum: skills.map((skill) => skill.name),
|
|
220
|
+
description: 'The exact skill name to activate.',
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
required: ['name'],
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
registerDynamicHandler(ACTIVATE_SKILL_TOOL_NAME, async (args) => activateSkill(args.name, options));
|
|
228
|
+
return skills;
|
|
229
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agents — Spawn isolated child agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* Sub-agents prevent context pollution by running tasks in a separate
|
|
5
|
+
* message history. The parent agent delegates a task, the sub-agent
|
|
6
|
+
* executes it with its own tool calls, and returns a summary.
|
|
7
|
+
*
|
|
8
|
+
* This is exposed as a `sub_agent` tool that the main agent can call.
|
|
9
|
+
*/
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { handleToolCall, getAllTools } from './tools/index.js';
|
|
12
|
+
import { generateSystemPrompt } from './system-prompt.js';
|
|
13
|
+
import { logger } from './utils/logger.js';
|
|
14
|
+
import { clearTodos } from './tools/todo.js';
|
|
15
|
+
export const subAgentTool = {
|
|
16
|
+
type: 'function',
|
|
17
|
+
function: {
|
|
18
|
+
name: 'sub_agent',
|
|
19
|
+
description: 'Spawn an isolated sub-agent to handle a task without polluting the main conversation context. ' +
|
|
20
|
+
'Use this for independent subtasks like exploring a codebase, researching a question, or making changes to a separate area. ' +
|
|
21
|
+
'The sub-agent has access to the same tools but runs in its own conversation.',
|
|
22
|
+
parameters: {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
task: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'A detailed description of the task for the sub-agent to complete.',
|
|
28
|
+
},
|
|
29
|
+
max_iterations: {
|
|
30
|
+
type: 'number',
|
|
31
|
+
description: 'Maximum tool-call iterations for the sub-agent. Defaults to 30.',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
required: ['task'],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Run a sub-agent with its own isolated conversation.
|
|
40
|
+
* Returns the sub-agent's final text response.
|
|
41
|
+
*/
|
|
42
|
+
export async function runSubAgent(client, model, task, maxIterations = 30) {
|
|
43
|
+
const op = logger.startOperation('sub-agent');
|
|
44
|
+
const subAgentSessionId = `sub-agent-${crypto.randomUUID()}`;
|
|
45
|
+
const systemPrompt = await generateSystemPrompt();
|
|
46
|
+
const subSystemPrompt = `${systemPrompt}
|
|
47
|
+
|
|
48
|
+
## Sub-Agent Mode
|
|
49
|
+
|
|
50
|
+
You are running as a sub-agent. You were given a specific task by the parent agent.
|
|
51
|
+
Complete the task thoroughly and return a clear, concise summary of what you did and found.
|
|
52
|
+
Do NOT ask the user questions — work autonomously with the tools available.`;
|
|
53
|
+
const messages = [
|
|
54
|
+
{ role: 'system', content: subSystemPrompt },
|
|
55
|
+
{ role: 'user', content: task },
|
|
56
|
+
];
|
|
57
|
+
try {
|
|
58
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
59
|
+
const response = await client.chat.completions.create({
|
|
60
|
+
model,
|
|
61
|
+
messages,
|
|
62
|
+
tools: getAllTools(),
|
|
63
|
+
tool_choice: 'auto',
|
|
64
|
+
});
|
|
65
|
+
const message = response.choices[0]?.message;
|
|
66
|
+
if (!message)
|
|
67
|
+
break;
|
|
68
|
+
// Check for tool calls
|
|
69
|
+
if (message.tool_calls && message.tool_calls.length > 0) {
|
|
70
|
+
messages.push(message);
|
|
71
|
+
for (const toolCall of message.tool_calls) {
|
|
72
|
+
const { name, arguments: argsStr } = toolCall.function;
|
|
73
|
+
logger.debug(`Sub-agent tool call: ${name}`);
|
|
74
|
+
try {
|
|
75
|
+
const args = JSON.parse(argsStr);
|
|
76
|
+
const result = await handleToolCall(name, args, { sessionId: subAgentSessionId });
|
|
77
|
+
messages.push({
|
|
78
|
+
role: 'tool',
|
|
79
|
+
tool_call_id: toolCall.id,
|
|
80
|
+
content: result,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
85
|
+
messages.push({
|
|
86
|
+
role: 'tool',
|
|
87
|
+
tool_call_id: toolCall.id,
|
|
88
|
+
content: `Error: ${msg}`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// Plain text response — we're done
|
|
95
|
+
return message.content || '(sub-agent completed with no response)';
|
|
96
|
+
}
|
|
97
|
+
return '(sub-agent reached iteration limit)';
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
op.end();
|
|
101
|
+
clearTodos(subAgentSessionId);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System prompt generation.
|
|
3
|
+
*
|
|
4
|
+
* Builds a dynamic system prompt that includes:
|
|
5
|
+
* - Role and behavioural instructions
|
|
6
|
+
* - Working directory and project structure
|
|
7
|
+
* - Tool descriptions (auto-generated from tool schemas)
|
|
8
|
+
* - Skills catalog (loaded progressively from skill directories)
|
|
9
|
+
* - Guidelines for file operations, TODO tracking, etc.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { getAllTools } from './tools/index.js';
|
|
14
|
+
import { buildSkillsCatalogSection, initializeSkillsSupport } from './skills.js';
|
|
15
|
+
/** Build a filtered directory tree (depth 3, excludes noise). */
|
|
16
|
+
async function buildDirectoryTree(dirPath = '.', depth = 0, maxDepth = 3) {
|
|
17
|
+
if (depth > maxDepth)
|
|
18
|
+
return '';
|
|
19
|
+
const indent = ' '.repeat(depth);
|
|
20
|
+
let tree = '';
|
|
21
|
+
try {
|
|
22
|
+
const fullPath = path.resolve(dirPath);
|
|
23
|
+
const entries = await fs.readdir(fullPath, { withFileTypes: true });
|
|
24
|
+
const filtered = entries.filter((e) => {
|
|
25
|
+
const n = e.name;
|
|
26
|
+
return !n.startsWith('.') && !['node_modules', 'dist', 'build', 'coverage', '__pycache__', '.git'].includes(n) && !n.endsWith('.log');
|
|
27
|
+
});
|
|
28
|
+
for (const entry of filtered.slice(0, 20)) {
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
tree += `${indent}${entry.name}/\n`;
|
|
31
|
+
tree += await buildDirectoryTree(path.join(dirPath, entry.name), depth + 1, maxDepth);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
tree += `${indent}${entry.name}\n`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (filtered.length > 20) {
|
|
38
|
+
tree += `${indent}... (${filtered.length - 20} more)\n`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Can't read directory — skip
|
|
43
|
+
}
|
|
44
|
+
return tree;
|
|
45
|
+
}
|
|
46
|
+
/** Auto-generate tool descriptions from their JSON schemas. */
|
|
47
|
+
function generateToolDescriptions() {
|
|
48
|
+
return getAllTools()
|
|
49
|
+
.map((tool, i) => {
|
|
50
|
+
const fn = tool.function;
|
|
51
|
+
const params = fn.parameters;
|
|
52
|
+
const required = params.required || [];
|
|
53
|
+
const props = Object.keys(params.properties || {});
|
|
54
|
+
const paramList = props
|
|
55
|
+
.map((p) => `${p}${required.includes(p) ? ' (required)' : ' (optional)'}`)
|
|
56
|
+
.join(', ');
|
|
57
|
+
return `${i + 1}. ${fn.name} — ${fn.description}\n Parameters: ${paramList || 'none'}`;
|
|
58
|
+
})
|
|
59
|
+
.join('\n\n');
|
|
60
|
+
}
|
|
61
|
+
/** Generate the complete system prompt. */
|
|
62
|
+
export async function generateSystemPrompt() {
|
|
63
|
+
const cwd = process.cwd();
|
|
64
|
+
const projectName = path.basename(cwd);
|
|
65
|
+
const tree = await buildDirectoryTree();
|
|
66
|
+
const skills = await initializeSkillsSupport();
|
|
67
|
+
const toolDescriptions = generateToolDescriptions();
|
|
68
|
+
const skillsSection = buildSkillsCatalogSection(skills);
|
|
69
|
+
return `You are ProtoAgent, a coding assistant with file system and shell command capabilities.
|
|
70
|
+
Your job is to help the user complete coding tasks in their project.
|
|
71
|
+
|
|
72
|
+
PROJECT CONTEXT
|
|
73
|
+
|
|
74
|
+
Working Directory: ${cwd}
|
|
75
|
+
Project Name: ${projectName}
|
|
76
|
+
|
|
77
|
+
PROJECT STRUCTURE:
|
|
78
|
+
${tree}
|
|
79
|
+
|
|
80
|
+
AVAILABLE TOOLS
|
|
81
|
+
|
|
82
|
+
${toolDescriptions}
|
|
83
|
+
${skillsSection ? `\n${skillsSection}\n` : ''}
|
|
84
|
+
GUIDELINES
|
|
85
|
+
|
|
86
|
+
OUTPUT FORMAT:
|
|
87
|
+
- You are running in a terminal. Be concise. Optimise for scannability.
|
|
88
|
+
- Use **bold** for important terms, *italic* for references.
|
|
89
|
+
- Use Markdown tables to present structured data — they render with full box-drawing borders.
|
|
90
|
+
- Use flat bullet lists with emojis to communicate information densely (e.g. ✅ done, ❌ failed, 📁 file, 🔍 searching).
|
|
91
|
+
- NEVER use nested indentation. Keep all lists flat — one level only.
|
|
92
|
+
- Markdown links [text](url) are NOT supported — just write URLs inline.
|
|
93
|
+
|
|
94
|
+
SUBAGENT STRATEGY:
|
|
95
|
+
Delegate work to specialized subagents aggressively. They excel at focused, parallel tasks.
|
|
96
|
+
- **When to use subagents**: Any task involving deep research, broad codebase exploration, complex analysis, or multi-step investigations.
|
|
97
|
+
- **Parallelizable tasks**: When a request can be split into independent subtasks, strongly prefer delegating those pieces to subagents so they can run concurrently.
|
|
98
|
+
- **Parallel work**: Launch multiple subagents simultaneously for independent tasks (e.g., search different parts of codebase, investigate different issues).
|
|
99
|
+
- **Thorough context**: Always provide subagents with complete task descriptions, relevant background, and specific success criteria. Be explicit about what "done" looks like.
|
|
100
|
+
- **Trust the delegation**: Subagents have access to the same tools and can work autonomously. Don't re-do their work in your main context.
|
|
101
|
+
- **Examples of good delegation**:
|
|
102
|
+
- Complex codebase exploration → Use Explore agent with "very thorough" or "medium" setting
|
|
103
|
+
- Cross-file code searches → Launch Bash agent for grep/find operations in parallel
|
|
104
|
+
- Architecture/design planning → Use Plan agent to explore codebase and design approach
|
|
105
|
+
- Multi-step debugging → Use general-purpose agent for systematic investigation
|
|
106
|
+
|
|
107
|
+
WORKFLOW:
|
|
108
|
+
- Before making tool calls, briefly explain what you're about to do and why.
|
|
109
|
+
- Always read files before editing them.
|
|
110
|
+
- Prefer edit_file over write_file for existing files.
|
|
111
|
+
- Use TODO tracking (todo_write / todo_read) by default for almost all work. The only exceptions are when the user explicitly asks you not to use TODOs, or when the task is truly trivial and can be completed in a single obvious step.
|
|
112
|
+
- Start by creating or refreshing the TODO list before doing substantive work, then keep it current throughout the task: mark items in_progress/completed as you go, and read it back whenever you need to check or communicate progress.
|
|
113
|
+
- When you update the TODO list, always write the full latest list so the user can see the current plan and status clearly in the tool output.
|
|
114
|
+
- Search first when you need to find something — use search_files or bash with grep/find, or delegate to a subagent for thorough exploration.
|
|
115
|
+
- Shell commands: safe commands (ls, grep, git status, etc.) run automatically. Other commands require user approval.
|
|
116
|
+
- **Diligence**: Don't cut corners. Verify assumptions before acting. Read related code. Test changes where possible. Leave the codebase better than you found it.
|
|
117
|
+
|
|
118
|
+
FILE OPERATIONS:
|
|
119
|
+
- ALWAYS use read_file before editing to get exact content.
|
|
120
|
+
- NEVER write over existing files unless explicitly asked — use edit_file instead.
|
|
121
|
+
- Create parent directories before creating files in them.
|
|
122
|
+
- Use bash for package management, git, building, testing, etc.
|
|
123
|
+
- When running interactive commands, add flags to avoid prompts (--yes, --template, etc.)
|
|
124
|
+
|
|
125
|
+
IMPLEMENTATION STANDARDS:
|
|
126
|
+
- **Thorough investigation**: Before implementing, understand the existing codebase, patterns, and related systems.
|
|
127
|
+
- **Completeness**: Ensure implementations are complete and tested, not partial or left in a broken state.
|
|
128
|
+
- **Code quality**: Follow existing code style and conventions. Make changes that fit naturally into the codebase.
|
|
129
|
+
- **Documentation**: Update relevant documentation and comments if the code isn't self-evident.
|
|
130
|
+
- **No half measures**: If a task requires 3 steps to do properly, do all 3 steps. Don't leave TODOs for later work unless explicitly scoped that way.`;
|
|
131
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bash tool — Execute shell commands with security controls.
|
|
3
|
+
*
|
|
4
|
+
* Three-tier security model:
|
|
5
|
+
* 1. Hard-blocked dangerous commands (cannot be overridden)
|
|
6
|
+
* 2. Auto-approved safe commands (read-only / info commands)
|
|
7
|
+
* 3. Everything else requires user approval
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { requestApproval } from '../utils/approval.js';
|
|
12
|
+
import { logger } from '../utils/logger.js';
|
|
13
|
+
import { getWorkingDirectory, validatePath } from '../utils/path-validation.js';
|
|
14
|
+
export const bashTool = {
|
|
15
|
+
type: 'function',
|
|
16
|
+
function: {
|
|
17
|
+
name: 'bash',
|
|
18
|
+
description: 'Execute a shell command. Safe commands (ls, grep, git status, etc.) run automatically. ' +
|
|
19
|
+
'Other commands require user approval. Some dangerous commands are blocked entirely.',
|
|
20
|
+
parameters: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
command: { type: 'string', description: 'The shell command to execute.' },
|
|
24
|
+
timeout_ms: { type: 'number', description: 'Timeout in milliseconds. Defaults to 30000 (30s).' },
|
|
25
|
+
},
|
|
26
|
+
required: ['command'],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
// Hard-blocked commands — these CANNOT be run, even with --dangerously-accept-all
|
|
31
|
+
const DANGEROUS_PATTERNS = [
|
|
32
|
+
'rm -rf /',
|
|
33
|
+
'sudo',
|
|
34
|
+
'su ',
|
|
35
|
+
'chmod 777',
|
|
36
|
+
'dd if=',
|
|
37
|
+
'mkfs',
|
|
38
|
+
'fdisk',
|
|
39
|
+
'format c:',
|
|
40
|
+
];
|
|
41
|
+
// Auto-approved safe commands — read-only / informational
|
|
42
|
+
const SAFE_COMMANDS = [
|
|
43
|
+
'pwd', 'whoami', 'date',
|
|
44
|
+
'git status', 'git log', 'git diff', 'git branch', 'git show', 'git remote',
|
|
45
|
+
'npm list', 'npm ls', 'yarn list',
|
|
46
|
+
'node --version', 'npm --version', 'python --version', 'python3 --version',
|
|
47
|
+
];
|
|
48
|
+
const SHELL_CONTROL_PATTERN = /(^|[^\\])(?:;|&&|\|\||\||>|<|`|\$\(|\*|\?)/;
|
|
49
|
+
const UNSAFE_BASH_TOKENS = new Set(['cat', 'head', 'tail', 'grep', 'rg', 'find', 'awk', 'sed', 'sort', 'uniq', 'cut', 'wc', 'tree', 'file', 'dir', 'ls', 'echo', 'which', 'type']);
|
|
50
|
+
function isDangerous(command) {
|
|
51
|
+
const lower = command.toLowerCase().trim();
|
|
52
|
+
return DANGEROUS_PATTERNS.some((p) => lower.includes(p));
|
|
53
|
+
}
|
|
54
|
+
function hasShellControlOperators(command) {
|
|
55
|
+
return SHELL_CONTROL_PATTERN.test(command);
|
|
56
|
+
}
|
|
57
|
+
function tokenizeCommand(command) {
|
|
58
|
+
const tokens = command.match(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\S+/g);
|
|
59
|
+
return tokens && tokens.length > 0 ? tokens : null;
|
|
60
|
+
}
|
|
61
|
+
function stripOuterQuotes(value) {
|
|
62
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
63
|
+
return value.slice(1, -1);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
function looksLikePath(token) {
|
|
68
|
+
if (!token)
|
|
69
|
+
return false;
|
|
70
|
+
if (token === '.' || token === '..')
|
|
71
|
+
return true;
|
|
72
|
+
if (token.startsWith('/') || token.startsWith('./') || token.startsWith('../') || token.startsWith('~/')) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
return token.includes(path.sep) || /\.[A-Za-z0-9_-]+$/.test(token);
|
|
76
|
+
}
|
|
77
|
+
async function validateCommandPaths(tokens) {
|
|
78
|
+
for (let index = 1; index < tokens.length; index++) {
|
|
79
|
+
const token = stripOuterQuotes(tokens[index]);
|
|
80
|
+
if (!looksLikePath(token))
|
|
81
|
+
continue;
|
|
82
|
+
if (token.startsWith('~'))
|
|
83
|
+
return false;
|
|
84
|
+
try {
|
|
85
|
+
await validatePath(token);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
async function isSafe(command) {
|
|
94
|
+
const trimmed = command.trim();
|
|
95
|
+
if (!trimmed || hasShellControlOperators(trimmed)) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
const tokens = tokenizeCommand(trimmed);
|
|
99
|
+
if (!tokens) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
const firstWord = trimmed.split(/\s+/)[0];
|
|
103
|
+
if (UNSAFE_BASH_TOKENS.has(firstWord)) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const matchedSafeCommand = SAFE_COMMANDS.some((safe) => {
|
|
107
|
+
if (safe.includes(' ')) {
|
|
108
|
+
return trimmed === safe || trimmed.startsWith(`${safe} `);
|
|
109
|
+
}
|
|
110
|
+
return firstWord === safe;
|
|
111
|
+
});
|
|
112
|
+
if (!matchedSafeCommand) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
return validateCommandPaths(tokens);
|
|
116
|
+
}
|
|
117
|
+
export async function runBash(command, timeoutMs = 30_000, sessionId) {
|
|
118
|
+
// Layer 1: hard block
|
|
119
|
+
if (isDangerous(command)) {
|
|
120
|
+
return `Error: Command blocked for safety. "${command}" contains a dangerous pattern that cannot be executed.`;
|
|
121
|
+
}
|
|
122
|
+
// Layer 2: safe commands skip approval
|
|
123
|
+
if (!(await isSafe(command))) {
|
|
124
|
+
// Layer 3: interactive approval
|
|
125
|
+
const approved = await requestApproval({
|
|
126
|
+
id: `bash-${Date.now()}`,
|
|
127
|
+
type: 'shell_command',
|
|
128
|
+
description: `Run command: ${command}`,
|
|
129
|
+
detail: `Working directory: ${getWorkingDirectory()}\nCommand: ${command}`,
|
|
130
|
+
sessionId,
|
|
131
|
+
sessionScopeKey: `shell:${command}`,
|
|
132
|
+
});
|
|
133
|
+
if (!approved) {
|
|
134
|
+
return `Command cancelled by user: ${command}`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
logger.debug(`Executing: ${command}`);
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
let stdout = '';
|
|
140
|
+
let stderr = '';
|
|
141
|
+
let timedOut = false;
|
|
142
|
+
const child = spawn(command, [], {
|
|
143
|
+
shell: true,
|
|
144
|
+
cwd: process.cwd(),
|
|
145
|
+
env: { ...process.env },
|
|
146
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
147
|
+
});
|
|
148
|
+
child.stdout?.on('data', (data) => { stdout += data.toString(); });
|
|
149
|
+
child.stderr?.on('data', (data) => { stderr += data.toString(); });
|
|
150
|
+
const timer = setTimeout(() => {
|
|
151
|
+
timedOut = true;
|
|
152
|
+
child.kill('SIGTERM');
|
|
153
|
+
setTimeout(() => child.kill('SIGKILL'), 2000);
|
|
154
|
+
}, timeoutMs);
|
|
155
|
+
child.on('close', (code) => {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
if (timedOut) {
|
|
158
|
+
resolve(`Command timed out after ${timeoutMs / 1000}s.\nPartial stdout:\n${stdout.slice(0, 5000)}\nPartial stderr:\n${stderr.slice(0, 2000)}`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// Truncate very long output
|
|
162
|
+
const maxLen = 50_000;
|
|
163
|
+
const truncatedStdout = stdout.length > maxLen
|
|
164
|
+
? stdout.slice(0, maxLen) + `\n... (output truncated, ${stdout.length} chars total)`
|
|
165
|
+
: stdout;
|
|
166
|
+
if (code === 0) {
|
|
167
|
+
resolve(truncatedStdout || '(command completed successfully with no output)');
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
resolve(`Command exited with code ${code}.\nstdout:\n${truncatedStdout}\nstderr:\n${stderr.slice(0, 5000)}`);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
child.on('error', (err) => {
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
resolve(`Error executing command: ${err.message}`);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|