@devang0907/agent-dev 0.1.3 → 0.1.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # agent-dev
2
2
 
3
- A minimal pi-like terminal coding agent with an Ink UI. Chat with an AI that can read, write, edit files, and run bash commands.
3
+ A minimal pi-like terminal coding agent with an Ink UI. Chat with an AI that can read, write, edit files, search the web, and run shell commands (with your approval).
4
4
 
5
5
  ## Quick start
6
6
 
@@ -51,7 +51,17 @@ Config and sessions are stored in `~/.agent-dev/`.
51
51
 
52
52
  ## Tools
53
53
 
54
- The agent has four built-in tools: `read`, `write`, `edit`, `bash`. File operations are restricted to the current working directory.
54
+ The agent has five built-in tools:
55
+
56
+ | Tool | Description |
57
+ |------|-------------|
58
+ | `read` | Read a file in the project directory |
59
+ | `write` | Create or overwrite a file |
60
+ | `edit` | Replace text in a file |
61
+ | `web_search` | Search the internet (DuckDuckGo, no extra API key) |
62
+ | `bash` | Run a shell command — **requires your approval** before execution |
63
+
64
+ File operations are restricted to the current working directory. When the agent proposes a shell command, the UI prompts you to approve (`y`) or deny (`n` / Esc).
55
65
 
56
66
  ## License
57
67
 
@@ -1,5 +1,11 @@
1
1
  import type { ChatMessage, Model, ToolCall } from "../providers/types.js";
2
2
  import type { Settings } from "../config/settings.js";
3
+ export interface PermissionRequest {
4
+ toolCallId: string;
5
+ name: string;
6
+ args: Record<string, unknown>;
7
+ command: string;
8
+ }
3
9
  export type AgentEvent = {
4
10
  type: "message_start";
5
11
  role: "assistant";
@@ -28,5 +34,6 @@ export interface AgentLoopOptions {
28
34
  systemPrompt?: string;
29
35
  signal?: AbortSignal;
30
36
  onEvent: (event: AgentEvent) => void;
37
+ onPermissionRequest?: (request: PermissionRequest) => Promise<boolean>;
31
38
  }
32
39
  export declare function runAgentLoop(options: AgentLoopOptions): Promise<ChatMessage[]>;
@@ -1,12 +1,23 @@
1
- import { normalizeToolCalls } from "../providers/openai-compat.js";
1
+ import { normalizeToolCalls, parseMalformedToolCalls, extractFailedGeneration, } from "../providers/openai-compat.js";
2
2
  import { streamChat } from "../providers/registry.js";
3
- import { getToolDefinitions, executeTool } from "./tools/index.js";
3
+ import { getToolDefinitions, executeTool, PERMISSION_REQUIRED_TOOLS } from "./tools/index.js";
4
+ import { getPlatformContext } from "./platform.js";
4
5
  const MAX_TOOL_ROUNDS = 6;
5
6
  const MAX_SAME_TOOL_CALLS = 2;
6
- const DEFAULT_SYSTEM_PROMPT = `You are a helpful coding assistant with access to tools: read, write, edit, and bash.
7
+ const DEFAULT_SYSTEM_PROMPT = `You are a helpful coding assistant with access to tools: read, write, edit, bash, and web_search.
7
8
  When the user asks you to create or modify files, call write or edit once with the full file content, then reply briefly to confirm.
9
+ Use web_search for news and current events. When headlines are returned, list them as a numbered list using the exact titles — do not give vague category summaries.
10
+ Shell commands via bash require user approval. Dev servers (npm run dev, npm start) run in the background and return a URL.
8
11
  Do NOT call the same tool repeatedly with the same arguments. One successful write is enough.
9
- Working directory: ${process.cwd()}`;
12
+ When calling tools, use the function-calling API with valid JSON arguments only (e.g. web_search: {"query": "search terms"}).
13
+
14
+ ${getPlatformContext()}`;
15
+ function systemPromptForModel(model, base = DEFAULT_SYSTEM_PROMPT) {
16
+ if (model.provider === "groq") {
17
+ return `${base}\nFor Groq: never output <function=...> text — use structured tool calls with JSON arguments.`;
18
+ }
19
+ return base;
20
+ }
10
21
  function isToolUseFailedError(message) {
11
22
  return /Failed to call a function|tool_use_failed|failed_generation/i.test(message);
12
23
  }
@@ -26,6 +37,74 @@ function dedupeToolCalls(toolCalls) {
26
37
  return true;
27
38
  });
