diff --git a/docker/Dockerfile b/docker/Dockerfile index efb3b1e..18964f5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -13,6 +13,8 @@ # # Runtime environment: # OPENAI_API_KEY required for model calls (checked at call time) +# AGENT_MODEL, GUARDRAIL_MODEL, COMPACTOR_MODEL +# optional "provider:model" overrides of the models # SANDBOX_PROVIDER "local" (default) or "modal" # MODAL_TOKEN_ID/SECRET credentials for the modal sandbox provider # RESTATE_ADMIN_URL admin API for dynamic tool discovery (default diff --git a/docs/configuration.md b/docs/configuration.md index 231ac23..888819b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,6 +18,7 @@ The agent service reads these environment variables: | `MCP_SERVERS_JSON` | MCP servers the agents may use; see below | | `SANDBOX_PROVIDER` | `local` (default) or `modal`, which also needs `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` | | `MODAL_APP_NAME`, `MODAL_SANDBOX_NAMESPACE`, `MODAL_SANDBOX_IMAGE`, `MODAL_SANDBOX_TIMEOUT_MS` | Optional Modal settings; see [sandboxes](sandboxes.md) | +| `AGENT_MODEL`, `GUARDRAIL_MODEL`, `COMPACTOR_MODEL` | Override the agent, guardrail and compactor models, as `provider:model`; see [models](#models) | | `AGENT_MODEL_MAX_OUTPUT_TOKENS` | Output budget per model call, 1024–64000 (default 32000) | | `RESTATE_ADMIN_URL`, `RESTATE_ADMIN_TOKEN` | Admin API used to discover dynamic tools | @@ -44,6 +45,16 @@ models: { }, ``` +Each can also be set from the environment without editing the code: +`AGENT_MODEL`, `GUARDRAIL_MODEL` and `COMPACTOR_MODEL` override the agent, +guardrail and compactor models, and an unset or empty variable keeps the +default from `agent-config.ts`. The service reads them at startup. + +```sh +export AGENT_MODEL=anthropic:claude-sonnet-5 +export COMPACTOR_MODEL=google:gemini-3.8-flash +``` + Only the providers in use need an API key. Every call asks for low reasoning, and each provider maps that to its own setting. When the agent model changes, set `context.windowTokens` to its context window. diff --git a/packages/libs/core/src/agent-config.ts b/packages/libs/core/src/agent-config.ts index 5db0630..80670f4 100644 --- a/packages/libs/core/src/agent-config.ts +++ b/packages/libs/core/src/agent-config.ts @@ -38,6 +38,11 @@ import { import {getWeatherTool} from "./tools/weather.js"; import {webSearchTool} from "./tools/web-search.js"; +/** An environment variable's value, or the default when it is unset or blank. */ +function fromEnv(name: string, fallback: string): string { + return process.env[name]?.trim() || fallback; +} + export const agentConfig = { /** * The models, as "provider:model", where the provider is openai, @@ -45,17 +50,21 @@ export const agentConfig = { * model/provider.ts and docs/configuration.md). Each provider needs its API * key in the service's environment. The three may use different providers. * + * AGENT_MODEL, GUARDRAIL_MODEL and COMPACTOR_MODEL override them from the + * environment; they are read once, at module load, so a change needs a + * restart. + * * A turn keeps one model: its working messages carry that provider's * reasoning and tool-call data. Running turns finish on the version they * started on, so a change here applies to new turns. */ models: { /** Runs the agent's turns. */ - agent: "openai:gpt-5.6-luna", + agent: fromEnv("AGENT_MODEL", "openai:gpt-5.6-luna"), /** Evaluates and reviews proposed actions against the guardrails. */ - guardrail: "openai:gpt-5.6-terra", + guardrail: fromEnv("GUARDRAIL_MODEL", "openai:gpt-5.6-terra"), /** Summarizes older conversation for later turns. */ - compactor: "openai:gpt-5.6-terra", + compactor: fromEnv("COMPACTOR_MODEL", "openai:gpt-5.6-terra"), }, /** diff --git a/packages/libs/core/test/model-env.test.mjs b/packages/libs/core/test/model-env.test.mjs new file mode 100644 index 0000000..0a2b1e5 --- /dev/null +++ b/packages/libs/core/test/model-env.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import {execFile} from "node:child_process"; +import {test} from "node:test"; +import {promisify} from "node:util"; + +// agentConfig reads the model variables once, at module load, so each case +// loads it in a fresh process with its own environment. +const probe = ` + const {agentConfig} = await import("./src/agent-config.ts"); + console.log(JSON.stringify(agentConfig.models)); +`; + +async function modelsWith(env) { + const base = {...process.env}; + for (const name of ["AGENT_MODEL", "GUARDRAIL_MODEL", "COMPACTOR_MODEL"]) + delete base[name]; + const {stdout} = await promisify(execFile)( + process.execPath, + ["--import", "tsx", "--input-type=module", "-e", probe], + {cwd: new URL("..", import.meta.url), env: {...base, ...env}}, + ); + return JSON.parse(stdout.trim().split("\n").at(-1)); +} + +test("the model variables override the configured models", async () => { + const defaults = await modelsWith({}); + assert.deepEqual( + await modelsWith({ + AGENT_MODEL: "anthropic:claude-sonnet-5", + GUARDRAIL_MODEL: " xai:grok-5 ", + COMPACTOR_MODEL: "google:gemini-3.8-flash", + }), + { + agent: "anthropic:claude-sonnet-5", + guardrail: "xai:grok-5", + compactor: "google:gemini-3.8-flash", + }, + ); + assert.deepEqual( + await modelsWith({ + AGENT_MODEL: "deepseek:deepseek-chat", + COMPACTOR_MODEL: "", + }), + {...defaults, agent: "deepseek:deepseek-chat"}, + ); +});