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
34 changes: 28 additions & 6 deletions AGENTS.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions eval-memory/harness/consolidator-tick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
import { DistillRunner } from "../../src/memory/consolidator/distill-runner.js";
import { LlamaServerClient } from "../../src/llm/llama-server-client.js";
import { createLinkGeneratorRunner } from "../../src/memory/links/link-generator-runner.js";
import { StructuredLogger, stderrSink } from "../../src/tracing/structured-logger.js";
import { StructuredLogger, createStderrSink } from "../../src/tracing/structured-logger.js";

export interface ConsolidatorTickInput {
stateDir: string;
Expand Down Expand Up @@ -262,7 +262,7 @@ export async function runLinkSweep(deps: LinkSweepDeps): Promise<LinkSweepStats>
// we cannot tell whether the model is rejecting the prompt, the
// parser is dropping malformed output, or we just need to bump
// the chunk timeout.
logger: new StructuredLogger({ level: "debug", sinks: [stderrSink()] }),
logger: new StructuredLogger({ level: "debug", sinks: [createStderrSink()] }),
});
const sessionId = "eval-link-sweep";
let chunks = 0;
Expand Down
204 changes: 204 additions & 0 deletions src/agent/agent-loop-cancel-race.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { AgentLoop, type AgentLoopEvent } from "./agent-loop.js";
import { buildDefaultToolRegistry } from "../tools/index.js";
import { SlotManager } from "../llm/slot-manager.js";
import { TransportError } from "../llm/reliability/llm-failures.js";
import type { CompletionResult } from "../llm/llama-server-client.js";
import { createEmptySessionState } from "../session/session-state.js";
import type {
CapabilitiesSummary,
ToolDescriptor,
} from "../prompt/stable-prefix.js";

/**
* A stop does not always arrive as an abort. When it lands as a stream
* ends, the request can come back as whatever the torn-down socket said
* — `terminated`, `fetch failed` — and the loop read that as a provider
* outage: it parked the turn on a wait the stop then cut short, closing
* the turn twice, or (with the wait off) failed the turn with the
* transport's words. A turn the user stopped is `cancelled`, closed once.
*/

const TOOLS: ToolDescriptor[] = [
{
name: "finish",
summary: "Finish the session with a summary.",
argsSchema: '{"summary": string}',
},
];

const CAPS: CapabilitiesSummary = {
platform: "darwin",
arch: "arm64",
browserChannel: "chrome",
workingDir: "/work",
hasClipboard: true,
hasWmctrl: false,
hasNotifications: true,
};

