Skip to content
Closed
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
6 changes: 6 additions & 0 deletions src/agent/agent-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { AgentMetrics } from "../tracing/agent-metrics.js";
import type { StructuredLogger } from "../tracing/structured-logger.js";
import type { ProfileClippedEvent } from "./profile-clip-warning.js";
import type { HistoryTrimmedEvent } from "./history-trim-warning.js";
import type { ToolDisabledEvent } from "./tool-disabled-notice.js";
import type { ReviewStallSignal } from "./review-stall.js";

/** See `AgentLoopDependencies.localEngineState`. */
Expand Down Expand Up @@ -925,6 +926,11 @@ export type AgentLoopEvent =
| ProfileClippedEvent
/** The packer left older history out of the prompt (ATO-295). */
| HistoryTrimmedEvent
/**
* A tool was left out of this turn's requests: the server refused its
* JSON schema (ATO-1417). Once per tool per turn.
*/
| ToolDisabledEvent
| { type: "step_started"; stepIndex: number }
| {
type: "step_finished";
Expand Down
138 changes: 138 additions & 0 deletions src/agent/agent-loop-tool-disabled.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { afterEach, beforeEach, describe, expect, it } from "vitest";

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

import { AgentLoop } from "./agent-loop.js";
import type { AgentLoopEvent, MemoryContextProvider } from "./agent-loop.js";
import type { ToolDisabledEvent } from "./tool-disabled-notice.js";

/**
* ATO-1417, loop wiring. A completion whose request went out without a
* tool (`droppedTools`, the server refused its schema) becomes one
* `tool_disabled` event per tool per turn, however many completions of
* the turn report it.
*/

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,
};
const NOOP_PROVIDER: MemoryContextProvider = {
buildMemoryContext: () => ({ recalled: [], index: [] }),
};
const DROPPED = [
{ name: "notion.search", reason: "schema_rejected" as const },
];
const completion = (content: string, dropped = true): CompletionResult => ({
content,
reasoningContent: "",
stop: true,
truncated: false,
cacheHitTokens: 0,
slotId: 0,
modelId: "mock",
timing: { promptMs: 1, predictedMs: 1, promptTokens: 10, predictedTokens: 5 },
...(dropped ? { droppedTools: DROPPED } : {}),
});
const REPLY = JSON.stringify({ tool: "reply", args: { text: "ok" } });

describe("AgentLoop says when a tool was left out of the request", () => {
let workingDir: string;
beforeEach(() => {
workingDir = mkdtempSync(join(tmpdir(), "atomic-tool-disabled-"));
});
afterEach(() => {
rmSync(workingDir, { recursive: true, force: true });
});

const makeLoop = (
events: AgentLoopEvent[],
complete: () => CompletionResult,
): AgentLoop =>
new AgentLoop({
registry: buildDefaultToolRegistry(),
slotManager: new SlotManager(2),
grammar: 'root ::= "ok"',
llmComplete: async () => complete(),
toolDescriptors: TOOLS,
capabilities: CAPS,
skillCatalog: [],
memoryContextProvider: NOOP_PROVIDER,
onEvent: (event) => events.push(event),
});
const disabled = (events: AgentLoopEvent[]): ToolDisabledEvent[] =>
events.filter((e): e is ToolDisabledEvent => e.type === "tool_disabled");
const completions = (events: AgentLoopEvent[]): number =>
events.filter(
(e) => e.type === "llm_event" && e.event.type === "llm_completed",
).length;
const turn = (loop: AgentLoop, id: string) =>
loop.runTurn(createEmptySessionState({ id, workingDir }), {
userMessage: "hello",
maxSteps: 3,
signal: new AbortController().signal,
});

it("names the tool once, however many completions of the turn drop it", async () => {
const events: AgentLoopEvent[] = [];
// An unparseable first completion makes the turn ask again: two
// completions in one turn, both sent without the same tool.
const replies = [completion("{{garbage"), completion(REPLY)];
const loop = makeLoop(events, () => replies.shift() ?? completion(REPLY));

await turn(loop, "s1");

expect(completions(events)).toBeGreaterThanOrEqual(2);
expect(disabled(events)).toEqual([
{
type: "tool_disabled",
stepIndex: 0,
tool: "notion.search",
reason: "schema_rejected",
},
]);
});

it("names it again on the next turn", async () => {
const events: AgentLoopEvent[] = [];
const loop = makeLoop(events, () => completion(REPLY));

await turn(loop, "s2");
await turn(loop, "s2");

expect(disabled(events)).toHaveLength(2);
});

it("says nothing when every tool went out", async () => {
const events: AgentLoopEvent[] = [];
const loop = makeLoop(events, () => completion(REPLY, false));

await turn(loop, "s3");

expect(completions(events)).toBeGreaterThan(0);
expect(disabled(events)).toEqual([]);
});
});
11 changes: 11 additions & 0 deletions src/agent/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { getConfig } from "../config/index.js";

