Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ This is the source-of-truth for automated contributors (LLM agents, codegen, etc
3. **One inference per step.** No reasoning loops inside a single LLM call — the runtime drives the loop. A single inference always emits a JSON **array** of `1..N` tool calls (`[{tool, args}, ...]`); a "solo" step is just a length-1 array (`[{...}]`). `N` is capped by `agent.maxParallelToolCalls` (default 8, hard ceiling 16 in the grammar). See §"Parallel tool calls per step" for the rationale (GBNF first-token bias) and the executor pipeline.
4. **Grammar-constrained tool calls.** The sidecar sends a GBNF grammar with every completion request that must produce a tool call. The root collapsed to **array-only** (`root ::= tool-call-array`) so the model cannot fall into the single-object form via first-token bias even when it only needs one call. Reasoning-prelude profiles (`qwen-think`, `gemma4-think`) prepend a `<think>...</think>` / `<|channel>thought...<channel|>` block to the array; the seam between the close sentinel and the leading `[` of the array routes through a dedicated **bounded** `prelude-trail-ws ::= ( [ \t\n\r] ){0,8}` rule rather than the global unbounded `ws`. This is the structural anti-degenerate-loop guard — small reasoning-capable models (Gemma 4 26B-A4B in particular) used to slide into a whitespace-only tail after a long reasoning block because the sampler could keep emitting newlines indefinitely. Pinned by [src/llm/grammar/build-grammar.test.ts](src/llm/grammar/build-grammar.test.ts) "bounds the whitespace between the reasoning-close sentinel and the tool-call array".

**Reasoning-open ownership differs by profile (`reasoningOpenEmittedByModel` in [src/llm/model-profile.ts](src/llm/model-profile.ts)).** `qwen-think` **prefills** its open tag at the end of the prompt (`### respond` → `<think>`); the grammar prelude starts after the open tag (`think-prelude ::= think-body "</think>" …`) and `step-executor` prepends the open tag back onto the completion before parsing. `gemma4-think` is the opposite: Gemma 4's QAT template treats a prefilled `<|channel>thought\n` as the *thinking-disabled* marker and immediately closes the channel, dumping its reasoning into `reply.text`. The fix is **native turn-framing** — the profile carries `turnFraming { systemOpen: "<|turn>system\n", turnClose: "<turn|>\n", assistantOpen: "<|turn>model\n" }` and `reasoningEmittedByModel: true`. `buildStablePrefix` opens the prompt with `<|turn>system\n<|think|>\n### system…` (the `<|think|>` token must sit at the very top of a real system turn to activate the channel — a one-time gemma-only stable-prefix byte change / KV invalidation), `buildPrompt` ends the tail with `…### respond\nRespond now.\n\n<turn|>\n<|turn>model\n` instead of a channel prefill, and the grammar prelude **includes the open sentinel** (`channel-prelude ::= "<|channel>thought\n" channel-body "<channel|>" prelude-trail-ws`) so the model emits its own `<|channel>thought` block. `step-executor.normalizeContent` does **not** prepend the open tag for `reasoningEmittedByModel` profiles (it is already in `completion.content`), the stream parser runs with `preOpenedThink: false` so it detects the open tag live, and the repair prompt strips/re-appends `<turn|>\n<|turn>model` rather than the bare open tag. `checkProfilePromptAligned` asserts a turn-framed prompt ends with the model-turn opener (not the open tag). Pinned by [src/agent/profile-matrix.test.ts](src/agent/profile-matrix.test.ts), [src/llm/grammar/build-grammar.test.ts](src/llm/grammar/build-grammar.test.ts), [src/prompt/build-prompt.test.ts](src/prompt/build-prompt.test.ts), [src/llm/profile-invariants.test.ts](src/llm/profile-invariants.test.ts), [src/llm/grammar/stream-parser.test.ts](src/llm/grammar/stream-parser.test.ts).
**Reasoning-open ownership differs by profile (`reasoningOpenEmittedByModel` in [src/llm/model-profile.ts](src/llm/model-profile.ts)).** `qwen-think` **prefills** its open tag at the end of the prompt (`### respond` → `<think>`); the grammar prelude starts after the open tag (`think-prelude ::= think-body "</think>" …`) and `step-executor` prepends the open tag back onto the completion before parsing. Nemotron 3.5 Lightning shares that ownership exactly: its template ends generation at `<|im_start|>assistant\n<think>\n`, so `selectBaseProfile` detects it separately (the alias hint differs) but returns `qwen-think` itself rather than a duplicate profile. `gemma4-think` is the opposite: Gemma 4's QAT template treats a prefilled `<|channel>thought\n` as the *thinking-disabled* marker and immediately closes the channel, dumping its reasoning into `reply.text`. The fix is **native turn-framing** — the profile carries `turnFraming { systemOpen: "<|turn>system\n", turnClose: "<turn|>\n", assistantOpen: "<|turn>model\n" }` and `reasoningEmittedByModel: true`. `buildStablePrefix` opens the prompt with `<|turn>system\n<|think|>\n### system…` (the `<|think|>` token must sit at the very top of a real system turn to activate the channel — a one-time gemma-only stable-prefix byte change / KV invalidation), `buildPrompt` ends the tail with `…### respond\nRespond now.\n\n<turn|>\n<|turn>model\n` instead of a channel prefill, and the grammar prelude **includes the open sentinel** (`channel-prelude ::= "<|channel>thought\n" channel-body "<channel|>" prelude-trail-ws`) so the model emits its own `<|channel>thought` block. `step-executor.normalizeContent` does **not** prepend the open tag for `reasoningEmittedByModel` profiles (it is already in `completion.content`), the stream parser runs with `preOpenedThink: false` so it detects the open tag live, and the repair prompt strips/re-appends `<turn|>\n<|turn>model` rather than the bare open tag. `checkProfilePromptAligned` asserts a turn-framed prompt ends with the model-turn opener (not the open tag). Pinned by [src/agent/profile-matrix.test.ts](src/agent/profile-matrix.test.ts), [src/llm/grammar/build-grammar.test.ts](src/llm/grammar/build-grammar.test.ts), [src/prompt/build-prompt.test.ts](src/prompt/build-prompt.test.ts), [src/llm/profile-invariants.test.ts](src/llm/profile-invariants.test.ts), [src/llm/grammar/stream-parser.test.ts](src/llm/grammar/stream-parser.test.ts).
5. **No global singletons.** Dependencies are passed explicitly. `getConfig()` is the only exception.
6. **Session is multi-turn chat only.** A session is a long-lived chat: `user message → 0..N tool steps → reply` is a macro-turn, multiple turns share one `SessionState.turns[]`. Two terminals exist — `reply` ends the turn, `finish` ends the whole session. All three frontends (CLI `run`, TUI, sidecar) go through `runtime.runTurn` only; there is no one-shot goal mode.

Expand Down Expand Up @@ -276,7 +276,7 @@ Symmetric **`EmbeddingProviderRegistry`** ([src/memory/embeddings/embedding-prov
`atomic-agent` supports two modes for the llama-server backend (`config.llama.mode`):

- `external` (default) — user runs `llama-server` out-of-band; runtime reads the URL from `config.llama.url` (env fallback `ATOMIC_AGENT_LLAMA_URL`).
- `managed` — `atomic-agent` downloads the llama.cpp binary from `AtomicBot-ai/atomic-llama-cpp-turboquant` GitHub Releases into `<stateDir>/llamacpp/backend/` and GGUF models into `<stateDir>/llamacpp/models/<id>/`. The server is **not** spawned by the runtime; operators control lifecycle via `atomic-agent llama start|stop|status|update`.
- `managed` — `atomic-agent` downloads the llama.cpp binary from `AtomicBot-ai/atomic-llama-cpp-turboquant-nightly` GitHub Releases into `<stateDir>/llamacpp/backend/` and GGUF models into `<stateDir>/llamacpp/models/<id>/`. The server is **not** spawned by the runtime; operators control lifecycle via `atomic-agent llama start|stop|status|update`. Managed start auto-pulls a newer zip when `localModels.managed.autoUpdate` is true (default since config v41). A failed check or download never blocks start — the existing binary is used. The two entry points differ deliberately: **TUI auto-start** brings the daemon up first and runs the update afterwards, off the start path, so the user never faces a typeable prompt with no model behind it; that pass also refuses to stop the live daemon (`keepDaemonRunning`), so the swap lands on the next start. **CLI `models start`** is an explicit one-shot command, so it still updates before starting. Both bound the download with a timeout.

**Invariant (preserved):** the agent runtime never starts a `llama-server` process. It only connects. Managed-mode lifecycle lives entirely in the `atomic-agent llama` CLI so runtime code paths stay single-mode.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ The model chooses actions. Atomic Agent owns the loop, the state, the approvals,

### Built to Make Local Models Work

We run local models on our own TurboQuant `llama.cpp` ([`AtomicBot-ai/atomic-llama-cpp-turboquant`](https://github.com/AtomicBot-ai/atomic-llama-cpp-turboquant)):
We run local models on our own TurboQuant `llama.cpp` ([`AtomicBot-ai/atomic-llama-cpp-turboquant-nightly`](https://github.com/AtomicBot-ai/atomic-llama-cpp-turboquant-nightly)):

- **TurboQuant KV-cache:** WHT-rotated low-bit quantization compresses the KV-cache up to ~6.4× versus F16, with a fused Metal decode kernel, so long-context sessions fit in far less memory.
- **TurboQuant weights:** Lloyd-Max weight quantization with WHT rotation and fused Metal/Vulkan kernels keeps quality usable while small models fit on consumer hardware.
Expand Down
16 changes: 16 additions & 0 deletions src/agent/profile-matrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
GEMMA4_PROPS,
GPT_OSS_PROPS,
LLAMA3_PROPS,
NEMOTRON_PROPS,
QWEN3_PROPS,
} from "../llm/model-profile.fixtures.js";
import { startTestHarness } from "../http/test-harness.js";
Expand Down Expand Up @@ -36,6 +37,21 @@ describe("profile matrix", () => {
});
});

it("streams nemotron inline reasoning through the full agent loop", async () => {
// Nemotron shares the qwen think-tags profile (its alias is what needs a
// dedicated detector, not its behaviour): the prompt prefills `<think>`,
// the stream starts mid-body and closes with `</think>` before the array.
// Without the nemotron detection branch this props payload falls through
// to plain-instruct and emits no reasoning at all.
await expectScenario({
props: NEMOTRON_PROPS,
chunks: [
'inner thought</think>[{"tool":"reply","args":{"text":"ok"}}]',
],
expectReasoning: true,
});
});

it("streams gemma 4 channel reasoning through the full agent loop", async () => {
// Turn-framed gemma: the model emits its OWN `<|channel>thought\n` opener
// (the prompt no longer prefills it), reasons, then closes with
Expand Down
16 changes: 12 additions & 4 deletions src/cli/models-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,18 @@ describe("modelsCommand", () => {
const code = await modelsCommand(["list"]);
expect(code).toBe(0);
const out = stdout();
// One row per curated model in LOCAL_MODELS_CATALOG: 4 gemma + 6 qwen.
expect(out.split("\n").filter((l) => l.includes("qwen-") || l.includes("gemma-"))).toHaveLength(
10,
);
// One row per curated model in LOCAL_MODELS_CATALOG.
expect(
out
.split("\n")
.filter(
(l) =>
l.includes("qwen-") ||
l.includes("gemma-") ||
l.includes("nemotron-") ||
l.includes("muse-"),
),
).toHaveLength(12);
});

it("pull with bad id exits 1", async () => {
Expand Down
69 changes: 66 additions & 3 deletions src/cli/models-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
isModelDownloaded,
listVulkanDevices,
LOCAL_MODELS_CATALOG,
maybeAutoUpdateBackend,
readBackendVersion,
removeModel,
resolveChatTemplatePath,
Expand Down Expand Up @@ -62,14 +63,14 @@ export async function runLocalModelsList(): Promise<number> {
const cfg = getConfig();
const dataDir = cfg.paths.localModelsDataDir;
process.stdout.write(
"ID | FAMILY | SIZE | CONTEXT | DL | ACTIVE\n",
"ID | FAMILY | SIZE | CONTEXT | DL | ACTIVE\n",
);
for (const m of LOCAL_MODELS_CATALOG) {
const dl = isModelDownloaded(dataDir, m) ? "yes" : "no";
const active =
cfg.localModels.managed.modelId === m.id && cfg.localModels.mode === "managed" ? "*" : " ";
process.stdout.write(
`${m.id.padEnd(19)} | ${m.family.padEnd(6)} | ${m.sizeLabel.padEnd(6)} | ${m.contextLabel.padEnd(7)} | ${dl.padEnd(3)} | ${active}\n`,
`${m.id.padEnd(20)} | ${m.family.padEnd(8)} | ${m.sizeLabel.padEnd(6)} | ${m.contextLabel.padEnd(7)} | ${dl.padEnd(3)} | ${active}\n`,
);
}
return 0;
Expand Down Expand Up @@ -218,6 +219,54 @@ export async function runLocalModelsStart(): Promise<number> {
return 1;
}
const dataDir = cfg.paths.localModelsDataDir;
try {
const auto = await maybeAutoUpdateBackend(dataDir, {
enabled: cfg.localModels.managed.autoUpdate,
// Unlike the TUI, `models start` is an explicit one-shot command:
// updating before the daemon comes up is what the operator asked
// for. It still needs a deadline — a stalled-open connection would
// otherwise pin the command forever with a progress bar at 12%.
signal: AbortSignal.timeout(BACKEND_DOWNLOAD_TIMEOUT_MS),
onProgress: (p: number, t: number, tot: number) => {
const line = renderPullProgress("backend zip", p, t, tot);
if (process.stderr.isTTY) process.stderr.write(`\r${line.padEnd(79)}`);
else if (p % 5 === 0 || p === 100) process.stderr.write(`${line}\n`);
},
});
if (auto.action === "updated") {
if (process.stderr.isTTY) process.stderr.write("\n");
process.stdout.write(
`backend: updated ${auto.from ?? "none"} → ${auto.to}\n`,
);
} else if (auto.action === "check_failed") {
process.stderr.write(
`note: backend update check failed — starting current binary (${auto.error})\n`,
);
} else if (auto.action === "deferred") {
process.stderr.write(
"note: backend update deferred — another session is using the current binary\n",
);
} else if (auto.action === "update_failed") {
if (process.stderr.isTTY) process.stderr.write("\n");
if (!auto.backendUsable) {
// The daemon was stopped for the update and there is no binary
// left to fall back to — nothing can be started.
process.stderr.write(
`backend auto-update failed and no usable backend remains: ${auto.error}\n` +
`run 'atomic-agent models update' once connectivity is back.\n`,
);
return 1;
}
process.stderr.write(
`note: backend update failed — starting current binary (${auto.error})\n`,
);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`backend auto-update failed: ${msg}\n`);
return 1;
}

const m = getLocalModelDef(mid);
const tpl = resolveChatTemplatePath(m) ?? undefined;
const mmprojFile =
Expand Down Expand Up @@ -337,6 +386,13 @@ function describeDeviceChoice(
return resolved ?? configured;
}

/**
* Deadline for the backend asset download. The zip is 27-39 MB, so this
* is generous for any working link; it exists because a stalled-but-open
* TCP connection never resolves on its own.
*/
const BACKEND_DOWNLOAD_TIMEOUT_MS = 10 * 60_000;

const DEVICE_ID_RE = /^[A-Za-z]+\d+$/;

/**
Expand Down Expand Up @@ -580,7 +636,14 @@ export async function runLocalModelsUpdate(): Promise<number> {
try {
const { updateAvailable, latestTag, currentTag } = await checkForBackendUpdate(dataDir);
if (!updateAvailable) {
process.stdout.write(`backend up to date (${latestTag})\n`);
// `latestTag` is null when no scanned release ships this
// platform's asset — nothing to compare against, so the install
// on disk stands.
process.stdout.write(
latestTag === null
? `backend unchanged (no published release for this platform)\n`
: `backend up to date (${latestTag})\n`,
);
return 0;
}
process.stdout.write(`current: ${currentTag ?? "none"} → latest: ${latestTag}\n`);
Expand Down
32 changes: 32 additions & 0 deletions src/config/config-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,38 @@ describe("parseUserConfigFile", () => {
expect(parsed.localModels.managed.autoUpdate).toBe(true);
});

it("defaults localModels.managed.autoUpdate to true", () => {
const parsed = parseUserConfigFile({ version: USER_CONFIG_VERSION });
expect(parsed.localModels.managed.autoUpdate).toBe(true);
});

it("migrates a pre-v41 autoUpdate:false (unused default) to true", () => {
const parsed = parseUserConfigFile({
version: 37,
localModels: { managed: { autoUpdate: false } },
});
expect(parsed.localModels.managed.autoUpdate).toBe(true);
});

// The migration gate is the LAST version that stored the dead default.
// v40 is the boundary: it must still migrate, v41 must be honoured.
// Without this pair the gate can drift off USER_CONFIG_VERSION unnoticed.
it("migrates a v40 autoUpdate:false (still the unused default) to true", () => {
const parsed = parseUserConfigFile({
version: 40,
localModels: { managed: { autoUpdate: false } },
});
expect(parsed.localModels.managed.autoUpdate).toBe(true);
});

it("preserves an explicit localModels.managed.autoUpdate=false on v41+", () => {
const parsed = parseUserConfigFile({
version: USER_CONFIG_VERSION,
localModels: { managed: { autoUpdate: false } },
});
expect(parsed.localModels.managed.autoUpdate).toBe(false);
});

it("preserves an explicit localModels.managed.device override", () => {
const parsed = parseUserConfigFile({
version: USER_CONFIG_VERSION,
Expand Down
Loading