Skip to content
Merged
458 changes: 458 additions & 0 deletions src/agent/agent-loop-local-gate.test.ts

Large diffs are not rendered by default.

58 changes: 54 additions & 4 deletions src/agent/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type ModelProfile,
} from "../llm/model-profile.js";
import type { ModelProfileManager } from "../llm/model-profile-manager.js";
import type { LocalBackendGate } from "../llm/local-backend-gate.js";
import type { ToolRegistry } from "../tools/tool-registry.js";
import {
CancelledError,
Expand Down Expand Up @@ -108,6 +109,19 @@ export interface AgentLoopDependencies {
* for the lifetime of the loop (test-mode wiring).
*/
profileManager?: ModelProfileManager;
/**
* Gate for the `profileManager` probes above (issue #112). The manager
* talks to the local llama-server, so on a cloud turn its refreshes
* are pure `/props` noise against a backend nothing is routed to —
* `isActive()` false skips them. `ensureProbed()` covers the reverse
* case: the operator switched back to a local provider after a cloud
* boot that deferred the probes, and this turn is the first local one.
* It returns `true` when it just ran them, which already includes a
* fresh `/props` — the loop then skips its own refresh rather than
* probing twice. Absent (test / legacy wiring) means "always local",
* preserving the pre-#112 behaviour.
*/
localBackend?: LocalBackendGate;
/** Skill catalog (name + description only), rebuilt on install/uninstall. */
skillCatalog: readonly SkillCatalogEntry[];
/**
Expand Down Expand Up @@ -394,6 +408,15 @@ export interface RunTurnResult {
export class AgentLoop {
constructor(private readonly deps: AgentLoopDependencies) {}

/**
* Whether the local llama-server is the route this turn takes. No gate
* wired (test / legacy deps) reads as `true` so the profile manager
* behaves exactly as it did before issue #112.
*/
private localBackendActive(): boolean {
return this.deps.localBackend?.isActive() ?? true;
}

/**
* Drive one macro-turn:
* user message → 0..N tool steps → `reply` (or `finish` / max_steps).
Expand Down Expand Up @@ -480,9 +503,27 @@ export class AgentLoop {
// Proactively sync with the live `llama-server` before the first
// step. Catches the case where the operator swapped the model
// between turns — without this, step 0 would still build the prompt
// with the previous model's template.
// with the previous model's template. Skipped whole on a cloud turn
// (issue #112): there is no llama-server behind the prompt to sync
// with, and the probe would fail against a backend nobody is using.
//
// ...unless the previous turn was actually SERVED by a local link
// through the fallback chain. `appendLocal` defaults to `true`, so a
// rate-limited cloud primary falls over to llama-server on every
// turn while the active provider stays cloud; without this second
// arm the profile and grammar would stay pinned to whatever the
// first fallover probed for the whole outage. Take-and-clear, so a
// recovered primary quiets the probes again after one turn.
const localLinkServedLastTurn =
this.deps.localBackend?.takeLinkServed?.() ?? false;
if (this.deps.profileManager) {
await this.deps.profileManager.refresh();
if (this.localBackendActive()) {
if (!(await this.deps.localBackend?.ensureProbed())) {
await this.deps.profileManager.refresh();
}
} else if (localLinkServedLastTurn) {
await this.deps.profileManager.refresh();
}
}

let reason: AgentLoopReason = "max_steps";
Expand Down Expand Up @@ -545,8 +586,17 @@ export class AgentLoop {
// Reactive refresh between steps: if the previous completion
// observed a foreign `modelId`, rebuild profile + grammar so the
// next prompt matches what `llama-server` is actually serving.
if (this.deps.profileManager) {
await this.deps.profileManager.refreshIfStale();
// Same cloud-turn gate as the turn-start refresh (issue #112).
// Nothing is lost on a cloud turn that falls over: the fallback
// seam's `prepareLink` runs this same `refreshIfStale` for a
// `llama-server` link at the point the link is picked, which is
// strictly later than here and strictly closer to the request —
// the completion that flagged the manager stale may not even have
// happened yet when this line runs.
if (this.deps.profileManager && this.localBackendActive()) {
if (!(await this.deps.localBackend?.ensureProbed())) {
await this.deps.profileManager.refreshIfStale();
}
}
this.deps.onEvent?.({ type: "step_started", stepIndex: i });
const started = Date.now();
Expand Down
189 changes: 189 additions & 0 deletions src/llm/local-backend-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { describe, expect, it, vi } from "vitest";

import {
createLocalLinkPreparer,
DeferredLocalBackendProbes,
} from "./local-backend-gate.js";

describe("DeferredLocalBackendProbes", () => {
it("never restores when boot already probed (local-from-boot run)", async () => {
const restore = vi.fn(async () => {});
const gate = new DeferredLocalBackendProbes(
{ isActive: () => true, restore },
true,
);
expect(await gate.ensureProbed()).toBe(false);
expect(await gate.ensureProbed()).toBe(false);
expect(restore).toHaveBeenCalledTimes(0);
});

it("restores exactly once, and only the winner may skip its own refresh", async () => {
const restore = vi.fn(async () => {});
const gate = new DeferredLocalBackendProbes(
{ isActive: () => true, restore },
false,
);
expect(await gate.ensureProbed()).toBe(true);
expect(await gate.ensureProbed()).toBe(false);
expect(await gate.ensureProbed()).toBe(false);
expect(restore).toHaveBeenCalledTimes(1);
});

it("a concurrent caller waits for the restore but does not claim it", async () => {
// Turn start racing a mid-turn fallover: both must see warm state
// when they proceed, and only one may report "a fresh /props landed".
let release!: () => void;
const started = vi.fn();
const gate = new DeferredLocalBackendProbes(
{
isActive: () => true,
restore: () =>
new Promise<void>((resolve) => {
started();
release = resolve;
}),
},
false,
);

const first = gate.ensureProbed();
const second = gate.ensureProbed();
expect(started).toHaveBeenCalledTimes(1);
release();

expect(await first).toBe(true);
expect(await second).toBe(false);
});

it("latches after a throwing restore so the probes cannot re-arm every step", async () => {
const restore = vi.fn(async () => {
throw new Error("llama-server is down");
});
const gate = new DeferredLocalBackendProbes(
{ isActive: () => true, restore },
false,
);
await expect(gate.ensureProbed()).rejects.toThrow("llama-server is down");
expect(await gate.ensureProbed()).toBe(false);
expect(restore).toHaveBeenCalledTimes(1);
});

it("latches after a SYNCHRONOUSLY throwing restore too", async () => {
// The async-throw test above passes even with the `restore()` call
// outside the try: the rejection is produced after `inFlight` has
// been assigned. A sync throw escapes before the assignment, so the
// latch never armed and every later call re-ran the probes — three
// `ensureProbed()` calls, three `restore()` calls.
const restore = vi.fn((): Promise<void> => {
throw new Error("config read blew up");
});
const gate = new DeferredLocalBackendProbes(
{ isActive: () => true, restore },
false,
);
await expect(gate.ensureProbed()).rejects.toThrow("config read blew up");
expect(await gate.ensureProbed()).toBe(false);
expect(await gate.ensureProbed()).toBe(false);
expect(restore).toHaveBeenCalledTimes(1);
});

it("take-and-clear reports whether a local link served since the last read", () => {
const gate = new DeferredLocalBackendProbes(
{ isActive: () => false, restore: async () => {} },
false,
);
// Nothing served yet: a pure cloud turn must not refresh anything.
expect(gate.takeLinkServed()).toBe(false);

gate.noteLinkServed();
expect(gate.takeLinkServed()).toBe(true);
// Cleared — one refresh per fallover, not one per turn forever.
expect(gate.takeLinkServed()).toBe(false);

gate.noteLinkServed();
gate.noteLinkServed();
expect(gate.takeLinkServed()).toBe(true);
expect(gate.takeLinkServed()).toBe(false);
});

it("reads `isActive` per call so a hot switch is observed", () => {
let active = false;
const gate = new DeferredLocalBackendProbes(
{ isActive: () => active, restore: async () => {} },
false,
);
expect(gate.isActive()).toBe(false);
active = true;
expect(gate.isActive()).toBe(true);
});
});

describe("createLocalLinkPreparer", () => {
/**
* The three decisions bootstrap's `prepareLink` makes. Covered here
* because deleting any one of them from an inline closure inside
* `buildRuntime` used to survive every test in the tree.
*/
const build = (opts: {
isLocalLink?: (id: string) => boolean;
probedAtBoot?: boolean;
} = {}) => {
const restore = vi.fn(async () => {});
const refreshIfStale = vi.fn(async () => {});
const gate = new DeferredLocalBackendProbes(
{ isActive: () => false, restore },
opts.probedAtBoot ?? false,
);
const prepare = createLocalLinkPreparer({
gate,
isLocalLink: opts.isLocalLink ?? ((id) => id === "local"),
refreshIfStale,
});
return { gate, prepare, restore, refreshIfStale };
};

it("does nothing at all for a link that is not llama-server", async () => {
const { prepare, gate, restore, refreshIfStale } = build();
await prepare("cloudy");
expect(restore).toHaveBeenCalledTimes(0);
expect(refreshIfStale).toHaveBeenCalledTimes(0);
// The zero-request criterion in one assertion: a cloud attempt does
// not even record that a local link served.
expect(gate.takeLinkServed()).toBe(false);
});

it("marks the link served so the loop's turn-start refresh reopens", async () => {
const { prepare, gate } = build();
await prepare("local");
expect(gate.takeLinkServed()).toBe(true);
});

it("restores on the first local attempt and does not also refresh", async () => {
const { prepare, restore, refreshIfStale } = build();
await prepare("local");
expect(restore).toHaveBeenCalledTimes(1);
// The restore's own `/props` just landed; refreshing again would
// probe twice for one attempt.
expect(refreshIfStale).toHaveBeenCalledTimes(0);
});

it("falls through to refreshIfStale on every later local attempt", async () => {
// The reactive path the loop's between-steps refresh cannot serve
// while the active provider is cloud.
const { prepare, restore, refreshIfStale } = build();
await prepare("local");
await prepare("local");
await prepare("local");
expect(restore).toHaveBeenCalledTimes(1);
expect(refreshIfStale).toHaveBeenCalledTimes(2);
});

it("refreshes from the very first attempt on a local-from-boot run", async () => {
// Boot already probed, so there is nothing to restore — but the
// staleness flag still needs a consumer.
const { prepare, restore, refreshIfStale } = build({ probedAtBoot: true });
await prepare("local");
expect(restore).toHaveBeenCalledTimes(0);
expect(refreshIfStale).toHaveBeenCalledTimes(1);
});
});
Loading
Loading