import { ProfileClipWarnings, reportProfileClip } from "./profile-clip-warning.js";
import { HistoryTrimWarnings, reportHistoryTrim } from "./history-trim-warning.js";
import { toolDisabledEvents } from "./tool-disabled-notice.js";
import { CompactPromptLatch } from "../prompt/compact-prompt.js";

/**
Expand Down Expand Up @@ -148,6 +149,9 @@ export class AgentLoop {
const { fusionOrchestratorTurn, toolRole, claimEvidence, linkEvidence, progressNotes, unknownTools } = policies;
let { fusionState, reviewStall, replan } = policies;
const turn = createTurnLoopState();
// ATO-1417: tools a provider left out of this turn's requests (the
// server refused their schema), each said once per turn.
const announcedDroppedTools = new Set<string>();
// ATO-486: a new message on a session whose plan is still open gets
// one short reminder on the turn's first step, so the model carries
// the checklist on or says it is dropped. Never on a plan that is
Expand Down Expand Up @@ -574,6 +578,13 @@ export class AgentLoop {
// spend ceiling exists for.
if (event.type === "llm_completed") {
recordStepSpend(turn, event.completion, contextProviderId, this.deps.priceCompletion);
for (const disabled of toolDisabledEvents(
event.completion.droppedTools,
announcedDroppedTools,
i,
)) {
this.deps.onEvent?.(disabled);
}
}
if (event.type === "prompt_built") {
turn.lastPromptTokens = event.prompt.tokens.total;
Expand Down
33 changes: 33 additions & 0 deletions src/agent/tool-disabled-notice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";

import { toolDisabledEvents, toolDisabledNotice } from "./tool-disabled-notice.js";

describe("tool_disabled (ATO-1417)", () => {
it("says which tool the model cannot use and why, in one sentence", () => {
expect(toolDisabledNotice("notion.search")).toBe(
"Tool notion.search is disabled for this turn: the model server did not accept its schema.",
);
});

it("one event per tool the turn has not announced yet", () => {
const announced = new Set<string>();
const dropped = [
{ name: "notion.search", reason: "schema_rejected" as const },
{ name: "records.lookup", reason: "schema_rejected" as const },
];
expect(toolDisabledEvents(dropped, announced, 1)).toEqual([
{ type: "tool_disabled", stepIndex: 1, tool: "notion.search", reason: "schema_rejected" },
{ type: "tool_disabled", stepIndex: 1, tool: "records.lookup", reason: "schema_rejected" },
]);
// The next step sends the same tools and is refused the same way.
expect(toolDisabledEvents(dropped, announced, 2)).toEqual([]);
expect([...announced]).toEqual(["notion.search", "records.lookup"]);
});

it("nothing for a completion that dropped nothing", () => {
const announced = new Set<string>();
expect(toolDisabledEvents(undefined, announced, 0)).toEqual([]);
expect(toolDisabledEvents([], announced, 0)).toEqual([]);
expect(announced.size).toBe(0);
});
});
52 changes: 52 additions & 0 deletions src/agent/tool-disabled-notice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { DroppedTool } from "../llm/provider/completion-types.js";

/**
* ATO-1417. The provider sent a request without one of the session's
* tools because the server refused that tool's JSON schema (ATO-1399:
* LM Studio and llama.cpp reject some MCP schemas outright), so the model
* cannot call it. Before this event the only trace was a log line, and
* the person saw a model that simply never used their tool. Rides the
* `AgentLoopEvent` union so the TUI and the HTTP stream can say it in the
* chat.
*
* Emitted once per tool per turn: every step of the turn sends the same
* tool list, and so is refused and resent the same way.
*/
export interface ToolDisabledEvent {
type: "tool_disabled";
stepIndex: number;
/** The tool's own name, as the registry knows it. */
tool: string;
/** `schema_rejected`: the server did not accept its JSON schema. */
reason: DroppedTool["reason"];
}