28
39
  }
40
+ function resolveToolCalls(content, toolCalls, error) {
41
+ const normalized = dedupeToolCalls(normalizeToolCalls(toolCalls.filter((tc) => tc.name)));
42
+ if (normalized.length > 0)
43
+ return normalized;
44
+ const fromContent = dedupeToolCalls(normalizeToolCalls(parseMalformedToolCalls(content)));
45
+ if (fromContent.length > 0)
46
+ return fromContent;
47
+ if (error) {
48
+ const failed = extractFailedGeneration(error);
49
+ if (failed) {
50
+ return dedupeToolCalls(normalizeToolCalls(parseMalformedToolCalls(failed)));
51
+ }
52
+ }
53
+ return [];
54
+ }
55
+ async function runToolBatch(uniqueCalls, context, workdir, callCounts, onEvent, onPermissionRequest) {
56
+ let stopAfterBatch = false;
57
+ for (const tc of uniqueCalls) {
58
+ onEvent({ type: "tool_call", toolCall: tc });
59
+ let args = {};
60
+ try {
61
+ args = JSON.parse(tc.arguments || "{}");
62
+ }
63
+ catch {
64
+ args = {};
65
+ }
66
+ const sig = toolSignature(tc.name, args);
67
+ const prev = callCounts.get(sig) ?? 0;
68
+ callCounts.set(sig, prev + 1);
69
+ if (prev >= MAX_SAME_TOOL_CALLS) {
70
+ const skip = "Skipped — already executed this action.";
71
+ onEvent({ type: "tool_result", toolCallId: tc.id, name: tc.name, result: skip });
72
+ context.push({ role: "tool", content: skip, toolCallId: tc.id, name: tc.name });
73
+ stopAfterBatch = true;
74
+ continue;
75
+ }
76
+ let result;
77
+ const needsPermission = PERMISSION_REQUIRED_TOOLS.has(tc.name);
78
+ if (needsPermission && onPermissionRequest) {
79
+ const approved = await onPermissionRequest({
80
+ toolCallId: tc.id,
81
+ name: tc.name,
82
+ args,
83
+ command: String(args.command ?? ""),
84
+ });
85
+ result = approved
86
+ ? await executeTool(tc.name, args, workdir)
87
+ : "Command execution denied by user.";
88
+ }
89
+ else if (needsPermission) {
90
+ result = "Command execution denied — permission handler not available.";
91
+ }
92
+ else {
93
+ result = await executeTool(tc.name, args, workdir);
94
+ }
95
+ onEvent({ type: "tool_result", toolCallId: tc.id, name: tc.name, result });
96
+ context.push({
97
+ role: "tool",
98
+ content: result,
99
+ toolCallId: tc.id,
100
+ name: tc.name,
101
+ });
102
+ if (!result.startsWith("Error:") && (tc.name === "write" || tc.name === "edit")) {
103
+ stopAfterBatch = true;
104
+ }
105
+ }
106
+ return stopAfterBatch;
107
+ }
29
108
  async function collectStream(model, messages, settings, systemPrompt, signal, onEvent) {
30
109
  const tools = getToolDefinitions();
31
110
  let content = "";
@@ -55,7 +134,7 @@ async function collectStream(model, messages, settings, systemPrompt, signal, on
55
134
  tc.arguments += event.argumentsDelta;
56
135
  }
57
136
  else if (event.type === "error") {
58
- return { content, toolCalls: [], error: event.message };
137
+ return { content, toolCalls: Array.from(toolCallMap.values()), error: event.message };
59
138
  }
60
139
  }
