nolo-cli 0.1.23 → 0.1.26
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/README.md +25 -3
- package/agent-runtime/hybridRecordStore.ts +30 -9
- package/agent-runtime/localLoop.ts +12 -4
- package/agent-runtime/localToolPolicy.ts +22 -10
- package/agent-runtime/localWorkspaceTools.ts +140 -374
- package/agent-runtime/runtimeToolPolicy.ts +43 -2
- package/agent-runtime/types.ts +5 -0
- package/agentAliases.ts +1 -1
- package/agentCommandSupport.ts +77 -0
- package/agentInternalCommandEntries.ts +34 -0
- package/agentListCommands.ts +123 -0
- package/agentListHelpers.ts +187 -0
- package/agentMachineCommands.ts +402 -0
- package/agentRecordCommands.ts +141 -0
- package/agentRecordHelpers.ts +395 -0
- package/agentRunCommand.ts +1 -2
- package/agentRuntimeCommands.ts +7 -701
- package/ai/agent/_executeModel.ts +29 -3
- package/ai/agent/buildSystemPrompt.ts +17 -0
- package/ai/agent/cliChatClient.ts +38 -4
- package/ai/agent/cliExecutor.ts +304 -25
- package/ai/agent/createAgentSchema.ts +2 -2
- package/ai/agent/guidedCreation/assistant.ts +82 -0
- package/ai/agent/guidedCreation/capabilities.ts +84 -0
- package/ai/agent/guidedCreation/draft.ts +62 -0
- package/ai/agent/guidedCreation/types.ts +38 -0
- package/ai/agent/machineRunPermissions.ts +21 -3
- package/ai/agent/runtimeGuidance.ts +1 -1
- package/ai/agent/server/fetchPublicAgents.ts +6 -1
- package/ai/agent/streamAgentChatTurn.ts +492 -19
- package/ai/agent/web/useGuidedAgentCreation.ts +217 -0
- package/ai/ai.locale.ts +14 -2
- package/ai/chat/sendOpenAICompletionsRequest.ts +28 -3
- package/ai/chat/sendOpenAIResponseRequest.ts +3 -2
- package/ai/llm/getPricing.ts +14 -0
- package/ai/llm/kimi.ts +3 -0
- package/ai/llm/modelAvatar.ts +1 -0
- package/ai/llm/providers.ts +1 -1
- package/ai/taskRun/taskRunProtocol.ts +24 -10
- package/ai/token/modelUsageQuery.ts +17 -0
- package/ai/tools/agent/agentTools.ts +21 -0
- package/ai/tools/agent/prepareAgentDraftTool.ts +159 -0
- package/ai/tools/checkEnvTool.ts +52 -20
- package/ai/tools/convertMarxistsBookTool.ts +103 -0
- package/ai/tools/{execBashTool.ts → execShellTool.ts} +44 -161
- package/ai/tools/index.ts +24 -14
- package/ai/tools/marxistsOfflineBook.ts +253 -0
- package/ai/tools/modelUsageTools.ts +4 -2
- package/ai/tools/readTool.ts +31 -0
- package/ai/tools/toolApiClient.ts +35 -1
- package/ai/tools/toolNameAliases.ts +13 -0
- package/cliCommandAdapters.ts +28 -0
- package/cliCommandDispatch.ts +66 -0
- package/cliCommandFactories.ts +34 -0
- package/cliCommandTypes.ts +49 -0
- package/cliEnvHelpers.ts +44 -0
- package/client/agentRun.test.ts +123 -19
- package/client/agentRun.ts +40 -1
- package/client/hybridRecordStore.test.ts +42 -0
- package/client/hybridRecordStore.ts +11 -0
- package/client/localRuntimeAdapter.test.ts +189 -21
- package/client/localRuntimeAdapter.ts +84 -8
- package/client/localRuntimeDryRun.test.ts +5 -2
- package/client/taskRunPrompt.ts +1 -0
- package/commandRegistry.ts +11 -61
- package/connector-experimental/capabilities.ts +1 -0
- package/database/server/coreDataProxy.ts +43 -26
- package/index.ts +54 -191
- package/internalCommandEntries.ts +12 -0
- package/machineCommands.ts +301 -32
- package/offlineMarxistsAgentCommand.ts +314 -0
- package/package.json +20 -2
- package/runtimeDoctorCommands.ts +118 -0
- package/scriptCommandEntries.ts +71 -0
- package/systemInternalCommandEntries.ts +71 -0
- package/workflowInternalCommandEntries.ts +11 -0
package/cliEnvHelpers.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
|
|
2
|
+
import { NOLO_CLUSTER_SERVERS } from "./database/config";
|
|
3
|
+
|
|
4
|
+
export type EnvLike = Record<string, string | undefined>;
|
|
5
|
+
|
|
6
|
+
export function resolveServerUrl(env: EnvLike) {
|
|
7
|
+
return (env.NOLO_SERVER || env.BASE_URL || DEFAULT_NOLO_SERVER_URL).replace(/\/+$/, "");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function resolveServerCandidates(env: EnvLike, preferred?: string | null) {
|
|
11
|
+
const values = [
|
|
12
|
+
preferred?.trim(),
|
|
13
|
+
env.NOLO_SERVER,
|
|
14
|
+
env.BASE_URL,
|
|
15
|
+
env.NOLO_SERVER_URL,
|
|
16
|
+
DEFAULT_NOLO_SERVER_URL,
|
|
17
|
+
...NOLO_CLUSTER_SERVERS,
|
|
18
|
+
];
|
|
19
|
+
return [...new Set(
|
|
20
|
+
values
|
|
21
|
+
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
22
|
+
.map((value) => value.replace(/\/+$/, ""))
|
|
23
|
+
)];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveAuthToken(env: EnvLike) {
|
|
27
|
+
return env.AUTH_TOKEN || env.AUTH || "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseUserIdFromAuthToken(token: string) {
|
|
31
|
+
const payload = token.trim().split(".")[0];
|
|
32
|
+
if (!payload) return "";
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
|
35
|
+
return typeof parsed?.userId === "string" ? parsed.userId : "";
|
|
36
|
+
} catch {
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readOption(args: string[], flag: string) {
|
|
42
|
+
const index = args.indexOf(flag);
|
|
43
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
44
|
+
}
|
package/client/agentRun.test.ts
CHANGED
|
@@ -45,7 +45,7 @@ describe("cli agent run client", () => {
|
|
|
45
45
|
"queryTableRows",
|
|
46
46
|
"updateTableRow",
|
|
47
47
|
]);
|
|
48
|
-
expect(findServerPlatformTools(["readWorkspaceFile", "
|
|
48
|
+
expect(findServerPlatformTools(["readWorkspaceFile", "execShell"])).toEqual([]);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
test("calls the Nolo HTTP API directly when AUTH_TOKEN is present", async () => {
|
|
@@ -84,15 +84,15 @@ describe("cli agent run client", () => {
|
|
|
84
84
|
agentKey: "agent-pub-test",
|
|
85
85
|
userInput: "hello",
|
|
86
86
|
continueDialogId: "dialog-existing",
|
|
87
|
-
runtimeContext: {
|
|
88
|
-
surface: "cli",
|
|
89
|
-
host: "terminal",
|
|
90
|
-
runtime: "bun",
|
|
91
|
-
entrypoint: "nolo-cli",
|
|
92
|
-
capabilities: ["text-io", "streaming", "slash-commands"],
|
|
93
|
-
},
|
|
94
|
-
stream: true,
|
|
95
|
-
},
|
|
87
|
+
runtimeContext: {
|
|
88
|
+
surface: "cli",
|
|
89
|
+
host: "terminal",
|
|
90
|
+
runtime: "bun",
|
|
91
|
+
entrypoint: "nolo-cli",
|
|
92
|
+
capabilities: ["text-io", "streaming", "slash-commands"],
|
|
93
|
+
},
|
|
94
|
+
stream: true,
|
|
95
|
+
},
|
|
96
96
|
},
|
|
97
97
|
]);
|
|
98
98
|
expect(result).toEqual({ exitCode: 0, dialogId: "dialog-1" });
|
|
@@ -463,7 +463,7 @@ describe("cli agent run client", () => {
|
|
|
463
463
|
name: "Frontend",
|
|
464
464
|
prompt: "Fix UI",
|
|
465
465
|
model: "fake-local",
|
|
466
|
-
toolNames: ["
|
|
466
|
+
toolNames: ["readWorkspaceFile"],
|
|
467
467
|
}),
|
|
468
468
|
loadDialogHistory: async () => [],
|
|
469
469
|
saveTurn: async () => ({ dialogId: "dialog-local" }),
|
|
@@ -476,9 +476,9 @@ describe("cli agent run client", () => {
|
|
|
476
476
|
content: "",
|
|
477
477
|
model: "fake-local",
|
|
478
478
|
tool_calls: [{
|
|
479
|
-
id: "call-
|
|
479
|
+
id: "call-read",
|
|
480
480
|
type: "function",
|
|
481
|
-
function: { name: "
|
|
481
|
+
function: { name: "readWorkspaceFile", arguments: JSON.stringify({ path: "README.md" }) },
|
|
482
482
|
}],
|
|
483
483
|
};
|
|
484
484
|
}
|
|
@@ -491,8 +491,8 @@ describe("cli agent run client", () => {
|
|
|
491
491
|
});
|
|
492
492
|
|
|
493
493
|
expect(result).toMatchObject({ exitCode: 0, dialogId: "dialog-local" });
|
|
494
|
-
expect(output.text()).toContain("[nolo:tool] round 1 ->
|
|
495
|
-
expect(output.text()).toContain("[nolo:tool] round 1 <-
|
|
494
|
+
expect(output.text()).toContain("[nolo:tool] round 1 -> readWorkspaceFile (call-read)");
|
|
495
|
+
expect(output.text()).toContain("[nolo:tool] round 1 <- readWorkspaceFile (call-read)");
|
|
496
496
|
});
|
|
497
497
|
|
|
498
498
|
test("passes local tool evidence to task-run writeback", async () => {
|
|
@@ -524,7 +524,7 @@ describe("cli agent run client", () => {
|
|
|
524
524
|
name: "Frontend",
|
|
525
525
|
prompt: "Fix UI",
|
|
526
526
|
model: "fake-local",
|
|
527
|
-
toolNames: ["
|
|
527
|
+
toolNames: ["readWorkspaceFile"],
|
|
528
528
|
}),
|
|
529
529
|
loadDialogHistory: async () => [],
|
|
530
530
|
saveTurn: async () => ({ dialogId: "dialog-local" }),
|
|
@@ -537,9 +537,9 @@ describe("cli agent run client", () => {
|
|
|
537
537
|
content: "",
|
|
538
538
|
model: "fake-local",
|
|
539
539
|
tool_calls: [{
|
|
540
|
-
id: "call-
|
|
540
|
+
id: "call-read",
|
|
541
541
|
type: "function",
|
|
542
|
-
function: { name: "
|
|
542
|
+
function: { name: "readWorkspaceFile", arguments: JSON.stringify({ path: "README.md" }) },
|
|
543
543
|
}],
|
|
544
544
|
};
|
|
545
545
|
}
|
|
@@ -554,7 +554,7 @@ describe("cli agent run client", () => {
|
|
|
554
554
|
expect(result).toMatchObject({ exitCode: 0, dialogId: "dialog-local" });
|
|
555
555
|
expect(writebacks).toHaveLength(1);
|
|
556
556
|
expect(writebacks[0].toolEvidence).toEqual({
|
|
557
|
-
toolsUsed: ["
|
|
557
|
+
toolsUsed: ["readWorkspaceFile"],
|
|
558
558
|
summary: {
|
|
559
559
|
callCount: 1,
|
|
560
560
|
resultCount: 1,
|
|
@@ -660,6 +660,110 @@ describe("cli agent run client", () => {
|
|
|
660
660
|
expect(output.text()).toContain("frontend -> working locally");
|
|
661
661
|
});
|
|
662
662
|
|
|
663
|
+
test("auto mode refreshes missing local agent config and retries local once before HTTP", async () => {
|
|
664
|
+
const output = new CaptureOutput();
|
|
665
|
+
const httpCalls: string[] = [];
|
|
666
|
+
let loadAgentConfigCalls = 0;
|
|
667
|
+
let localCompletions = 0;
|
|
668
|
+
|
|
669
|
+
const result = await runAgentTurn({
|
|
670
|
+
agentName: "frontend",
|
|
671
|
+
agentKey: "frontend-local",
|
|
672
|
+
serverUrl: "https://nolo.chat",
|
|
673
|
+
message: "polish notifications",
|
|
674
|
+
scriptDir: "C:/missing/scripts",
|
|
675
|
+
env: { AUTH_TOKEN: "token-123" },
|
|
676
|
+
output,
|
|
677
|
+
runtimeMode: "auto",
|
|
678
|
+
localRuntimeAdapter: {
|
|
679
|
+
host: "cli",
|
|
680
|
+
capabilities: ["leveldb-agent-config", "local-provider", "leveldb-persistence"],
|
|
681
|
+
loadAgentConfig: async (agentRef) => {
|
|
682
|
+
loadAgentConfigCalls += 1;
|
|
683
|
+
if (loadAgentConfigCalls < 3) return null;
|
|
684
|
+
return {
|
|
685
|
+
key: agentRef,
|
|
686
|
+
name: "Frontend",
|
|
687
|
+
prompt: "Fix UI",
|
|
688
|
+
model: "fake-local",
|
|
689
|
+
};
|
|
690
|
+
},
|
|
691
|
+
loadDialogHistory: async () => [],
|
|
692
|
+
saveTurn: async () => ({ dialogId: "dialog-auto-local-retried" }),
|
|
693
|
+
resolveProvider: async () => ({
|
|
694
|
+
model: "fake-local",
|
|
695
|
+
complete: async () => {
|
|
696
|
+
localCompletions += 1;
|
|
697
|
+
return { content: "auto local after refresh", model: "fake-local" };
|
|
698
|
+
},
|
|
699
|
+
}),
|
|
700
|
+
executeTool: async () => {
|
|
701
|
+
throw new Error("no tools expected");
|
|
702
|
+
},
|
|
703
|
+
},
|
|
704
|
+
scriptPathExists: () => false,
|
|
705
|
+
fetchImpl: async (url) => {
|
|
706
|
+
httpCalls.push(String(url));
|
|
707
|
+
return Response.json({ content: "server" });
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
expect(result).toEqual({ exitCode: 0, dialogId: "dialog-auto-local-retried" });
|
|
712
|
+
expect(loadAgentConfigCalls).toBe(4);
|
|
713
|
+
expect(localCompletions).toBe(1);
|
|
714
|
+
expect(httpCalls).toEqual([]);
|
|
715
|
+
expect(output.text()).toContain("refreshing from the configured server and retrying local once");
|
|
716
|
+
expect(output.text().match(/working locally/g)?.length).toBe(2);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
test("auto mode falls back to HTTP when local agent config is still missing after one refresh retry", async () => {
|
|
720
|
+
const output = new CaptureOutput();
|
|
721
|
+
const httpCalls: Array<{ url: string; body: any }> = [];
|
|
722
|
+
let loadAgentConfigCalls = 0;
|
|
723
|
+
|
|
724
|
+
const result = await runAgentTurn({
|
|
725
|
+
agentName: "frontend",
|
|
726
|
+
agentKey: "frontend-local",
|
|
727
|
+
serverUrl: "https://nolo.chat",
|
|
728
|
+
message: "polish notifications",
|
|
729
|
+
scriptDir: "C:/missing/scripts",
|
|
730
|
+
env: { AUTH_TOKEN: "token-123" },
|
|
731
|
+
output,
|
|
732
|
+
runtimeMode: "auto",
|
|
733
|
+
localRuntimeAdapter: {
|
|
734
|
+
host: "cli",
|
|
735
|
+
capabilities: ["leveldb-agent-config", "local-provider", "leveldb-persistence"],
|
|
736
|
+
loadAgentConfig: async () => {
|
|
737
|
+
loadAgentConfigCalls += 1;
|
|
738
|
+
return null;
|
|
739
|
+
},
|
|
740
|
+
loadDialogHistory: async () => [],
|
|
741
|
+
saveTurn: async () => {
|
|
742
|
+
throw new Error("local runtime should not save when config is missing");
|
|
743
|
+
},
|
|
744
|
+
resolveProvider: async () => {
|
|
745
|
+
throw new Error("local provider should not run when config is missing");
|
|
746
|
+
},
|
|
747
|
+
executeTool: async () => {
|
|
748
|
+
throw new Error("no tools expected");
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
scriptPathExists: () => false,
|
|
752
|
+
fetchImpl: async (url, init) => {
|
|
753
|
+
httpCalls.push({ url: String(url), body: JSON.parse(String(init?.body)) });
|
|
754
|
+
return Response.json({ content: "server fallback", dialogId: "dialog-server-fallback" });
|
|
755
|
+
},
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
expect(result).toEqual({ exitCode: 0, dialogId: "dialog-server-fallback" });
|
|
759
|
+
expect(loadAgentConfigCalls).toBe(3);
|
|
760
|
+
expect(httpCalls).toHaveLength(1);
|
|
761
|
+
expect(httpCalls[0]?.body.agentKey).toBe("frontend-local");
|
|
762
|
+
expect(output.text()).toContain("refreshing from the configured server and retrying local once");
|
|
763
|
+
expect(output.text()).toContain("frontend -> working");
|
|
764
|
+
expect(output.text()).toContain("frontend > server fallback");
|
|
765
|
+
});
|
|
766
|
+
|
|
663
767
|
test("auto mode skips local runtime for agents that declare server platform tools", async () => {
|
|
664
768
|
const output = new CaptureOutput();
|
|
665
769
|
const httpCalls: Array<{ url: string; body: any }> = [];
|
package/client/agentRun.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { runLocalAgentTurn } from "../agentRuntimeLocal";
|
|
4
|
+
import { LOCAL_AGENT_CONFIG_MISSING_CODE } from "../agent-runtime/localLoop";
|
|
4
5
|
import {
|
|
5
6
|
FRONTEND_IMPLEMENTER_AGENT_KEY,
|
|
6
7
|
NOLO_FULLSTACK_AGENT_KEY,
|
|
@@ -250,7 +251,7 @@ function summarizeLocalToolEvidence(events: LocalAgentToolEvent[]): TaskRunToolE
|
|
|
250
251
|
};
|
|
251
252
|
}
|
|
252
253
|
|
|
253
|
-
const DEFAULT_LOCAL_AGENT_TOOL_ROUND_RETRY_LIMIT =
|
|
254
|
+
const DEFAULT_LOCAL_AGENT_TOOL_ROUND_RETRY_LIMIT = 512;
|
|
254
255
|
|
|
255
256
|
function parseLocalToolRoundLimitError(error: unknown) {
|
|
256
257
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -259,6 +260,16 @@ function parseLocalToolRoundLimitError(error: unknown) {
|
|
|
259
260
|
return Number(match[1]);
|
|
260
261
|
}
|
|
261
262
|
|
|
263
|
+
function isMissingLocalAgentConfigError(error: unknown, agentRef: string) {
|
|
264
|
+
return Boolean(
|
|
265
|
+
error &&
|
|
266
|
+
typeof error === "object" &&
|
|
267
|
+
"code" in error &&
|
|
268
|
+
(error as { code?: string; agentRef?: string }).code === LOCAL_AGENT_CONFIG_MISSING_CODE &&
|
|
269
|
+
(error as { code?: string; agentRef?: string }).agentRef === agentRef
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
262
273
|
function parsePositiveInteger(value: string | undefined) {
|
|
263
274
|
if (!value) return null;
|
|
264
275
|
const parsed = Number(value);
|
|
@@ -683,6 +694,13 @@ async function runInjectedLocalAgentTurn(options: RunAgentTurnOptions) {
|
|
|
683
694
|
return runLocalAgentTurnWithAutoRoundExpansion(options, { reportFailure: true });
|
|
684
695
|
}
|
|
685
696
|
|
|
697
|
+
async function refreshMissingLocalAgentConfig(options: RunAgentTurnOptions) {
|
|
698
|
+
const adapter = resolveLocalRuntimeAdapter(options);
|
|
699
|
+
if (!adapter) return false;
|
|
700
|
+
const agentConfig = await adapter.loadAgentConfig(options.agentKey);
|
|
701
|
+
return Boolean(agentConfig);
|
|
702
|
+
}
|
|
703
|
+
|
|
686
704
|
async function runLocalAgentTurnWithAutoRoundExpansion(
|
|
687
705
|
options: RunAgentTurnOptions,
|
|
688
706
|
settings: { reportFailure: boolean }
|
|
@@ -926,6 +944,27 @@ export async function runAgentTurn(options: RunAgentTurnOptions) {
|
|
|
926
944
|
...(localResult.dialogId ? { dialogId: localResult.dialogId } : {}),
|
|
927
945
|
};
|
|
928
946
|
}
|
|
947
|
+
if (isMissingLocalAgentConfigError(localResult.localError, options.agentKey)) {
|
|
948
|
+
options.output.write(
|
|
949
|
+
`[nolo] Local agent config was missing; refreshing from the configured server and retrying local once.\n`
|
|
950
|
+
);
|
|
951
|
+
try {
|
|
952
|
+
const refreshed = await refreshMissingLocalAgentConfig(options);
|
|
953
|
+
if (refreshed) {
|
|
954
|
+
const retriedLocalResult = await runLocalAgentTurnWithAutoRoundExpansion(options, {
|
|
955
|
+
reportFailure: false,
|
|
956
|
+
});
|
|
957
|
+
if (retriedLocalResult.exitCode === 0) {
|
|
958
|
+
return {
|
|
959
|
+
exitCode: retriedLocalResult.exitCode,
|
|
960
|
+
...(retriedLocalResult.dialogId ? { dialogId: retriedLocalResult.dialogId } : {}),
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
} catch {
|
|
965
|
+
// Fall through to the existing server runtime fallback below.
|
|
966
|
+
}
|
|
967
|
+
}
|
|
929
968
|
}
|
|
930
969
|
}
|
|
931
970
|
|
|
@@ -87,6 +87,48 @@ describe("CLI hybrid record store", () => {
|
|
|
87
87
|
});
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
test("falls back across known cluster servers on local miss and caches the first remote hit", async () => {
|
|
91
|
+
const { db, store: memory } = createMemoryDb();
|
|
92
|
+
const requests: string[] = [];
|
|
93
|
+
const hybrid = createCliHybridRecordStore({
|
|
94
|
+
db,
|
|
95
|
+
env: {
|
|
96
|
+
NOLO_SERVER: "http://127.0.0.1:38123",
|
|
97
|
+
AUTH_TOKEN: "token-1",
|
|
98
|
+
},
|
|
99
|
+
fetchImpl: async (url) => {
|
|
100
|
+
const target = String(url);
|
|
101
|
+
requests.push(target);
|
|
102
|
+
if (target.startsWith("http://127.0.0.1:38123/")) {
|
|
103
|
+
throw new Error("ConnectionRefused");
|
|
104
|
+
}
|
|
105
|
+
if (target.startsWith("https://nolo.chat/")) {
|
|
106
|
+
return Response.json({
|
|
107
|
+
data: {
|
|
108
|
+
dbKey: "agent-user-1-cluster",
|
|
109
|
+
name: "cluster remote",
|
|
110
|
+
updatedAt: "2026-05-24T00:00:00.000Z",
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return new Response(null, { status: 404 });
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await expect(hybrid.read("agent-user-1-cluster")).resolves.toMatchObject({
|
|
119
|
+
name: "cluster remote",
|
|
120
|
+
serverOrigin: "https://nolo.chat",
|
|
121
|
+
});
|
|
122
|
+
expect(requests).toEqual([
|
|
123
|
+
"http://127.0.0.1:38123/api/v1/db/read/agent-user-1-cluster",
|
|
124
|
+
"https://nolo.chat/api/v1/db/read/agent-user-1-cluster",
|
|
125
|
+
]);
|
|
126
|
+
expect(memory.get("agent-user-1-cluster")).toMatchObject({
|
|
127
|
+
name: "cluster remote",
|
|
128
|
+
serverOrigin: "https://nolo.chat",
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
90
132
|
test("keeps newer local records over stale remote records", () => {
|
|
91
133
|
expect(shouldCacheRemoteRecord(
|
|
92
134
|
{ updatedAt: "2026-05-12T00:00:00.000Z" },
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_NOLO_SERVER_URL } from "../defaultServer";
|
|
2
|
+
import { NOLO_CLUSTER_SERVERS } from "../database/config";
|
|
2
3
|
import {
|
|
3
4
|
createHybridRecordStore,
|
|
4
5
|
shouldCacheHybridRemoteRecord,
|
|
@@ -21,6 +22,15 @@ function normalizeServer(value: string) {
|
|
|
21
22
|
return value.trim().replace(/\/+$/, "");
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
function resolveFallbackServers(env: EnvLike) {
|
|
26
|
+
const values = [
|
|
27
|
+
env.NOLO_SERVER_URL,
|
|
28
|
+
env.BASE_URL,
|
|
29
|
+
...NOLO_CLUSTER_SERVERS,
|
|
30
|
+
].filter((value): value is string => typeof value === "string" && value.trim().length > 0);
|
|
31
|
+
return [...new Set(values.map(normalizeServer))];
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
function resolveAuthToken(env: EnvLike) {
|
|
25
35
|
return env.AUTH_TOKEN || env.AUTH || env.BENCHMARK_AUTH_TOKEN || "";
|
|
26
36
|
}
|
|
@@ -35,6 +45,7 @@ export function createCliHybridRecordStore(
|
|
|
35
45
|
return createHybridRecordStore({
|
|
36
46
|
db: deps.db,
|
|
37
47
|
defaultServer: normalizeServer(deps.env.NOLO_SERVER || deps.env.BASE_URL || DEFAULT_NOLO_SERVER_URL),
|
|
48
|
+
fallbackServers: resolveFallbackServers(deps.env),
|
|
38
49
|
authToken: resolveAuthToken(deps.env),
|
|
39
50
|
fetchImpl: deps.fetchImpl,
|
|
40
51
|
});
|
|
@@ -8,26 +8,22 @@ import { createCliLocalRuntimeAdapter } from "./localRuntimeAdapter";
|
|
|
8
8
|
|
|
9
9
|
describe("CLI local runtime adapter", () => {
|
|
10
10
|
const DEFAULT_LOCAL_CODING_TOOL_NAMES = [
|
|
11
|
-
"
|
|
11
|
+
"listFiles",
|
|
12
|
+
"readFile",
|
|
13
|
+
"writeFile",
|
|
14
|
+
"editFile",
|
|
15
|
+
"searchFiles",
|
|
16
|
+
];
|
|
17
|
+
const LEGACY_WRITE_LOCAL_CODING_TOOL_NAMES = [
|
|
12
18
|
"writeWorkspaceFile",
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"gitDiff",
|
|
18
|
-
"gitCreateBranch",
|
|
19
|
-
"commitWorkspace",
|
|
20
|
-
"runPackageScript",
|
|
21
|
-
"startPreview",
|
|
22
|
-
"getPreviewStatus",
|
|
23
|
-
"stopPreview",
|
|
24
|
-
"releasePreview",
|
|
25
|
-
"captureVisualState",
|
|
19
|
+
"listFiles",
|
|
20
|
+
"readFile",
|
|
21
|
+
"editFile",
|
|
22
|
+
"searchFiles",
|
|
26
23
|
];
|
|
27
24
|
const SHELL_LOCAL_CODING_TOOL_NAMES = [
|
|
28
25
|
...DEFAULT_LOCAL_CODING_TOOL_NAMES,
|
|
29
26
|
"execShell",
|
|
30
|
-
"execBash",
|
|
31
27
|
];
|
|
32
28
|
|
|
33
29
|
function toolNamesFromRequest(request: any) {
|
|
@@ -211,6 +207,77 @@ describe("CLI local runtime adapter", () => {
|
|
|
211
207
|
});
|
|
212
208
|
});
|
|
213
209
|
|
|
210
|
+
test("keeps local runtime runnable when the configured local server is down but a cluster server can provide agent config", async () => {
|
|
211
|
+
const memory = new Map<string, any>();
|
|
212
|
+
const requests: string[] = [];
|
|
213
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
214
|
+
env: {
|
|
215
|
+
NOLO_LOCAL_USER_ID: "user-1",
|
|
216
|
+
NOLO_SERVER: "http://127.0.0.1:38123",
|
|
217
|
+
AUTH_TOKEN: "token-1",
|
|
218
|
+
OPENAI_API_KEY: "sk-local",
|
|
219
|
+
NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
|
|
220
|
+
},
|
|
221
|
+
db: {
|
|
222
|
+
get: async (key) => {
|
|
223
|
+
if (!memory.has(key)) throw new Error(`not found: ${key}`);
|
|
224
|
+
return memory.get(key);
|
|
225
|
+
},
|
|
226
|
+
put: async (key, value) => {
|
|
227
|
+
memory.set(key, value);
|
|
228
|
+
},
|
|
229
|
+
batch: async (ops) => {
|
|
230
|
+
for (const op of ops) {
|
|
231
|
+
if (op.type === "put") memory.set(op.key, op.value);
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
iterator: () => (async function* () {})(),
|
|
235
|
+
},
|
|
236
|
+
createId: () => "01CLUSTER",
|
|
237
|
+
fetchImpl: async (url, init) => {
|
|
238
|
+
const target = String(url);
|
|
239
|
+
requests.push(target);
|
|
240
|
+
if (target === "http://127.0.0.1:38123/api/v1/db/read/agent-user-1-cluster") {
|
|
241
|
+
throw new Error("ConnectionRefused");
|
|
242
|
+
}
|
|
243
|
+
if (target === "https://nolo.chat/api/v1/db/read/agent-user-1-cluster") {
|
|
244
|
+
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer token-1");
|
|
245
|
+
return Response.json({
|
|
246
|
+
data: {
|
|
247
|
+
dbKey: "agent-user-1-cluster",
|
|
248
|
+
name: "Cluster cached",
|
|
249
|
+
prompt: "Use cached config",
|
|
250
|
+
model: "gpt-4.1-mini",
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (target === "http://127.0.0.1:11434/v1/chat/completions") {
|
|
255
|
+
return Response.json({
|
|
256
|
+
choices: [{ message: { content: "cluster cache ok" } }],
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return new Response(null, { status: 404 });
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const result = await runLocalAgentTurn({
|
|
264
|
+
adapter,
|
|
265
|
+
agentRef: "agent-user-1-cluster",
|
|
266
|
+
input: "hello",
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(result.content).toBe("cluster cache ok");
|
|
270
|
+
expect(requests).toEqual([
|
|
271
|
+
"http://127.0.0.1:38123/api/v1/db/read/agent-user-1-cluster",
|
|
272
|
+
"https://nolo.chat/api/v1/db/read/agent-user-1-cluster",
|
|
273
|
+
"http://127.0.0.1:11434/v1/chat/completions",
|
|
274
|
+
]);
|
|
275
|
+
expect(memory.get("agent-user-1-cluster")).toMatchObject({
|
|
276
|
+
name: "Cluster cached",
|
|
277
|
+
serverOrigin: "https://nolo.chat",
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
214
281
|
test("uses agent customProviderUrl for local OpenAI-compatible requests", async () => {
|
|
215
282
|
const requests: Array<{ url: string; auth: string | null; body: any }> = [];
|
|
216
283
|
const store = new Map<string, any>([
|
|
@@ -356,7 +423,7 @@ describe("CLI local runtime adapter", () => {
|
|
|
356
423
|
agentKey: "agent-user-1-frontend",
|
|
357
424
|
},
|
|
358
425
|
});
|
|
359
|
-
expect(toolNamesFromRequest(requests[0])).toEqual(
|
|
426
|
+
expect(toolNamesFromRequest(requests[0])).toEqual(LEGACY_WRITE_LOCAL_CODING_TOOL_NAMES);
|
|
360
427
|
});
|
|
361
428
|
|
|
362
429
|
test("uses the Nolo chat proxy for platform agents even when direct provider env exists", async () => {
|
|
@@ -370,7 +437,7 @@ describe("CLI local runtime adapter", () => {
|
|
|
370
437
|
provider: "fireworks",
|
|
371
438
|
apiSource: "platform",
|
|
372
439
|
useServerProxy: true,
|
|
373
|
-
tools: ["
|
|
440
|
+
tools: ["readWorkspaceFile"],
|
|
374
441
|
}],
|
|
375
442
|
]);
|
|
376
443
|
const adapter = createCliLocalRuntimeAdapter({
|
|
@@ -575,6 +642,12 @@ describe("CLI local runtime adapter", () => {
|
|
|
575
642
|
cwd: import.meta.dir,
|
|
576
643
|
now: () => 1710000000000,
|
|
577
644
|
createId: () => "01TRACE",
|
|
645
|
+
localToolExecutors: {
|
|
646
|
+
execShell: async () => ({
|
|
647
|
+
content: "stdout:\ntrace-ok\n\nexitCode: 0",
|
|
648
|
+
metadata: { exitCode: 0, timedOut: false },
|
|
649
|
+
}),
|
|
650
|
+
},
|
|
578
651
|
fetchImpl: async (_url, init) => {
|
|
579
652
|
const body = JSON.parse(String(init?.body));
|
|
580
653
|
const hasToolResult = body.messages.some((message: any) => message.role === "tool");
|
|
@@ -588,7 +661,11 @@ describe("CLI local runtime adapter", () => {
|
|
|
588
661
|
type: "function",
|
|
589
662
|
function: {
|
|
590
663
|
name: "execShell",
|
|
591
|
-
arguments: JSON.stringify({
|
|
664
|
+
arguments: JSON.stringify({
|
|
665
|
+
cmd: process.platform === "win32"
|
|
666
|
+
? "Write-Output trace-ok"
|
|
667
|
+
: "printf trace-ok",
|
|
668
|
+
}),
|
|
592
669
|
},
|
|
593
670
|
}],
|
|
594
671
|
},
|
|
@@ -953,7 +1030,7 @@ describe("CLI local runtime adapter", () => {
|
|
|
953
1030
|
await adapter.loadAgentConfig("default-tools");
|
|
954
1031
|
await expect(adapter.executeTool({
|
|
955
1032
|
id: "call-read",
|
|
956
|
-
name: "
|
|
1033
|
+
name: "readFile",
|
|
957
1034
|
arguments: JSON.stringify({ path: "README.md" }),
|
|
958
1035
|
})).resolves.toMatchObject({
|
|
959
1036
|
content: "local ok\n",
|
|
@@ -963,6 +1040,91 @@ describe("CLI local runtime adapter", () => {
|
|
|
963
1040
|
}
|
|
964
1041
|
});
|
|
965
1042
|
|
|
1043
|
+
test("exposes declared taskRun as a platform tool in local runtime requests", async () => {
|
|
1044
|
+
const requests: Array<{ body: any }> = [];
|
|
1045
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
1046
|
+
env: {
|
|
1047
|
+
OPENAI_API_KEY: "sk-local",
|
|
1048
|
+
NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
|
|
1049
|
+
},
|
|
1050
|
+
db: {
|
|
1051
|
+
get: async () => ({
|
|
1052
|
+
dbKey: "agent-local-taskrun",
|
|
1053
|
+
prompt: "Use taskRun.",
|
|
1054
|
+
model: "gpt-4.1-mini",
|
|
1055
|
+
toolNames: ["taskRun"],
|
|
1056
|
+
}),
|
|
1057
|
+
put: async () => {},
|
|
1058
|
+
batch: async () => {},
|
|
1059
|
+
iterator: () => (async function* () {})(),
|
|
1060
|
+
},
|
|
1061
|
+
fetchImpl: async (_url, init) => {
|
|
1062
|
+
requests.push({ body: JSON.parse(String(init?.body)) });
|
|
1063
|
+
return Response.json({
|
|
1064
|
+
choices: [{ message: { content: "done" } }],
|
|
1065
|
+
});
|
|
1066
|
+
},
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
await runLocalAgentTurn({
|
|
1070
|
+
adapter,
|
|
1071
|
+
agentRef: "taskrun",
|
|
1072
|
+
input: "read task context",
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
expect(toolNamesFromRequest(requests[0])).toContain("taskRun");
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
test("executes declared taskRun through the server task-run bridge", async () => {
|
|
1079
|
+
const requests: Array<{ url: string; auth: string | null; body: any }> = [];
|
|
1080
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
1081
|
+
env: {
|
|
1082
|
+
BASE_URL: "https://us.nolo.chat",
|
|
1083
|
+
AUTH_TOKEN: "machine-token",
|
|
1084
|
+
},
|
|
1085
|
+
db: {
|
|
1086
|
+
get: async () => ({
|
|
1087
|
+
dbKey: "agent-local-taskrun",
|
|
1088
|
+
toolNames: ["taskRun"],
|
|
1089
|
+
}),
|
|
1090
|
+
put: async () => {},
|
|
1091
|
+
batch: async () => {},
|
|
1092
|
+
iterator: () => (async function* () {})(),
|
|
1093
|
+
},
|
|
1094
|
+
fetchImpl: async (url, init) => {
|
|
1095
|
+
requests.push({
|
|
1096
|
+
url: String(url),
|
|
1097
|
+
auth: new Headers(init?.headers).get("authorization"),
|
|
1098
|
+
body: JSON.parse(String(init?.body)),
|
|
1099
|
+
});
|
|
1100
|
+
return Response.json({ ok: true, row: { rowId: "task-1" } });
|
|
1101
|
+
},
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
await adapter.loadAgentConfig("taskrun");
|
|
1105
|
+
const result = await adapter.executeTool({
|
|
1106
|
+
id: "call-taskrun",
|
|
1107
|
+
name: "taskRun",
|
|
1108
|
+
arguments: JSON.stringify({
|
|
1109
|
+
action: "readTaskContext",
|
|
1110
|
+
dbKey: "row-user-NOLOTASKBOARD-task-1",
|
|
1111
|
+
}),
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
expect(requests).toEqual([
|
|
1115
|
+
{
|
|
1116
|
+
url: "https://us.nolo.chat/api/task-run",
|
|
1117
|
+
auth: "Bearer machine-token",
|
|
1118
|
+
body: {
|
|
1119
|
+
action: "readTaskContext",
|
|
1120
|
+
dbKey: "row-user-NOLOTASKBOARD-task-1",
|
|
1121
|
+
},
|
|
1122
|
+
},
|
|
1123
|
+
]);
|
|
1124
|
+
expect(result.content).toContain("\"ok\":true");
|
|
1125
|
+
expect(result.metadata).toMatchObject({ serverPlatformTool: true });
|
|
1126
|
+
});
|
|
1127
|
+
|
|
966
1128
|
test("activates an agent-declared task worktree before running workspace tools", async () => {
|
|
967
1129
|
const taskWorktreeRoot = mkdtempSync(join(tmpdir(), "nolo-cli-runtime-worktree-"));
|
|
968
1130
|
const chunks: string[] = [];
|
|
@@ -994,7 +1156,7 @@ describe("CLI local runtime adapter", () => {
|
|
|
994
1156
|
await adapter.loadAgentConfig("frontend");
|
|
995
1157
|
const result = await adapter.executeTool({
|
|
996
1158
|
id: "call-read",
|
|
997
|
-
name: "
|
|
1159
|
+
name: "readFile",
|
|
998
1160
|
arguments: JSON.stringify({ path: "README.md" }),
|
|
999
1161
|
});
|
|
1000
1162
|
|
|
@@ -1019,6 +1181,12 @@ describe("CLI local runtime adapter", () => {
|
|
|
1019
1181
|
iterator: () => (async function* () {})(),
|
|
1020
1182
|
},
|
|
1021
1183
|
cwd: import.meta.dir,
|
|
1184
|
+
localToolExecutors: {
|
|
1185
|
+
execShell: async () => ({
|
|
1186
|
+
content: import.meta.dir,
|
|
1187
|
+
metadata: { exitCode: 0, timedOut: false },
|
|
1188
|
+
}),
|
|
1189
|
+
},
|
|
1022
1190
|
fetchImpl: async () => Response.json({}),
|
|
1023
1191
|
});
|
|
1024
1192
|
|
|
@@ -1070,7 +1238,7 @@ describe("CLI local runtime adapter", () => {
|
|
|
1070
1238
|
}),
|
|
1071
1239
|
});
|
|
1072
1240
|
|
|
1073
|
-
expect(result.content).toBe(
|
|
1241
|
+
expect(result.content).toBe(`wrote ${join("src", "app.ts")}`);
|
|
1074
1242
|
expect(readFileSync(join(workspaceRoot, "src/app.ts"), "utf8")).toBe("export const cliValue = 1;\n");
|
|
1075
1243
|
} finally {
|
|
1076
1244
|
rmSync(workspaceRoot, { recursive: true, force: true });
|