Skip to content

Commit bb4ce7c

Browse files
authored
feat(settings): expose the agent execution mode (CodeAct) in the Settings UI (#4765)
1 parent f811124 commit bb4ce7c

6 files changed

Lines changed: 240 additions & 11 deletions

File tree

packages/agents/CLAUDE.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -482,8 +482,12 @@ Design and the research it follows (CodeAct, ICML 2024): docs/codeact-design.md.
482482
- The mode threads through `TaskExecutor`, `ParallelTaskExecutor`, and
483483
`ScriptRunner` sub-agents; each resolves `resolveExecutionMode(explicit)`
484484
explicit option > `NODETOOL_AGENT_EXECUTION_MODE` > `"tools"`. The setting
485-
is registered in the websocket settings registry (Settings UI, group
486-
Execution) and mirrored into the environment at server startup. CLI:
485+
is registered in the websocket settings registry and exposed in the web
486+
Settings UI (General → Execution → "Agent Execution Mode"). The stored value
487+
is mirrored into the environment at server startup and again on every write,
488+
so switching modes applies to the next run without a restart; a real
489+
`NODETOOL_AGENT_EXECUTION_MODE` environment variable pins the mode and wins
490+
over both. CLI:
487491
`nodetool agent run <yaml> --codeact` (YAML: `execution_mode: codeact`).
488492
- Eval suite `codeact` runs the same offline instrumented cases through either
489493
executor for a mode comparison: `nodetool eval codeact -p <p> -m <m>`.

packages/websocket/src/settings-registry.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,41 @@ export function getRegisteredSettings(): SettingDefinition[] {
4242
export const AGENT_EXECUTION_MODE_ENV = "NODETOOL_AGENT_EXECUTION_MODE";
4343

4444
/**
45-
* Mirror the stored agent execution mode into the environment so the agents
46-
* package (which resolves NODETOOL_AGENT_EXECUTION_MODE) honors what the
47-
* Settings UI saved. A real environment variable wins over the stored value;
48-
* an unavailable settings store leaves the env/default in place.
45+
* Whether a real environment variable pins the execution mode. Captured once
46+
* at module load, before anything mirrors a stored value into `process.env` —
47+
* afterwards the two are indistinguishable, and a deployment that set the mode
48+
* in its environment must keep winning over whatever the Settings UI saved.
49+
*/
50+
const AGENT_EXECUTION_MODE_PINNED_BY_ENV = Boolean(
51+
process.env[AGENT_EXECUTION_MODE_ENV]
52+
);
53+
54+
/**
55+
* Mirror an agent execution mode into the environment so the agents package
56+
* (which resolves NODETOOL_AGENT_EXECUTION_MODE) honors what the Settings UI
57+
* saved. Called at startup with the stored value and again whenever the
58+
* setting is written, so a change applies to the next run instead of waiting
59+
* for a restart. Returns false when the value is not a mode, or when an
60+
* environment variable pins it.
61+
*/
62+
export function setAgentExecutionModeEnv(value: string | null): boolean {
63+
if (AGENT_EXECUTION_MODE_PINNED_BY_ENV) return false;
64+
const mode = value?.trim().toLowerCase();
65+
if (mode !== "codeact" && mode !== "tools") return false;
66+
process.env[AGENT_EXECUTION_MODE_ENV] = mode;
67+
return true;
68+
}
69+
70+
/**
71+
* Load the stored agent execution mode into the environment at server startup.
72+
* A real environment variable wins over the stored value; an unavailable
73+
* settings store leaves the env/default in place.
4974
*/
5075
export async function applyAgentExecutionModeSetting(): Promise<void> {
51-
if (process.env[AGENT_EXECUTION_MODE_ENV]) return;
76+
if (AGENT_EXECUTION_MODE_PINNED_BY_ENV) return;
5277
try {
5378
const setting = await Setting.find("1", AGENT_EXECUTION_MODE_ENV);
54-
const value = setting?.value.trim().toLowerCase();
55-
if (value === "codeact" || value === "tools") {
56-
process.env[AGENT_EXECUTION_MODE_ENV] = value;
57-
}
79+
setAgentExecutionModeEnv(setting?.value ?? null);
5880
} catch {
5981
// Settings store unavailable — the env var / default applies.
6082
}

packages/websocket/src/trpc/routers/settings.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import { router } from "../index.js";
2020
import { protectedProcedure } from "../middleware.js";
2121
import { throwApiError } from "../error-formatter.js";
2222
import {
23+
AGENT_EXECUTION_MODE_ENV,
2324
getRegisteredSettings,
25+
setAgentExecutionModeEnv,
2426
type SettingWithValue
2527
} from "../../settings-registry.js";
2628
import {
@@ -252,6 +254,14 @@ export const settingsRouter = router({
252254
value: String(value ?? "")
253255
});
254256
}
257+
// The agents package reads the execution mode off the environment, so
258+
// mirror a write immediately — otherwise the switch only takes effect
259+
// after a restart.
260+
if (AGENT_EXECUTION_MODE_ENV in input.settings) {
261+
setAgentExecutionModeEnv(
262+
String(input.settings[AGENT_EXECUTION_MODE_ENV] ?? "")
263+
);
264+
}
255265
}
256266

257267
// Secrets: skip the unchanged-display placeholder, upsert the rest,
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import React, { useCallback } from "react";
2+
import { useQuery } from "@tanstack/react-query";
3+
import { SelectField, Text, type SelectOption } from "../ui_primitives";
4+
import useRemoteSettingsStore from "../../stores/RemoteSettingStore";
5+
6+
export interface ServerSelectSettingProps {
7+
/** Registry env var key (e.g. NODETOOL_AGENT_EXECUTION_MODE). */
8+
envVar: string;
9+
label: string;
10+
description: React.ReactNode;
11+
options: readonly SelectOption[];
12+
defaultValue: string;
13+
id?: string;
14+
}
15+
16+
/**
17+
* A select backed by a server-side registry setting (read/written via tRPC
18+
* `settings.list`/`settings.update`), the enum counterpart to
19+
* {@link ServerNumberSetting}. These live on the server because the runner
20+
* reads them via `getSetting`, not in the local SettingsStore.
21+
*/
22+
export const ServerSelectSetting = React.memo(function ServerSelectSetting({
23+
envVar,
24+
label,
25+
description,
26+
options,
27+
defaultValue,
28+
id
29+
}: ServerSelectSettingProps) {
30+
const fetchSettings = useRemoteSettingsStore((s) => s.fetchSettings);
31+
const updateSettings = useRemoteSettingsStore((s) => s.updateSettings);
32+
// Shared ["settings"] cache: dedupes with the API & Keys tab's own query.
33+
useQuery({ queryKey: ["settings"], queryFn: fetchSettings });
34+
35+
const stored = useRemoteSettingsStore(
36+
(s) => s.settings.find((x) => x.env_var === envVar)?.value
37+
);
38+
const value =
39+
stored != null && stored !== "" ? String(stored) : defaultValue;
40+
41+
const handleChange = useCallback(
42+
(next: string) => {
43+
if (next === value) return;
44+
void updateSettings({ [envVar]: next });
45+
},
46+
[value, updateSettings, envVar]
47+
);
48+
49+
return (
50+
<>
51+
<SelectField
52+
id={id}
53+
label={label}
54+
value={value}
55+
onChange={handleChange}
56+
options={options}
57+
variant="standard"
58+
size="small"
59+
/>
60+
<Text className="description">{description}</Text>
61+
</>
62+
);
63+
});
64+
65+
export default ServerSelectSetting;

web/src/components/menus/SettingsMenu.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
getDisplayedSettingGroups
4141
} from "./RemoteSettingsMenu";
4242
import ServerNumberSetting from "./ServerNumberSetting";
43+
import ServerSelectSetting from "./ServerSelectSetting";
4344
import { getAboutSidebarSections } from "./aboutSidebarUtils";
4445
import DefaultModelsMenu from "./DefaultModelsMenu";
4546
import MCPSettingsMenu from "./MCPSettingsMenu";
@@ -75,6 +76,11 @@ const CLOSE_BEHAVIOR_OPTIONS = [
7576
{ value: "background", label: "Keep Running in Background" }
7677
] as const;
7778

79+
const AGENT_EXECUTION_MODE_OPTIONS = [
80+
{ value: "tools", label: "Tools (JSON tool calls)" },
81+
{ value: "codeact", label: "CodeAct (JavaScript actions)" }
82+
] as const;
83+
7884
const PAN_CONTROLS_OPTIONS = [
7985
{ value: "LMB", label: "Pan canvas" },
8086
{ value: "RMB", label: "Select nodes (box)" }
@@ -840,6 +846,32 @@ function SettingsPage() {
840846
description="How many runs of the same workflow may run at once before further runs queue. Applies to concurrent generation (timeline, sketch); canvas runs always stay sequential."
841847
/>
842848
</SearchItem>
849+
850+
<SearchItem
851+
search={generalSearch}
852+
keywords="agent execution mode codeact code act tools javascript sandbox action space"
853+
>
854+
<ServerSelectSetting
855+
envVar="NODETOOL_AGENT_EXECUTION_MODE"
856+
id="agent-execution-mode-select"
857+
label="Agent Execution Mode"
858+
defaultValue="tools"
859+
options={AGENT_EXECUTION_MODE_OPTIONS}
860+
description={
861+
<>
862+
How agent steps act on their toolbelt.
863+
<br />
864+
<b>Tools:</b> one JSON tool call per action (the
865+
default).
866+
<br />
867+
<b>CodeAct:</b> each step writes JavaScript that
868+
runs in the sandbox and calls the same tools, so
869+
one action can loop, branch, and combine several
870+
tools.
871+
</>
872+
}
873+
/>
874+
</SearchItem>
843875
</div>
844876

845877
<div className="settings-section">
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { render, screen } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4+
import { ThemeProvider } from "@mui/material/styles";
5+
import mockTheme from "../../../__mocks__/themeMock";
6+
import ServerSelectSetting from "../ServerSelectSetting";
7+
import useRemoteSettingsStore from "../../../stores/RemoteSettingStore";
8+
9+
jest.mock("../../../stores/RemoteSettingStore");
10+
11+
const fetchSettings = jest.fn().mockResolvedValue([]);
12+
const updateSettings = jest.fn().mockResolvedValue(undefined);
13+
14+
const mockStore = useRemoteSettingsStore as unknown as jest.Mock;
15+
16+
const OPTIONS = [
17+
{ value: "tools", label: "Tools (JSON tool calls)" },
18+
{ value: "codeact", label: "CodeAct (JavaScript actions)" }
19+
] as const;
20+
21+
const setStoredValue = (value: string | undefined) => {
22+
const state = {
23+
settings:
24+
value === undefined
25+
? []
26+
: [{ env_var: "NODETOOL_AGENT_EXECUTION_MODE", value }],
27+
fetchSettings,
28+
updateSettings
29+
};
30+
mockStore.mockImplementation((selector: (s: typeof state) => unknown) =>
31+
selector(state)
32+
);
33+
};
34+
35+
const renderSetting = () =>
36+
render(
37+
<QueryClientProvider client={new QueryClient()}>
38+
<ThemeProvider theme={mockTheme}>
39+
<ServerSelectSetting
40+
envVar="NODETOOL_AGENT_EXECUTION_MODE"
41+
label="Agent Execution Mode"
42+
defaultValue="tools"
43+
options={OPTIONS}
44+
description="How agent steps act on their toolbelt."
45+
/>
46+
</ThemeProvider>
47+
</QueryClientProvider>
48+
);
49+
50+
describe("ServerSelectSetting", () => {
51+
beforeEach(() => {
52+
jest.clearAllMocks();
53+
});
54+
55+
it("falls back to the default when the setting is unset", () => {
56+
setStoredValue(undefined);
57+
renderSetting();
58+
expect(screen.getByRole("combobox")).toHaveTextContent(
59+
"Tools (JSON tool calls)"
60+
);
61+
});
62+
63+
it("shows the stored value", () => {
64+
setStoredValue("codeact");
65+
renderSetting();
66+
expect(screen.getByRole("combobox")).toHaveTextContent(
67+
"CodeAct (JavaScript actions)"
68+
);
69+
});
70+
71+
it("writes the picked value back to the server setting", async () => {
72+
setStoredValue("tools");
73+
renderSetting();
74+
75+
await userEvent.click(screen.getByRole("combobox"));
76+
await userEvent.click(
77+
screen.getByRole("option", { name: "CodeAct (JavaScript actions)" })
78+
);
79+
80+
expect(updateSettings).toHaveBeenCalledWith({
81+
NODETOOL_AGENT_EXECUTION_MODE: "codeact"
82+
});
83+
});
84+
85+
it("does not write when the value is unchanged", async () => {
86+
setStoredValue("codeact");
87+
renderSetting();
88+
89+
await userEvent.click(screen.getByRole("combobox"));
90+
await userEvent.click(
91+
screen.getByRole("option", { name: "CodeAct (JavaScript actions)" })
92+
);
93+
94+
expect(updateSettings).not.toHaveBeenCalled();
95+
});
96+
});

0 commit comments

Comments
 (0)