nolo-cli 0.1.14 → 0.1.16

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.
@@ -3,6 +3,7 @@ export const AGENT_RUNTIME_PACKAGE_ID = "agent-runtime";
3
3
  export { createRuntimeHostDescriptor } from "./hostAdapter";
4
4
  export { runLocalAgentTurn } from "./localLoop";
5
5
  export { resolveAgentRuntimeDecision } from "./runtimeDecision";
6
+ export { buildAgentRuntimeDecisionInput } from "./runtimeFacts";
6
7
  export type {
7
8
  AgentRuntimeAgentConfig,
8
9
  AgentRuntimeHostAdapter,
@@ -3,13 +3,14 @@ import type {
3
3
  } from "./hostAdapter";
4
4
  import type {
5
5
  AgentRuntimeChatMessage,
6
+ AgentRuntimeMessageContent,
6
7
  AgentRuntimeResult,
7
8
  } from "./types";
8
9
 
9
10
  export type LocalAgentTurnInput = {
10
11
  adapter: AgentRuntimeHostAdapter;
11
12
  agentRef: string;
12
- input: string;
13
+ input: AgentRuntimeMessageContent;
13
14
  continueDialogId?: string;
14
15
  };
15
16
 
@@ -20,7 +21,7 @@ export type LocalAgentTurnResult = AgentRuntimeResult & {
20
21
  function buildMessages(args: {
21
22
  prompt?: string;
22
23
  history: AgentRuntimeChatMessage[];
23
- input: string;
24
+ input: AgentRuntimeMessageContent;
24
25
  }): AgentRuntimeChatMessage[] {
25
26
  return [
26
27
  ...(args.prompt?.trim()
@@ -0,0 +1,40 @@
1
+ import type {
2
+ AgentRuntimeDecisionInput,
3
+ AgentRuntimeHost,
4
+ AgentRuntimeRequestedMode,
5
+ } from "./types";
6
+
7
+ export type AgentRuntimeCapabilityFacts = {
8
+ host: AgentRuntimeHost;
9
+ requestedMode?: AgentRuntimeRequestedMode;
10
+ syncRequested?: boolean;
11
+ capabilities: string[];
12
+ requiresServer?: boolean;
13
+ serverFallbackAvailable: boolean;
14
+ };
15
+
16
+ export function buildAgentRuntimeDecisionInput(
17
+ facts: AgentRuntimeCapabilityFacts
18
+ ): AgentRuntimeDecisionInput {
19
+ const capabilities = new Set(facts.capabilities);
20
+ const hasLocalAgentConfig = capabilities.has("leveldb-agent-config") || capabilities.has("agent-config");
21
+ const hasLocalProvider = capabilities.has("local-provider") || capabilities.has("provider");
22
+ const hasLocalPersistence = capabilities.has("leveldb-persistence") || capabilities.has("persistence");
23
+ const missingLocalCapabilities = [
24
+ ...(hasLocalAgentConfig ? [] : ["agent-config"]),
25
+ ...(hasLocalProvider ? [] : ["provider"]),
26
+ ...(hasLocalPersistence ? [] : ["persistence"]),
27
+ ];
28
+
29
+ return {
30
+ host: facts.host,
31
+ ...(facts.requestedMode ? { requestedMode: facts.requestedMode } : {}),
32
+ syncRequested: Boolean(facts.syncRequested),
33
+ hasLocalAgentConfig,
34
+ hasLocalProvider,
35
+ hasLocalPersistence,
36
+ missingLocalCapabilities,
37
+ requiresServer: Boolean(facts.requiresServer),
38
+ serverFallbackAvailable: facts.serverFallbackAvailable,
39
+ };
40
+ }
@@ -0,0 +1,146 @@
1
+ import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
2
+ import { getDefaultCliLocalRuntimeDb } from "./localRuntimeDb";
3
+ import type { CliLocalRuntimeDb } from "./client/localRuntimeAdapter";
4
+
5
+ type EnvLike = Record<string, string | undefined>;
6
+
7
+ type OutputLike = {
8
+ write(chunk: string): unknown;
9
+ };
10
+
11
+ type AgentPullCommandDeps = {
12
+ env?: EnvLike;
13
+ output?: OutputLike;
14
+ db?: CliLocalRuntimeDb;
15
+ fetchImpl?: typeof fetch;
16
+ };
17
+
18
+ export type ParsedAgentPullArgs = {
19
+ agentKey: string;
20
+ };
21
+
22
+ function readFlagValue(args: string[], flag: string) {
23
+ const index = args.indexOf(flag);
24
+ return index >= 0 ? args[index + 1] : undefined;
25
+ }
26
+
27
+ function positionalArgs(args: string[]) {
28
+ const values: string[] = [];
29
+ for (let index = 0; index < args.length; index += 1) {
30
+ const arg = args[index];
31
+ if (arg.startsWith("--")) {
32
+ index += 1;
33
+ continue;
34
+ }
35
+ values.push(arg);
36
+ }
37
+ return values;
38
+ }
39
+
40
+ export function parseAgentPullArgs(args: string[]): ParsedAgentPullArgs | null {
41
+ const agentKey = readFlagValue(args, "--agent") ?? positionalArgs(args)[0];
42
+ if (!agentKey?.trim()) return null;
43
+ return { agentKey: agentKey.trim() };
44
+ }
45
+
46
+ function resolveServerUrl(env: EnvLike) {
47
+ return (env.NOLO_SERVER || env.BASE_URL || DEFAULT_NOLO_SERVER_URL).replace(/\/+$/, "");
48
+ }
49
+
50
+ function resolveAuthToken(env: EnvLike) {
51
+ return env.AUTH_TOKEN || env.AUTH || env.BENCHMARK_AUTH_TOKEN || "";
52
+ }
53
+
54
+ function extractToolNames(record: any) {
55
+ if (Array.isArray(record?.toolNames)) return record.toolNames.filter((tool: unknown) => typeof tool === "string");
56
+ if (!Array.isArray(record?.tools)) return [];
57
+ return record.tools
58
+ .map((tool: any) => typeof tool === "string" ? tool : tool?.name)
59
+ .filter((tool: unknown): tool is string => typeof tool === "string" && tool.length > 0);
60
+ }
61
+
62
+ function normalizeAgentRecord(agentKey: string, record: any) {
63
+ return {
64
+ ...record,
65
+ dbKey: record?.dbKey || record?.key || agentKey,
66
+ key: record?.key || record?.dbKey || agentKey,
67
+ ...(typeof record?.provider === "string"
68
+ ? { provider: record.provider }
69
+ : typeof record?.apiSource === "string"
70
+ ? { provider: record.apiSource }
71
+ : {}),
72
+ toolNames: extractToolNames(record),
73
+ cachedBy: "nolo-cli",
74
+ cachedAt: new Date().toISOString(),
75
+ };
76
+ }
77
+
78
+ async function readRemoteAgent(args: {
79
+ agentKey: string;
80
+ serverUrl: string;
81
+ authToken: string;
82
+ fetchImpl: typeof fetch;
83
+ }) {
84
+ const res = await args.fetchImpl(
85
+ `${args.serverUrl}/api/v1/db/read/${encodeURIComponent(args.agentKey)}`,
86
+ {
87
+ headers: {
88
+ ...(args.authToken ? { Authorization: `Bearer ${args.authToken}` } : {}),
89
+ },
90
+ }
91
+ );
92
+ const data = await res.json().catch(() => ({}));
93
+ if (!res.ok) {
94
+ throw new Error(`HTTP ${res.status} ${JSON.stringify(data)}`);
95
+ }
96
+ return data?.data ?? data;
97
+ }
98
+
99
+ function writeUsage(output: OutputLike) {
100
+ output.write(
101
+ "Usage: nolo agent pull <agent>\n" +
102
+ " nolo agent pull --agent <agent>\n"
103
+ );
104
+ }
105
+
106
+ export async function runAgentPullCommand(args: string[], deps: AgentPullCommandDeps = {}) {
107
+ const output = deps.output ?? process.stdout;
108
+ const env = deps.env ?? process.env;
109
+ const parsed = parseAgentPullArgs(args);
110
+ if (!parsed) {
111
+ writeUsage(output);
112
+ return 1;
113
+ }
114
+
115
+ const serverUrl = resolveServerUrl(env);
116
+ const authToken = resolveAuthToken(env);
117
+ const fetchImpl = deps.fetchImpl ?? fetch;
118
+ const db = deps.db ?? await getDefaultCliLocalRuntimeDb();
119
+
120
+ let record: any;
121
+ try {
122
+ record = await readRemoteAgent({
123
+ agentKey: parsed.agentKey,
124
+ serverUrl,
125
+ authToken,
126
+ fetchImpl,
127
+ });
128
+ } catch (error) {
129
+ output.write(
130
+ `[nolo] Failed to pull ${parsed.agentKey} from ${serverUrl}: ${
131
+ error instanceof Error ? error.message : String(error)
132
+ }\n`
133
+ );
134
+ return 1;
135
+ }
136
+
137
+ if (!record || typeof record !== "object") {
138
+ output.write(`[nolo] Server returned an empty agent record for ${parsed.agentKey}.\n`);
139
+ return 1;
140
+ }
141
+
142
+ const localRecord = normalizeAgentRecord(parsed.agentKey, record);
143
+ await db.put(parsed.agentKey, localRecord);
144
+ output.write(`[nolo] cached ${parsed.agentKey} in local LevelDB\n`);
145
+ return 0;
146
+ }
@@ -0,0 +1,146 @@
1
+ import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
2
+ import { runAgentTurn, type RunAgentTurnResult } from "./client/agentRun";
3
+ import type { AgentRuntimeRequestedMode } from "./agentRuntimeLocal";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { extname, resolve } from "node:path";
6
+
7
+ type EnvLike = Record<string, string | undefined>;
8
+
9
+ type OutputLike = {
10
+ write(chunk: string): unknown;
11
+ };
12
+
13
+ type AgentRunCommandDeps = {
14
+ env?: EnvLike;
15
+ scriptDir: string;
16
+ output?: OutputLike;
17
+ runner?: typeof runAgentTurn;
18
+ };
19
+
20
+ type ParsedAgentRunArgs = {
21
+ agentKey: string;
22
+ message: string;
23
+ imageUrls: string[];
24
+ runtimeMode?: AgentRuntimeRequestedMode;
25
+ continueDialogId?: string;
26
+ };
27
+
28
+ function readFlagValue(args: string[], flag: string) {
29
+ const index = args.indexOf(flag);
30
+ if (index < 0) return undefined;
31
+ return args[index + 1];
32
+ }
33
+
34
+ function readRepeatedFlagValues(args: string[], flag: string) {
35
+ const values: string[] = [];
36
+ for (let index = 0; index < args.length; index += 1) {
37
+ if (args[index] === flag && args[index + 1]) {
38
+ values.push(args[index + 1]);
39
+ index += 1;
40
+ }
41
+ }
42
+ return values;
43
+ }
44
+
45
+ function runtimeModeFromArgs(args: string[]): AgentRuntimeRequestedMode | undefined {
46
+ if (args.includes("--local")) return "local";
47
+ if (args.includes("--server")) return "server";
48
+ if (args.includes("--auto")) return "auto";
49
+ return undefined;
50
+ }
51
+
52
+ function positionalArgs(args: string[]) {
53
+ const values: string[] = [];
54
+ for (let index = 0; index < args.length; index += 1) {
55
+ const arg = args[index];
56
+ if (arg === "--local" || arg === "--server" || arg === "--auto") continue;
57
+ if (arg.startsWith("--")) {
58
+ index += 1;
59
+ continue;
60
+ }
61
+ values.push(arg);
62
+ }
63
+ return values;
64
+ }
65
+
66
+ export function parseAgentRunArgs(args: string[]): ParsedAgentRunArgs | null {
67
+ const agentKey = readFlagValue(args, "--agent") ?? positionalArgs(args)[0];
68
+ const explicitMsg = readFlagValue(args, "--msg");
69
+ const message = explicitMsg ?? positionalArgs(args).slice(1).join(" ");
70
+ if (!agentKey || !message.trim()) return null;
71
+ const runtimeMode = runtimeModeFromArgs(args);
72
+ const continueDialogId = readFlagValue(args, "--continue") ?? readFlagValue(args, "--dialog");
73
+ const imageUrls = [
74
+ ...readRepeatedFlagValues(args, "--image"),
75
+ ...readRepeatedFlagValues(args, "--image-url"),
76
+ ];
77
+ return {
78
+ agentKey,
79
+ message: message.trim(),
80
+ imageUrls,
81
+ ...(runtimeMode ? { runtimeMode } : {}),
82
+ ...(continueDialogId ? { continueDialogId } : {}),
83
+ };
84
+ }
85
+
86
+ function mimeTypeForPath(path: string) {
87
+ switch (extname(path).toLowerCase()) {
88
+ case ".jpg":
89
+ case ".jpeg":
90
+ return "image/jpeg";
91
+ case ".webp":
92
+ return "image/webp";
93
+ case ".gif":
94
+ return "image/gif";
95
+ default:
96
+ return "image/png";
97
+ }
98
+ }
99
+
100
+ export function normalizeCliImageInput(input: string) {
101
+ if (/^(https?:|data:|file:)/i.test(input)) return input;
102
+ const absolutePath = resolve(input);
103
+ if (!existsSync(absolutePath)) return input;
104
+ const base64 = readFileSync(absolutePath).toString("base64");
105
+ return `data:${mimeTypeForPath(absolutePath)};base64,${base64}`;
106
+ }
107
+
108
+ function resolveServerUrl(env: EnvLike) {
109
+ return (env.NOLO_SERVER || env.BASE_URL || DEFAULT_NOLO_SERVER_URL).replace(/\/+$/, "");
110
+ }
111
+
112
+ function writeUsage(output: OutputLike) {
113
+ output.write(
114
+ "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"
116
+ );
117
+ }
118
+
119
+ export async function runAgentRunCommand(args: string[], deps: AgentRunCommandDeps) {
120
+ const env = deps.env ?? process.env;
121
+ const output = deps.output ?? process.stdout;
122
+ const parsed = parseAgentRunArgs(args);
123
+ if (!parsed) {
124
+ writeUsage(output);
125
+ return 1;
126
+ }
127
+
128
+ const runner = deps.runner ?? runAgentTurn;
129
+ const result: RunAgentTurnResult = await runner({
130
+ agentName: parsed.agentKey,
131
+ agentKey: parsed.agentKey,
132
+ serverUrl: resolveServerUrl(env),
133
+ message: parsed.message,
134
+ imageUrls: parsed.imageUrls.map(normalizeCliImageInput),
135
+ scriptDir: deps.scriptDir,
136
+ env,
137
+ output,
138
+ ...(parsed.runtimeMode ? { runtimeMode: parsed.runtimeMode } : {}),
139
+ ...(parsed.continueDialogId ? { continueDialogId: parsed.continueDialogId } : {}),
140
+ });
141
+
142
+ if (result.dialogId) {
143
+ output.write(`\n[nolo] dialog ${result.dialogId}\n`);
144
+ }
145
+ return result.exitCode;
146
+ }
@@ -73,7 +73,34 @@ describe("cli agent run client", () => {
73
73
  expect(result).toEqual({ exitCode: 0, dialogId: "dialog-1" });
74
74
  expect(output.text()).toContain("nolo -> working");
75
75
  expect(output.text()).toContain("nolo > hi");
76
- expect(output.text()).not.toContain("tokens=");
76
+ expect(output.text()).not.toContain("tokens=");
77
+ });
78
+
79
+ test("sends image inputs as multimodal userInput over HTTP", async () => {
80
+ const output = new CaptureOutput();
81
+ const requests: Array<{ body: any }> = [];
82
+
83
+ await runAgentTurn({
84
+ agentName: "vision",
85
+ agentKey: "agent-pub-vision",
86
+ serverUrl: "https://nolo.chat",
87
+ message: "describe this",
88
+ imageUrls: ["https://example.com/a.png"],
89
+ scriptDir: "C:/missing/scripts",
90
+ env: { AUTH_TOKEN: "token-123" },
91
+ output,
92
+ runtimeMode: "server",
93
+ scriptPathExists: () => false,
94
+ fetchImpl: async (_url, init) => {
95
+ requests.push({ body: JSON.parse(String(init?.body)) });
96
+ return Response.json({ content: "ok" });
97
+ },
98
+ });
99
+
100
+ expect(requests[0]?.body.userInput).toEqual([
101
+ { type: "text", text: "describe this" },
102
+ { type: "image_url", image_url: { url: "https://example.com/a.png" } },
103
+ ]);
77
104
  });
78
105
 
79
106
  test("runs forced local turns through the injected runtime adapter without HTTP", async () => {
@@ -121,6 +148,50 @@ describe("cli agent run client", () => {
121
148
  expect(output.text()).toContain("frontend > local:polish notifications");
122
149
  });
123
150
 
151
+ test("auto mode prefers a working local runtime before HTTP", async () => {
152
+ const output = new CaptureOutput();
153
+ const httpCalls: string[] = [];
154
+
155
+ const result = await runAgentTurn({
156
+ agentName: "frontend",
157
+ agentKey: "frontend-local",
158
+ serverUrl: "https://nolo.chat",
159
+ message: "polish notifications",
160
+ scriptDir: "C:/missing/scripts",
161
+ env: { AUTH_TOKEN: "token-123" },
162
+ output,
163
+ runtimeMode: "auto",
164
+ localRuntimeAdapter: {
165
+ host: "cli",
166
+ capabilities: ["leveldb-agent-config", "local-provider"],
167
+ loadAgentConfig: async (agentRef) => ({
168
+ key: agentRef,
169
+ name: "Frontend",
170
+ prompt: "Fix UI",
171
+ model: "fake-local",
172
+ }),
173
+ loadDialogHistory: async () => [],
174
+ saveTurn: async () => ({ dialogId: "dialog-auto-local" }),
175
+ resolveProvider: async () => ({
176
+ model: "fake-local",
177
+ complete: async () => ({ content: "auto local ok", model: "fake-local" }),
178
+ }),
179
+ executeTool: async () => {
180
+ throw new Error("no tools expected");
181
+ },
182
+ },
183
+ scriptPathExists: () => false,
184
+ fetchImpl: async (url) => {
185
+ httpCalls.push(String(url));
186
+ return Response.json({ content: "server" });
187
+ },
188
+ });
189
+
190
+ expect(result).toEqual({ exitCode: 0, dialogId: "dialog-auto-local" });
191
+ expect(httpCalls).toEqual([]);
192
+ expect(output.text()).toContain("frontend -> working locally");
193
+ });
194
+
124
195
  test("builds the default local adapter when env requests local mode", async () => {
125
196
  const output = new CaptureOutput();
126
197
  const builtModes: string[] = [];
@@ -11,12 +11,13 @@ type OutputLike = {
11
11
  write(chunk: string): unknown;
12
12
  };
13
13
 
14
- type RunAgentTurnOptions = {
15
- agentName: string;
16
- agentKey: string;
17
- serverUrl: string;
18
- message: string;
19
- continueDialogId?: string;
14
+ type RunAgentTurnOptions = {
15
+ agentName: string;
16
+ agentKey: string;
17
+ serverUrl: string;
18
+ message: string;
19
+ imageUrls?: string[];
20
+ continueDialogId?: string;
20
21
  scriptDir: string;
21
22
  env: EnvLike;
22
23
  output: OutputLike;
@@ -62,6 +63,28 @@ function buildDefaultLocalRuntimeAdapter(options: RunAgentTurnOptions) {
62
63
  fetchImpl: options.fetchImpl,
63
64
  });
64
65
  }
66
+
67
+ function buildUserInputContent(message: string, imageUrls: string[] = []) {
68
+ if (imageUrls.length === 0) return message;
69
+ return [
70
+ ...(message.trim() ? [{ type: "text" as const, text: message }] : []),
71
+ ...imageUrls.map((url) => ({
72
+ type: "image_url" as const,
73
+ image_url: { url },
74
+ })),
75
+ ];
76
+ }
77
+
78
+ function shouldAttemptAutoLocal(options: RunAgentTurnOptions) {
79
+ if (options.localRuntimeAdapter || options.localRuntimeAdapterFactory) return true;
80
+ return Boolean(
81
+ options.env.NOLO_LOCAL_OPENAI_API_KEY ||
82
+ options.env.OPENAI_API_KEY ||
83
+ options.env.NOLO_LOCAL_OPENAI_BASE_URL ||
84
+ options.env.OPENAI_BASE_URL ||
85
+ options.env.NOLO_LOCAL_AGENT_KEY
86
+ );
87
+ }
65
88
 
66
89
  function formatUsage(usage: any, dialogId: unknown) {
67
90
  const parts: string[] = [];
@@ -146,9 +169,9 @@ async function runHttpAgentTurn(options: RunAgentTurnOptions, authToken: string)
146
169
  "Content-Type": "application/json",
147
170
  },
148
171
  body: JSON.stringify({
149
- agentKey: options.agentKey,
150
- userInput: options.message,
151
- runtimeContext: {
172
+ agentKey: options.agentKey,
173
+ userInput: buildUserInputContent(options.message, options.imageUrls),
174
+ runtimeContext: {
152
175
  surface: "cli",
153
176
  host: "terminal",
154
177
  runtime: "bun",
@@ -204,6 +227,13 @@ async function runHttpAgentTurn(options: RunAgentTurnOptions, authToken: string)
204
227
  }
205
228
 
206
229
  async function runInjectedLocalAgentTurn(options: RunAgentTurnOptions) {
230
+ return runLocalAgentTurnForCli(options, { reportFailure: true });
231
+ }
232
+
233
+ async function runLocalAgentTurnForCli(
234
+ options: RunAgentTurnOptions,
235
+ settings: { reportFailure: boolean }
236
+ ) {
207
237
  const adapter =
208
238
  options.localRuntimeAdapter ||
209
239
  options.localRuntimeAdapterFactory?.(options.env) ||
@@ -218,7 +248,7 @@ async function runInjectedLocalAgentTurn(options: RunAgentTurnOptions) {
218
248
  const result = await runLocalAgentTurn({
219
249
  adapter,
220
250
  agentRef: options.agentKey,
221
- input: options.message,
251
+ input: buildUserInputContent(options.message, options.imageUrls),
222
252
  continueDialogId: options.continueDialogId,
223
253
  });
224
254
  const content = result.content.trim();
@@ -229,12 +259,14 @@ async function runInjectedLocalAgentTurn(options: RunAgentTurnOptions) {
229
259
  }
230
260
  return { exitCode: 0, dialogId: result.dialogId };
231
261
  } catch (error) {
232
- options.output.write(
233
- `[nolo] Local agent run failed: ${
234
- error instanceof Error ? error.message : String(error)
235
- }\n`
236
- );
237
- return { exitCode: 1 };
262
+ if (settings.reportFailure) {
263
+ options.output.write(
264
+ `[nolo] Local agent run failed: ${
265
+ error instanceof Error ? error.message : String(error)
266
+ }\n`
267
+ );
268
+ }
269
+ return { exitCode: 1, localError: error };
238
270
  }
239
271
  }
240
272
 
@@ -340,11 +372,22 @@ export async function runAgentTurn(options: RunAgentTurnOptions) {
340
372
  const authToken = resolveAuthToken(options.env);
341
373
  const scriptPath = join(options.scriptDir, "chatWithAgent.ts");
342
374
  const scriptPathExists = (options.scriptPathExists ?? existsSync)(scriptPath);
375
+ const runtimeMode = resolveRequestedRuntimeMode(options);
343
376
 
344
- if (resolveRequestedRuntimeMode(options) === "local") {
377
+ if (runtimeMode === "local") {
345
378
  return runInjectedLocalAgentTurn(options);
346
379
  }
347
380
 
381
+ if (runtimeMode === "auto" && shouldAttemptAutoLocal(options)) {
382
+ const localResult = await runLocalAgentTurnForCli(options, { reportFailure: false });
383
+ if (localResult.exitCode === 0) {
384
+ return {
385
+ exitCode: localResult.exitCode,
386
+ ...(localResult.dialogId ? { dialogId: localResult.dialogId } : {}),
387
+ };
388
+ }
389
+ }
390
+
348
391
  if (shouldUseScriptBridge({ hasAuthToken: Boolean(authToken), scriptPathExists })) {
349
392
  return runScriptBridge(options, scriptPath);
350
393
  }
@@ -120,7 +120,50 @@ describe("CLI local runtime adapter", () => {
120
120
  });
121
121
  });
122
122
 
123
- test("rejects tools until local tool policy is implemented", async () => {
123
+ test("passes image_url message parts through to OpenAI-compatible providers", async () => {
124
+ const requests: Array<{ body: any }> = [];
125
+ const adapter = createCliLocalRuntimeAdapter({
126
+ env: {
127
+ OPENAI_API_KEY: "sk-local",
128
+ NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
129
+ },
130
+ db: {
131
+ get: async () => ({
132
+ dbKey: "agent-local-vision",
133
+ prompt: "Describe images.",
134
+ model: "gpt-4.1-mini",
135
+ }),
136
+ put: async () => {},
137
+ batch: async () => {},
138
+ iterator: () => (async function* () {})(),
139
+ },
140
+ fetchImpl: async (_url, init) => {
141
+ requests.push({ body: JSON.parse(String(init?.body)) });
142
+ return Response.json({
143
+ choices: [{ message: { content: "image ok" } }],
144
+ });
145
+ },
146
+ });
147
+
148
+ await runLocalAgentTurn({
149
+ adapter,
150
+ agentRef: "vision",
151
+ input: [
152
+ { type: "text", text: "describe this" },
153
+ { type: "image_url", image_url: { url: "https://example.com/a.png" } },
154
+ ],
155
+ });
156
+
157
+ expect(requests[0]?.body.messages.at(-1)).toEqual({
158
+ role: "user",
159
+ content: [
160
+ { type: "text", text: "describe this" },
161
+ { type: "image_url", image_url: { url: "https://example.com/a.png" } },
162
+ ],
163
+ });
164
+ });
165
+
166
+ test("rejects tools unless the local tool policy allows and registers them", async () => {
124
167
  const adapter = createCliLocalRuntimeAdapter({
125
168
  env: {},
126
169
  fetchImpl: async () => Response.json({}),
@@ -130,6 +173,45 @@ describe("CLI local runtime adapter", () => {
130
173
  id: "call-1",
131
174
  name: "execShell",
132
175
  arguments: "{}",
133
- })).rejects.toThrow("Local runtime tools are not enabled");
176
+ })).rejects.toThrow("blocked by the local CLI safety policy");
177
+ });
178
+
179
+ test("executes explicitly allowed registered local tools declared by the agent", async () => {
180
+ const store = new Map<string, any>([
181
+ ["agent-user-1-reader", {
182
+ dbKey: "agent-user-1-reader",
183
+ id: "reader",
184
+ prompt: "Read files.",
185
+ toolNames: ["readFile"],
186
+ }],
187
+ ]);
188
+ const adapter = createCliLocalRuntimeAdapter({
189
+ env: {
190
+ NOLO_LOCAL_USER_ID: "user-1",
191
+ NOLO_LOCAL_ALLOWED_TOOLS: "readFile",
192
+ },
193
+ db: {
194
+ get: async (key) => {
195
+ if (!store.has(key)) throw new Error(`not found: ${key}`);
196
+ return store.get(key);
197
+ },
198
+ put: async () => {},
199
+ batch: async () => {},
200
+ iterator: () => (async function* () {})(),
201
+ },
202
+ localToolExecutors: {
203
+ readFile: async (call) => ({ content: `read:${call.arguments}` }),
204
+ },
205
+ fetchImpl: async () => Response.json({}),
206
+ });
207
+
208
+ await adapter.loadAgentConfig("reader");
209
+ const result = await adapter.executeTool({
210
+ id: "call-1",
211
+ name: "readFile",
212
+ arguments: "{\"path\":\"README.md\"}",
213
+ });
214
+
215
+ expect(result.content).toContain("README.md");
134
216
  });
135
217
  });
@@ -5,6 +5,7 @@ import type {
5
5
  } from "../agentRuntimeLocal";
6
6
  import type { AgentRuntimeChatMessage } from "../../agent-runtime";
7
7
  import { getDefaultCliLocalRuntimeDb } from "../localRuntimeDb";
8
+ import { executeLocalToolWithPolicy } from "./localToolPolicy";
8
9
 
9
10
  type EnvLike = Record<string, string | undefined>;
10
11
 
@@ -21,6 +22,7 @@ export type CliLocalRuntimeAdapterDeps = {
21
22
  now?: () => number;
22
23
  createId?: () => string;
23
24
  fetchImpl?: typeof fetch;
25
+ localToolExecutors?: Record<string, (call: any) => Promise<{ content: string; metadata?: Record<string, unknown> }>>;
24
26
  };
25
27
 
26
28
  async function defaultLocalRuntimeDb(): Promise<CliLocalRuntimeDb> {
@@ -80,13 +82,7 @@ async function readAgentFromDb(args: {
80
82
  function toOpenAiMessages(messages: AgentRuntimeChatMessage[]) {
81
83
  return messages.map((message) => ({
82
84
  role: message.role,
83
- content: typeof message.content === "string"
84
- ? message.content
85
- : Array.isArray(message.content)
86
- ? message.content
87
- .map((part) => part.type === "text" ? part.text : `[image:${part.image_url.url}]`)
88
- .join("\n")
89
- : "",
85
+ content: message.content ?? "",
90
86
  }));
91
87
  }
92
88
 
@@ -124,6 +120,14 @@ async function writeDialog(args: {
124
120
  const nowIso = new Date(now).toISOString();
125
121
  const dialogKey = `dialog-${args.userId}-${dialogId}`;
126
122
  const lastUser = [...args.input.messages].reverse().find((message) => message.role === "user");
123
+ const lastUserText = typeof lastUser?.content === "string"
124
+ ? lastUser.content
125
+ : Array.isArray(lastUser?.content)
126
+ ? lastUser.content
127
+ .filter((part) => part.type === "text")
128
+ .map((part) => part.text)
129
+ .join(" ")
130
+ : "";
127
131
  const ops: Array<{ type: "put"; key: string; value: any }> = [
128
132
  {
129
133
  type: "put",
@@ -135,8 +139,8 @@ async function writeDialog(args: {
135
139
  userId: args.userId,
136
140
  cybots: [args.input.agentKey],
137
141
  primaryAgentKey: args.input.agentKey,
138
- title: typeof lastUser?.content === "string" && lastUser.content.trim()
139
- ? lastUser.content.trim().slice(0, 80)
142
+ title: lastUserText.trim()
143
+ ? lastUserText.trim().slice(0, 80)
140
144
  : "Local agent run",
141
145
  status: "done",
142
146
  triggerType: "cli-local",
@@ -186,15 +190,20 @@ export function createCliLocalRuntimeAdapter(
186
190
  const createId = deps.createId ?? createFallbackId;
187
191
  const fetchImpl = deps.fetchImpl ?? fetch;
188
192
  const userId = resolveLocalUserId(deps.env);
193
+ let activeAgentToolNames: string[] = [];
189
194
 
190
195
  return {
191
196
  host: "cli",
192
197
  capabilities: ["leveldb-agent-config", "local-provider", "leveldb-persistence"],
193
- loadAgentConfig: async (agentRef) => readAgentFromDb({
194
- agentRef,
195
- db: await resolveDb(deps),
196
- userId,
197
- }),
198
+ loadAgentConfig: async (agentRef) => {
199
+ const agentConfig = await readAgentFromDb({
200
+ agentRef,
201
+ db: await resolveDb(deps),
202
+ userId,
203
+ });
204
+ activeAgentToolNames = agentConfig?.toolNames ?? [];
205
+ return agentConfig;
206
+ },
198
207
  loadDialogHistory: async (dialogId) => readDialogMessages({
199
208
  dialogId,
200
209
  db: await resolveDb(deps),
@@ -237,8 +246,11 @@ export function createCliLocalRuntimeAdapter(
237
246
  };
238
247
  },
239
248
  }),
240
- executeTool: async () => {
241
- throw new Error("Local runtime tools are not enabled for text-only CLI runs.");
242
- },
249
+ executeTool: async (call) => executeLocalToolWithPolicy({
250
+ env: deps.env,
251
+ agentToolNames: activeAgentToolNames,
252
+ call,
253
+ executors: deps.localToolExecutors,
254
+ }),
243
255
  };
244
256
  }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { executeLocalToolWithPolicy, resolveLocalToolPolicy } from "./localToolPolicy";
4
+
5
+ describe("CLI local tool policy", () => {
6
+ test("blocks dangerous tools even when env tries to allow them", () => {
7
+ expect(resolveLocalToolPolicy({
8
+ env: { NOLO_LOCAL_ALLOWED_TOOLS: "execShell" },
9
+ agentToolNames: ["execShell"],
10
+ toolName: "execShell",
11
+ })).toMatchObject({
12
+ allowed: false,
13
+ reason: expect.stringContaining("blocked"),
14
+ });
15
+ });
16
+
17
+ test("requires both env allowlist and agent declaration", () => {
18
+ expect(resolveLocalToolPolicy({
19
+ env: { NOLO_LOCAL_ALLOWED_TOOLS: "readFile" },
20
+ agentToolNames: ["readFile"],
21
+ toolName: "readFile",
22
+ })).toEqual({ allowed: true, toolName: "readFile" });
23
+
24
+ expect(resolveLocalToolPolicy({
25
+ env: { NOLO_LOCAL_ALLOWED_TOOLS: "readFile" },
26
+ agentToolNames: [],
27
+ toolName: "readFile",
28
+ })).toMatchObject({ allowed: false });
29
+ });
30
+
31
+ test("executes only registered tools after policy allows them", async () => {
32
+ const result = await executeLocalToolWithPolicy({
33
+ env: { NOLO_LOCAL_ALLOWED_TOOLS: "readFile" },
34
+ agentToolNames: ["readFile"],
35
+ call: { id: "call-1", name: "readFile", arguments: "{\"path\":\"README.md\"}" },
36
+ executors: {
37
+ readFile: async (call) => ({ content: `read:${call.arguments}` }),
38
+ },
39
+ });
40
+
41
+ expect(result.content).toContain("README.md");
42
+ });
43
+ });
@@ -0,0 +1,70 @@
1
+ import type { AgentRuntimeToolCallInput, AgentRuntimeToolResult } from "../../agent-runtime";
2
+
3
+ type EnvLike = Record<string, string | undefined>;
4
+
5
+ export type LocalToolPolicyDecision =
6
+ | { allowed: true; toolName: string }
7
+ | { allowed: false; toolName: string; reason: string };
8
+
9
+ const NEVER_LOCAL_TOOLS = new Set([
10
+ "execShell",
11
+ "deleteSpaces",
12
+ "updateAgent",
13
+ "updateSelf",
14
+ "createAgent",
15
+ ]);
16
+
17
+ function parseCsv(value: string | undefined) {
18
+ return (value ?? "")
19
+ .split(",")
20
+ .map((item) => item.trim())
21
+ .filter(Boolean);
22
+ }
23
+
24
+ export function resolveLocalToolPolicy(args: {
25
+ env: EnvLike;
26
+ agentToolNames?: string[];
27
+ toolName: string;
28
+ }): LocalToolPolicyDecision {
29
+ if (NEVER_LOCAL_TOOLS.has(args.toolName)) {
30
+ return {
31
+ allowed: false,
32
+ toolName: args.toolName,
33
+ reason: `${args.toolName} is blocked by the local CLI safety policy.`,
34
+ };
35
+ }
36
+
37
+ const allowedByEnv = new Set(parseCsv(args.env.NOLO_LOCAL_ALLOWED_TOOLS));
38
+ const agentTools = new Set(args.agentToolNames ?? []);
39
+ if (allowedByEnv.has(args.toolName) && agentTools.has(args.toolName)) {
40
+ return { allowed: true, toolName: args.toolName };
41
+ }
42
+
43
+ return {
44
+ allowed: false,
45
+ toolName: args.toolName,
46
+ reason:
47
+ `${args.toolName} is not enabled for local CLI runs. ` +
48
+ "Set NOLO_LOCAL_ALLOWED_TOOLS and make sure the agent declares the tool before using it locally.",
49
+ };
50
+ }
51
+
52
+ export async function executeLocalToolWithPolicy(args: {
53
+ env: EnvLike;
54
+ agentToolNames?: string[];
55
+ call: AgentRuntimeToolCallInput;
56
+ executors?: Record<string, (call: AgentRuntimeToolCallInput) => Promise<AgentRuntimeToolResult>>;
57
+ }): Promise<AgentRuntimeToolResult> {
58
+ const decision = resolveLocalToolPolicy({
59
+ env: args.env,
60
+ agentToolNames: args.agentToolNames,
61
+ toolName: args.call.name,
62
+ });
63
+ if (!decision.allowed) throw new Error(decision.reason);
64
+
65
+ const executor = args.executors?.[args.call.name];
66
+ if (!executor) {
67
+ throw new Error(`${args.call.name} is allowed by policy but no local executor is registered.`);
68
+ }
69
+ return executor(args.call);
70
+ }
@@ -28,7 +28,9 @@ export const COMMANDS: CommandEntry[] = [
28
28
  { path: ["table", "meta"], script: "upsertTableMeta.ts", description: "Create or update table metadata" },
29
29
 
30
30
  { path: ["agent", "list"], script: "listMyAgents.ts", description: "List owned agents" },
31
+ { path: ["agent", "pull"], script: "", description: "Cache an agent for local runs" },
31
32
  { path: ["agent", "read"], script: "readAgent.ts", description: "Read a single agent" },
33
+ { path: ["agent", "run"], script: "", description: "Run an agent" },
32
34
  { path: ["agent", "update"], script: "updateAgent.ts", description: "Update agent fields" },
33
35
  { path: ["agent", "bind-current"], script: "", description: "Bind an agent to this machine" },
34
36
  { path: ["agent", "smoke-current"], script: "", description: "Smoke test a bound agent through this machine" },
@@ -93,7 +95,9 @@ export function renderHelpText() {
93
95
  ' nolo doc create --title "Trip Notes" --body "hello" --sync local,us --dry-run',
94
96
  ' nolo skill-doc create --title "Agent Query Skill" --description "Inspect recent agent dialogs" --sync local,main,us',
95
97
  " nolo agent list --json",
98
+ " nolo agent pull agent-pub-01APPBUILDER00000001YAII3I",
96
99
  " nolo agent read agent-pub-01APPBUILDER00000001YAII3I",
100
+ ' nolo agent run frontend-implementer --msg "polish notifications" --local',
97
101
  " nolo agent bind-current agent-user-1-agent-1",
98
102
  " nolo agent runtime-doctor agent-user-1-agent-1",
99
103
  ' nolo agent smoke-current agent-user-1-agent-1 --msg "ping"',
package/index.ts CHANGED
@@ -11,6 +11,8 @@ import {
11
11
  runAgentSmokeCurrentCommand,
12
12
  runDoctorRuntimeCommand,
13
13
  } from "./agentRuntimeCommands";
14
+ import { runAgentRunCommand } from "./agentRunCommand";
15
+ import { runAgentPullCommand } from "./agentPullCommand";
14
16
  import { buildEnvFromProfile, loadProfileConfig } from "./client/profileConfig";
15
17
  import { resolveTuiLaunchMode } from "./runtimeModeArgs";
16
18
  import {
@@ -118,10 +120,21 @@ if (args[0] === "agent" && args[1] === "bind-current") {
118
120
  process.exit(await runAgentBindCurrentCommand(args.slice(2), { env: runtimeEnv }));
119
121
  }
120
122
 
121
- if (args[0] === "agent" && args[1] === "smoke-current") {
122
- process.exit(await runAgentSmokeCurrentCommand(args.slice(2), { env: runtimeEnv }));
123
- }
124
-
123
+ if (args[0] === "agent" && args[1] === "smoke-current") {
124
+ process.exit(await runAgentSmokeCurrentCommand(args.slice(2), { env: runtimeEnv }));
125
+ }
126
+
127
+ if (args[0] === "agent" && args[1] === "run") {
128
+ process.exit(await runAgentRunCommand(args.slice(2), {
129
+ env: runtimeEnv,
130
+ scriptDir: SCRIPT_DIR,
131
+ }));
132
+ }
133
+
134
+ if (args[0] === "agent" && args[1] === "pull") {
135
+ process.exit(await runAgentPullCommand(args.slice(2), { env: runtimeEnv }));
136
+ }
137
+
125
138
  if (args[0] === "agent" && args[1] === "runtime-doctor") {
126
139
  process.exit(await runAgentRuntimeDoctorCommand(args.slice(2), { env: runtimeEnv }));
127
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nolo-cli",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "type": "module",
5
5
  "description": "Agent-first terminal workspace for Nolo",
6
6
  "bin": {
@@ -12,6 +12,8 @@
12
12
  "agentRuntimeLocal.ts",
13
13
  "localRuntimeDb.ts",
14
14
  "agentRuntimeCommands.ts",
15
+ "agentPullCommand.ts",
16
+ "agentRunCommand.ts",
15
17
  "authCommands.ts",
16
18
  "commandRegistry.ts",
17
19
  "connectorWebSocketTarget.ts",