mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 23:09:50 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5cf9081c9 | |||
| ac5203f5a3 | |||
| c64043481d | |||
| caa0be1f95 |
@@ -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 })
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }),
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user