protoagent 0.1.10 → 0.1.11

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/dist/sub-agent.js CHANGED
@@ -28,7 +28,7 @@ export const subAgentTool = {
28
28
  },
29
29
  max_iterations: {
30
30
  type: 'number',
31
- description: 'Maximum tool-call iterations for the sub-agent. Defaults to 30.',
31
+ description: 'Maximum tool-call iterations for the sub-agent. Defaults to 500.',
32
32
  },
33
33
  },
34
34
  required: ['task'],
@@ -39,7 +39,7 @@ export const subAgentTool = {
39
39
  * Run a sub-agent with its own isolated conversation.
40
40
  * Returns the sub-agent's final text response.
41
41
  */
42
- export async function runSubAgent(client, model, task, maxIterations = 30, requestDefaults = {}, onProgress, abortSignal) {
42
+ export async function runSubAgent(client, model, task, maxIterations = 500, requestDefaults = {}, onProgress, abortSignal, pricing) {
43
43
  const op = logger.startOperation('sub-agent');
44
44
  const subAgentSessionId = `sub-agent-${crypto.randomUUID()}`;
45
45
  const systemPrompt = await generateSystemPrompt();
@@ -54,35 +54,142 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
54
54
  { role: 'system', content: subSystemPrompt },
55
55
  { role: 'user', content: task },
56
56
  ];
57
+ // Track cumulative usage across all API calls in the sub-agent
58
+ let totalInputTokens = 0;
59
+ let totalOutputTokens = 0;
60
+ let totalCost = 0;
57
61
  try {
58
62
  for (let i = 0; i < maxIterations; i++) {
59
63
  // Check abort at the top of each iteration
60
64
  if (abortSignal?.aborted) {
61
- return '(sub-agent aborted)';
65
+ return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cost: totalCost } };
62
66
  }
63
- const response = await client.chat.completions.create({
64
- ...requestDefaults,
65
- model,
66
- messages,
67
- tools: getAllTools(),
68
- tool_choice: 'auto',
69
- }, { signal: abortSignal });
70
- const message = response.choices[0]?.message;
67
+ let assistantMessage;
68
+ let hasToolCalls = false;
69
+ try {
70
+ const stream = await client.chat.completions.create({
71
+ ...requestDefaults,
72
+ model,
73
+ messages,
74
+ tools: getAllTools(),
75
+ tool_choice: 'auto',
76
+ stream: true,
77
+ stream_options: { include_usage: true },
78
+ }, { signal: abortSignal });
79
+ // Accumulate the streamed response
80
+ assistantMessage = {
81
+ role: 'assistant',
82
+ content: '',
83
+ tool_calls: [],
84
+ };
85
+ let streamedContent = '';
86
+ hasToolCalls = false;
87
+ let actualUsage;
88
+ for await (const chunk of stream) {
89
+ const delta = chunk.choices[0]?.delta;
90
+ if (chunk.usage) {
91
+ actualUsage = chunk.usage;
92
+ }
93
+ // Stream text content
94
+ if (delta?.content) {
95
+ streamedContent += delta.content;
96
+ assistantMessage.content = streamedContent;
97
+ }
98
+ // Accumulate tool calls across stream chunks
99
+ if (delta?.tool_calls) {
100
+ hasToolCalls = true;
101
+ for (const tc of delta.tool_calls) {
102
+ const idx = tc.index || 0;
103
+ if (!assistantMessage.tool_calls[idx]) {
104
+ assistantMessage.tool_calls[idx] = {
105
+ id: '',
106
+ type: 'function',
107
+ function: { name: '', arguments: '' },
108
+ };
109
+ }
110
+ if (tc.id)
111
+ assistantMessage.tool_calls[idx].id = tc.id;
112
+ if (tc.function?.name) {
113
+ assistantMessage.tool_calls[idx].function.name += tc.function.name;
114
+ }
115
+ if (tc.function?.arguments) {
116
+ assistantMessage.tool_calls[idx].function.arguments += tc.function.arguments;
117
+ }
118
+ }
119
+ }
120
+ }
121
+ // Accumulate usage for this iteration
122
+ const iterationInputTokens = actualUsage?.prompt_tokens || 0;
123
+ const iterationOutputTokens = actualUsage?.completion_tokens || 0;
124
+ totalInputTokens += iterationInputTokens;
125
+ totalOutputTokens += iterationOutputTokens;
126
+ // Calculate cost if pricing is available
127
+ if (pricing && (iterationInputTokens > 0 || iterationOutputTokens > 0)) {
128
+ const cachedTokens = actualUsage?.prompt_tokens_details?.cached_tokens;
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
+ }
136
+ }
137
+ }
138
+ catch (err) {
139
+ // If aborted during streaming, return gracefully
140
+ if (abortSignal?.aborted || (err instanceof Error && (err.name === 'AbortError' || err.message === 'Operation aborted'))) {
141
+ logger.debug('Sub-agent aborted during streaming');
142
+ return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cost: totalCost } };
143
+ }
144
+ throw err;
145
+ }
146
+ const message = assistantMessage;
71
147
  if (!message)