/** The chat line for a `tool_disabled` event, shared by the TUI and the HTTP frame. */
export function toolDisabledNotice(tool: string): string {
return `Tool ${tool} is disabled for this turn: the model server did not accept its schema.`;
}

/**
* The `tool_disabled` events a completion calls for: one per dropped
* tool that this turn has not announced yet. `announced` is the turn's
* own set of tool names and is updated in place.
*/
export function toolDisabledEvents(
droppedTools: readonly DroppedTool[] | undefined,
announced: Set<string>,
stepIndex: number,
): ToolDisabledEvent[] {
if (!droppedTools || droppedTools.length === 0) return [];
const events: ToolDisabledEvent[] = [];
for (const dropped of droppedTools) {
if (!dropped.name || announced.has(dropped.name)) continue;
announced.add(dropped.name);
events.push({
type: "tool_disabled",
stepIndex,
tool: dropped.name,
reason: dropped.reason,
});
}
return events;
}
2 changes: 1 addition & 1 deletion src/http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Read README.md and ../runtime/docs/lifecycle.md for session/stream/steering chan

`npx vitest run src/http`; `npm run lint`; `npm run docs:check`. The production typecheck excludes tests; see [development checks](../../docs/development.md).

[Compaction routes](route-compaction.ts) expose `POST /api/sessions/{id}/compact` and `GET /api/sessions/{id}/compaction`. The POST waits for runtime completion and cancels only its operation on disconnect. See [the shared contract](../runtime/docs/compaction.md). With `x-atomic-extensions: 1`, a chat stream also carries `compaction_started`, `compaction_progress`, `compaction_completed`, `compaction_failed` and `history_trimmed` frames ([mapping](stream-maintenance-frames.ts)): counts and statuses only. It also carries `usage_updated` (ATO-569): the session's spend so far, priced / unpriced / local / plan call counts, and the account's rate-limit windows (used percent, remaining, reset time); the same snapshot is persisted as `sessionUsage` on `GET /api/sessions/{id}`.
[Compaction routes](route-compaction.ts) expose `POST /api/sessions/{id}/compact` and `GET /api/sessions/{id}/compaction`. The POST waits for runtime completion and cancels only its operation on disconnect. See [the shared contract](../runtime/docs/compaction.md). With `x-atomic-extensions: 1`, a chat stream also carries `compaction_started`, `compaction_progress`, `compaction_completed`, `compaction_failed` and `history_trimmed` frames ([mapping](stream-maintenance-frames.ts)): counts and statuses only. It also carries `usage_updated` (ATO-569): the session's spend so far, priced / unpriced / local / plan call counts, and the account's rate-limit windows (used percent, remaining, reset time); the same snapshot is persisted as `sessionUsage` on `GET /api/sessions/{id}`. A `tool_disabled` frame (ATO-1417) says a tool was left out of the turn's requests because the server refused its JSON schema: `tool`, `reason` (`schema_rejected`), `step_index` and the chat sentence in `notice`, once per tool per turn.

