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
9 changes: 9 additions & 0 deletions src/agent/background-shell-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createPermissionGate } from "../permission/gate.js";
import { shellCollectDefinition } from "./background-shell-tool.js";
import { createAgentToolset } from "./tools.js";
import type { BackgroundShellExit } from "../shell/background-shell.js";
import { buildShellBackgroundMessage } from "../session/runtime-assembly.js";
Expand Down Expand Up @@ -151,3 +152,11 @@ describe("background shell through the agent toolset", () => {
expect(probe.status).not.toBe(0);
});
});

describe("shell_collect tool copy", () => {
test("names the doom-loop exemption for still-running polls", () => {
expect(shellCollectDefinition.description).toContain("doom-loop guard");
expect(shellCollectDefinition.description).toContain("liveness");
expect(shellCollectDefinition.description).toContain("running");
});
});
5 changes: 4 additions & 1 deletion src/agent/background-shell-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ export const shellCollectDefinition: ToolDefinition = {
'action="collect" returns the result once finished (or status running); ' +
'action="cancel" kills the process group. Completion also arrives as a ' +
"system message on a later turn — collect is for polling or retrieving " +
"output again after eviction risk.",
'output again after eviction risk. A "running" result is liveness, not a ' +
"stall or a crash: repeated identical collects of a still-running shell " +
"are exempt from the run's doom-loop guard, so keep polling rather than " +
"treating it as a failure.",
inputSchema: {
type: "object",
properties: {
Expand Down
2 changes: 2 additions & 0 deletions src/provider/inference-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from "./codex-responses.js";
import { GROK_RESPONSES_PROVIDER } from "./grok-responses.js";
import { withReplaySanitizer } from "./replay-sanitizer.js";
import { isPollOnlyPendingBatch } from "../subagent/poll-exempt.js";
import { OPENCODE_GO_PROVIDER_ID } from "../../packages/opencode-go/src/index.js";
import { BIFROST_PROVIDER } from "./bifrost-adapter.js";
import { OPENAI_RESPONSES_PROVIDER } from "./openai-responses.js";
Expand Down Expand Up @@ -89,6 +90,7 @@ export function createInferenceDependencies(): Promise<Dependencies> {
.then((deps) => ({
...deps,
fetch: withCodexContentTypeRepair(deps.fetch),
isPollOnlyPendingBatch,
}));
}
return cached;
Expand Down
9 changes: 9 additions & 0 deletions src/subagent/agent-fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createSpawnAgentTool,
createWaitAgentsTool,
createListAgentsTool,
waitAgentsToolDefinition,
MAX_FLEET_RECORDS,
type AgentFleetDeps,
} from "./agent-fleet.js";
Expand Down Expand Up @@ -3176,3 +3177,11 @@ describe("wait_agents occupancy yield (CL-7518)", () => {
gate.resolve({ report: "ok" });
});
});

