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.
- package/agent-runtime/localLoop.ts +36 -2
- package/agent-runtime/types.ts +1 -0
- package/agentRunCommand.ts +14 -3
- package/client/localRuntimeAdapter.test.ts +69 -1
- package/client/localRuntimeAdapter.ts +79 -2
- package/client/localToolPolicy.test.ts +10 -5
- package/client/localToolPolicy.ts +16 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
}
|
package/agent-runtime/types.ts
CHANGED
package/agentRunCommand.ts
CHANGED
|
@@ -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
|
|
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("
|
|
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
|
|
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:
|
|
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("
|
|
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,
|