[Turn phases](stream-turn-phase.ts) (ATO-1027): with `x-atomic-extensions: 1`, a chat stream carries `event: turn_phase` on every change of what the turn is doing, `{ object: "atomic.turn_phase", session_id, phase, step_index?, tool?, phase_elapsed_ms, turn_elapsed_ms, progress? }`. `phase` is `starting`, `loading_model` (the local server answered "Loading model"), `waiting_provider`, `processing_prompt` (`progress: { processed, total, cached, fraction }` when llama-server reports prompt progress), `thinking`, `generating`, `running_tool` (`tool` names the call) or `compacting`. A host counts the seconds itself from `phase_elapsed_ms`. Once per turn, when a local model has produced nothing 45 s after the turn began, `event: slow_first_token` carries `{ object: "atomic.slow_first_token", session_id, waited_ms, phase?, model_id?, lighter_model?: { id, name, file_size_gb, downloaded }, cloud_available }` ([hint](slow-first-token-hint.ts)): what the host may offer instead. Nothing is switched.

Expand Down
21 changes: 21 additions & 0 deletions src/http/openai-chat-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
} from "./request-context.js";
import { deriveChatSessionId } from "./openai-session-id.js";
import { maintenanceFrame } from "./stream-maintenance-frames.js";
import { toolDisabledNotice } from "../agent/tool-disabled-notice.js";
import { describeToolCall } from "../tools/describe-tool-call.js";
import { createTurnPhaseTracker } from "./stream-turn-phase.js";
import { claimTurnProgress } from "../agent/step/turn-progress-opt-in.js";
Expand Down Expand Up @@ -446,6 +447,8 @@ async function handleStream(
* (extensions opt-in only, ATO-486)
* - compaction lifecycle and `history_trimmed` → named frames of the
* same names (extensions opt-in only, `stream-maintenance-frames.ts`)
* - `tool_disabled` → `event: tool_disabled` with the chat sentence
* (extensions opt-in only, ATO-1417)
* - `step_error` / `loop_failed` → `emitStreamError` (shape depends on
* whether extensions are opted in)
*
Expand Down Expand Up @@ -763,6 +766,24 @@ export function buildStreamEventHook(
}
return;
}
/* ATO-1417: the server refused a tool's JSON schema and the provider
resent the request without it, so the model cannot call that tool
this turn. One plain sentence (`notice`) for the chat, once per
tool per turn. Extensions-only: an older host never asked for named
frames, and one that did ignores a frame name it does not know. */
if (event.type === "tool_disabled") {
if (env.request.extensionsEnabled) {
sse.writeEvent("tool_disabled", {
object: "atomic.tool_disabled",
session_id: env.session.id,
step_index: event.stepIndex,
tool: event.tool,
reason: event.reason,
notice: toolDisabledNotice(event.tool),
});
}
return;
}
if (event.type === "provider_recovered") {
if (env.request.extensionsEnabled) {
sse.writeEvent("provider_recovered", {
Expand Down
61 changes: 61 additions & 0 deletions src/http/tool-disabled-stream.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";

import { buildStreamEventHook } from "./openai-chat-completions.js";

/**
* ATO-1417: the server refused a tool's schema and the request went out
* without it. A host that opted into extensions gets one named frame
* with the chat sentence; the plain OpenAI stream stays as it was.
*/
describe("tool_disabled over SSE", () => {
const makeSse = () => {
const written: Array<{ name: string | null; payload: unknown }> = [];
return {
written,
writer: {
closed: false,
writeEvent(name: string | null, payload: unknown) {
written.push({ name, payload });
},
},
};
};
const env = (extensionsEnabled: boolean) =>
({
completionId: "cmpl-1",
created: 0,
session: { id: "sess-1" },
request: { model: "atomic-agent", extensionsEnabled },
}) as never;
const event = {
type: "tool_disabled" as const,
stepIndex: 0,
tool: "notion.search",
reason: "schema_rejected" as const,
};

it("names the tool and carries the sentence for the chat", () => {
const sse = makeSse();
buildStreamEventHook(sse.writer as never, env(true))(event as never);
expect(sse.written).toEqual([
{
name: "tool_disabled",
payload: {
object: "atomic.tool_disabled",
session_id: "sess-1",
step_index: 0,
tool: "notion.search",
reason: "schema_rejected",
notice:
"Tool notion.search is disabled for this turn: the model server did not accept its schema.",
},
},
]);
});

it("stays off the plain OpenAI stream", () => {
const plain = makeSse();
buildStreamEventHook(plain.writer as never, env(false))(event as never);
expect(plain.written).toEqual([]);
});
});
Loading