describe("wait_agents tool copy", () => {
test("names the doom-loop exemption for still-pending polls", () => {
expect(waitAgentsToolDefinition.description).toContain("doom-loop guard");
expect(waitAgentsToolDefinition.description).toContain("liveness");
expect(waitAgentsToolDefinition.description).toContain("timeout");
});
});
5 changes: 4 additions & 1 deletion src/subagent/agent-fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,10 @@ export const waitAgentsToolDefinition: ToolDefinition = {
`(interrupted, cancelled, incomplete-report, and similar). awaiting_director is not terminal: re-wait while still pending re-delivers the same question. ` +
`Answer with send_input (soft). Do not call this in a tight zero-progress loop: a timeout means the targets are still ` +
`queued, running, or awaiting a director answer, not "try again right away" — do other work, reply to the operator, or change the brief. Calling again with the ` +
`same targets is a real timed wait, not a spin, but wastes turns if nothing has changed.`,
`same targets is a real timed wait, not a spin, but wastes turns if nothing has changed. ` +
`Repeated identical waits that keep timing out are exempt from the run's doom-loop guard while ` +
`targets stay live — a timeout or still-running result is liveness, not a stall or a crash, so ` +
`keep waiting (or do other work) rather than treating it as a failure.`,
inputSchema: {
type: "object",
properties: {
Expand Down
155 changes: 155 additions & 0 deletions src/subagent/poll-exempt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { describe, expect, test } from "bun:test";
import type { ToolCall, ToolResult } from "@intx/types/runtime";
import { isPollOnlyPendingBatch } from "./poll-exempt.js";

function call(name: string, id = "c1"): ToolCall {
return { id, name, arguments: {} };
}

function result(content: string | Record<string, unknown>): ToolResult {
return { callId: "c1", content };
}

function waitContent(
statuses: string[],
timedOut: boolean,
): Record<string, unknown> {
return {
results: statuses.map((status, i) => ({ agent_id: `w${i}`, status })),
timed_out: timedOut,
};
}

describe("isPollOnlyPendingBatch", () => {
test("timed-out wait_agents batch is exempt", () => {
expect(
isPollOnlyPendingBatch(
[call("wait_agents")],
[result(waitContent(["running"], true))],
),
).toBe(true);
});

test("live wait statuses without timeout are exempt", () => {
for (const status of ["running", "queued", "awaiting_director"]) {
expect(
isPollOnlyPendingBatch(
[call("wait_agents")],
[result(waitContent([status], false))],
),
).toBe(true);
}
});

test("one live entry keeps a mixed-status wait exempt", () => {
expect(
isPollOnlyPendingBatch(
[call("wait_agents")],
[result(waitContent(["done", "running"], false))],
),
).toBe(true);
});

test("terminal wait_agents batch counts normally", () => {
for (const statuses of [
["done"],
["failed"],
["interrupted"],
["done", "failed"],
["unknown"],
[],
]) {
expect(
isPollOnlyPendingBatch(
[call("wait_agents")],
[result(waitContent(statuses, false))],
),
).toBe(false);
}
});

test("running shell_collect is exempt; completed or cancelling counts", () => {
const collect = call("shell_collect");
expect(
isPollOnlyPendingBatch(
[collect],
[result({ shell_id: "s1", status: "running" })],
),
).toBe(true);
for (const status of ["completed", "cancelling"]) {
expect(
isPollOnlyPendingBatch([collect], [result({ shell_id: "s1", status })]),
).toBe(false);
}
});

test("unparseable or error poll output counts normally", () => {
expect(
isPollOnlyPendingBatch(
[call("wait_agents")],
[result("Error: timed out waiting")],
),
).toBe(false);
expect(
isPollOnlyPendingBatch(
[call("shell_collect")],
[result("No background shell with id s9.")],
),
).toBe(false);
});

test("non-poll calls are never exempt", () => {
expect(
isPollOnlyPendingBatch(
[call("spawn_agent")],
[result(waitContent(["running"], true))],
),
).toBe(false);
});

test("mixed poll and non-poll batches count normally", () => {
expect(
isPollOnlyPendingBatch(
[call("wait_agents"), call("shell_collect")],
[
result(waitContent(["running"], true)),
result({ shell_id: "s1", status: "running" }),
],
),
).toBe(true);
expect(
isPollOnlyPendingBatch(
[call("wait_agents"), call("read")],
[
result(waitContent(["running"], true)),
result({ shell_id: "s1", status: "running" }),
],
),
).toBe(false);
});

test("a settled poll beside a pending poll counts normally", () => {
expect(
isPollOnlyPendingBatch(
[call("wait_agents", "c1"), call("shell_collect", "c2")],
[
{ callId: "c1", content: waitContent(["done"], false) },
{ callId: "c2", content: { shell_id: "s1", status: "running" } },
],
),
).toBe(false);
});

test("empty batches and misaligned results are never exempt", () => {
expect(isPollOnlyPendingBatch([], [])).toBe(false);
expect(
isPollOnlyPendingBatch(
[call("wait_agents")],
[
result(waitContent(["running"], true)),
result(waitContent(["running"], true)),
],
),
).toBe(false);
});
});
62 changes: 62 additions & 0 deletions src/subagent/poll-exempt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { type } from "arktype";
import type { ToolCall, ToolResult } from "@intx/types/runtime";
import { isLiveWaitStatus, type WaitJSONStatus } from "./lifecycle.js";

const WaitAgentsPayload = type({
"timed_out?": "boolean",
"results?": type({ status: "string" }).array(),
});

const ShellCollectPayload = type({
status: "string",
});

function resultPayload(result: ToolResult): unknown {
if (typeof result.content !== "string") return result.content;
try {
return JSON.parse(result.content) as unknown;
} catch {
return undefined;
}
}

function isWaitAgentsPending(payload: unknown): boolean {
const parsed = WaitAgentsPayload(payload);
if (parsed instanceof type.errors) return false;
if (parsed.timed_out === true) return true;
return (parsed.results ?? []).some((entry) =>
isLiveWaitStatus(entry.status as WaitJSONStatus),
);
}

function isShellCollectPending(payload: unknown): boolean {
const parsed = ShellCollectPayload(payload);
if (parsed instanceof type.errors) return false;
return parsed.status === "running";
}

/**
* Doom-loop liveness policy for poll tools. A batch is exempt only when every
* call is a known poll (`wait_agents`, `shell_collect`) and every result
* still shows pending — a timed-out or live-status wait, a `running` collect.
* Anything else (terminal polls, non-poll calls, mixed batches, unparseable
* output) returns false so the guard counts the batch normally.
*/
export function isPollOnlyPendingBatch(
calls: readonly ToolCall[],
results: readonly ToolResult[],
): boolean {
if (calls.length === 0 || results.length !== calls.length) return false;
return calls.every((call, index) => {
const result = results[index];
if (result === undefined) return false;
if (call.name !== "wait_agents" && call.name !== "shell_collect") {
return false;
}
const payload = resultPayload(result);
if (payload === undefined) return false;
return call.name === "wait_agents"
? isWaitAgentsPending(payload)
: isShellCollectPending(payload);
});
}
22 changes: 22 additions & 0 deletions vendor/intx-inference/PATCHES.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,28 @@ path:** Upstream PR wrapping `tryCorrelate` in try/finally to clear
in `reactor.ts`; re-verify exit paths after upstream changes to gate
clearing.

## reactor-ts-doom-loop-poll-exemption

`reactor.ts` — Doom-loop guard exempts still-pending poll batches. The batch
accounting asks an optional first-party liveness predicate
(`PollBatchLivenessPredicate`, typed in `harness.ts` on `Dependencies` and
resolved direct-wins-over-deps through `assembly.ts` into `ReactorConfig`,
mirroring `assembly-ts-deps-context-transforms`): when the batch is
poll-only and every result still shows pending, the stale signature and
repeat count reset instead of counting. Reset — not skip — so an earlier
streak cannot false-positive later; mixed batches and poll-only-terminal
batches count normally. Without the predicate every batch counts, same as
upstream.

**Disposition:** Promotion candidate. Legitimate `wait_agents` /
`shell_collect` polling repeats the identical batch once per model turn and
trips the upstream guard at the default threshold while targets are still
running. **Removal path:** Upstream PR adding a liveness exemption to the
doom-loop accounting.
**Re-carry:** new at this patch; sits inside the upstream doom-loop
accounting block, re-verify the reset branch after upstream changes to batch
signatures or repeat counting.

## reactor-ts-checkpoint-after-tool-cycle

`reactor.ts` — Checkpoint after a tool cycle that appends to history.
Expand Down
22 changes: 21 additions & 1 deletion vendor/intx-inference/src/assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
type AuthzExtensionOptions,
} from "./authz-extension";
import type { CorrelationValidator } from "./correlation";
import type { Dependencies } from "./harness";
import type { Dependencies, PollBatchLivenessPredicate } from "./harness";
import {
createReactor,
type Reactor,
Expand Down Expand Up @@ -82,6 +82,13 @@ export type ReactorAssemblyConfig = {
beforeToolExtensions?: BeforeToolExtension[];
toolResultTransforms?: ToolResultTransform[];
contextTransforms?: ContextTransform[];
/**
* Liveness policy for the doom-loop guard's batch accounting. A direct
* value wins over the one riding `deps`.
*
* Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-poll-exemption
*/
isPollOnlyPendingBatch?: PollBatchLivenessPredicate;
compactors?: Record<string, Compactor>;
sizeCapMaxChars?: number;

Expand Down Expand Up @@ -135,6 +142,7 @@ export function createReactorAssembly(
beforeToolExtensions: callerBeforeToolExtensions,
toolResultTransforms: callerToolResultTransforms,
contextTransforms,
isPollOnlyPendingBatch,
compactors,
sizeCapMaxChars,
afterCheckpoint: callerAfterCheckpoint,
Expand Down Expand Up @@ -242,6 +250,15 @@ export function createReactorAssembly(
// Locally patched — see vendor/intx-inference/PATCHES.md#assembly-ts-deps-context-transforms
const resolvedContextTransforms = contextTransforms ?? deps.contextTransforms;

// The liveness policy resolves the same way: directly on the assembly
// config, or riding `deps` (the only channel the published `@intx/agent`
// forwards verbatim). A direct value wins so callers composing their own
// assembly are unaffected by whatever a shared deps object carries.
//
// Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-poll-exemption
const resolvedIsPollOnlyPendingBatch =
isPollOnlyPendingBatch ?? deps.isPollOnlyPendingBatch;

// exactOptionalPropertyTypes is on: only set optional keys when defined.
const reactorConfig: ReactorConfig = {
sessionId,
Expand All @@ -260,6 +277,9 @@ export function createReactorAssembly(
...(resolvedContextTransforms !== undefined
? { contextTransforms: resolvedContextTransforms }
: {}),
...(resolvedIsPollOnlyPendingBatch !== undefined
? { isPollOnlyPendingBatch: resolvedIsPollOnlyPendingBatch }
: {}),
...(compactors !== undefined ? { compactors } : {}),
...(composedAfterCheckpoint !== undefined
? { afterCheckpoint: composedAfterCheckpoint }
Expand Down
Loading
Loading