diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e3aaaa359..6eb6caa0e 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -214,7 +214,6 @@ import { findExactModelReferenceMatch } from "./model-resolver.js"; import { throwIfPromptAdmissionCancelled } from "./prompt-admission.js"; import { evaluateActiveCacheTtl, extractSessionCacheState, type PromptCacheSnapshot } from "./prompt-cache.js"; import { expandPromptTemplate, type PromptTemplate, parseCommandArgs } from "./prompt-templates.js"; -import { buildRlmChildTaskEnvelope } from "./prompts/index.js"; import { isAgentLifecycleFailure, isFauxProviderQueueExhausted, @@ -12464,9 +12463,9 @@ export class AgentSession { } }); run.unsubscribe = unsubscribeChildEvents; - // R11: the reply obligation rides the task envelope, so a child cannot miss it - // the way it can miss a line buried in the system prompt. - const content = buildRlmChildTaskEnvelope(prompt); + // The task envelope stays exactly the task: the reply obligation lives in the + // child doctrine (system prompt), so an echo of the prompt is still the task. + const content = `[task from parent]\n\n${prompt}`; const spawnMessage: AgentSessionMessage = { role: "custom", customType: AGENT_MESSAGE_CUSTOM_TYPE, diff --git a/packages/coding-agent/src/core/prompts/index.ts b/packages/coding-agent/src/core/prompts/index.ts index 251f7f69b..11bcbf732 100644 --- a/packages/coding-agent/src/core/prompts/index.ts +++ b/packages/coding-agent/src/core/prompts/index.ts @@ -10,7 +10,6 @@ export { } from "./payload-ceiling.js"; export { buildChildAgentDoctrine, - buildRlmChildTaskEnvelope, buildRlmPrompt, buildSubagentGuidance, type ChildAgentDoctrineOptions, diff --git a/packages/coding-agent/src/core/prompts/rlm.ts b/packages/coding-agent/src/core/prompts/rlm.ts index 48a2edf5a..e7b23b6ee 100644 --- a/packages/coding-agent/src/core/prompts/rlm.ts +++ b/packages/coding-agent/src/core/prompts/rlm.ts @@ -66,18 +66,6 @@ export interface ChildAgentDoctrineOptions { activeTools?: string[]; } -/** - * The envelope a parent's task arrives in. The reply obligation rides here rather than only - * in the system prompt, because a child cannot miss the message that carries its task. - */ -export function buildRlmChildTaskEnvelope(prompt: string): string { - return `[task from parent] - -${prompt} - -When this task is done, send its result to your parent with \`await agent_message.send(result, receiver_role="parent")\`. Finishing without a reply forwards your last assistant text to the parent, tagged as unreviewed.`; -} - export function buildChildAgentDoctrine(options: ChildAgentDoctrineOptions): string | undefined { const depth = options.depth ?? 0; const hasIpython = options.activeTools === undefined || options.activeTools.includes("ipython"); diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index b171aee3f..25a947c68 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -2825,12 +2825,11 @@ export class AgentDaemon { (runtimeConfig?.provider ?? parentState.runtime.session.model?.provider) === options.model.provider ? runtimeConfig?.apiKey : undefined; - // The worker already carries the launching shell's environment (the supervisor - // seeds it with the client's launchEnv). Forwarding PATH alone starved the child - // session of CARGO_HOME/RUSTUP_HOME/NIX_*/SSH_AUTH_SOCK, which is how a swarm - // died with exit 127 `cargo: command not found` under a parent shell that had - // the toolchain. - const launchEnv = collectDaemonLaunchEnv(); + // Deliberately narrow: PATH comes from the worker, which the supervisor seeded with + // the launching client's environment, plus the one provider key this session needs. + // Forwarding the whole environment here would leak unrelated provider keys into a + // session that was never granted them. + const launchEnv = collectDaemonLaunchEnv({ PATH: process.env.PATH }); const envKey = authSource === "environment" ? findEnvKeys(options.model.provider)?.[0] : undefined; if (envKey && process.env[envKey]) launchEnv[envKey] = process.env[envKey]; if (options.model.provider === "prime-inference" && process.env.PRIME_TEAM_ID !== undefined) { @@ -6594,7 +6593,10 @@ export class AgentDaemon { private async waitForAgentMessageCapacity(targetState: ActiveSessionState): Promise { const deadline = Date.now() + AGENT_MESSAGE_BACKPRESSURE_WAIT_MS; while (Date.now() < deadline && !this.shuttingDown) { - if (targetState.runtime.session.unfinishedActionCount < DEFAULT_AGENT_MESSAGE_MAX_PENDING_PER_SESSION) return; + // A session that cannot report a depth (a stub, a passive child) is never treated as + // saturated: waiting on an unknown would stall delivery for the whole window. + const unfinished = targetState.runtime.session.unfinishedActionCount ?? 0; + if (!(unfinished >= DEFAULT_AGENT_MESSAGE_MAX_PENDING_PER_SESSION)) return; await new Promise((resolveDelay) => setTimeout(resolveDelay, 250)); } } diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index 176be843a..a0f157825 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -1428,7 +1428,7 @@ describe("AgentSession rlm recursion", () => { ])("injects exactly one $label notice for a child run", async ({ cancel, expected }) => { const { root, releaseChild, hasStarted } = createGatedRoot(); - it("injects exactly one notice with a preview when a child completes without replying", async () => { + it("harvests a silent child's last assistant text instead of only reporting the silence", async () => { const root = createSession(); await spawnChild(root, "silent child", { name: "silent-worker" }); @@ -1437,6 +1437,8 @@ describe("AgentSession rlm recursion", () => { (message) => message.role === "custom" && message.customType === "rlm_child_terminal_notice", ); expect(notices).toHaveLength(1); + // R11: a child that produced text hands it over, tagged, rather than making the + // parent wait for a reply that will never come. expect(notices[0]).toMatchObject({ content: "[child-exited: no-reply child:silent-worker]\n\nLast assistant text: child answer: silent child", details: { diff --git a/packages/coding-agent/test/suite/subagent-reply-harvesting.test.ts b/packages/coding-agent/test/suite/subagent-reply-harvesting.test.ts index 89ad2b215..ed315e405 100644 --- a/packages/coding-agent/test/suite/subagent-reply-harvesting.test.ts +++ b/packages/coding-agent/test/suite/subagent-reply-harvesting.test.ts @@ -1,7 +1,7 @@ import { type FauxModelDefinition, fauxAssistantMessage } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; import type { CustomMessage } from "../../src/core/messages.js"; -import { buildChildAgentDoctrine, buildRlmChildTaskEnvelope } from "../../src/core/prompts/index.js"; +import { buildChildAgentDoctrine } from "../../src/core/prompts/index.js"; import { createHarness, type Harness } from "./harness.js"; const PARENT_MODELS: FauxModelDefinition[] = [{ id: "faux-parent", reasoning: true }]; @@ -78,12 +78,7 @@ describe("subagent reply harvesting", () => { expect(notices(parent!, "harvested_fallback")).toHaveLength(0); }); - it("states the reply obligation in the envelope that carries the task", () => { - const envelope = buildRlmChildTaskEnvelope("do the work"); - expect(envelope.startsWith("[task from parent]\n\ndo the work")).toBe(true); - expect(envelope).toContain('receiver_role="parent"'); - expect(envelope).toContain("tagged as unreviewed"); - + it("states the reply obligation in the child doctrine, not in the bare task", () => { const doctrine = buildChildAgentDoctrine({ depth: 1, installedSkills: ["agent_message", "ipython"],