61
140
  const toolCalls = normalizeToolCalls(Array.from(toolCallMap.values()).filter((tc) => tc.name));
@@ -70,9 +149,10 @@ function finishGracefully(context, content, onEvent) {
70
149
  onEvent({ type: "turn_end" });
71
150
  }
72
151
  export async function runAgentLoop(options) {
73
- const { model, messages, settings, workdir, systemPrompt = DEFAULT_SYSTEM_PROMPT, signal, onEvent, } = options;
152
+ const { model, messages, settings, workdir, systemPrompt = DEFAULT_SYSTEM_PROMPT, signal, onEvent, onPermissionRequest, } = options;
74
153
  const context = [...messages];
75
154
  const callCounts = new Map();
155
+ const effectivePrompt = systemPromptForModel(model, systemPrompt);
76
156
  let toolRound = 0;
77
157
  while (true) {
78
158
  if (signal?.aborted)
@@ -83,8 +163,9 @@ export async function runAgentLoop(options) {
83
163
  break;
84
164
  }
85
165
  onEvent({ type: "message_start", role: "assistant" });
86
- const { content, toolCalls, error } = await collectStream(model, context, settings, systemPrompt, signal, onEvent);
87
- if (error) {
166
+ const { content, toolCalls, error } = await collectStream(model, context, settings, effectivePrompt, signal, onEvent);
167
+ const uniqueCalls = resolveToolCalls(content, toolCalls, error);
168
+ if (error && uniqueCalls.length === 0) {
88
169
  if (isToolUseFailedError(error) && hadSuccessfulToolResults(context)) {
89
170
  finishGracefully(context, content, onEvent);
90
171
  break;
@@ -92,49 +173,22 @@ export async function runAgentLoop(options) {
92
173
  onEvent({ type: "error", message: error });
93
174
  break;
94
175
  }
95
- const uniqueCalls = dedupeToolCalls(toolCalls);
96
176
  const assistantMsg = {
97
177
  role: "assistant",
98
- content,
178
+ content: error ? "" : content,
99
179
  toolCalls: uniqueCalls.length > 0 ? uniqueCalls : undefined,
100
180
  };
101
181
  context.push(assistantMsg);
102
182
  if (uniqueCalls.length === 0) {
103
- onEvent({ type: "turn_end" });
104
- break;
105
- }
106
- let stopAfterBatch = false;
107
- for (const tc of uniqueCalls) {
108
- onEvent({ type: "tool_call", toolCall: tc });
109
- let args = {};
110
- try {
111
- args = JSON.parse(tc.arguments || "{}");
183
+ if (error) {
184
+ onEvent({ type: "error", message: error });
112
185
  }
113
- catch {
114
- args = {};
115
- }
116
- const sig = toolSignature(tc.name, args);
117
- const prev = callCounts.get(sig) ?? 0;
118
- callCounts.set(sig, prev + 1);
119
- if (prev >= MAX_SAME_TOOL_CALLS) {
120
- const skip = "Skipped — already executed this action.";
121
- onEvent({ type: "tool_result", toolCallId: tc.id, name: tc.name, result: skip });
122
- context.push({ role: "tool", content: skip, toolCallId: tc.id, name: tc.name });
123
- stopAfterBatch = true;
124
- continue;
125
- }
126
- const result = await executeTool(tc.name, args, workdir);
127
- onEvent({ type: "tool_result", toolCallId: tc.id, name: tc.name, result });
128
- context.push({
129
- role: "tool",
130
- content: result,
131
- toolCallId: tc.id,
132
- name: tc.name,
133
- });
134
- if (!result.startsWith("Error:") && (tc.name === "write" || tc.name === "edit")) {
135
- stopAfterBatch = true;
186
+ else {
187
+ onEvent({ type: "turn_end" });
136
188
  }
189
+ break;
137
190
  }
191
+ const stopAfterBatch = await runToolBatch(uniqueCalls, context, workdir, callCounts, onEvent, onPermissionRequest);
138
192
  if (stopAfterBatch) {
139
193
  finishGracefully(context, content, onEvent);
140
194
  break;
@@ -0,0 +1,10 @@
1
+ export interface ShellConfig {
2
+ executable: string;
3
+ args: string[];
4
+ name: string;
5
+ supportsAndAnd: boolean;
6
+ }
7
+ export declare function getShellConfig(): ShellConfig;
8
+ /** Adapt common Unix-isms and command chaining for the host shell. */
9
+ export declare function normalizeCommand(command: string, shell: ShellConfig): string;
10
+ export declare function getPlatformContext(): string;
@@ -0,0 +1,74 @@
1
+ import { existsSync } from "node:fs";
2
+ import { arch, platform, release } from "node:os";
3
+ import { join } from "node:path";
4
+ function findPwsh() {
5
+ const candidates = [
6
+ process.env.PWSH_PATH,
7
+ join(process.env.ProgramFiles ?? "C:\\Program Files", "PowerShell", "7", "pwsh.exe"),
8
+ join(process.env["ProgramFiles(x86)"] ?? "", "PowerShell", "7", "pwsh.exe"),
9
+ ].filter(Boolean);
10
+ for (const candidate of candidates) {
11
+ if (existsSync(candidate))
12
+ return candidate;
13
+ }
14
+ return undefined;
15
+ }
16
+ export function getShellConfig() {
17
+ if (platform() === "win32") {
18
+ const pwsh = findPwsh();
19
+ if (pwsh) {
20
+ return {
21
+ executable: pwsh,
22
+ name: "PowerShell 7+ (pwsh)",
23
+ supportsAndAnd: true,
24
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"],
25
+ };
26
+ }
27
+ return {
28
+ executable: "powershell.exe",
29
+ name: "Windows PowerShell",
30
+ supportsAndAnd: false,
31
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"],
32
+ };
33
+ }
34
+ return {
35
+ executable: process.env.SHELL ?? "/bin/bash",
36
+ name: "bash",
37
+ supportsAndAnd: true,
38
+ args: ["-lc"],
39
+ };
40
+ }
41
+ /** Adapt common Unix-isms and command chaining for the host shell. */
42
+ export function normalizeCommand(command, shell) {
43
+ let cmd = command.trim();
44
+ if (!cmd)
45
+ return cmd;
46
+ if (platform() === "win32") {
47
+ if (!shell.supportsAndAnd) {
48
+ cmd = cmd.replace(/\s&&\s/g, "; ");
49
+ }
50
+ cmd = cmd
51
+ .replace(/\bmkdir -p\s+/g, "New-Item -ItemType Directory -Force -Path ")
52
+ .replace(/\brm -rf\s+/g, "Remove-Item -Recurse -Force ")
53
+ .replace(/\brm -r\s+/g, "Remove-Item -Recurse -Force ")
54
+ .replace(/\btouch\s+/g, "New-Item -ItemType File -Force ");
55
+ }
56
+ return cmd;
57
+ }
58
+ export function getPlatformContext() {
59
+ const shell = getShellConfig();
60
+ const lines = [
61
+ `Platform: ${platform()} ${arch()} (${release()})`,
62
+ `Shell: ${shell.name}`,
63
+ `Working directory: ${process.cwd()}`,
64
+ ];
65
+ if (platform() === "win32") {
66
+ lines.push("This agent runs on Windows. Use PowerShell-compatible commands.", shell.supportsAndAnd
67
+ ? "You may chain commands with ; or &&."
68
+ : "Chain commands with ; (&& is NOT supported in Windows PowerShell 5).", "Examples: New-Item -ItemType Directory -Force todo-app; Set-Location todo-app", "For npx/npm scaffolding, always use non-interactive flags (--yes, -y, --defaults) and set CI=1.", "Do not use mkdir -p, rm -rf, or touch — use PowerShell equivalents or the write tool.", "Dev servers (npm run dev, next dev) start in the background via bash and return a localhost URL.", "Do not run npm audit fix unless the user explicitly asks.");
69
+ }
70
+ else {
71
+ lines.push("Use bash/sh syntax. Chain commands with && or ;.", "For npx/npm scaffolding, use non-interactive flags (--yes, -y) to avoid prompts.", "Dev servers (npm run dev) start in the background via bash and return a localhost URL.");
72
+ }
73
+ return lines.join("\n");
74
+ }
@@ -1,7 +1,7 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import type { ChatMessage, Model } from "../providers/types.js";
3
3
  import type { Settings } from "../config/settings.js";
4
- import { type AgentEvent } from "./loop.js";
4
+ import { type AgentEvent, type PermissionRequest } from "./loop.js";
5
5
  import { SessionManager } from "../session/manager.js";
6
6
  export type SessionEvent = AgentEvent | {
7
7
  type: "user_message";
@@ -9,6 +9,9 @@ export type SessionEvent = AgentEvent | {
9
9
  } | {
10
10
  type: "model_changed";
11
11
  model: Model;
12
+ } | {
13
+ type: "permission_request";
14
+ request: PermissionRequest;
12
15
  };
13
16
  export declare class AgentSession extends EventEmitter {
14
17
  private messages;
@@ -18,6 +21,7 @@ export declare class AgentSession extends EventEmitter {
18
21
  private sessionManager;
19
22
  private abortController?;
20
23
  private running;
24
+ private pendingPermission?;
21
25
  constructor(settings: Settings, sessionManager: SessionManager, workdir: string, initialModel?: Model);
22
26
  getModel(): Model;
23
27
  getSettings(): Settings;
@@ -26,6 +30,9 @@ export declare class AgentSession extends EventEmitter {
26
30
  setModel(model: Model): void;
27
31
  updateSettings(settings: Settings): void;
28
32
  abort(): void;
33
+ respondToPermission(approved: boolean): void;
34
+ private resolvePermission;
35
+ private requestCommandPermission;
29
36
  prompt(content: string): Promise<void>;
30
37
  newSession(): void;
31
38
  getAvailableModels(): Model[];
@@ -11,6 +11,7 @@ export class AgentSession extends EventEmitter {
11
11
  sessionManager;
12
12
  abortController;
13
13
  running = false;
14
+ pendingPermission;
14
15
  constructor(settings, sessionManager, workdir, initialModel) {
15
16
  super();
16
17
  this.settings = settings;
@@ -49,6 +50,23 @@ export class AgentSession extends EventEmitter {
49
50
  }
50
51
  abort() {
51
52
  this.abortController?.abort();
53
+ this.resolvePermission(false);
54
+ }
55
+ respondToPermission(approved) {
56
+ this.resolvePermission(approved);
57
+ }
58
+ resolvePermission(approved) {
59
+ const resolve = this.pendingPermission;
60
+ if (resolve) {
61
+ this.pendingPermission = undefined;
62
+ resolve(approved);
63
+ }
64
+ }
65
+ requestCommandPermission(request) {
66
+ return new Promise((resolve) => {
67
+ this.pendingPermission = resolve;
68
+ this.emit("event", { type: "permission_request", request });
69
+ });
52
70
  }
53
71
  async prompt(content) {
54
72
  if (this.running)
@@ -67,6 +85,7 @@ export class AgentSession extends EventEmitter {
67
85
  workdir: this.workdir,
68
86
  signal: this.abortController.signal,
69
87
  onEvent: (event) => this.emit("event", event),
88
+ onPermissionRequest: (request) => this.requestCommandPermission(request),
70
89
  });
71
90
  for (const msg of newMessages) {
72
91
  if (msg.role !== "user") {
@@ -4,5 +4,6 @@ export interface AgentTool {
4
4
  execute: (args: Record<string, unknown>, workdir: string) => Promise<string>;
5
5
  }
6
6
  export declare const BUILTIN_TOOLS: AgentTool[];
7
+ export declare const PERMISSION_REQUIRED_TOOLS: Set<string>;
7
8
  export declare function getToolDefinitions(): ToolDefinition[];
8
9
  export declare function executeTool(name: string, args: Record<string, unknown>, workdir: string): Promise<string>;
@@ -1,10 +1,13 @@
1
1
  import { readTool, writeTool, editTool, bashTool, executeRead, executeWrite, executeEdit, executeBash, } from "./read.js";
2
+ import { webSearchTool, executeWebSearch } from "./search.js";
2
3
  export const BUILTIN_TOOLS = [
3
4
  { definition: readTool, execute: (args, wd) => executeRead(args, wd) },
4
5
  { definition: writeTool, execute: (args, wd) => executeWrite(args, wd) },
5
6
  { definition: editTool, execute: (args, wd) => executeEdit(args, wd) },
6
7
  { definition: bashTool, execute: (args, wd) => executeBash(args, wd) },
8
+ { definition: webSearchTool, execute: (args) => executeWebSearch(args) },
7
9
  ];
10
+ export const PERMISSION_REQUIRED_TOOLS = new Set(["bash"]);
8
11
  export function getToolDefinitions() {
9
12
  return BUILTIN_TOOLS.map((t) => t.definition);
10
13
  }
@@ -1,5 +1,8 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
2
  import { resolve, isAbsolute } from "node:path";
3
+ import { platform as osPlatform } from "node:os";
4
+ import { getShellConfig } from "../platform.js";
5
+ import { executeShellCommand } from "./shell.js";
3
6
  const DEFAULT_WORKDIR = process.cwd();
4
7
  function resolvePath(path, workdir = DEFAULT_WORKDIR) {
5
8
  return isAbsolute(path) ? path : resolve(workdir, path);
@@ -81,7 +84,9 @@ export async function executeEdit(args, workdir = DEFAULT_WORKDIR) {
81
84
  }
82
85
  export const bashTool = {
83
86
  name: "bash",
84
- description: "Run a shell command in the project directory",
87
+ description: osPlatform() === "win32"
88
+ ? "Run a PowerShell command. Dev servers (npm run dev) start in background. Chain with ; on Windows PowerShell 5."
89
+ : "Run a bash command. Dev servers (npm run dev) start in background and return a URL.",
85
90
  parameters: {
86
91
  type: "object",
87
92
  properties: {
@@ -92,21 +97,10 @@ export const bashTool = {
92
97
  },
93
98
  };
94
99
  export async function executeBash(args, workdir = DEFAULT_WORKDIR) {
95
- const { exec } = await import("node:child_process");
96
- const { promisify } = await import("node:util");
97
- const execAsync = promisify(exec);
98
- try {
99
- const { stdout, stderr } = await execAsync(args.command, {
100
- cwd: workdir,
101
- timeout: 120000,
102
- maxBuffer: 1024 * 1024,
103
- });
104
- const out = stdout + (stderr ? `\n${stderr}` : "");
105
- return out.trim() || "(no output)";
106
- }
107
- catch (err) {
108
- const e = err;
109
- const out = (e.stdout ?? "") + (e.stderr ? `\n${e.stderr}` : "");
110
- return out.trim() || (e.message ?? "Command failed");
100
+ const shell = getShellConfig();
101
+ const result = await executeShellCommand(args.command, workdir);
102
+ if (result.startsWith("Error:")) {
103
+ return `${result}\n(shell: ${shell.name})`;
111
104
  }
105
+ return result;
112
106
  }
@@ -0,0 +1,5 @@
1
+ import type { ToolDefinition } from "../../providers/types.js";
2
+ export declare const webSearchTool: ToolDefinition;
3
+ export declare function executeWebSearch(args: {
4
+ query: string;
5
+ }): Promise<string>;