nolo-cli 0.1.16 → 0.1.17

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.
@@ -12,6 +12,7 @@ export type LocalAgentTurnInput = {
12
12
  agentRef: string;
13
13
  input: AgentRuntimeMessageContent;
14
14
  continueDialogId?: string;
15
+ maxToolRounds?: number;
15
16
  };
16
17
 
17
18
  export type LocalAgentTurnResult = AgentRuntimeResult & {
@@ -49,15 +50,48 @@ export async function runLocalAgentTurn(
49
50
  input: input.input,
50
51
  });
51
52
  const provider = await input.adapter.resolveProvider(agentConfig);
52
- const result = await provider.complete(messages);
53
+ const maxToolRounds = input.maxToolRounds ?? 6;
54
+ let toolCallCount = 0;
55
+ let result: AgentRuntimeResult;
56
+ for (let round = 0; round <= maxToolRounds; round += 1) {
57
+ result = await provider.complete(messages);
58
+ const toolCalls = result.tool_calls ?? [];
59
+ if (toolCalls.length === 0) break;
60
+ if (round === maxToolRounds) {
61
+ throw new Error(`Local agent exceeded max tool rounds: ${maxToolRounds}`);
62
+ }
63
+ toolCallCount += toolCalls.length;
64
+ messages.push({
65
+ role: "assistant",
66
+ content: result.content || null,
67
+ tool_calls: toolCalls,
68
+ });
69
+ for (const toolCall of toolCalls) {
70
+ const toolResult = await input.adapter.executeTool({
71
+ id: toolCall.id,
72
+ name: toolCall.function.name,
73
+ arguments: toolCall.function.arguments,
74
+ });
75
+ messages.push({
76
+ role: "tool",
77
+ content: toolResult.content,
78
+ tool_call_id: toolCall.id,
79
+ });
80
+ }
81
+ }
82
+ result = result!;
53
83
  const saved = await input.adapter.saveTurn({
54
84
  agentKey: agentConfig.key,
55
85
  messages,
56
- result,
86
+ result: {
87
+ ...result,
88
+ ...(toolCallCount > 0 ? { toolCallCount } : {}),
89
+ },
57
90
  });
58
91
 
59
92
  return {
60
93
  ...result,
94
+ ...(toolCallCount > 0 ? { toolCallCount } : {}),
61
95
  dialogId: saved.dialogId,
62
96
  };
63
97
  }
