diff --git a/.changeset/goal-subagents.md b/.changeset/goal-subagents.md
new file mode 100644
index 000000000000..bd8a80477201
--- /dev/null
+++ b/.changeset/goal-subagents.md
@@ -0,0 +1,7 @@
+---
+"@reddb-io/redcode": minor
+---
+
+Subagents inherit the goal, and the loop waits for them
+
+When the parent session has an active goal, every `task` call opens the child's prompt with the objective and the contract — not the budget, not the completion tool: the child does one part, and only the parent's turn is judged. A turn that ends with a background subagent still running parks the loop on WAIT instead of spending a turn; the subagent's report re-enters the parent and the judge runs again on that turn.
diff --git a/packages/redcode/src/session/goal.ts b/packages/redcode/src/session/goal.ts
index 4ae0ba42c328..49a41c7b3c79 100644
--- a/packages/redcode/src/session/goal.ts
+++ b/packages/redcode/src/session/goal.ts
@@ -179,6 +179,24 @@ export function render(goal: Goal): string {
return lines.join("\n")
}
+/**
+ * What a subagent is told. Children start blank by design, so the goal is copied in — the
+ * objective and the contract, never the budget or the completion tool: the child does one part,
+ * and only the parent's turn is judged.
+ */
+export function inherit(goal: Goal): string {
+ return [
+ "",
+ "This task is one part of a larger goal the calling agent is pursuing. Do the task you were given so that it fits the goal; do not attempt the rest of the goal, and do not redefine the task to something smaller.",
+ "",
+ `Objective: ${goal.objective}`,
+ ...contractLines(goal.contract),
+ "",
+ "Report what you did with evidence — file contents, command output, test results — and say plainly what you could not do.",
+ "",
+ ].join("\n")
+}
+
export interface Gates {
readonly command: string
readonly ok: boolean
diff --git a/packages/redcode/src/tool/task.ts b/packages/redcode/src/tool/task.ts
index aa3965e298a8..6895d973d35a 100644
--- a/packages/redcode/src/tool/task.ts
+++ b/packages/redcode/src/tool/task.ts
@@ -14,6 +14,7 @@ import { Effect, Exit, Schema, Scope } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Database } from "@reddb-io/redcode-core/database/database"
+import { SessionGoal } from "@/session/goal"
export interface TaskPromptOps {
cancel(sessionID: SessionID): Effect.Effect
@@ -198,7 +199,15 @@ export const TaskTool = Tool.define(
if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra"))
const runTask = Effect.fn("TaskTool.runTask")(function* () {
- const parts = yield* ops.resolvePromptParts(params.prompt)
+ const resolved = yield* ops.resolvePromptParts(params.prompt)
+ // The goal is copied, never shared: a child session is blank by design, so the parent's
+ // objective rides in as a synthetic part ahead of the task, read fresh each run — the
+ // goal may have been dropped or changed since the child was first created.
+ const goal = SessionGoal.fromMetadata((yield* sessions.get(ctx.sessionID)).metadata)
+ const parts =
+ goal?.status === "active"
+ ? [{ type: "text" as const, text: SessionGoal.inherit(goal), synthetic: true }, ...resolved]
+ : resolved
const result = yield* ops.prompt({
messageID: MessageID.ascending(),
sessionID: nextSession.id,
diff --git a/packages/redcode/test/session/goal.test.ts b/packages/redcode/test/session/goal.test.ts
index 42b751ca375c..438937305350 100644
--- a/packages/redcode/test/session/goal.test.ts
+++ b/packages/redcode/test/session/goal.test.ts
@@ -166,3 +166,17 @@ describe("one line for a status bar", () => {
expect(SessionGoal.describe({ ...g, status: "done" })).toBe("goal · done")
})
})
+
+describe("a goal, inherited by a subagent", () => {
+ test("carries the objective and the contract, not the budget or the completion tool", () => {
+ const g = SessionGoal.parse("ship the cache fix; verify: bun test; constraints: no new deps", { maxTurns: 7 })
+ const block = SessionGoal.inherit(g)
+ expect(block.startsWith("")).toBe(true)
+ expect(block).toContain("Objective: ship the cache fix")
+ expect(block).toContain("Verification: bun test")
+ expect(block).toContain("Constraints: no new deps")
+ expect(block).toContain("one part of a larger goal")
+ expect(block).not.toContain("Turn 1 of 7")
+ expect(block).not.toContain("goal_complete")
+ })
+})
diff --git a/packages/redcode/test/session/prompt.test.ts b/packages/redcode/test/session/prompt.test.ts
index df3d46c3dfe2..a53508938ed6 100644
--- a/packages/redcode/test/session/prompt.test.ts
+++ b/packages/redcode/test/session/prompt.test.ts
@@ -173,7 +173,7 @@ const blockingProcessor = Layer.succeed(
}),
)
-const runtimeFlags = RuntimeFlags.layer({ experimentalEventSystem: true })
+const runtimeFlags = RuntimeFlags.layer({ experimentalEventSystem: true, experimentalBackgroundSubagents: true })
const testLLMServerNode = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] })
@@ -3200,3 +3200,72 @@ it.instance("a goal driven by another process pauses instead of restarting itsel
expect(yield* llm.calls).toBe(1)
}),
)
+
+it.instance(
+ "a background subagent parks the loop on WAIT; its report re-enters the parent and the judge runs again",
+ () =>
+ Effect.gen(function* () {
+ const { llm } = yield* useServerConfig((url) => providerCfg(url))
+ const { chat, goals, prompt, sessions } = yield* startGoal("fix the cache key; verify: bun test", { maxTurns: 5 })
+ const jobs = yield* BackgroundJob.Service
+ const gate = defer()
+ const has = (needle: string) => (hit: { body: Record }) =>
+ JSON.stringify(hit.body).includes(needle)
+
+ // Turn 1: the model hands the work to a background subagent and yields.
+ yield* llm.tool("task", {
+ description: "fix cache key",
+ prompt: "look into the cache key path",
+ subagent_type: "general",
+ background: true,
+ })
+ yield* llm.textMatch(has("Background task started"), "Launched a subagent for the cache key; waiting on it.")
+ // The child answers only when the test lets it, so the parent's turn ends with the job running.
+ yield* llm.pushMatch(
+ has("look into the cache key path"),
+ reply().wait(gate.promise).text("Fixed the key in cache.ts; bun test: 12 pass.").stop(),
+ )
+ yield* llm.textMatch(judgeRequest, verdict("wait", "the subagent is still running"))
+ // Turn 2 is the child's report re-entering the parent.
+ yield* llm.textMatch(has("Background task completed"), "The subagent fixed it and the tests pass.")
+ yield* llm.textMatch(judgeRequest, verdict("done", "cache.ts changed and bun test shows 12 pass"))
+
+ yield* awaitWithTimeout(prompt.loop({ sessionID: chat.id }), "the first turn never ended", "30 seconds")
+
+ const parked = yield* goals.get(chat.id)
+ expect(parked?.status).toBe("active")
+ expect(parked?.last?.verdict).toBe("wait")
+ expect(parked?.turns.used).toBe(0)
+ const running = (yield* jobs.list()).filter((job) => job.metadata?.["parentSessionId"] === chat.id)
+ expect(running).toHaveLength(1)
+
+ // The child was told what the whole is for, ahead of its own task.
+ const [child] = yield* sessions.children(chat.id)
+ expect(child).toBeDefined()
+ const childUsers = yield* userTexts(child!.id)
+ expect(childUsers[0]).toContain("Objective: fix the cache key")
+ expect(childUsers[0]).toContain("look into the cache key path")
+
+ gate.resolve()
+ const settled = yield* awaitWithTimeout(
+ Effect.gen(function* () {
+ while (true) {
+ const goal = yield* goals.get(chat.id)
+ if (goal?.status !== "active") return goal
+ yield* Effect.sleep("50 millis")
+ }
+ }),
+ "the goal never settled after the subagent reported",
+ "30 seconds",
+ )
+ expect(settled?.status).toBe("done")
+ expect(settled?.last?.verdict).toBe("done")
+
+ const users = yield* userTexts(chat.id)
+ expect(users.some((text) => text.includes("Background task completed"))).toBe(true)
+ const guards = yield* SessionGuardLog.Service
+ const trips = (yield* guards.recent()).filter((t) => t.guard === "goal")
+ expect(trips.map((t) => t.action).sort()).toEqual(["stop", "warn"])
+ }),
+ 60_000,
+)
diff --git a/packages/redcode/test/tool/task.test.ts b/packages/redcode/test/tool/task.test.ts
index 459beb9cf14b..7ee2925e0e38 100644
--- a/packages/redcode/test/tool/task.test.ts
+++ b/packages/redcode/test/tool/task.test.ts
@@ -6,6 +6,7 @@ import { SessionProjector } from "@reddb-io/redcode-core/session/projector"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Agent } from "../../src/agent/agent"
import { BackgroundJob } from "@/background/job"
+import { SessionGoal } from "@/session/goal"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Config } from "@/config/config"
import { CrossSpawnSpawner } from "@reddb-io/redcode-core/cross-spawn-spawner"
@@ -982,4 +983,43 @@ describe("tool.task", () => {
expect((yield* jobs.get(grandchild.id))?.status).toBe("cancelled")
}),
)
+
+ it.instance("the child's prompt opens with the parent's active goal; a paused goal stays home", () =>
+ Effect.gen(function* () {
+ const sessions = yield* Session.Service
+ const { chat, assistant } = yield* seed()
+ const goal = SessionGoal.parse("fix the cache key; verify: bun test", {})
+ yield* sessions.setMetadata({ sessionID: chat.id, metadata: SessionGoal.toMetadata({}, goal) })
+ const tool = yield* TaskTool
+ const def = yield* tool.init()
+ const seen: SessionPrompt.PromptInput[] = []
+ const promptOps = stubOps({ onPrompt: (input) => void seen.push(input) })
+ const ctx = {
+ sessionID: chat.id,
+ messageID: assistant.id,
+ agent: "build",
+ abort: new AbortController().signal,
+ extra: { promptOps },
+ messages: [],
+ metadata: () => Effect.void,
+ ask: () => Effect.void,
+ }
+ const params = { description: "inspect bug", prompt: "look into the cache key path", subagent_type: "general" }
+
+ yield* def.execute(params, ctx)
+ const first = seen[0]?.parts ?? []
+ expect(first).toHaveLength(2)
+ expect(first[0]?.type === "text" && first[0].synthetic).toBe(true)
+ expect(first[0]?.type === "text" ? first[0].text : "").toContain("Objective: fix the cache key")
+ expect(first[0]?.type === "text" ? first[0].text : "").toContain("Verification: bun test")
+ expect(first[1]?.type === "text" ? first[1].text : "").toBe("look into the cache key path")
+
+ yield* sessions.setMetadata({
+ sessionID: chat.id,
+ metadata: SessionGoal.toMetadata({}, { ...goal, status: "paused", reason: "interrupted" }),
+ })
+ yield* def.execute(params, ctx)
+ expect(seen[1]?.parts).toHaveLength(1)
+ }),
+ )
})