nolo-cli 0.1.18 → 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.
@@ -33,6 +33,7 @@ export type AgentRuntimeSaveTurnInput = {
33
33
  agentKey: string;
34
34
  messages: AgentRuntimeChatMessage[];
35
35
  result: AgentRuntimeResult;
36
+ continueDialogId?: string;
36
37
  };
37
38
 
38
39
  export type AgentRuntimeHostAdapter = {
@@ -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 {
@@ -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;
@@ -181,10 +181,19 @@ export async function runAgentRunCommand(args: string[], deps: AgentRunCommandDe
181
181
  const runner = deps.runner ?? runAgentTurn;
182
182
  let localRuntimeCwd: string | undefined;
183
183
  if (parsed.allowShell) {
184
- localRuntimeCwd = await (deps.prepareShellWorktree ?? prepareShellWorktree)({
185
- agentKey: parsed.agentKey,
186
- env,
187
- });
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
+ }
188
197
  }
189
198
  const runEnv = parsed.allowShell
190
199
  ? {
@@ -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: "01LOCAL",
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-01LOCAL",
101
- "dialog-01LOCAL-msg-user",
102
- "dialog-01LOCAL-msg-assistant",
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-01LOCAL")).toMatchObject({
105
- id: "01LOCAL",
106
- dbKey: "dialog-user-1-01LOCAL",
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-01LOCAL-msg-user")).toMatchObject({
112
- dialogId: "01LOCAL",
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-01LOCAL-msg-assistant")).toMatchObject({
117
- dialogId: "01LOCAL",
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({
@@ -161,6 +161,8 @@ function toOpenAiMessages(messages: AgentRuntimeChatMessage[]) {
161
161
  return messages.map((message) => ({
162
162
  role: message.role,
163
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 } : {}),
164
166
  }));
165
167
  }
166
168
 
@@ -192,8 +194,9 @@ async function writeDialog(args: {
192
194
  userId: string;
193
195
  now: () => number;
194
196
  createId: () => string;
197
+ cwd?: string;
195
198
  }) {
196
- const dialogId = args.createId();
199
+ const dialogId = args.input.continueDialogId || args.createId();
197
200
  const now = args.now();
198
201
  const nowIso = new Date(now).toISOString();
199
202
  const dialogKey = `dialog-${args.userId}-${dialogId}`;
@@ -206,56 +209,74 @@ async function writeDialog(args: {
206
209
  .map((part) => part.text)
207
210
  .join(" ")
208
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
+ });
209
246
  const ops: Array<{ type: "put"; key: string; value: any }> = [
210
247
  {
211
248
  type: "put",
212
249
  key: dialogKey,
213
250
  value: {
251
+ ...(existingDialog && typeof existingDialog === "object" ? existingDialog : {}),
214
252
  id: dialogId,
215
253
  dbKey: dialogKey,
216
254
  type: "dialog",
217
255
  userId: args.userId,
218
256
  cybots: [args.input.agentKey],
219
257
  primaryAgentKey: args.input.agentKey,
220
- title: lastUserText.trim()
221
- ? lastUserText.trim().slice(0, 80)
222
- : "Local agent run",
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",
223
263
  status: "done",
224
264
  triggerType: "cli-local",
225
265
  executionMode: "foreground",
226
- createdAt: nowIso,
266
+ createdAt: existingDialog?.createdAt ?? nowIso,
227
267
  updatedAt: nowIso,
228
268
  finishedAt: now,
229
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
+ },
230
277
  },
231
278
  },
232
- {
233
- type: "put",
234
- key: `dialog-${dialogId}-msg-user`,
235
- value: {
236
- id: "msg-user",
237
- dbKey: `dialog-${dialogId}-msg-user`,
238
- dialogId,
239
- role: "user",
240
- content: lastUser?.content ?? "",
241
- userId: args.userId,
242
- createdAt: nowIso,
243
- },
244
- },
245
- {
246
- type: "put",
247
- key: `dialog-${dialogId}-msg-assistant`,
248
- value: {
249
- id: "msg-assistant",
250
- dbKey: `dialog-${dialogId}-msg-assistant`,
251
- dialogId,
252
- role: "assistant",
253
- content: args.input.result.content,
254
- agentKey: args.input.agentKey,
255
- cybotKey: args.input.agentKey,
256
- createdAt: nowIso,
257
- },
258
- },
279
+ ...messageOps,
259
280
  ];
260
281
  await args.db.batch(ops);
261
282
  return { dialogId };
@@ -293,6 +314,7 @@ export function createCliLocalRuntimeAdapter(
293
314
  userId,
294
315
  now,
295
316
  createId,
317
+ cwd: deps.cwd,
296
318
  }),
297
319
  resolveProvider: async (agentConfig) => ({
298
320
  model: agentConfig.model || "gpt-4.1-mini",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nolo-cli",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "type": "module",
5
5
  "description": "Agent-first terminal workspace for Nolo",
6
6
  "bin": {