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.
- package/agent-runtime/index.ts +1 -0
- package/agent-runtime/localLoop.ts +3 -2
- package/agent-runtime/runtimeFacts.ts +40 -0
- package/agentPullCommand.ts +146 -0
- package/agentRunCommand.ts +146 -0
- package/client/agentRun.test.ts +72 -1
- package/client/agentRun.ts +60 -17
- package/client/localRuntimeAdapter.test.ts +84 -2
- package/client/localRuntimeAdapter.ts +29 -17
- package/client/localToolPolicy.test.ts +43 -0
- package/client/localToolPolicy.ts +70 -0
- package/commandRegistry.ts +4 -0
- package/index.ts +17 -4
- package/package.json +3 -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,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:
|
|
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:
|
|
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
|
+
}
|
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,45 @@ describe("CLI local runtime adapter", () => {
|
|
|
130
173
|
id: "call-1",
|
|
131
174
|
name: "execShell",
|
|
132
175
|
arguments: "{}",
|
|
133
|
-
})).rejects.toThrow("
|
|
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:
|
|
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:
|
|
139
|
-
?
|
|
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) =>
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/commandRegistry.ts
CHANGED
|
@@ -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.
|
|
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",
|