72
148
  break;
73
149
  // Check for tool calls
74
- if (message.tool_calls && message.tool_calls.length > 0) {
75
- messages.push(message);
76
- for (const toolCall of message.tool_calls) {
150
+ if (hasToolCalls && assistantMessage.tool_calls.length > 0) {
151
+ // Clean up empty tool_calls entries (from sparse array)
152
+ assistantMessage.tool_calls = assistantMessage.tool_calls.filter(Boolean);
153
+ // Filter out tool calls with malformed JSON arguments (can happen if stream aborted mid-tool-call)
154
+ assistantMessage.tool_calls = assistantMessage.tool_calls.filter((tc) => {
155
+ const args = tc.function?.arguments;
156
+ if (!args)
157
+ return true; // No args is valid
158
+ try {
159
+ JSON.parse(args);
160
+ return true;
161
+ }
162
+ catch {
163
+ logger.warn('Filtering out sub-agent tool call with malformed JSON', {
164
+ tool: tc.function?.name,
165
+ argsPreview: args.slice(0, 100),
166
+ });
167
+ return false;
168
+ }
169
+ });
170
+ // Only add message if we have valid tool calls
171
+ if (assistantMessage.tool_calls.length === 0) {
172
+ hasToolCalls = false;
173
+ }
174
+ else {
175
+ messages.push(message);
176
+ }
177
+ for (const toolCall of assistantMessage.tool_calls) {
77
178
  // Check abort between tool calls
78
179
  if (abortSignal?.aborted) {
79
- return '(sub-agent aborted)';
180
+ return { response: '(sub-agent aborted)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cost: totalCost } };
80
181
  }
81
182
  const { name, arguments: argsStr } = toolCall.function;
82
- logger.debug(`Sub-agent tool call: ${name}`);
83
- onProgress?.({ tool: name, status: 'running', iteration: i });
183
+ let args;
184
+ try {
185
+ args = JSON.parse(argsStr);
186
+ }
187
+ catch {
188
+ args = {};
189
+ }
190
+ logger.debug(`Sub-agent tool call: ${name}`, { args });
191
+ onProgress?.({ tool: name, status: 'running', iteration: i, args });
84
192
  try {
85
- const args = JSON.parse(argsStr);
86
193
  const result = await handleToolCall(name, args, { sessionId: subAgentSessionId, abortSignal });
87
194
  messages.push({
88
195
  role: 'tool',
@@ -104,9 +211,20 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
104
211
  continue;
105
212
  }
106
213
  // Plain text response — we're done
107
- return message.content || '(sub-agent completed with no response)';
214
+ if (message.content) {
215
+ messages.push({
216
+ role: 'assistant',
217
+ content: message.content,
218
+ });
219
+ return { response: message.content, usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cost: totalCost } };
220
+ }
221
+ // The model produced an empty text response (e.g. it only called tools
222
+ // and issued no final summary). Log it and return a sentinel so the
223
+ // parent agent knows the sub-agent finished but had nothing to say.
224
+ logger.debug('Sub-agent returned empty content', { iteration: i });
225
+ return { response: '(sub-agent completed with no response)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cost: totalCost } };
108
226
  }
109
- return '(sub-agent reached iteration limit)';
227
+ return { response: '(sub-agent reached iteration limit)', usage: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cost: totalCost } };
110
228
  }
111
229
  finally {
112
230
  op.end();
@@ -6,12 +6,45 @@
6
6
  * - Working directory and project structure
7
7
  * - Tool descriptions (auto-generated from tool schemas)
8
8
  * - Skills catalog (loaded progressively from skill directories)
9
+ * - AGENTS.md content (custom instructions for the agent)
9
10
  * - Guidelines for file operations, TODO tracking, etc.
10
11
  */
11
12
  import fs from 'node:fs/promises';
12
13
  import path from 'node:path';
13
14
  import { getAllTools } from './tools/index.js';
14
15
  import { buildSkillsCatalogSection, initializeSkillsSupport } from './skills.js';
16
+ import { getActiveRuntimeConfigPath } from './runtime-config.js';
17
+ /**
18
+ * Load AGENTS.md content from cwd and parent directories.
19
+ *
20
+ * AGENTS.md (https://agents.md/) is a simple, open format for guiding coding agents.
21
+ * It's like a README for agents — a dedicated place to give AI coding tools the
22
+ * context they need to work on a project.
23
+ *
24
+ * The lookup is hierarchical:
25
+ * - Checks cwd, then parent directories up to the filesystem root
26
+ * - First AGENTS.md found wins
27
+ * - Returns null if no AGENTS.md is found
28
+ */
29
+ async function loadAgentsMd() {
30
+ let currentDir = path.resolve('.');
31
+ while (true) {
32
+ const agentsPath = path.join(currentDir, 'AGENTS.md');
33
+ try {
34
+ await fs.access(agentsPath);
35
+ const content = await fs.readFile(agentsPath, 'utf-8');
36
+ return { content, path: agentsPath };
37
+ }
38
+ catch {
39
+ // File doesn't exist here — check parent
40
+ }
41
+ const parentDir = path.dirname(currentDir);
42
+ if (parentDir === currentDir)
43
+ break; // Reached filesystem root
44
+ currentDir = parentDir;
45
+ }
46
+ return null;
47
+ }
15
48
  /** Build a filtered directory tree (depth 3, excludes noise). */
16
49
  async function buildDirectoryTree(dirPath = '.', depth = 0, maxDepth = 3) {
17
50
  if (depth > maxDepth)
@@ -66,6 +99,11 @@ export async function generateSystemPrompt() {
66
99
  const skills = await initializeSkillsSupport();
67
100
  const toolDescriptions = generateToolDescriptions();
68
101
  const skillsSection = buildSkillsCatalogSection(skills);
102
+ const configPath = getActiveRuntimeConfigPath();
103
+ const agentsMd = await loadAgentsMd();
104
+ const agentsMdSection = agentsMd
105
+ ? `\nAGENTS.md INSTRUCTIONS\n\nThe following instructions are from the AGENTS.md file at: ${agentsMd.path}\n\n${agentsMd.content}\n`
106
+ : '';
69
107
  return `You are ProtoAgent, a coding assistant with file system and shell command capabilities.
70
108
  Your job is to help the user complete coding tasks in their project. You must be absolutely careful and diligent in your work, and follow all guidelines to the letter. Always prefer thoroughness and correctness over speed. Never cut corners.
71
109
 
@@ -73,9 +111,16 @@ PROJECT CONTEXT
73
111
 
74
112
  Working Directory: ${cwd}
75
113
  Project Name: ${projectName}
114
+ Configuration Path: ${configPath || 'none (using defaults)'}
76
115
 
77
116
  PROJECT STRUCTURE:
78
117
  ${tree}
118
+ ${agentsMdSection}
119
+ PROTOAGENT DOCUMENTATION
120
+
121
+ ProtoAgent is a build-your-own coding agent — a lean, readable implementation that gives you the blueprint to understand and build your own AI coding assistant.
122
+
123
+ Configuration guide: https://protoagent.dev/guide/configuration
79
124
 
80
125
  AVAILABLE TOOLS
81
126
 
@@ -27,7 +27,7 @@ export const bashTool = {
27
27
  },
28
28
  },
29
29
  };
30
- // Hard-blocked commands — these CANNOT be run, even with --dangerously-accept-all
30
+ // Hard-blocked commands — these CANNOT be run, even with --dangerously-skip-permissions
31
31
  const DANGEROUS_PATTERNS = [
32
32
  'rm -rf /',
33
33
  'sudo',
@@ -16,7 +16,7 @@ import { searchFilesTool, searchFiles } from './search-files.js';
16
16
  import { bashTool, runBash } from './bash.js';
17
17
  import { todoReadTool, todoWriteTool, readTodos, writeTodos } from './todo.js';
18
18
  import { webfetchTool, webfetch } from './webfetch.js';
19
- export { setDangerouslyAcceptAll, setApprovalHandler, clearApprovalHandler } from '../utils/approval.js';
19
+ export { setDangerouslySkipPermissions, setApprovalHandler, clearApprovalHandler } from '../utils/approval.js';
20
20
  // All tool definitions — passed to the LLM
21
21
  export const tools = [
22
22
  readFileTool,
@@ -8,21 +8,21 @@
8
8
  * Approval can be granted:
9
9
  * - Per-operation (one-time)
10
10
  * - Per-operation-type for the session (e.g., "approve all writes")
11
- * - Globally via --dangerously-accept-all
11
+ * - Globally via --dangerously-skip-permissions
12
12
  *
13
13
  * In the Ink UI, approvals are handled by emitting an event and waiting
14
14
  * for the UI to resolve it (instead of blocking on stdin with inquirer).
15
15
  */
16
16
  // Global state
17
- let dangerouslyAcceptAll = false;
17
+ let dangerouslySkipPermissions = false;
18
18
  const sessionApprovals = new Set(); // stores approval keys scoped by session
19
19
  // Callback that the Ink UI provides to handle interactive approval
20
20
  let approvalHandler = null;
21
- export function setDangerouslyAcceptAll(value) {
22
- dangerouslyAcceptAll = value;
21
+ export function setDangerouslySkipPermissions(value) {
22
+ dangerouslySkipPermissions = value;
23
23
  }
24
- export function isDangerouslyAcceptAll() {
25
- return dangerouslyAcceptAll;
24
+ export function isDangerouslySkipPermissions() {
25
+ return dangerouslySkipPermissions;
26
26
  }
27
27
  export function setApprovalHandler(handler) {
28
28
  approvalHandler = handler;
@@ -42,13 +42,13 @@ function getApprovalScopeKey(req) {
42
42
  * Request approval for an operation. Returns true if approved.
43
43
  *
44
44
  * Check order:
45
- * 1. --dangerously-accept-all → auto-approve
45
+ * 1. --dangerously-skip-permissions → auto-approve
46
46
  * 2. Session approval for this type → auto-approve
47
47
  * 3. Interactive prompt via the UI handler
48
48
  * 4. No handler registered → reject (fail closed)
49
49
  */
50
50
  export async function requestApproval(req) {
51
- if (dangerouslyAcceptAll)
51
+ if (dangerouslySkipPermissions)
52
52
  return true;
53
53
  const sessionKey = getApprovalScopeKey(req);
54
54
  if (sessionApprovals.has(sessionKey))
@@ -26,7 +26,13 @@ export function estimateConversationTokens(messages) {
26
26
  return messages.reduce((sum, m) => sum + estimateMessageTokens(m), 0) + 10;
27
27
  }
28
28
  /** Calculate dollar cost for a given number of tokens. */
29
- export function calculateCost(inputTokens, outputTokens, pricing) {
29
+ export function calculateCost(inputTokens, outputTokens, pricing, cachedTokens) {
30
+ if (cachedTokens && cachedTokens > 0 && pricing.cachedPerToken != null) {
31
+ const uncachedTokens = inputTokens - cachedTokens;
32
+ return (uncachedTokens * pricing.inputPerToken +
33
+ cachedTokens * pricing.cachedPerToken +
34
+ outputTokens * pricing.outputPerToken);
35
+ }
30
36
  return inputTokens * pricing.inputPerToken + outputTokens * pricing.outputPerToken;
31
37
  }
32
38
  /** Get context window utilisation info. */
@@ -42,11 +48,11 @@ export function getContextInfo(messages, pricing) {
42
48
  };
43
49
  }
44
50
  /** Build a UsageInfo from actual or estimated token counts. */
45
- export function createUsageInfo(inputTokens, outputTokens, pricing) {
51
+ export function createUsageInfo(inputTokens, outputTokens, pricing, cachedTokens) {
46
52
  return {
47
53
  inputTokens,
48
54
  outputTokens,
49
55
  totalTokens: inputTokens + outputTokens,
50
- estimatedCost: calculateCost(inputTokens, outputTokens, pricing),
56
+ estimatedCost: calculateCost(inputTokens, outputTokens, pricing, cachedTokens),
51
57
  };
52
58
  }
@@ -42,15 +42,6 @@ export function checkReadBefore(sessionId, absolutePath) {
42
42
  }
43
43
  return null;
44
44
  }
45
- /**
46
- * @deprecated Use checkReadBefore instead — it returns a string rather than
47
- * throwing, so the error surfaces cleanly as a tool result.
48
- */
49
- export function assertReadBefore(sessionId, absolutePath) {
50
- const err = checkReadBefore(sessionId, absolutePath);
51
- if (err)
52
- throw new Error(err);
53
- }
54
45
  /**
55
46
  * Clear all read-time entries for a session (e.g. on session end).
56
47
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protoagent",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -20,8 +20,28 @@
20
20
  "docs:build": "vitepress build docs",
21
21
  "docs:preview": "vitepress preview docs"
22
22
  },
23
- "author": "",
24
- "license": "ISC",
23
+ "author": "Thomas Gauvin",
24
+ "license": "MIT",
25
+ "homepage": "https://protoagent.dev",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/thomasgauvin/protoagent.git"
29
+ },
30
+ "keywords": [
31
+ "ai",
32
+ "agent",
33
+ "cli",
34
+ "coding-agent",
35
+ "llm",
36
+ "openai",
37
+ "anthropic",
38
+ "gemini",
39
+ "terminal",
40
+ "typescript"
41
+ ],
42
+ "engines": {
43
+ "node": ">=20"
44
+ },
25
45
  "dependencies": {
26
46
  "@inkjs/ui": "^2.0.0",
27
47
  "@modelcontextprotocol/sdk": "^1.27.1",