Compare commits

...

4 Commits

Author SHA1 Message Date
Aiden Cline b5cf9081c9 refactor(core): keep subagent commands focused 2026-08-13 22:24:05 +00:00
Aiden Cline ac5203f5a3 refactor(core): narrow subagent commands 2026-08-13 22:22:42 +00:00
Aiden Cline c64043481d refactor(core): reuse subagent orchestration 2026-08-13 22:22:42 +00:00
Aiden Cline caa0be1f95 feat(core): run subagent commands in background 2026-08-13 22:22:42 +00:00
4 changed files with 291 additions and 143 deletions
+27 -7
View File
@@ -50,6 +50,7 @@ import { Global } from "@opencode-ai/util/global"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { fileURLToPath } from "url"
import { Subagent } from "./subagent.js"
// get project -> project.locations
//
@@ -608,14 +609,33 @@ const layer = Layer.effect(
})
const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments })
// TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands.
const agents = yield* Agent.Service.pipe(Effect.provide(locations.get(session.location)))
const commandAgent = command.agent ? yield* agents.get(command.agent) : undefined
const model = command.model ?? commandAgent?.model ?? input.model ?? session.model
if (commandAgent?.mode === "subagent") {
const childAgent = command.agent ?? Agent.ID.make("general")
const title = command.description ?? input.command
const run = yield* Subagent.run({
runtime: { session: result, job: jobs },
scope,
parentID: input.sessionID,
agent: childAgent,
title,
prompt: evaluated.text,
id: input.id,
model,
files: input.files,
agents: input.agents,
skills: input.skills,
delivery: input.delivery,
background: true,
notifyStarted: true,
metadata: { source: "command", command: input.command, parentID: input.sessionID },
})
return run.admitted
}
const agent = command.agent ?? input.agent
const commandAgent = yield* Effect.gen(function* () {
if (!command.agent) return undefined
const agents = yield* Agent.Service.pipe(Effect.provide(locations.get(session.location)))
return yield* agents.get(Agent.ID.make(command.agent))
})
const model = command.model ?? commandAgent?.model ?? input.model
if (agent !== undefined && session.agent !== Agent.ID.make(agent))
yield* result.switchAgent({ sessionID: input.sessionID, agent: Agent.ID.make(agent) })
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
+139
View File
@@ -0,0 +1,139 @@
export * as Subagent from "./subagent.js"
import { Effect, Scope } from "effect"
import type { Agent } from "./agent.js"
import type { Job } from "./job.js"
import type { Model } from "./model.js"
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { Session } from "./session.js"
import type { SessionInbox } from "./session/inbox.js"
import type { SessionMessage } from "./session/message.js"
import type { SessionSchema } from "./session/schema.js"
const NO_TEXT = "Subagent completed without a text response."
export const backgroundStarted = (sessionID: SessionSchema.ID) =>
[
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes.`,
"DO NOT sleep, poll for progress, ask the subagent for status, or duplicate this subagent's work; avoid working with the same files or topics it is using.",
"Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.",
].join("\n")
export type Runtime = {
readonly session: Pick<Session.Interface, "create" | "messages" | "prompt" | "resume" | "interrupt" | "synthetic">
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
}
export interface Input {
readonly runtime: Runtime
readonly scope: Scope.Scope
readonly parentID: SessionSchema.ID
readonly agent: Agent.ID
readonly title: string
readonly prompt: string
readonly id?: SessionMessage.ID
readonly model?: Model.Ref
readonly files?: PromptInput.Prompt["files"]
readonly agents?: PromptInput.Prompt["agents"]
readonly skills?: PromptInput.Prompt["skills"]
readonly delivery?: SessionInbox.Delivery
readonly background: boolean
readonly notifyStarted?: boolean
readonly progress?: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly metadata?: Record<string, unknown>
}
export const run = Effect.fn("Subagent.run")(function* (input: Input) {
const child = yield* input.runtime.session.create({
parentID: input.parentID,
title: input.title,
agent: input.agent,
model: input.model,
})
yield* input.progress?.(child.id) ?? Effect.void
const admitted = yield* input.runtime.session.prompt({
id: input.id,
sessionID: child.id,
text: input.prompt,
files: input.files,
agents: input.agents,
skills: input.skills,
delivery: input.delivery,
resume: false,
})
const info = yield* input.runtime.job.start({
id: child.id,
type: "subagent",
title: input.title,
metadata: input.metadata,
run: Effect.gen(function* () {
yield* input.runtime.session.resume(child.id)
const messages = yield* input.runtime.session.messages({ sessionID: child.id, order: "desc", limit: 20 })
const assistant = messages.find(
(message) =>
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
)
if (assistant === undefined || assistant.type !== "assistant") return NO_TEXT
const text = assistant.content
.filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("")
return text.length > 0 ? text : NO_TEXT
}).pipe(Effect.onInterrupt(() => input.runtime.session.interrupt(child.id))),
})
if (input.background) {
yield* input.runtime.job.background(info.id)
if (input.notifyStarted)
yield* notify(input, child.id, "running", backgroundStarted(child.id)).pipe(
Effect.catchTag("Session.SyntheticConflictError", Effect.die),
)
yield* notifyWhenDone(input, child.id)
return { sessionID: child.id, status: "running" as const, output: backgroundStarted(child.id), admitted }
}
const result = yield* input.runtime.job
.block({ id: child.id, sessionID: input.parentID })
.pipe(
Effect.onInterrupt(() =>
Effect.all([input.runtime.session.interrupt(child.id), input.runtime.job.cancel(child.id)], { discard: true }),
),
)
if (result?.type === "backgrounded") {
yield* notifyWhenDone(input, child.id)
return { sessionID: child.id, status: "running" as const, output: backgroundStarted(child.id), admitted }
}
if (result?.info.status === "error")
return { sessionID: child.id, status: "error" as const, output: result.info.error ?? "Subagent failed", admitted }
if (result?.info.status === "cancelled")
return { sessionID: child.id, status: "cancelled" as const, output: "Subagent cancelled", admitted }
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT, admitted }
})
function notifyWhenDone(input: Input, childID: SessionSchema.ID) {
return input.runtime.job.wait({ id: childID }).pipe(
Effect.flatMap((result) => {
if (result.info?.status === "completed") return notify(input, childID, "completed", result.info.output ?? NO_TEXT)
if (result.info?.status === "error")
return notify(input, childID, "error", result.info.error ?? "Subagent failed")
if (result.info?.status === "cancelled") return notify(input, childID, "cancelled", "Subagent cancelled")
return Effect.void
}),
Effect.catchTag("Session.SyntheticConflictError", Effect.die),
Effect.forkIn(input.scope, { startImmediately: true }),
)
}
function notify(
input: Input,
childID: SessionSchema.ID,
state: "running" | "completed" | "error" | "cancelled",
text: string,
) {
return input.runtime.session.synthetic({
sessionID: input.parentID,
text: `<subagent id="${childID}" state="${state}" description="${input.title}">\n${text}\n</subagent>`,
description: input.title,
metadata: { source: "subagent", ...input.metadata, childID, agent: input.agent, state },
})
}
+15 -136
View File
@@ -8,17 +8,10 @@ import { Config } from "../../config.js"
import { PluginRuntime } from "../../plugin/runtime.js"
import { Permission } from "../../permission.js"
import { SessionSchema } from "../../session/schema.js"
import { Subagent } from "../../subagent.js"
export const name = "subagent"
const NO_TEXT = "Subagent completed without a text response."
const backgroundStarted = (sessionID: SessionSchema.ID) =>
[
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes.`,
"DO NOT sleep, poll for progress, ask the subagent for status, or duplicate this subagent's work; avoid working with the same files or topics it is using.",
"Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.",
].join("\n")
export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
@@ -51,65 +44,6 @@ export const Plugin = {
const permission = yield* Permission.Service
const scope = yield* Scope.Scope
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
// text" (generic string) from "failed" (the run effect fails, surfaced as a job error).
const latestAssistantText = Effect.fn("SubagentTool.latestAssistantText")(function* (sessionID: SessionSchema.ID) {
const messages = yield* runtime.session.messages({ sessionID, order: "desc", limit: 20 })
const assistant = messages.find(
(message) =>
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
)
if (assistant === undefined || assistant.type !== "assistant") return NO_TEXT
const text = assistant.content
.filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("")
return text.length > 0 ? text : NO_TEXT
})
const injectCompletion = Effect.fn("SubagentTool.injectCompletion")(function* (
parentID: SessionSchema.ID,
childID: SessionSchema.ID,
agent: string,
description: string,
state: "completed" | "error" | "cancelled",
text: string,
) {
yield* runtime.session.synthetic({
sessionID: parentID,
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
description,
metadata: { source: "subagent", childID, agent, state },
})
})
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
parentID: SessionSchema.ID,
childID: SessionSchema.ID,
agent: string,
description: string,
) {
yield* runtime.job.wait({ id: childID }).pipe(
Effect.flatMap((result) => {
if (result.info?.status === "completed")
return injectCompletion(parentID, childID, agent, description, "completed", result.info.output ?? NO_TEXT)
if (result.info?.status === "error")
return injectCompletion(
parentID,
childID,
agent,
description,
"error",
result.info.error ?? "Subagent failed",
)
if (result.info?.status === "cancelled")
return injectCompletion(parentID, childID, agent, description, "cancelled", "Subagent cancelled")
return Effect.void
}),
Effect.forkIn(scope, { startImmediately: true }),
)
})
yield* ctx.tool
.transform((draft) =>
draft.add({
@@ -163,76 +97,21 @@ export const Plugin = {
})
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
// Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model
const child = yield* runtime.session
.create({
parentID: context.sessionID,
title: input.description,
agent: Agent.ID.make(input.agent),
model,
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
})
.pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
)
const background = input.background === true
yield* context.progress({
metadata: { sessionID: child.id, status: "running" },
})
const run = Effect.gen(function* () {
// The child session owns its agent/model (set at create); prompt only admits input.
yield* runtime.session.prompt({
sessionID: child.id,
text: ["You are a subagent spawned by another session.", input.prompt].join("\n"),
resume: false,
})
yield* runtime.session.resume(child.id)
return yield* latestAssistantText(child.id)
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
const info = yield* runtime.job.start({
id: child.id,
type: name,
const output = yield* Subagent.run({
runtime,
scope,
parentID: context.sessionID,
agent: agent.id,
title: input.description,
metadata: {},
run,
})
if (background) {
yield* runtime.job.background(info.id)
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
return {
sessionID: child.id,
status: "running" as const,
output: backgroundStarted(child.id),
}
}
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
Effect.onInterrupt(() =>
Effect.all([runtime.session.interrupt(child.id), runtime.job.cancel(child.id)], {
discard: true,
}),
),
)
if (result?.type === "backgrounded") {
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description)
return {
sessionID: child.id,
status: "running" as const,
output: backgroundStarted(child.id),
}
}
if (result?.info.status === "error")
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
prompt: ["You are a subagent spawned by another session.", input.prompt].join("\n"),
model: agent.model ?? parent.model,
background: input.background === true,
progress: (sessionID) =>
context.progress({ metadata: { sessionID, status: "running" } }).pipe(Effect.asVoid),
}).pipe(Effect.mapError((error) => new ToolFailure({ message: error.message, error })))
if (output.status === "error" || output.status === "cancelled")
return yield* new ToolFailure({ message: output.output })
return output
}).pipe(
Effect.map((output) => ({
output,
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, LayerMap } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Command } from "@opencode-ai/core/command"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Job } from "@opencode-ai/core/job"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Model } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { testEffect } from "./lib/effect"
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const model = Model.Ref.make({ id: Model.ID.make("sonnet"), providerID: Provider.ID.make("anthropic") })
const reviewer = Agent.ID.make("reviewer")
const commands = Layer.mock(Command.Service, {
get: (name) => {
if (name === "review")
return Effect.succeed(
Command.Info.make({
name,
template: "Review this",
description: "review changes",
agent: reviewer,
}),
)
return Effect.succeed(undefined)
},
evaluate: () => Effect.succeed({ text: "Review this" }),
})
const agents = Layer.mock(Agent.Service, {
get: (id) =>
Effect.succeed(
id === reviewer ? Agent.Info.make({ ...Agent.Info.default(id), mode: "subagent", model }) : undefined,
),
})
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// This endpoint only needs the Location-scoped Command and Agent services.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.merge(commands, agents) as unknown as Layer.Layer<LocationServices>,
),
)
const projects = Layer.mock(Project.Service, {
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
const execution = Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.succeed(new Set()),
resume: () => Effect.never,
wake: () => Effect.void,
interrupt: () => Effect.void,
awaitIdle: () => Effect.void,
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, Job.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[LocationServiceMap.node, locations],
[Project.node, projects],
[SessionExecution.node, execution],
],
),
)
describe("Session.command", () => {
it.effect("runs commands targeting subagent-mode agents in background child sessions", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const parent = yield* sessions.create({ location, model })
const admitted = yield* sessions.command({ sessionID: parent.id, command: "review" })
const children = yield* sessions.list({ parentID: parent.id })
expect(children.data).toHaveLength(1)
expect(children.data[0]).toMatchObject({
parentID: parent.id,
title: "review changes",
agent: reviewer,
model,
})
expect(admitted).toMatchObject({ sessionID: children.data[0]!.id, payload: { text: "Review this" } })
expect(yield* Job.Service.use((jobs) => jobs.get(children.data[0]!.id))).toMatchObject({
id: children.data[0]!.id,
type: "subagent",
status: "running",
})
expect(yield* sessions.inbox(parent.id)).toEqual([
expect.objectContaining({
type: "synthetic",
payload: expect.objectContaining({ text: expect.stringContaining(children.data[0]!.id) }),
}),
])
}),
)
})