nolo-cli 0.1.17 → 0.1.19
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/hostAdapter.ts +1 -0
- package/agent-runtime/localLoop.ts +9 -1
- package/agent-runtime/types.ts +1 -0
- package/agentAliases.ts +26 -0
- package/agentPullCommand.ts +2 -1
- package/agentRunCommand.ts +69 -3
- package/client/agentRun.ts +2 -0
- package/client/localRuntimeAdapter.test.ts +193 -13
- package/client/localRuntimeAdapter.ts +66 -35
- package/client/localToolPolicy.test.ts +1 -1
- package/client/localToolPolicy.ts +2 -6
- package/package.json +2 -1
|
@@ -44,6 +44,8 @@ export async function runLocalAgentTurn(
|
|
|
44
44
|
const history = input.continueDialogId
|
|
45
45
|
? await input.adapter.loadDialogHistory(input.continueDialogId)
|
|
46
46
|
: [];
|
|
47
|
+
const promptMessageCount = agentConfig.prompt?.trim() ? 1 : 0;
|
|
48
|
+
const turnStartIndex = promptMessageCount + history.length;
|
|
47
49
|
const messages = buildMessages({
|
|
48
50
|
prompt: agentConfig.prompt,
|
|
49
51
|
history,
|
|
@@ -76,17 +78,23 @@ export async function runLocalAgentTurn(
|
|
|
76
78
|
role: "tool",
|
|
77
79
|
content: toolResult.content,
|
|
78
80
|
tool_call_id: toolCall.id,
|
|
81
|
+
...(toolResult.metadata ? { tool_result_metadata: toolResult.metadata } : {}),
|
|
79
82
|
});
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
result = result!;
|
|
86
|
+
messages.push({
|
|
87
|
+
role: "assistant",
|
|
88
|
+
content: result.content,
|
|
89
|
+
});
|
|
83
90
|
const saved = await input.adapter.saveTurn({
|
|
84
91
|
agentKey: agentConfig.key,
|
|
85
|
-
messages,
|
|
92
|
+
messages: messages.slice(turnStartIndex),
|
|
86
93
|
result: {
|
|
87
94
|
...result,
|
|
88
95
|
...(toolCallCount > 0 ? { toolCallCount } : {}),
|
|
89
96
|
},
|
|
97
|
+
...(input.continueDialogId ? { continueDialogId: input.continueDialogId } : {}),
|
|
90
98
|
});
|
|
91
99
|
|
|
92
100
|
return {
|
package/agent-runtime/types.ts
CHANGED
|
@@ -33,6 +33,7 @@ export interface AgentRuntimeChatMessage {
|
|
|
33
33
|
content: AgentRuntimeMessageContent;
|
|
34
34
|
tool_call_id?: string;
|
|
35
35
|
tool_calls?: AgentRuntimeToolCall[];
|
|
36
|
+
tool_result_metadata?: Record<string, unknown>;
|
|
36
37
|
reasoning_content?: string;
|
|
37
38
|
cybotKey?: string;
|
|
38
39
|
agentKey?: string;
|
package/agentAliases.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const PLATFORM_DEMO_USER_ID = "b2e06f801f";
|
|
2
|
+
export const FRONTEND_IMPLEMENTER_AGENT_ID = "01FRONTENDAG0000000115N4E1";
|
|
3
|
+
export const FRONTEND_IMPLEMENTER_AGENT_KEY =
|
|
4
|
+
`agent-${PLATFORM_DEMO_USER_ID}-${FRONTEND_IMPLEMENTER_AGENT_ID}`;
|
|
5
|
+
|
|
6
|
+
const AGENT_ALIAS_TO_KEY: Record<string, string> = {
|
|
7
|
+
"frontend-implementer": FRONTEND_IMPLEMENTER_AGENT_KEY,
|
|
8
|
+
"frontend-agent": FRONTEND_IMPLEMENTER_AGENT_KEY,
|
|
9
|
+
frontend: FRONTEND_IMPLEMENTER_AGENT_KEY,
|
|
10
|
+
"front-end": FRONTEND_IMPLEMENTER_AGENT_KEY,
|
|
11
|
+
"前端": FRONTEND_IMPLEMENTER_AGENT_KEY,
|
|
12
|
+
"前端实现员": FRONTEND_IMPLEMENTER_AGENT_KEY,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function parseAgentKeyFromInput(raw: string): string {
|
|
16
|
+
const trimmed = raw.trim();
|
|
17
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
|
18
|
+
const url = new URL(trimmed);
|
|
19
|
+
return url.pathname.replace(/^\/+/, "");
|
|
20
|
+
}
|
|
21
|
+
return trimmed;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resolveCliAgentKeyInput(raw: string): string {
|
|
25
|
+
return AGENT_ALIAS_TO_KEY[raw.trim().toLowerCase()] ?? parseAgentKeyFromInput(raw);
|
|
26
|
+
}
|
package/agentPullCommand.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
|
|
2
2
|
import { getDefaultCliLocalRuntimeDb } from "./localRuntimeDb";
|
|
3
3
|
import type { CliLocalRuntimeDb } from "./client/localRuntimeAdapter";
|
|
4
|
+
import { resolveCliAgentKeyInput } from "./agentAliases";
|
|
4
5
|
|
|
5
6
|
type EnvLike = Record<string, string | undefined>;
|
|
6
7
|
|
|
@@ -40,7 +41,7 @@ function positionalArgs(args: string[]) {
|
|
|
40
41
|
export function parseAgentPullArgs(args: string[]): ParsedAgentPullArgs | null {
|
|
41
42
|
const agentKey = readFlagValue(args, "--agent") ?? positionalArgs(args)[0];
|
|
42
43
|
if (!agentKey?.trim()) return null;
|
|
43
|
-
return { agentKey: agentKey
|
|
44
|
+
return { agentKey: resolveCliAgentKeyInput(agentKey) };
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
function resolveServerUrl(env: EnvLike) {
|
package/agentRunCommand.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
|
|
|
2
2
|
import { runAgentTurn, type RunAgentTurnResult } from "./client/agentRun";
|
|
3
3
|
import type { AgentRuntimeRequestedMode } from "./agentRuntimeLocal";
|
|
4
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
-
import { extname, resolve } from "node:path";
|
|
5
|
+
import { extname, join, resolve } from "node:path";
|
|
6
|
+
import { resolveCliAgentKeyInput } from "./agentAliases";
|
|
6
7
|
|
|
7
8
|
type EnvLike = Record<string, string | undefined>;
|
|
8
9
|
|
|
@@ -15,6 +16,7 @@ type AgentRunCommandDeps = {
|
|
|
15
16
|
scriptDir: string;
|
|
16
17
|
output?: OutputLike;
|
|
17
18
|
runner?: typeof runAgentTurn;
|
|
19
|
+
prepareShellWorktree?: typeof prepareShellWorktree;
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
type ParsedAgentRunArgs = {
|
|
@@ -82,7 +84,7 @@ export function parseAgentRunArgs(args: string[]): ParsedAgentRunArgs | null {
|
|
|
82
84
|
...readRepeatedFlagValues(args, "--image-url"),
|
|
83
85
|
];
|
|
84
86
|
return {
|
|
85
|
-
agentKey,
|
|
87
|
+
agentKey: resolveCliAgentKeyInput(agentKey),
|
|
86
88
|
message: message.trim(),
|
|
87
89
|
imageUrls,
|
|
88
90
|
allowShell: args.includes("--dangerously-allow-shell"),
|
|
@@ -117,6 +119,49 @@ function resolveServerUrl(env: EnvLike) {
|
|
|
117
119
|
return (env.NOLO_SERVER || env.BASE_URL || DEFAULT_NOLO_SERVER_URL).replace(/\/+$/, "");
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
function safePathSegment(input: string) {
|
|
123
|
+
return input.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "agent";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function createTaskId() {
|
|
127
|
+
return Date.now().toString(36).toUpperCase();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function runGit(args: string[], cwd: string) {
|
|
131
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
132
|
+
cwd,
|
|
133
|
+
stdout: "pipe",
|
|
134
|
+
stderr: "pipe",
|
|
135
|
+
stdin: "ignore",
|
|
136
|
+
});
|
|
137
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
138
|
+
new Response(proc.stdout).text(),
|
|
139
|
+
new Response(proc.stderr).text(),
|
|
140
|
+
proc.exited,
|
|
141
|
+
]);
|
|
142
|
+
if (exitCode !== 0) {
|
|
143
|
+
throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`);
|
|
144
|
+
}
|
|
145
|
+
return stdout.trim();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function prepareShellWorktree(args: {
|
|
149
|
+
agentKey: string;
|
|
150
|
+
cwd?: string;
|
|
151
|
+
env?: EnvLike;
|
|
152
|
+
}) {
|
|
153
|
+
if (args.env?.NOLO_LOCAL_WORKTREE) return args.env.NOLO_LOCAL_WORKTREE;
|
|
154
|
+
const cwd = args.cwd ?? process.cwd();
|
|
155
|
+
const root = await runGit(["rev-parse", "--show-toplevel"], cwd);
|
|
156
|
+
const parent = join(root, ".worktrees");
|
|
157
|
+
const safeAgent = safePathSegment(args.agentKey);
|
|
158
|
+
const taskId = createTaskId();
|
|
159
|
+
const worktreePath = join(parent, `nolo-agent-${safeAgent}-${taskId}`);
|
|
160
|
+
const branchName = `nolo-agent-${safeAgent}-${taskId}`;
|
|
161
|
+
await runGit(["worktree", "add", worktreePath, "-b", branchName, "HEAD"], root);
|
|
162
|
+
return worktreePath;
|
|
163
|
+
}
|
|
164
|
+
|
|
120
165
|
function writeUsage(output: OutputLike) {
|
|
121
166
|
output.write(
|
|
122
167
|
"Usage: nolo agent run <agent> <message> [--local|--server|--auto] [--continue <dialogId>]\n" +
|
|
@@ -134,8 +179,28 @@ export async function runAgentRunCommand(args: string[], deps: AgentRunCommandDe
|
|
|
134
179
|
}
|
|
135
180
|
|
|
136
181
|
const runner = deps.runner ?? runAgentTurn;
|
|
182
|
+
let localRuntimeCwd: string | undefined;
|
|
183
|
+
if (parsed.allowShell) {
|
|
184
|
+
try {
|
|
185
|
+
localRuntimeCwd = await (deps.prepareShellWorktree ?? prepareShellWorktree)({
|
|
186
|
+
agentKey: parsed.agentKey,
|
|
187
|
+
env,
|
|
188
|
+
});
|
|
189
|
+
} catch (error) {
|
|
190
|
+
output.write(
|
|
191
|
+
"[nolo] Could not prepare a local shell worktree.\n" +
|
|
192
|
+
"Run from inside a git repository, or set NOLO_LOCAL_WORKTREE to an existing isolated checkout.\n" +
|
|
193
|
+
`Reason: ${error instanceof Error ? error.message : String(error)}\n`
|
|
194
|
+
);
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
137
198
|
const runEnv = parsed.allowShell
|
|
138
|
-
? {
|
|
199
|
+
? {
|
|
200
|
+
...env,
|
|
201
|
+
NOLO_LOCAL_SHELL_MODE: "worktree",
|
|
202
|
+
...(localRuntimeCwd ? { NOLO_LOCAL_WORKTREE: localRuntimeCwd } : {}),
|
|
203
|
+
}
|
|
139
204
|
: env;
|
|
140
205
|
const result: RunAgentTurnResult = await runner({
|
|
141
206
|
agentName: parsed.agentKey,
|
|
@@ -146,6 +211,7 @@ export async function runAgentRunCommand(args: string[], deps: AgentRunCommandDe
|
|
|
146
211
|
scriptDir: deps.scriptDir,
|
|
147
212
|
env: runEnv,
|
|
148
213
|
output,
|
|
214
|
+
...(localRuntimeCwd ? { localRuntimeCwd } : {}),
|
|
149
215
|
...(parsed.runtimeMode ? { runtimeMode: parsed.runtimeMode } : {}),
|
|
150
216
|
...(parsed.continueDialogId ? { continueDialogId: parsed.continueDialogId } : {}),
|
|
151
217
|
});
|
package/client/agentRun.ts
CHANGED
|
@@ -24,6 +24,7 @@ type RunAgentTurnOptions = {
|
|
|
24
24
|
runtimeMode?: AgentRuntimeRequestedMode;
|
|
25
25
|
localRuntimeAdapter?: AgentRuntimeHostAdapter;
|
|
26
26
|
localRuntimeAdapterFactory?: (env: EnvLike) => AgentRuntimeHostAdapter;
|
|
27
|
+
localRuntimeCwd?: string;
|
|
27
28
|
scriptPathExists?: (path: string) => boolean;
|
|
28
29
|
fetchImpl?: typeof fetch;
|
|
29
30
|
};
|
|
@@ -61,6 +62,7 @@ function buildDefaultLocalRuntimeAdapter(options: RunAgentTurnOptions) {
|
|
|
61
62
|
return createCliLocalRuntimeAdapter({
|
|
62
63
|
env: options.env,
|
|
63
64
|
fetchImpl: options.fetchImpl,
|
|
65
|
+
cwd: options.localRuntimeCwd,
|
|
64
66
|
});
|
|
65
67
|
}
|
|
66
68
|
|
|
@@ -81,7 +81,7 @@ describe("CLI local runtime adapter", () => {
|
|
|
81
81
|
expect(result).toMatchObject({
|
|
82
82
|
content: "local adapter ok",
|
|
83
83
|
model: "gpt-4.1-mini",
|
|
84
|
-
dialogId: "
|
|
84
|
+
dialogId: "dialog-existing",
|
|
85
85
|
});
|
|
86
86
|
expect(requests).toEqual([{
|
|
87
87
|
url: "http://127.0.0.1:11434/v1/chat/completions",
|
|
@@ -97,29 +97,210 @@ describe("CLI local runtime adapter", () => {
|
|
|
97
97
|
},
|
|
98
98
|
}]);
|
|
99
99
|
expect(batchOps.map((op) => op.key)).toEqual([
|
|
100
|
-
"dialog-user-1-
|
|
101
|
-
"dialog-
|
|
102
|
-
"dialog-
|
|
100
|
+
"dialog-user-1-dialog-existing",
|
|
101
|
+
"dialog-dialog-existing-msg-1710000000000-001",
|
|
102
|
+
"dialog-dialog-existing-msg-1710000000000-002",
|
|
103
103
|
]);
|
|
104
|
-
expect(store.get("dialog-user-1-
|
|
105
|
-
id: "
|
|
106
|
-
dbKey: "dialog-user-1-
|
|
104
|
+
expect(store.get("dialog-user-1-dialog-existing")).toMatchObject({
|
|
105
|
+
id: "dialog-existing",
|
|
106
|
+
dbKey: "dialog-user-1-dialog-existing",
|
|
107
107
|
type: "dialog",
|
|
108
108
|
primaryAgentKey: "agent-user-1-frontend",
|
|
109
109
|
status: "done",
|
|
110
110
|
});
|
|
111
|
-
expect(store.get("dialog-
|
|
112
|
-
dialogId: "
|
|
111
|
+
expect(store.get("dialog-dialog-existing-msg-1710000000000-001")).toMatchObject({
|
|
112
|
+
dialogId: "dialog-existing",
|
|
113
113
|
role: "user",
|
|
114
114
|
content: "make it cleaner",
|
|
115
115
|
});
|
|
116
|
-
expect(store.get("dialog-
|
|
117
|
-
dialogId: "
|
|
116
|
+
expect(store.get("dialog-dialog-existing-msg-1710000000000-002")).toMatchObject({
|
|
117
|
+
dialogId: "dialog-existing",
|
|
118
118
|
role: "assistant",
|
|
119
119
|
content: "local adapter ok",
|
|
120
120
|
});
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
test("saves local tool call trace and shell metadata into the local dialog", async () => {
|
|
124
|
+
const store = new Map<string, any>([
|
|
125
|
+
["agent-user-1-shell", {
|
|
126
|
+
dbKey: "agent-user-1-shell",
|
|
127
|
+
id: "shell",
|
|
128
|
+
prompt: "Use shell.",
|
|
129
|
+
model: "gpt-4.1-mini",
|
|
130
|
+
}],
|
|
131
|
+
]);
|
|
132
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
133
|
+
env: {
|
|
134
|
+
NOLO_LOCAL_USER_ID: "user-1",
|
|
135
|
+
NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
|
|
136
|
+
NOLO_LOCAL_SHELL_MODE: "worktree",
|
|
137
|
+
},
|
|
138
|
+
db: {
|
|
139
|
+
get: async (key) => {
|
|
140
|
+
if (!store.has(key)) throw new Error(`not found: ${key}`);
|
|
141
|
+
return store.get(key);
|
|
142
|
+
},
|
|
143
|
+
put: async (key, value) => {
|
|
144
|
+
store.set(key, value);
|
|
145
|
+
},
|
|
146
|
+
batch: async (ops) => {
|
|
147
|
+
for (const op of ops) {
|
|
148
|
+
if (op.type === "put") store.set(op.key, op.value);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
iterator: () => (async function* () {})(),
|
|
152
|
+
},
|
|
153
|
+
cwd: import.meta.dir,
|
|
154
|
+
now: () => 1710000000000,
|
|
155
|
+
createId: () => "01TRACE",
|
|
156
|
+
fetchImpl: async (_url, init) => {
|
|
157
|
+
const body = JSON.parse(String(init?.body));
|
|
158
|
+
const hasToolResult = body.messages.some((message: any) => message.role === "tool");
|
|
159
|
+
if (!hasToolResult) {
|
|
160
|
+
return Response.json({
|
|
161
|
+
choices: [{
|
|
162
|
+
message: {
|
|
163
|
+
content: "",
|
|
164
|
+
tool_calls: [{
|
|
165
|
+
id: "call-shell",
|
|
166
|
+
type: "function",
|
|
167
|
+
function: {
|
|
168
|
+
name: "execShell",
|
|
169
|
+
arguments: JSON.stringify({ cmd: "printf trace-ok" }),
|
|
170
|
+
},
|
|
171
|
+
}],
|
|
172
|
+
},
|
|
173
|
+
}],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return Response.json({
|
|
177
|
+
choices: [{ message: { content: "done" } }],
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const result = await runLocalAgentTurn({
|
|
183
|
+
adapter,
|
|
184
|
+
agentRef: "shell",
|
|
185
|
+
input: "inspect",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(result.dialogId).toBe("01TRACE");
|
|
189
|
+
expect(store.get("dialog-user-1-01TRACE")).toMatchObject({
|
|
190
|
+
toolCallCount: 1,
|
|
191
|
+
localRuntime: expect.objectContaining({
|
|
192
|
+
host: "cli",
|
|
193
|
+
worktreePath: import.meta.dir,
|
|
194
|
+
}),
|
|
195
|
+
});
|
|
196
|
+
const messages = [...store.entries()]
|
|
197
|
+
.filter(([key]) => key.startsWith("dialog-01TRACE-msg-"))
|
|
198
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
199
|
+
.map(([, value]) => value);
|
|
200
|
+
expect(messages.map((message) => message.role)).toEqual([
|
|
201
|
+
"user",
|
|
202
|
+
"assistant",
|
|
203
|
+
"tool",
|
|
204
|
+
"assistant",
|
|
205
|
+
]);
|
|
206
|
+
expect(messages[1]).toMatchObject({
|
|
207
|
+
tool_calls: [{
|
|
208
|
+
id: "call-shell",
|
|
209
|
+
function: { name: "execShell" },
|
|
210
|
+
}],
|
|
211
|
+
});
|
|
212
|
+
expect(messages[2]).toMatchObject({
|
|
213
|
+
role: "tool",
|
|
214
|
+
toolCallId: "call-shell",
|
|
215
|
+
metadata: { exitCode: 0 },
|
|
216
|
+
});
|
|
217
|
+
expect(messages[2].content).toContain("trace-ok");
|
|
218
|
+
expect(messages[3]).toMatchObject({
|
|
219
|
+
role: "assistant",
|
|
220
|
+
content: "done",
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("continues a local dialog instead of creating a new one", async () => {
|
|
225
|
+
const store = new Map<string, any>([
|
|
226
|
+
["agent-user-1-frontend", {
|
|
227
|
+
dbKey: "agent-user-1-frontend",
|
|
228
|
+
id: "frontend",
|
|
229
|
+
prompt: "Fix UI",
|
|
230
|
+
model: "gpt-4.1-mini",
|
|
231
|
+
}],
|
|
232
|
+
["dialog-user-1-dialog-existing", {
|
|
233
|
+
dbKey: "dialog-user-1-dialog-existing",
|
|
234
|
+
id: "dialog-existing",
|
|
235
|
+
type: "dialog",
|
|
236
|
+
userId: "user-1",
|
|
237
|
+
title: "Existing dialog",
|
|
238
|
+
}],
|
|
239
|
+
["dialog-dialog-existing-msg-001", {
|
|
240
|
+
dbKey: "dialog-dialog-existing-msg-001",
|
|
241
|
+
id: "msg-001",
|
|
242
|
+
dialogId: "dialog-existing",
|
|
243
|
+
role: "assistant",
|
|
244
|
+
content: "previous answer",
|
|
245
|
+
}],
|
|
246
|
+
]);
|
|
247
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
248
|
+
env: {
|
|
249
|
+
NOLO_LOCAL_USER_ID: "user-1",
|
|
250
|
+
NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
|
|
251
|
+
},
|
|
252
|
+
db: {
|
|
253
|
+
get: async (key) => {
|
|
254
|
+
if (!store.has(key)) throw new Error(`not found: ${key}`);
|
|
255
|
+
return store.get(key);
|
|
256
|
+
},
|
|
257
|
+
put: async (key, value) => {
|
|
258
|
+
store.set(key, value);
|
|
259
|
+
},
|
|
260
|
+
batch: async (ops) => {
|
|
261
|
+
for (const op of ops) {
|
|
262
|
+
if (op.type === "put") store.set(op.key, op.value);
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
iterator: ({ gte, lte }) => (async function* () {
|
|
266
|
+
for (const entry of [...store.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
267
|
+
if (entry[0] >= gte && entry[0] <= lte) yield entry;
|
|
268
|
+
}
|
|
269
|
+
})(),
|
|
270
|
+
},
|
|
271
|
+
now: () => 1710000000000,
|
|
272
|
+
createId: () => "SHOULDNOTUSE",
|
|
273
|
+
fetchImpl: async (_url, init) => {
|
|
274
|
+
const body = JSON.parse(String(init?.body));
|
|
275
|
+
expect(body.messages).toContainEqual({
|
|
276
|
+
role: "assistant",
|
|
277
|
+
content: "previous answer",
|
|
278
|
+
});
|
|
279
|
+
return Response.json({
|
|
280
|
+
choices: [{ message: { content: "continued" } }],
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const result = await runLocalAgentTurn({
|
|
286
|
+
adapter,
|
|
287
|
+
agentRef: "frontend",
|
|
288
|
+
input: "continue",
|
|
289
|
+
continueDialogId: "dialog-existing",
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
expect(result.dialogId).toBe("dialog-existing");
|
|
293
|
+
expect(store.has("dialog-user-1-SHOULDNOTUSE")).toBe(false);
|
|
294
|
+
expect(store.get("dialog-user-1-dialog-existing")).toMatchObject({
|
|
295
|
+
id: "dialog-existing",
|
|
296
|
+
title: "Existing dialog",
|
|
297
|
+
status: "done",
|
|
298
|
+
});
|
|
299
|
+
expect([...store.keys()].filter((key) => key.startsWith("dialog-dialog-existing-msg-"))).toContain(
|
|
300
|
+
"dialog-dialog-existing-msg-1710000000000-001"
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
123
304
|
test("passes image_url message parts through to OpenAI-compatible providers", async () => {
|
|
124
305
|
const requests: Array<{ body: any }> = [];
|
|
125
306
|
const adapter = createCliLocalRuntimeAdapter({
|
|
@@ -215,7 +396,7 @@ describe("CLI local runtime adapter", () => {
|
|
|
215
396
|
expect(result.content).toContain("README.md");
|
|
216
397
|
});
|
|
217
398
|
|
|
218
|
-
test("advertises execShell to OpenAI-compatible providers when
|
|
399
|
+
test("advertises execShell to OpenAI-compatible providers when shell mode is enabled", async () => {
|
|
219
400
|
const requests: Array<{ body: any }> = [];
|
|
220
401
|
const adapter = createCliLocalRuntimeAdapter({
|
|
221
402
|
env: {
|
|
@@ -228,7 +409,6 @@ describe("CLI local runtime adapter", () => {
|
|
|
228
409
|
dbKey: "agent-local-shell",
|
|
229
410
|
prompt: "Use shell.",
|
|
230
411
|
model: "gpt-4.1-mini",
|
|
231
|
-
toolNames: ["execShell"],
|
|
232
412
|
}),
|
|
233
413
|
put: async () => {},
|
|
234
414
|
batch: async () => {},
|
|
@@ -67,8 +67,14 @@ function buildExecShellTool() {
|
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function
|
|
71
|
-
return
|
|
70
|
+
function shellModeEnabled(env: EnvLike) {
|
|
71
|
+
return ["worktree", "dangerous", "1", "true"].includes(env.NOLO_LOCAL_SHELL_MODE || "");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildOpenAiTools(args: { toolNames?: string[]; env: EnvLike }) {
|
|
75
|
+
return args.toolNames?.includes("execShell") || shellModeEnabled(args.env)
|
|
76
|
+
? [buildExecShellTool()]
|
|
77
|
+
: [];
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
function parseToolArguments(raw: string) {
|
|
@@ -155,6 +161,8 @@ function toOpenAiMessages(messages: AgentRuntimeChatMessage[]) {
|
|
|
155
161
|
return messages.map((message) => ({
|
|
156
162
|
role: message.role,
|
|
157
163
|
content: message.content ?? "",
|
|
164
|
+
...(message.tool_call_id ? { tool_call_id: message.tool_call_id } : {}),
|
|
165
|
+
...(Array.isArray(message.tool_calls) ? { tool_calls: message.tool_calls } : {}),
|
|
158
166
|
}));
|
|
159
167
|
}
|
|
160
168
|
|
|
@@ -186,8 +194,9 @@ async function writeDialog(args: {
|
|
|
186
194
|
userId: string;
|
|
187
195
|
now: () => number;
|
|
188
196
|
createId: () => string;
|
|
197
|
+
cwd?: string;
|
|
189
198
|
}) {
|
|
190
|
-
const dialogId = args.createId();
|
|
199
|
+
const dialogId = args.input.continueDialogId || args.createId();
|
|
191
200
|
const now = args.now();
|
|
192
201
|
const nowIso = new Date(now).toISOString();
|
|
193
202
|
const dialogKey = `dialog-${args.userId}-${dialogId}`;
|
|
@@ -200,56 +209,74 @@ async function writeDialog(args: {
|
|
|
200
209
|
.map((part) => part.text)
|
|
201
210
|
.join(" ")
|
|
202
211
|
: "";
|
|
212
|
+
let existingDialog: any = null;
|
|
213
|
+
if (args.input.continueDialogId) {
|
|
214
|
+
try {
|
|
215
|
+
existingDialog = await args.db.get(dialogKey);
|
|
216
|
+
} catch {
|
|
217
|
+
existingDialog = null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const messageOps = args.input.messages
|
|
221
|
+
.filter((message) => message.role !== "system")
|
|
222
|
+
.map((message, index) => {
|
|
223
|
+
const id = `${now}-${String(index + 1).padStart(3, "0")}`;
|
|
224
|
+
const key = `dialog-${dialogId}-msg-${id}`;
|
|
225
|
+
return {
|
|
226
|
+
type: "put" as const,
|
|
227
|
+
key,
|
|
228
|
+
value: {
|
|
229
|
+
id,
|
|
230
|
+
dbKey: key,
|
|
231
|
+
dialogId,
|
|
232
|
+
role: message.role,
|
|
233
|
+
content: message.content ?? "",
|
|
234
|
+
...(message.role === "user" ? { userId: args.userId } : {}),
|
|
235
|
+
...(message.role === "assistant" ? {
|
|
236
|
+
agentKey: args.input.agentKey,
|
|
237
|
+
cybotKey: args.input.agentKey,
|
|
238
|
+
} : {}),
|
|
239
|
+
...(message.tool_call_id ? { toolCallId: message.tool_call_id } : {}),
|
|
240
|
+
...(Array.isArray(message.tool_calls) ? { tool_calls: message.tool_calls } : {}),
|
|
241
|
+
...(message.tool_result_metadata ? { metadata: message.tool_result_metadata } : {}),
|
|
242
|
+
createdAt: nowIso,
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
});
|
|
203
246
|
const ops: Array<{ type: "put"; key: string; value: any }> = [
|
|
204
247
|
{
|
|
205
248
|
type: "put",
|
|
206
249
|
key: dialogKey,
|
|
207
250
|
value: {
|
|
251
|
+
...(existingDialog && typeof existingDialog === "object" ? existingDialog : {}),
|
|
208
252
|
id: dialogId,
|
|
209
253
|
dbKey: dialogKey,
|
|
210
254
|
type: "dialog",
|
|
211
255
|
userId: args.userId,
|
|
212
256
|
cybots: [args.input.agentKey],
|
|
213
257
|
primaryAgentKey: args.input.agentKey,
|
|
214
|
-
title:
|
|
215
|
-
?
|
|
216
|
-
:
|
|
258
|
+
title: typeof existingDialog?.title === "string" && existingDialog.title.trim()
|
|
259
|
+
? existingDialog.title
|
|
260
|
+
: lastUserText.trim()
|
|
261
|
+
? lastUserText.trim().slice(0, 80)
|
|
262
|
+
: "Local agent run",
|
|
217
263
|
status: "done",
|
|
218
264
|
triggerType: "cli-local",
|
|
219
265
|
executionMode: "foreground",
|
|
220
|
-
createdAt: nowIso,
|
|
266
|
+
createdAt: existingDialog?.createdAt ?? nowIso,
|
|
221
267
|
updatedAt: nowIso,
|
|
222
268
|
finishedAt: now,
|
|
223
269
|
usage: args.input.result.usage,
|
|
270
|
+
...(typeof args.input.result.toolCallCount === "number"
|
|
271
|
+
? { toolCallCount: args.input.result.toolCallCount }
|
|
272
|
+
: {}),
|
|
273
|
+
localRuntime: {
|
|
274
|
+
host: "cli",
|
|
275
|
+
...(args.cwd ? { worktreePath: args.cwd } : {}),
|
|
276
|
+
},
|
|
224
277
|
},
|
|
225
278
|
},
|
|
226
|
-
|
|
227
|
-
type: "put",
|
|
228
|
-
key: `dialog-${dialogId}-msg-user`,
|
|
229
|
-
value: {
|
|
230
|
-
id: "msg-user",
|
|
231
|
-
dbKey: `dialog-${dialogId}-msg-user`,
|
|
232
|
-
dialogId,
|
|
233
|
-
role: "user",
|
|
234
|
-
content: lastUser?.content ?? "",
|
|
235
|
-
userId: args.userId,
|
|
236
|
-
createdAt: nowIso,
|
|
237
|
-
},
|
|
238
|
-
},
|
|
239
|
-
{
|
|
240
|
-
type: "put",
|
|
241
|
-
key: `dialog-${dialogId}-msg-assistant`,
|
|
242
|
-
value: {
|
|
243
|
-
id: "msg-assistant",
|
|
244
|
-
dbKey: `dialog-${dialogId}-msg-assistant`,
|
|
245
|
-
dialogId,
|
|
246
|
-
role: "assistant",
|
|
247
|
-
content: args.input.result.content,
|
|
248
|
-
agentKey: args.input.agentKey,
|
|
249
|
-
cybotKey: args.input.agentKey,
|
|
250
|
-
createdAt: nowIso,
|
|
251
|
-
},
|
|
252
|
-
},
|
|
279
|
+
...messageOps,
|
|
253
280
|
];
|
|
254
281
|
await args.db.batch(ops);
|
|
255
282
|
return { dialogId };
|
|
@@ -287,11 +314,15 @@ export function createCliLocalRuntimeAdapter(
|
|
|
287
314
|
userId,
|
|
288
315
|
now,
|
|
289
316
|
createId,
|
|
317
|
+
cwd: deps.cwd,
|
|
290
318
|
}),
|
|
291
319
|
resolveProvider: async (agentConfig) => ({
|
|
292
320
|
model: agentConfig.model || "gpt-4.1-mini",
|
|
293
321
|
complete: async (messages) => {
|
|
294
|
-
const tools = buildOpenAiTools(
|
|
322
|
+
const tools = buildOpenAiTools({
|
|
323
|
+
toolNames: agentConfig.toolNames,
|
|
324
|
+
env: deps.env,
|
|
325
|
+
});
|
|
295
326
|
const res = await fetchImpl(`${resolveOpenAiCompatibleBaseUrl(deps.env)}/chat/completions`, {
|
|
296
327
|
method: "POST",
|
|
297
328
|
headers: {
|
|
@@ -6,7 +6,7 @@ describe("CLI local tool policy", () => {
|
|
|
6
6
|
test("allows execShell in explicit worktree shell mode", () => {
|
|
7
7
|
expect(resolveLocalToolPolicy({
|
|
8
8
|
env: { NOLO_LOCAL_SHELL_MODE: "worktree" },
|
|
9
|
-
agentToolNames: [
|
|
9
|
+
agentToolNames: [],
|
|
10
10
|
toolName: "execShell",
|
|
11
11
|
})).toEqual({ allowed: true, toolName: "execShell" });
|
|
12
12
|
});
|
|
@@ -27,17 +27,13 @@ export function resolveLocalToolPolicy(args: {
|
|
|
27
27
|
}): LocalToolPolicyDecision {
|
|
28
28
|
if (args.toolName === "execShell") {
|
|
29
29
|
const shellMode = args.env.NOLO_LOCAL_SHELL_MODE || "";
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
agentTools.has("execShell") &&
|
|
33
|
-
["worktree", "dangerous", "1", "true"].includes(shellMode)
|
|
34
|
-
) {
|
|
30
|
+
if (["worktree", "dangerous", "1", "true"].includes(shellMode)) {
|
|
35
31
|
return { allowed: true, toolName: args.toolName };
|
|
36
32
|
}
|
|
37
33
|
return {
|
|
38
34
|
allowed: false,
|
|
39
35
|
toolName: args.toolName,
|
|
40
|
-
reason: "execShell requires NOLO_LOCAL_SHELL_MODE=worktree
|
|
36
|
+
reason: "execShell requires NOLO_LOCAL_SHELL_MODE=worktree.",
|
|
41
37
|
};
|
|
42
38
|
}
|
|
43
39
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nolo-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Agent-first terminal workspace for Nolo",
|
|
6
6
|
"bin": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"index.ts",
|
|
12
12
|
"agentRuntimeLocal.ts",
|
|
13
|
+
"agentAliases.ts",
|
|
13
14
|
"localRuntimeDb.ts",
|
|
14
15
|
"agentRuntimeCommands.ts",
|
|
15
16
|
"agentPullCommand.ts",
|