@@ -51,6 +51,7 @@ export interface AgentRuntimeResult {
51
51
  outputPrice?: number;
52
52
  usage?: Record<string, any>;
53
53
  trace?: AgentRuntimeChatMessage[];
54
+ tool_calls?: AgentRuntimeToolCall[];
54
55
  runtimeToolNames?: string[];
55
56
  toolCallCount?: number;
56
57
  policyState?: unknown;
@@ -21,6 +21,7 @@ type ParsedAgentRunArgs = {
21
21
  agentKey: string;
22
22
  message: string;
23
23
  imageUrls: string[];
24
+ allowShell: boolean;
24
25
  runtimeMode?: AgentRuntimeRequestedMode;
25
26
  continueDialogId?: string;
26
27
  };
@@ -51,9 +52,15 @@ function runtimeModeFromArgs(args: string[]): AgentRuntimeRequestedMode | undefi
51
52
 
52
53
  function positionalArgs(args: string[]) {
53
54
  const values: string[] = [];
55
+ const valuelessFlags = new Set([
56
+ "--local",
57
+ "--server",
58
+ "--auto",
59
+ "--dangerously-allow-shell",
60
+ ]);
54
61
  for (let index = 0; index < args.length; index += 1) {
55
62
  const arg = args[index];
56
- if (arg === "--local" || arg === "--server" || arg === "--auto") continue;
63
+ if (valuelessFlags.has(arg)) continue;
57
64
  if (arg.startsWith("--")) {
58
65
  index += 1;
59
66
  continue;
@@ -78,6 +85,7 @@ export function parseAgentRunArgs(args: string[]): ParsedAgentRunArgs | null {
78
85
  agentKey,
79
86
  message: message.trim(),
80
87
  imageUrls,
88
+ allowShell: args.includes("--dangerously-allow-shell"),
81
89
  ...(runtimeMode ? { runtimeMode } : {}),
82
90
  ...(continueDialogId ? { continueDialogId } : {}),
83
91
  };
@@ -112,7 +120,7 @@ function resolveServerUrl(env: EnvLike) {
112
120
  function writeUsage(output: OutputLike) {
113
121
  output.write(
114
122
  "Usage: nolo agent run <agent> <message> [--local|--server|--auto] [--continue <dialogId>]\n" +
115
- " nolo agent run --agent <agent> --msg <message> [--image <url-or-path>] [--local|--server|--auto]\n"
123
+ " nolo agent run --agent <agent> --msg <message> [--image <url-or-path>] [--dangerously-allow-shell] [--local|--server|--auto]\n"
116
124
  );
117
125
  }
118
126
 
@@ -126,6 +134,9 @@ export async function runAgentRunCommand(args: string[], deps: AgentRunCommandDe
126
134
  }
127
135
 
128
136
  const runner = deps.runner ?? runAgentTurn;
137
+ const runEnv = parsed.allowShell
138
+ ? { ...env, NOLO_LOCAL_SHELL_MODE: "worktree" }
139
+ : env;
129
140
  const result: RunAgentTurnResult = await runner({
130
141
  agentName: parsed.agentKey,
131
142
  agentKey: parsed.agentKey,
@@ -133,7 +144,7 @@ export async function runAgentRunCommand(args: string[], deps: AgentRunCommandDe
133
144
  message: parsed.message,
134
145
  imageUrls: parsed.imageUrls.map(normalizeCliImageInput),
135
146
  scriptDir: deps.scriptDir,
136
- env,
147
+ env: runEnv,
137
148
  output,
138
149
  ...(parsed.runtimeMode ? { runtimeMode: parsed.runtimeMode } : {}),
139
150
  ...(parsed.continueDialogId ? { continueDialogId: parsed.continueDialogId } : {}),
@@ -173,7 +173,7 @@ describe("CLI local runtime adapter", () => {
173
173
  id: "call-1",
174
174
  name: "execShell",
175
175
  arguments: "{}",
176
- })).rejects.toThrow("blocked by the local CLI safety policy");
176
+ })).rejects.toThrow("execShell requires NOLO_LOCAL_SHELL_MODE");
177
177
  });
178
178
 
179
179
  test("executes explicitly allowed registered local tools declared by the agent", async () => {
@@ -214,4 +214,72 @@ describe("CLI local runtime adapter", () => {
214
214
 
215
215
  expect(result.content).toContain("README.md");
216
216
  });
217
+
218
+ test("advertises execShell to OpenAI-compatible providers when the agent declares it", async () => {
219
+ const requests: Array<{ body: any }> = [];
220
+ const adapter = createCliLocalRuntimeAdapter({
221
+ env: {
222
+ OPENAI_API_KEY: "sk-local",
223
+ NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
224
+ NOLO_LOCAL_SHELL_MODE: "worktree",
225
+ },
226
+ db: {
227
+ get: async () => ({
228
+ dbKey: "agent-local-shell",
229
+ prompt: "Use shell.",
230
+ model: "gpt-4.1-mini",
231
+ toolNames: ["execShell"],
232
+ }),
233
+ put: async () => {},
234
+ batch: async () => {},
235
+ iterator: () => (async function* () {})(),
236
+ },
237
+ fetchImpl: async (_url, init) => {
238
+ requests.push({ body: JSON.parse(String(init?.body)) });
239
+ return Response.json({
240
+ choices: [{ message: { content: "done" } }],
241
+ });
242
+ },
243
+ });
244
+
245
+ await runLocalAgentTurn({
246
+ adapter,
247
+ agentRef: "shell",
248
+ input: "pwd",
249
+ });
250
+
251
+ expect(requests[0]?.body.tools).toEqual([{
252
+ type: "function",
253
+ function: expect.objectContaining({
254
+ name: "execShell",
255
+ }),
256
+ }]);
257
+ });
258
+
259
+ test("runs execShell locally in explicit worktree shell mode", async () => {
260
+ const adapter = createCliLocalRuntimeAdapter({
261
+ env: { NOLO_LOCAL_SHELL_MODE: "worktree" },
262
+ db: {
263
+ get: async () => ({
264
+ dbKey: "agent-local-shell",
265
+ toolNames: ["execShell"],
266
+ }),
267
+ put: async () => {},
268
+ batch: async () => {},
269
+ iterator: () => (async function* () {})(),
270
+ },
271
+ cwd: import.meta.dir,
272
+ fetchImpl: async () => Response.json({}),
273
+ });
274
+
275
+ await adapter.loadAgentConfig("shell");
276
+ const result = await adapter.executeTool({
277
+ id: "call-1",
278
+ name: "execShell",
279
+ arguments: "{\"cmd\":\"pwd\"}",
280
+ });
281
+
282
+ expect(result.content).toContain(import.meta.dir);
283
+ expect(result.metadata).toMatchObject({ exitCode: 0 });
284
+ });
217
285
  });
@@ -22,6 +22,7 @@ export type CliLocalRuntimeAdapterDeps = {
22
22
  now?: () => number;
23
23
  createId?: () => string;
24
24
  fetchImpl?: typeof fetch;
25
+ cwd?: string;
25
26
  localToolExecutors?: Record<string, (call: any) => Promise<{ content: string; metadata?: Record<string, unknown> }>>;
26
27
  };
27
28
 
@@ -46,6 +47,77 @@ function resolveLocalUserId(env: EnvLike) {
46
47
  return env.NOLO_LOCAL_USER_ID || env.NOLO_USER_ID || "local";
47
48
  }
48
49
 
50
+ function buildExecShellTool() {
51
+ return {
52
+ type: "function",
53
+ function: {
54
+ name: "execShell",
55
+ description: "Run a shell command in the current isolated local workspace.",
56
+ parameters: {
57
+ type: "object",
58
+ properties: {
59
+ cmd: {
60
+ type: "string",
61
+ description: "Shell command to run.",
62
+ },
63
+ },
64
+ required: ["cmd"],
65
+ },
66
+ },
67
+ };
68
+ }
69
+
70
+ function buildOpenAiTools(toolNames: string[] = []) {
71
+ return toolNames.includes("execShell") ? [buildExecShellTool()] : [];
72
+ }
73
+
74
+ function parseToolArguments(raw: string) {
75
+ try {
76
+ const parsed = JSON.parse(raw || "{}");
77
+ return parsed && typeof parsed === "object" ? parsed : {};
78
+ } catch {
79
+ return {};
80
+ }
81
+ }
82
+
83
+ async function executeShellCommand(args: {
84
+ call: any;
85
+ cwd: string;
86
+ }) {
87
+ const parsed = parseToolArguments(String(args.call.arguments ?? ""));
88
+ const cmd = String(parsed.cmd ?? parsed.command ?? "").trim();
89
+ if (!cmd) throw new Error("execShell requires a non-empty cmd argument.");
90
+ const proc = Bun.spawn(["/bin/sh", "-lc", cmd], {
91
+ cwd: args.cwd,
92
+ stdout: "pipe",
93
+ stderr: "pipe",
94
+ stdin: "ignore",
95
+ });
96
+ const [stdout, stderr, exitCode] = await Promise.all([
97
+ new Response(proc.stdout).text(),
98
+ new Response(proc.stderr).text(),
99
+ proc.exited,
100
+ ]);
101
+ return {
102
+ content: [
103
+ stdout.trim() ? `stdout:\n${stdout.trim()}` : "",
104
+ stderr.trim() ? `stderr:\n${stderr.trim()}` : "",
105
+ `exitCode: ${exitCode}`,
106
+ ].filter(Boolean).join("\n\n"),
107
+ metadata: { exitCode },
108
+ };
109
+ }
110
+
111
+ function buildLocalToolExecutors(deps: CliLocalRuntimeAdapterDeps) {
112
+ return {
113
+ execShell: (call: any) => executeShellCommand({
114
+ call,
115
+ cwd: deps.cwd ?? process.cwd(),
116
+ }),
117
+ ...(deps.localToolExecutors ?? {}),
118
+ };
119
+ }
120
+
49
121
  async function resolveDb(deps: CliLocalRuntimeAdapterDeps) {
50
122
  return deps.db ?? defaultLocalRuntimeDb();
51
123
  }
@@ -191,6 +263,7 @@ export function createCliLocalRuntimeAdapter(
191
263
  const fetchImpl = deps.fetchImpl ?? fetch;
192
264
  const userId = resolveLocalUserId(deps.env);
193
265
  let activeAgentToolNames: string[] = [];
266
+ const localToolExecutors = buildLocalToolExecutors(deps);
194
267
 
195
268
  return {
196
269
  host: "cli",
@@ -218,6 +291,7 @@ export function createCliLocalRuntimeAdapter(
218
291
  resolveProvider: async (agentConfig) => ({
219
292
  model: agentConfig.model || "gpt-4.1-mini",
220
293
  complete: async (messages) => {
294
+ const tools = buildOpenAiTools(agentConfig.toolNames ?? []);
221
295
  const res = await fetchImpl(`${resolveOpenAiCompatibleBaseUrl(deps.env)}/chat/completions`, {
222
296
  method: "POST",
223
297
  headers: {
@@ -230,17 +304,20 @@ export function createCliLocalRuntimeAdapter(
230
304
  model: agentConfig.model || "gpt-4.1-mini",
231
305
  messages: toOpenAiMessages(messages),
232
306
  stream: false,
307
+ ...(tools.length > 0 ? { tools } : {}),
233
308
  }),
234
309
  });
235
310
  const data = await res.json().catch(() => ({}));
236
311
  if (!res.ok) {
237
312
  throw new Error(`local provider failed: HTTP ${res.status} ${JSON.stringify(data)}`);
238
313
  }
239
- const content = String(data?.choices?.[0]?.message?.content ?? "");
314
+ const choiceMessage = data?.choices?.[0]?.message ?? {};
315
+ const content = String(choiceMessage?.content ?? "");
240
316
  return {
241
317
  content,
242
318
  model: agentConfig.model || "gpt-4.1-mini",
243
319
  provider: agentConfig.provider || "openai-compatible",
320
+ ...(Array.isArray(choiceMessage?.tool_calls) ? { tool_calls: choiceMessage.tool_calls } : {}),
244
321
  usage: data?.usage,
245
322
  trace: messages,
246
323
  };
@@ -250,7 +327,7 @@ export function createCliLocalRuntimeAdapter(
250
327
  env: deps.env,
251
328
  agentToolNames: activeAgentToolNames,
252
329
  call,
253
- executors: deps.localToolExecutors,
330
+ executors: localToolExecutors,
254
331
  }),
255
332
  };
256
333
  }
@@ -3,15 +3,20 @@ import { describe, expect, test } from "bun:test";
3
3
  import { executeLocalToolWithPolicy, resolveLocalToolPolicy } from "./localToolPolicy";
4
4
 
5
5
  describe("CLI local tool policy", () => {
6
- test("blocks dangerous tools even when env tries to allow them", () => {
6
+ test("allows execShell in explicit worktree shell mode", () => {
7
+ expect(resolveLocalToolPolicy({
8
+ env: { NOLO_LOCAL_SHELL_MODE: "worktree" },
9
+ agentToolNames: ["execShell"],
10
+ toolName: "execShell",
11
+ })).toEqual({ allowed: true, toolName: "execShell" });
12
+ });
13
+
14
+ test("blocks execShell unless explicit shell mode is enabled", () => {
7
15
  expect(resolveLocalToolPolicy({
8
16
  env: { NOLO_LOCAL_ALLOWED_TOOLS: "execShell" },
9
17
  agentToolNames: ["execShell"],
10
18
  toolName: "execShell",
11
- })).toMatchObject({
12
- allowed: false,
13
- reason: expect.stringContaining("blocked"),
14
- });
19
+ })).toMatchObject({ allowed: false });
15
20
  });
16
21
 
17
22
  test("requires both env allowlist and agent declaration", () => {
@@ -7,7 +7,6 @@ export type LocalToolPolicyDecision =
7
7
  | { allowed: false; toolName: string; reason: string };
8
8
 
9
9
  const NEVER_LOCAL_TOOLS = new Set([
10
- "execShell",
11
10
  "deleteSpaces",
12
11
  "updateAgent",
13
12
  "updateSelf",
@@ -26,6 +25,22 @@ export function resolveLocalToolPolicy(args: {
26
25
  agentToolNames?: string[];
27
26
  toolName: string;
28
27
  }): LocalToolPolicyDecision {
28
+ if (args.toolName === "execShell") {
29
+ const shellMode = args.env.NOLO_LOCAL_SHELL_MODE || "";
30
+ const agentTools = new Set(args.agentToolNames ?? []);
31
+ if (
32
+ agentTools.has("execShell") &&
33
+ ["worktree", "dangerous", "1", "true"].includes(shellMode)
34
+ ) {
35
+ return { allowed: true, toolName: args.toolName };
36
+ }
37
+ return {
38
+ allowed: false,
39
+ toolName: args.toolName,
40
+ reason: "execShell requires NOLO_LOCAL_SHELL_MODE=worktree and an agent that declares execShell.",
41
+ };
42
+ }
43
+
29
44
  if (NEVER_LOCAL_TOOLS.has(args.toolName)) {
30
45
  return {
31
46
  allowed: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nolo-cli",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "type": "module",
5
5
  "description": "Agent-first terminal workspace for Nolo",
6
6
  "bin": {