mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-18 20:03:34 +00:00
feat(core): run subagent commands in background
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
This commit is contained in:
parent
394e0b9045
commit
56eddcfa1f
14 changed files with 269 additions and 20 deletions
|
|
@ -3690,7 +3690,7 @@ export type CommandListOutput = {
|
|||
readonly description?: string
|
||||
readonly agent?: string
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly subtask?: boolean
|
||||
readonly subagent?: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ const layer = Layer.effect(
|
|||
const info = Option.getOrUndefined(
|
||||
ConfigMigrateV1.isV1(input)
|
||||
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
|
||||
: decodeInfo(input),
|
||||
: decodeInfo(normalizeCommandAliases(input)),
|
||||
)
|
||||
if (!info) return
|
||||
return new Document({ type: "document", path: filepath, info })
|
||||
|
|
@ -223,3 +223,21 @@ export const node = makeLocationNode({
|
|||
layer,
|
||||
deps: [FSUtil.node, Global.node, Location.node, Policy.node],
|
||||
})
|
||||
|
||||
function normalizeCommandAliases(input: unknown) {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return input
|
||||
const commands = (input as Record<string, unknown>).commands
|
||||
if (typeof commands !== "object" || commands === null || Array.isArray(commands)) return input
|
||||
return {
|
||||
...input,
|
||||
commands: Object.fromEntries(
|
||||
Object.entries(commands).map(([name, command]) => {
|
||||
if (typeof command !== "object" || command === null || Array.isArray(command)) return [name, command]
|
||||
const data = command as Record<string, unknown>
|
||||
if (data.subagent !== undefined || typeof data.subtask !== "boolean") return [name, command]
|
||||
const { subtask, ...rest } = data
|
||||
return [name, { ...rest, subagent: subtask }]
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,5 @@ export class Info extends Schema.Class<Info>("ConfigV2.Command")({
|
|||
agent: Schema.String.pipe(Schema.optional),
|
||||
model: Schema.String.pipe(Schema.optional),
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
subtask: Schema.Boolean.pipe(Schema.optional),
|
||||
subagent: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export const Plugin = define({
|
|||
if (command.variant !== undefined && item.model !== undefined) {
|
||||
item.model.variant = ModelV2.VariantID.make(command.variant)
|
||||
}
|
||||
if (command.subtask !== undefined) item.subtask = command.subtask
|
||||
if (command.subagent !== undefined) item.subagent = command.subagent
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -70,7 +70,9 @@ function loadDirectory(fs: FSUtil.Interface, directory: string) {
|
|||
function decode(directory: string, filepath: string, content: string) {
|
||||
const markdown = ConfigMarkdown.parseOption(content)
|
||||
if (!markdown) return
|
||||
const info = Option.getOrUndefined(decodeCommand({ ...markdown.data, template: markdown.content.trim() }))
|
||||
const info = Option.getOrUndefined(
|
||||
decodeCommand({ ...normalizeFrontmatter(markdown.data), template: markdown.content.trim() }),
|
||||
)
|
||||
if (!info) return
|
||||
return {
|
||||
name: path
|
||||
|
|
@ -81,3 +83,9 @@ function decode(directory: string, filepath: string, content: string) {
|
|||
info,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFrontmatter(data: Record<string, unknown>) {
|
||||
if (data.subagent !== undefined || typeof data.subtask !== "boolean") return data
|
||||
const { subtask, ...rest } = data
|
||||
return { ...rest, subagent: subtask }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export const Plugin = define({
|
|||
draft.update("review", (command) => {
|
||||
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
|
||||
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
|
||||
command.subagent = true
|
||||
})
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -303,7 +303,8 @@ model: anthropic/claude-sonnet-4-6
|
|||
|
||||
- `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter.
|
||||
- `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments.
|
||||
- Optional: `description`, `agent`, `model`, `variant`, `subtask`.
|
||||
- Optional: `description`, `agent`, `model`, `variant`, `subagent`.
|
||||
- `subtask` is still accepted for older commands and is treated like `subagent`.
|
||||
|
||||
## Plugins
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ import { Identifier } from "./util/identifier"
|
|||
import { Shell } from "./shell"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
|
||||
const SUBAGENT_COMMAND_STARTED =
|
||||
"The command is running in a background subagent session. You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress."
|
||||
const SUBAGENT_COMMAND_NO_TEXT = "Subagent command completed without a text response."
|
||||
|
||||
export const RevertState = Revert.State
|
||||
export type RevertState = Revert.State
|
||||
|
||||
|
|
@ -524,14 +528,77 @@ 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* AgentV2.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const commandAgent = command.agent ? yield* agents.get(AgentV2.ID.make(command.agent)) : undefined
|
||||
const model = command.model ?? commandAgent?.model ?? input.model ?? session.model
|
||||
const subagent = command.subagent ?? false
|
||||
|
||||
if (subagent) {
|
||||
const childAgent = AgentV2.ID.make(command.agent ?? "general")
|
||||
const childAgentInfo = yield* agents.get(childAgent)
|
||||
const title = command.description ?? input.command
|
||||
const child = yield* result.create({
|
||||
parentID: input.sessionID,
|
||||
title,
|
||||
agent: childAgent,
|
||||
model: command.model ?? childAgentInfo?.model ?? input.model ?? session.model,
|
||||
})
|
||||
const completion = (state: "completed" | "error" | "cancelled", text: string) =>
|
||||
result.synthetic({
|
||||
sessionID: input.sessionID,
|
||||
text: `<subagent id="${child.id}" state="${state}" description="${title}">\n${text}\n</subagent>`,
|
||||
description: command.description,
|
||||
metadata: { command: input.command, subagent: true, sessionID: child.id },
|
||||
})
|
||||
const admitted = yield* result.prompt({
|
||||
id: input.id,
|
||||
sessionID: child.id,
|
||||
prompt: { text: evaluated.text, files: input.files, agents: input.agents },
|
||||
delivery: input.delivery,
|
||||
resume: false,
|
||||
})
|
||||
yield* jobs.start({
|
||||
id: child.id,
|
||||
type: "subagent",
|
||||
title,
|
||||
metadata: { command: input.command, parentID: input.sessionID, agent: childAgent.toString() },
|
||||
run: Effect.gen(function* () {
|
||||
yield* result.resume(child.id)
|
||||
const messages = yield* result.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 SUBAGENT_COMMAND_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 : SUBAGENT_COMMAND_NO_TEXT
|
||||
}).pipe(Effect.onInterrupt(() => result.interrupt(child.id))),
|
||||
})
|
||||
yield* jobs.background(child.id)
|
||||
yield* jobs.wait({ id: child.id }).pipe(
|
||||
Effect.flatMap((result) => {
|
||||
if (result.info?.status === "completed")
|
||||
return completion("completed", result.info.output ?? SUBAGENT_COMMAND_NO_TEXT)
|
||||
if (result.info?.status === "error")
|
||||
return completion("error", result.info.error ?? "Subagent command failed")
|
||||
if (result.info?.status === "cancelled") return completion("cancelled", "Subagent command cancelled")
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
yield* result.synthetic({
|
||||
sessionID: input.sessionID,
|
||||
text: `<subagent id="${child.id}" state="running" description="${title}">\n${SUBAGENT_COMMAND_STARTED}\n</subagent>`,
|
||||
description: command.description,
|
||||
metadata: { command: input.command, subagent: true, sessionID: child.id },
|
||||
})
|
||||
return admitted
|
||||
}
|
||||
|
||||
const agent = command.agent ?? input.agent
|
||||
const commandAgent = yield* Effect.gen(function* () {
|
||||
if (!command.agent) return undefined
|
||||
const agents = yield* AgentV2.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
return yield* agents.get(AgentV2.ID.make(command.agent))
|
||||
})
|
||||
const model = command.model ?? commandAgent?.model ?? input.model
|
||||
if (agent !== undefined && session.agent !== AgentV2.ID.make(agent))
|
||||
yield* result.switchAgent({ sessionID: input.sessionID, agent })
|
||||
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
|||
buffer: info.compaction.reserved,
|
||||
},
|
||||
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
|
||||
commands: info.command,
|
||||
commands: commands(info.command),
|
||||
instructions: info.instructions,
|
||||
references: info.references ?? info.reference,
|
||||
plugins: info.plugin?.map((plugin) =>
|
||||
|
|
@ -83,6 +83,17 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
|||
}
|
||||
}
|
||||
|
||||
function commands(info?: typeof ConfigV1.Info.Type.command) {
|
||||
if (!info) return undefined
|
||||
return Object.fromEntries(
|
||||
Object.entries(info).map(([name, command]) => {
|
||||
if (command?.subtask === undefined) return [name, command]
|
||||
const { subtask, ...rest } = command
|
||||
return [name, { ...rest, subagent: subtask }]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<string, boolean>>) {
|
||||
const rules: Array<{ action: string; resource: string; effect: ConfigPermissionV1.Action }> = Object.entries(
|
||||
tools ?? {},
|
||||
|
|
|
|||
|
|
@ -44,9 +44,16 @@ description: File review
|
|||
agent: reviewer
|
||||
model: anthropic/claude
|
||||
variant: high
|
||||
subtask: true
|
||||
subagent: true
|
||||
---
|
||||
Review files`,
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(tmp.path, "commands", "legacy.md"),
|
||||
`---
|
||||
subtask: true
|
||||
---
|
||||
Legacy review`,
|
||||
)
|
||||
await fs.writeFile(path.join(tmp.path, "commands", "nested", "docs.md"), "Write docs")
|
||||
await fs.writeFile(path.join(tmp.path, "commands", "empty.md"), "")
|
||||
|
|
@ -80,9 +87,10 @@ Review files`,
|
|||
id: ModelV2.ID.make("claude"),
|
||||
variant: ModelV2.VariantID.make("high"),
|
||||
},
|
||||
subtask: true,
|
||||
subagent: true,
|
||||
}),
|
||||
CommandV2.Info.make({ name: "empty", template: "" }),
|
||||
CommandV2.Info.make({ name: "legacy", template: "Legacy review", subagent: true }),
|
||||
CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }),
|
||||
])
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ describe("Config", () => {
|
|||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
subagent: true,
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ describe("CommandPlugin.Plugin", () => {
|
|||
expect(yield* command.get("review")).toMatchObject({
|
||||
name: "review",
|
||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||
subagent: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
134
packages/core/test/session-command.test.ts
Normal file
134
packages/core/test/session-command.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } 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 { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
ProjectV2.Service,
|
||||
ProjectV2.Service.of({
|
||||
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
Job.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
SessionV2.node,
|
||||
LocationServiceMap.node,
|
||||
]),
|
||||
[
|
||||
[ProjectV2.node, projects],
|
||||
[
|
||||
SessionExecution.node,
|
||||
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 model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.make("anthropic") })
|
||||
|
||||
function withTmp<A, E, R>(f: (location: Location.Ref) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))))
|
||||
}
|
||||
|
||||
describe("SessionV2.command", () => {
|
||||
it.effect("runs subagent commands as background child sessions", () =>
|
||||
withTmp((location) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* SessionV2.Service
|
||||
const parent = yield* sessions.create({ location, model })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* commands.transform((draft) => {
|
||||
draft.update("review", (command) => {
|
||||
command.template = "Review this"
|
||||
command.description = "review changes"
|
||||
command.agent = "reviewer"
|
||||
command.subagent = true
|
||||
})
|
||||
})
|
||||
|
||||
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: AgentV2.ID.make("reviewer"),
|
||||
model,
|
||||
})
|
||||
expect(admitted).toMatchObject({ sessionID: children.data[0]!.id, prompt: { 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.messages({ sessionID: parent.id })).toEqual([
|
||||
expect.objectContaining({ type: "synthetic", text: expect.stringContaining(children.data[0]!.id) }),
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("defaults subagent commands without an agent to the general agent", () =>
|
||||
withTmp((location) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* SessionV2.Service
|
||||
const parent = yield* sessions.create({ location })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* commands.transform((draft) => {
|
||||
draft.update("research", (command) => {
|
||||
command.template = "Legacy task"
|
||||
command.subagent = true
|
||||
})
|
||||
})
|
||||
|
||||
const admitted = yield* sessions.command({ sessionID: parent.id, command: "research" })
|
||||
const children = yield* sessions.list({ parentID: parent.id })
|
||||
|
||||
expect(children.data).toHaveLength(1)
|
||||
expect(children.data[0]).toMatchObject({ agent: AgentV2.ID.make("general") })
|
||||
expect(admitted).toMatchObject({ sessionID: children.data[0]!.id, prompt: { text: "Legacy task" } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -14,7 +14,7 @@ export const Info = Schema.Struct({
|
|||
description: Schema.String.pipe(optional),
|
||||
agent: Schema.String.pipe(optional),
|
||||
model: Model.Ref.pipe(optional),
|
||||
subtask: Schema.Boolean.pipe(optional),
|
||||
subagent: Schema.Boolean.pipe(optional),
|
||||
}).annotate({ identifier: "CommandV2.Info" })
|
||||
|
||||
export const Event = {
|
||||
|
|
|
|||
|
|
@ -5506,7 +5506,7 @@ export type CommandV2Info = {
|
|||
description?: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
subtask?: boolean
|
||||
subagent?: boolean
|
||||
}
|
||||
|
||||
export type SkillV2Info = {
|
||||
|
|
@ -9461,7 +9461,7 @@ export type CommandV2Info2 = {
|
|||
description?: string
|
||||
agent?: string
|
||||
model?: ModelRef2
|
||||
subtask?: boolean
|
||||
subagent?: boolean
|
||||
}
|
||||
|
||||
export type SkillV2Info2 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue