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
7 changes: 7 additions & 0 deletions .changeset/goal-subagents.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions packages/redcode/src/session/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
"<goal>",
"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.",
"</goal>",
].join("\n")
}

export interface Gates {
readonly command: string
readonly ok: boolean
Expand Down
11 changes: 10 additions & 1 deletion packages/redcode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions packages/redcode/test/session/goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<goal>")).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")
})
})
71 changes: 70 additions & 1 deletion packages/redcode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] })

Expand Down Expand Up @@ -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<void>()
const has = (needle: string) => (hit: { body: Record<string, unknown> }) =>
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,
)
40 changes: 40 additions & 0 deletions packages/redcode/test/tool/task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}),
)
})
Loading