nolo-cli 0.1.15 → 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/index.ts +1 -0
- package/agent-runtime/localLoop.ts +39 -4
- package/agent-runtime/runtimeFacts.ts +40 -0
- package/agent-runtime/types.ts +1 -0
- package/agentPullCommand.ts +146 -0
- package/agentRunCommand.ts +56 -3
- package/client/agentRun.test.ts +72 -1
- package/client/agentRun.ts +60 -17
- package/client/localRuntimeAdapter.test.ts +152 -2
- package/client/localRuntimeAdapter.ts +107 -18
- package/client/localToolPolicy.test.ts +48 -0
- package/client/localToolPolicy.ts +85 -0
- package/commandRegistry.ts +2 -0
- package/index.ts +5 -0
- package/package.json +2 -1
package/agent-runtime/index.ts
CHANGED
|
@@ -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,14 +3,16 @@ 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:
|
|
13
|
+
input: AgentRuntimeMessageContent;
|
|
13
14
|
continueDialogId?: string;
|
|
15
|
+
maxToolRounds?: number;
|
|
14
16
|
};
|
|
15
17
|
|
|
16
18
|
export type LocalAgentTurnResult = AgentRuntimeResult & {
|
|
@@ -20,7 +22,7 @@ export type LocalAgentTurnResult = AgentRuntimeResult & {
|
|
|
20
22
|
function buildMessages(args: {
|
|
21
23
|
prompt?: string;
|
|
22
24
|
history: AgentRuntimeChatMessage[];
|
|
23
|
-
input:
|
|
25
|
+
input: AgentRuntimeMessageContent;
|
|
24
26
|
}): AgentRuntimeChatMessage[] {
|
|
25
27
|
return [
|
|
26
28
|
...(args.prompt?.trim()
|
|
@@ -48,15 +50,48 @@ export async function runLocalAgentTurn(
|
|
|
48
50
|
input: input.input,
|
|
49
51
|
});
|
|
50
52
|
const provider = await input.adapter.resolveProvider(agentConfig);
|
|
51
|
-
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!;
|
|
52
83
|
const saved = await input.adapter.saveTurn({
|
|
53
84
|
agentKey: agentConfig.key,
|
|
54
85
|
messages,
|
|
55
|
-
result
|
|
86
|
+
result: {
|
|
87
|
+
...result,
|
|
88
|
+
...(toolCallCount > 0 ? { toolCallCount } : {}),
|
|
89
|
+
},
|
|
56
90
|
});
|
|
57
91
|
|
|
58
92
|
return {
|
|
59
93
|
...result,
|
|
94
|
+
...(toolCallCount > 0 ? { toolCallCount } : {}),
|
|
60
95
|
dialogId: saved.dialogId,
|
|
61
96
|
};
|
|
62
97
|
}
|
|
@@ -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
|
+
}
|
package/agent-runtime/types.ts
CHANGED
|
@@ -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
|
+
}
|
package/agentRunCommand.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
|
|
2
2
|
import { runAgentTurn, type RunAgentTurnResult } from "./client/agentRun";
|
|
3
3
|
import type { AgentRuntimeRequestedMode } from "./agentRuntimeLocal";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { extname, resolve } from "node:path";
|
|
4
6
|
|
|
5
7
|
type EnvLike = Record<string, string | undefined>;
|
|
6
8
|
|
|
@@ -18,6 +20,8 @@ type AgentRunCommandDeps = {
|
|
|
18
20
|
type ParsedAgentRunArgs = {
|
|
19
21
|
agentKey: string;
|
|
20
22
|
message: string;
|
|
23
|
+
imageUrls: string[];
|
|
24
|
+
allowShell: boolean;
|
|
21
25
|
runtimeMode?: AgentRuntimeRequestedMode;
|
|
22
26
|
continueDialogId?: string;
|
|
23
27
|
};
|
|
@@ -28,6 +32,17 @@ function readFlagValue(args: string[], flag: string) {
|
|
|
28
32
|
return args[index + 1];
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
function readRepeatedFlagValues(args: string[], flag: string) {
|
|
36
|
+
const values: string[] = [];
|
|
37
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
38
|
+
if (args[index] === flag && args[index + 1]) {
|
|
39
|
+
values.push(args[index + 1]);
|
|
40
|
+
index += 1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return values;
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
function runtimeModeFromArgs(args: string[]): AgentRuntimeRequestedMode | undefined {
|
|
32
47
|
if (args.includes("--local")) return "local";
|
|
33
48
|
if (args.includes("--server")) return "server";
|
|
@@ -37,9 +52,15 @@ function runtimeModeFromArgs(args: string[]): AgentRuntimeRequestedMode | undefi
|
|
|
37
52
|
|
|
38
53
|
function positionalArgs(args: string[]) {
|
|
39
54
|
const values: string[] = [];
|
|
55
|
+
const valuelessFlags = new Set([
|
|
56
|
+
"--local",
|
|
57
|
+
"--server",
|
|
58
|
+
"--auto",
|
|
59
|
+
"--dangerously-allow-shell",
|
|
60
|
+
]);
|
|
40
61
|
for (let index = 0; index < args.length; index += 1) {
|
|
41
62
|
const arg = args[index];
|
|
42
|
-
if (arg
|
|
63
|
+
if (valuelessFlags.has(arg)) continue;
|
|
43
64
|
if (arg.startsWith("--")) {
|
|
44
65
|
index += 1;
|
|
45
66
|
continue;
|
|
@@ -56,14 +77,42 @@ export function parseAgentRunArgs(args: string[]): ParsedAgentRunArgs | null {
|
|
|
56
77
|
if (!agentKey || !message.trim()) return null;
|
|
57
78
|
const runtimeMode = runtimeModeFromArgs(args);
|
|
58
79
|
const continueDialogId = readFlagValue(args, "--continue") ?? readFlagValue(args, "--dialog");
|
|
80
|
+
const imageUrls = [
|
|
81
|
+
...readRepeatedFlagValues(args, "--image"),
|
|
82
|
+
...readRepeatedFlagValues(args, "--image-url"),
|
|
83
|
+
];
|
|
59
84
|
return {
|
|
60
85
|
agentKey,
|
|
61
86
|
message: message.trim(),
|
|
87
|
+
imageUrls,
|
|
88
|
+
allowShell: args.includes("--dangerously-allow-shell"),
|
|
62
89
|
...(runtimeMode ? { runtimeMode } : {}),
|
|
63
90
|
...(continueDialogId ? { continueDialogId } : {}),
|
|
64
91
|
};
|
|
65
92
|
}
|
|
66
93
|
|
|
94
|
+
function mimeTypeForPath(path: string) {
|
|
95
|
+
switch (extname(path).toLowerCase()) {
|
|
96
|
+
case ".jpg":
|
|
97
|
+
case ".jpeg":
|
|
98
|
+
return "image/jpeg";
|
|
99
|
+
case ".webp":
|
|
100
|
+
return "image/webp";
|
|
101
|
+
case ".gif":
|
|
102
|
+
return "image/gif";
|
|
103
|
+
default:
|
|
104
|
+
return "image/png";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function normalizeCliImageInput(input: string) {
|
|
109
|
+
if (/^(https?:|data:|file:)/i.test(input)) return input;
|
|
110
|
+
const absolutePath = resolve(input);
|
|
111
|
+
if (!existsSync(absolutePath)) return input;
|
|
112
|
+
const base64 = readFileSync(absolutePath).toString("base64");
|
|
113
|
+
return `data:${mimeTypeForPath(absolutePath)};base64,${base64}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
67
116
|
function resolveServerUrl(env: EnvLike) {
|
|
68
117
|
return (env.NOLO_SERVER || env.BASE_URL || DEFAULT_NOLO_SERVER_URL).replace(/\/+$/, "");
|
|
69
118
|
}
|
|
@@ -71,7 +120,7 @@ function resolveServerUrl(env: EnvLike) {
|
|
|
71
120
|
function writeUsage(output: OutputLike) {
|
|
72
121
|
output.write(
|
|
73
122
|
"Usage: nolo agent run <agent> <message> [--local|--server|--auto] [--continue <dialogId>]\n" +
|
|
74
|
-
" nolo agent run --agent <agent> --msg <message> [--local|--server|--auto]\n"
|
|
123
|
+
" nolo agent run --agent <agent> --msg <message> [--image <url-or-path>] [--dangerously-allow-shell] [--local|--server|--auto]\n"
|
|
75
124
|
);
|
|
76
125
|
}
|
|
77
126
|
|
|
@@ -85,13 +134,17 @@ export async function runAgentRunCommand(args: string[], deps: AgentRunCommandDe
|
|
|
85
134
|
}
|
|
86
135
|
|
|
87
136
|
const runner = deps.runner ?? runAgentTurn;
|
|
137
|
+
const runEnv = parsed.allowShell
|
|
138
|
+
? { ...env, NOLO_LOCAL_SHELL_MODE: "worktree" }
|
|
139
|
+
: env;
|
|
88
140
|
const result: RunAgentTurnResult = await runner({
|
|
89
141
|
agentName: parsed.agentKey,
|
|
90
142
|
agentKey: parsed.agentKey,
|
|
91
143
|
serverUrl: resolveServerUrl(env),
|
|
92
144
|
message: parsed.message,
|
|
145
|
+
imageUrls: parsed.imageUrls.map(normalizeCliImageInput),
|
|
93
146
|
scriptDir: deps.scriptDir,
|
|
94
|
-
env,
|
|
147
|
+
env: runEnv,
|
|
95
148
|
output,
|
|
96
149
|
...(parsed.runtimeMode ? { runtimeMode: parsed.runtimeMode } : {}),
|
|
97
150
|
...(parsed.continueDialogId ? { continueDialogId: parsed.continueDialogId } : {}),
|
package/client/agentRun.test.ts
CHANGED
|
@@ -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[] = [];
|
package/client/agentRun.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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 (
|
|
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("
|
|
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,113 @@ describe("CLI local runtime adapter", () => {
|
|
|
130
173
|
id: "call-1",
|
|
131
174
|
name: "execShell",
|
|
132
175
|
arguments: "{}",
|
|
133
|
-
})).rejects.toThrow("
|
|
176
|
+
})).rejects.toThrow("execShell requires NOLO_LOCAL_SHELL_MODE");
|
|
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");
|
|
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 });
|
|
134
284
|
});
|
|
135
285
|
});
|
|
@@ -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,8 @@ export type CliLocalRuntimeAdapterDeps = {
|
|
|
21
22
|
now?: () => number;
|
|
22
23
|
createId?: () => string;
|
|
23
24
|
fetchImpl?: typeof fetch;
|
|
25
|
+
cwd?: string;
|
|
26
|
+
localToolExecutors?: Record<string, (call: any) => Promise<{ content: string; metadata?: Record<string, unknown> }>>;
|
|
24
27
|
};
|
|
25
28
|
|
|
26
29
|
async function defaultLocalRuntimeDb(): Promise<CliLocalRuntimeDb> {
|
|
@@ -44,6 +47,77 @@ function resolveLocalUserId(env: EnvLike) {
|
|
|
44
47
|
return env.NOLO_LOCAL_USER_ID || env.NOLO_USER_ID || "local";
|
|
45
48
|
}
|
|
46
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
|
+
|
|
47
121
|
async function resolveDb(deps: CliLocalRuntimeAdapterDeps) {
|
|
48
122
|
return deps.db ?? defaultLocalRuntimeDb();
|
|
49
123
|
}
|
|
@@ -80,13 +154,7 @@ async function readAgentFromDb(args: {
|
|
|
80
154
|
function toOpenAiMessages(messages: AgentRuntimeChatMessage[]) {
|
|
81
155
|
return messages.map((message) => ({
|
|
82
156
|
role: message.role,
|
|
83
|
-
content:
|
|
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
|
-
: "",
|
|
157
|
+
content: message.content ?? "",
|
|
90
158
|
}));
|
|
91
159
|
}
|
|
92
160
|
|
|
@@ -124,6 +192,14 @@ async function writeDialog(args: {
|
|
|
124
192
|
const nowIso = new Date(now).toISOString();
|
|
125
193
|
const dialogKey = `dialog-${args.userId}-${dialogId}`;
|
|
126
194
|
const lastUser = [...args.input.messages].reverse().find((message) => message.role === "user");
|
|
195
|
+
const lastUserText = typeof lastUser?.content === "string"
|
|
196
|
+
? lastUser.content
|
|
197
|
+
: Array.isArray(lastUser?.content)
|
|
198
|
+
? lastUser.content
|
|
199
|
+
.filter((part) => part.type === "text")
|
|
200
|
+
.map((part) => part.text)
|
|
201
|
+
.join(" ")
|
|
202
|
+
: "";
|
|
127
203
|
const ops: Array<{ type: "put"; key: string; value: any }> = [
|
|
128
204
|
{
|
|
129
205
|
type: "put",
|
|
@@ -135,8 +211,8 @@ async function writeDialog(args: {
|
|
|
135
211
|
userId: args.userId,
|
|
136
212
|
cybots: [args.input.agentKey],
|
|
137
213
|
primaryAgentKey: args.input.agentKey,
|
|
138
|
-
title:
|
|
139
|
-
?
|
|
214
|
+
title: lastUserText.trim()
|
|
215
|
+
? lastUserText.trim().slice(0, 80)
|
|
140
216
|
: "Local agent run",
|
|
141
217
|
status: "done",
|
|
142
218
|
triggerType: "cli-local",
|
|
@@ -186,15 +262,21 @@ export function createCliLocalRuntimeAdapter(
|
|
|
186
262
|
const createId = deps.createId ?? createFallbackId;
|
|
187
263
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
188
264
|
const userId = resolveLocalUserId(deps.env);
|
|
265
|
+
let activeAgentToolNames: string[] = [];
|
|
266
|
+
const localToolExecutors = buildLocalToolExecutors(deps);
|
|
189
267
|
|
|
190
268
|
return {
|
|
191
269
|
host: "cli",
|
|
192
270
|
capabilities: ["leveldb-agent-config", "local-provider", "leveldb-persistence"],
|
|
193
|
-
loadAgentConfig: async (agentRef) =>
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
271
|
+
loadAgentConfig: async (agentRef) => {
|
|
272
|
+
const agentConfig = await readAgentFromDb({
|
|
273
|
+
agentRef,
|
|
274
|
+
db: await resolveDb(deps),
|
|
275
|
+
userId,
|
|
276
|
+
});
|
|
277
|
+
activeAgentToolNames = agentConfig?.toolNames ?? [];
|
|
278
|
+
return agentConfig;
|
|
279
|
+
},
|
|
198
280
|
loadDialogHistory: async (dialogId) => readDialogMessages({
|
|
199
281
|
dialogId,
|
|
200
282
|
db: await resolveDb(deps),
|
|
@@ -209,6 +291,7 @@ export function createCliLocalRuntimeAdapter(
|
|
|
209
291
|
resolveProvider: async (agentConfig) => ({
|
|
210
292
|
model: agentConfig.model || "gpt-4.1-mini",
|
|
211
293
|
complete: async (messages) => {
|
|
294
|
+
const tools = buildOpenAiTools(agentConfig.toolNames ?? []);
|
|
212
295
|
const res = await fetchImpl(`${resolveOpenAiCompatibleBaseUrl(deps.env)}/chat/completions`, {
|
|
213
296
|
method: "POST",
|
|
214
297
|
headers: {
|
|
@@ -221,24 +304,30 @@ export function createCliLocalRuntimeAdapter(
|
|
|
221
304
|
model: agentConfig.model || "gpt-4.1-mini",
|
|
222
305
|
messages: toOpenAiMessages(messages),
|
|
223
306
|
stream: false,
|
|
307
|
+
...(tools.length > 0 ? { tools } : {}),
|
|
224
308
|
}),
|
|
225
309
|
});
|
|
226
310
|
const data = await res.json().catch(() => ({}));
|
|
227
311
|
if (!res.ok) {
|
|
228
312
|
throw new Error(`local provider failed: HTTP ${res.status} ${JSON.stringify(data)}`);
|
|
229
313
|
}
|
|
230
|
-
const
|
|
314
|
+
const choiceMessage = data?.choices?.[0]?.message ?? {};
|
|
315
|
+
const content = String(choiceMessage?.content ?? "");
|
|
231
316
|
return {
|
|
232
317
|
content,
|
|
233
318
|
model: agentConfig.model || "gpt-4.1-mini",
|
|
234
319
|
provider: agentConfig.provider || "openai-compatible",
|
|
320
|
+
...(Array.isArray(choiceMessage?.tool_calls) ? { tool_calls: choiceMessage.tool_calls } : {}),
|
|
235
321
|
usage: data?.usage,
|
|
236
322
|
trace: messages,
|
|
237
323
|
};
|
|
238
324
|
},
|
|
239
325
|
}),
|
|
240
|
-
executeTool: async () => {
|
|
241
|
-
|
|
242
|
-
|
|
326
|
+
executeTool: async (call) => executeLocalToolWithPolicy({
|
|
327
|
+
env: deps.env,
|
|
328
|
+
agentToolNames: activeAgentToolNames,
|
|
329
|
+
call,
|
|
330
|
+
executors: localToolExecutors,
|
|
331
|
+
}),
|
|
243
332
|
};
|
|
244
333
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { executeLocalToolWithPolicy, resolveLocalToolPolicy } from "./localToolPolicy";
|
|
4
|
+
|
|
5
|
+
describe("CLI local tool policy", () => {
|
|
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", () => {
|
|
15
|
+
expect(resolveLocalToolPolicy({
|
|
16
|
+
env: { NOLO_LOCAL_ALLOWED_TOOLS: "execShell" },
|
|
17
|
+
agentToolNames: ["execShell"],
|
|
18
|
+
toolName: "execShell",
|
|
19
|
+
})).toMatchObject({ allowed: false });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("requires both env allowlist and agent declaration", () => {
|
|
23
|
+
expect(resolveLocalToolPolicy({
|
|
24
|
+
env: { NOLO_LOCAL_ALLOWED_TOOLS: "readFile" },
|
|
25
|
+
agentToolNames: ["readFile"],
|
|
26
|
+
toolName: "readFile",
|
|
27
|
+
})).toEqual({ allowed: true, toolName: "readFile" });
|
|
28
|
+
|
|
29
|
+
expect(resolveLocalToolPolicy({
|
|
30
|
+
env: { NOLO_LOCAL_ALLOWED_TOOLS: "readFile" },
|
|
31
|
+
agentToolNames: [],
|
|
32
|
+
toolName: "readFile",
|
|
33
|
+
})).toMatchObject({ allowed: false });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("executes only registered tools after policy allows them", async () => {
|
|
37
|
+
const result = await executeLocalToolWithPolicy({
|
|
38
|
+
env: { NOLO_LOCAL_ALLOWED_TOOLS: "readFile" },
|
|
39
|
+
agentToolNames: ["readFile"],
|
|
40
|
+
call: { id: "call-1", name: "readFile", arguments: "{\"path\":\"README.md\"}" },
|
|
41
|
+
executors: {
|
|
42
|
+
readFile: async (call) => ({ content: `read:${call.arguments}` }),
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(result.content).toContain("README.md");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
"deleteSpaces",
|
|
11
|
+
"updateAgent",
|
|
12
|
+
"updateSelf",
|
|
13
|
+
"createAgent",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
function parseCsv(value: string | undefined) {
|
|
17
|
+
return (value ?? "")
|
|
18
|
+
.split(",")
|
|
19
|
+
.map((item) => item.trim())
|
|
20
|
+
.filter(Boolean);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveLocalToolPolicy(args: {
|
|
24
|
+
env: EnvLike;
|
|
25
|
+
agentToolNames?: string[];
|
|
26
|
+
toolName: string;
|
|
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
|
+
|
|
44
|
+
if (NEVER_LOCAL_TOOLS.has(args.toolName)) {
|
|
45
|
+
return {
|
|
46
|
+
allowed: false,
|
|
47
|
+
toolName: args.toolName,
|
|
48
|
+
reason: `${args.toolName} is blocked by the local CLI safety policy.`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const allowedByEnv = new Set(parseCsv(args.env.NOLO_LOCAL_ALLOWED_TOOLS));
|
|
53
|
+
const agentTools = new Set(args.agentToolNames ?? []);
|
|
54
|
+
if (allowedByEnv.has(args.toolName) && agentTools.has(args.toolName)) {
|
|
55
|
+
return { allowed: true, toolName: args.toolName };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
allowed: false,
|
|
60
|
+
toolName: args.toolName,
|
|
61
|
+
reason:
|
|
62
|
+
`${args.toolName} is not enabled for local CLI runs. ` +
|
|
63
|
+
"Set NOLO_LOCAL_ALLOWED_TOOLS and make sure the agent declares the tool before using it locally.",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function executeLocalToolWithPolicy(args: {
|
|
68
|
+
env: EnvLike;
|
|
69
|
+
agentToolNames?: string[];
|
|
70
|
+
call: AgentRuntimeToolCallInput;
|
|
71
|
+
executors?: Record<string, (call: AgentRuntimeToolCallInput) => Promise<AgentRuntimeToolResult>>;
|
|
72
|
+
}): Promise<AgentRuntimeToolResult> {
|
|
73
|
+
const decision = resolveLocalToolPolicy({
|
|
74
|
+
env: args.env,
|
|
75
|
+
agentToolNames: args.agentToolNames,
|
|
76
|
+
toolName: args.call.name,
|
|
77
|
+
});
|
|
78
|
+
if (!decision.allowed) throw new Error(decision.reason);
|
|
79
|
+
|
|
80
|
+
const executor = args.executors?.[args.call.name];
|
|
81
|
+
if (!executor) {
|
|
82
|
+
throw new Error(`${args.call.name} is allowed by policy but no local executor is registered.`);
|
|
83
|
+
}
|
|
84
|
+
return executor(args.call);
|
|
85
|
+
}
|
package/commandRegistry.ts
CHANGED
|
@@ -28,6 +28,7 @@ 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" },
|
|
32
33
|
{ path: ["agent", "run"], script: "", description: "Run an agent" },
|
|
33
34
|
{ path: ["agent", "update"], script: "updateAgent.ts", description: "Update agent fields" },
|
|
@@ -94,6 +95,7 @@ export function renderHelpText() {
|
|
|
94
95
|
' nolo doc create --title "Trip Notes" --body "hello" --sync local,us --dry-run',
|
|
95
96
|
' nolo skill-doc create --title "Agent Query Skill" --description "Inspect recent agent dialogs" --sync local,main,us',
|
|
96
97
|
" nolo agent list --json",
|
|
98
|
+
" nolo agent pull agent-pub-01APPBUILDER00000001YAII3I",
|
|
97
99
|
" nolo agent read agent-pub-01APPBUILDER00000001YAII3I",
|
|
98
100
|
' nolo agent run frontend-implementer --msg "polish notifications" --local',
|
|
99
101
|
" nolo agent bind-current agent-user-1-agent-1",
|
package/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
runDoctorRuntimeCommand,
|
|
13
13
|
} from "./agentRuntimeCommands";
|
|
14
14
|
import { runAgentRunCommand } from "./agentRunCommand";
|
|
15
|
+
import { runAgentPullCommand } from "./agentPullCommand";
|
|
15
16
|
import { buildEnvFromProfile, loadProfileConfig } from "./client/profileConfig";
|
|
16
17
|
import { resolveTuiLaunchMode } from "./runtimeModeArgs";
|
|
17
18
|
import {
|
|
@@ -130,6 +131,10 @@ if (args[0] === "agent" && args[1] === "run") {
|
|
|
130
131
|
}));
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
if (args[0] === "agent" && args[1] === "pull") {
|
|
135
|
+
process.exit(await runAgentPullCommand(args.slice(2), { env: runtimeEnv }));
|
|
136
|
+
}
|
|
137
|
+
|
|
133
138
|
if (args[0] === "agent" && args[1] === "runtime-doctor") {
|
|
134
139
|
process.exit(await runAgentRuntimeDoctorCommand(args.slice(2), { env: runtimeEnv }));
|
|
135
140
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nolo-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Agent-first terminal workspace for Nolo",
|
|
6
6
|
"bin": {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"agentRuntimeLocal.ts",
|
|
13
13
|
"localRuntimeDb.ts",
|
|
14
14
|
"agentRuntimeCommands.ts",
|
|
15
|
+
"agentPullCommand.ts",
|
|
15
16
|
"agentRunCommand.ts",
|
|
16
17
|
"authCommands.ts",
|
|
17
18
|
"commandRegistry.ts",
|