describe("a stop that races the end of a request", () => {
let workingDir: string;

beforeEach(() => {
workingDir = mkdtempSync(join(tmpdir(), "atomic-agent-cancel-race-"));
});

afterEach(() => {
rmSync(workingDir, { recursive: true, force: true });
});

function loopWith(
llmComplete: () => Promise<CompletionResult>,
events: AgentLoopEvent[],
): AgentLoop {
return new AgentLoop({
registry: buildDefaultToolRegistry(),
slotManager: new SlotManager(2),
grammar: 'root ::= "ok"',
llmComplete,
toolDescriptors: TOOLS,
capabilities: CAPS,
skillCatalog: [],
onEvent: (event) => events.push(event),
});
}

function closes(events: AgentLoopEvent[]): string[] {
return events.flatMap((event) =>
event.type === "loop_completed" ? [event.reason] : [],
);
}

it("ends the turn cancelled, not failed, when the stopped request comes back as a transport error", async () => {
const controller = new AbortController();
const events: AgentLoopEvent[] = [];
const loop = loopWith(async () => {
// The stop lands, and the request it tore down reports the socket.
controller.abort();
throw new TransportError("terminated", null, "");
}, events);
const result = await loop.runTurn(
createEmptySessionState({ id: "s-race-nowait", workingDir }),
{
userMessage: "stop me",
maxSteps: 5,
taskMaxSteps: 5,
providerWaitEnabled: false,
signal: controller.signal,
},
);
expect(result.reason).toBe("cancelled");
expect(result.session.status).toBe("cancelled");
expect(result.session.lastError).toBeNull();
expect(result.session.turnCount).toBe(1);
expect(closes(events)).toEqual(["cancelled"]);
});

it("does not park a stopped turn on a provider wait", async () => {
const controller = new AbortController();
const events: AgentLoopEvent[] = [];
const loop = loopWith(async () => {
controller.abort();
throw new TransportError("fetch failed", null, "");
}, events);
const result = await loop.runTurn(
createEmptySessionState({ id: "s-race-wait", workingDir }),
{
userMessage: "stop me",
maxSteps: 5,
taskMaxSteps: 5,
signal: controller.signal,
},
);
expect(result.reason).toBe("cancelled");
expect(result.session.status).toBe("cancelled");
expect(events.some((event) => event.type === "provider_waiting")).toBe(
false,
);
expect(result.session.turnCount).toBe(1);
expect(closes(events)).toEqual(["cancelled"]);
});

it("closes a turn stopped while parked on an outage exactly once", async () => {
const controller = new AbortController();
const events: AgentLoopEvent[] = [];
const loop = loopWith(async () => {
// The provider is down; the operator stops the turn while it waits.
setTimeout(() => controller.abort(), 5);
throw new TransportError("fetch failed", null, "");
}, events);
const result = await loop.runTurn(
createEmptySessionState({ id: "s-park-stop", workingDir }),
{
userMessage: "stop me while you wait",
maxSteps: 5,
taskMaxSteps: 5,
signal: controller.signal,
},
);
expect(result.reason).toBe("cancelled");
expect(result.session.status).toBe("cancelled");
expect(events.some((event) => event.type === "provider_waiting")).toBe(
true,
);
// One close, one turn — the wait's exit used to close it a second time.
expect(closes(events)).toEqual(["cancelled"]);
expect(
events.filter((event) => event.type === "turn_finished"),
).toHaveLength(1);
expect(result.session.turnCount).toBe(1);
});

it("still fails, and reports, an error no request makes even when the turn was stopped", async () => {
// `classifyFailure` answers `tool` for an error it does not know —
// the shape a programming error arrives in. A stop happening at the
// same moment must not file it away as a cancel.
const controller = new AbortController();
const events: AgentLoopEvent[] = [];
const loop = loopWith(async () => {
controller.abort();
throw new Error("bug: step context is undefined");
}, events);
const result = await loop.runTurn(
createEmptySessionState({ id: "s-bug", workingDir }),
{
userMessage: "work",
maxSteps: 5,
taskMaxSteps: 5,
signal: controller.signal,
},
);
expect(result.reason).toBe("failed");
expect(result.session.status).toBe("failed");
expect(result.session.lastError).toBe("bug: step context is undefined");
const failure = events.find((event) => event.type === "loop_failed");
expect(failure?.type === "loop_failed" ? failure.category : null).toBe(
"tool",
);
});

it("still fails a request that breaks while nobody has stopped the turn", async () => {
const events: AgentLoopEvent[] = [];
const loop = loopWith(async () => {
throw new TransportError("terminated", null, "");
}, events);
const result = await loop.runTurn(
createEmptySessionState({ id: "s-no-stop", workingDir }),
{
userMessage: "work",
maxSteps: 5,
taskMaxSteps: 5,
providerWaitEnabled: false,
signal: new AbortController().signal,
},
);
expect(result.reason).toBe("failed");
expect(result.session.status).toBe("failed");
expect(result.session.lastError).toBe("terminated");
});
});
51 changes: 51 additions & 0 deletions src/agent/agent-loop-lesson-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,20 @@ import type {
ToolDescriptor,
} from "../prompt/stable-prefix.js";
import type { LessonIndexEntry } from "../memory/lessons/lesson-store.js";
import {
humanizeOpenAiHttpError,
OpenAiHttpError,
} from "../llm/provider/openai/openai-http.js";
import { parseProviderErrorBody } from "../llm/provider/openai/parse-provider-error-body.js";
import { TransportError } from "../llm/reliability/llm-failures.js";

/**
* Phase 6 — lesson lifecycle integration with `AgentLoop.runTurn`.
*
* Pins:
* - `reply` / `finish` → hook fires with outcome="success".
* - thrown error → hook fires with outcome="failure".
* - a refusal because the account cannot pay → hook is NOT called.
* - `cancelled` (signal aborted) → hook is NOT called.
* - `max_steps` → hook is NOT called.
* - Once-per-turn dedup: surfacing the same lesson across many
Expand Down Expand Up @@ -208,6 +215,50 @@ describe("AgentLoop lesson lifecycle hook (phase 6)", () => {
expect(calls).toEqual([{ outcome: "failure", surfaced: [3, 4] }]);
});

it("does NOT fire the hook when the provider refused because the account cannot pay (item 40)", async () => {
// The turn fails at once on its first request, but an empty account
// says nothing about the lessons recalled for it: neutral, as the
// paused path for exhausted credit always was.
const calls: HookCall[] = [];
const body =
'{"title":"Forbidden","status":403,"message":"You\'ve run out of funds. Please top up your balance"}';
const http = new OpenAiHttpError(
`openai provider 403: ${body}`,
403,
"https://api.aimlapi.com/v1/chat/completions",
false,
null,
"aimlapi",
undefined,
{ body: parseProviderErrorBody(body) },
);
const loop = new AgentLoop({
registry: buildDefaultToolRegistry(),
slotManager: new SlotManager(2),
grammar: 'root ::= "ok"',
llmComplete: async () => {
throw new TransportError(humanizeOpenAiHttpError(http), 403, http.url, {
cause: http,
});
},
toolDescriptors: TOOLS,
capabilities: CAPS,
skillCatalog: SKILLS,
memoryContextProvider: makeProvider([[5, 6]]),
lessonLifecycle: captureHook(calls),
});
const result = await loop.runTurn(
createEmptySessionState({ id: "s-no-funds", workingDir }),
{
userMessage: "x",
maxSteps: 2,
signal: new AbortController().signal,
},
);
expect(result.reason).toBe("failed");
expect(calls).toEqual([]);
});

it("does NOT fire the hook when the turn is cancelled", async () => {
const calls: HookCall[] = [];
const ac = new AbortController();
Expand Down
Loading
Loading