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) + }), + ) })