diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 9c045338ee..6c62ea2200 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -33,6 +33,7 @@ import { Effect, Layer, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { Env } from "../env" +import { Agent as AgentSvc } from "../agent/agent" import { Question } from "../question" import { Todo } from "../session/todo" import { LSP } from "../lsp" @@ -68,6 +69,7 @@ export namespace ToolRegistry { | Plugin.Service | Question.Service | Todo.Service + | AgentSvc.Service | LSP.Service | FileTime.Service | Instruction.Service @@ -237,6 +239,7 @@ export namespace ToolRegistry { layer.pipe( Layer.provide(Config.defaultLayer), Layer.provide(Plugin.defaultLayer), + Layer.provide(AgentSvc.defaultLayer), Layer.provide(Question.defaultLayer), Layer.provide(Todo.defaultLayer), Layer.provide(LSP.defaultLayer), diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index af130a70d9..254fdd1a83 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -1,6 +1,7 @@ import { Tool } from "./tool" import DESCRIPTION from "./task.txt" import z from "zod" +import { Effect } from "effect" import { Session } from "../session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" @@ -25,142 +26,156 @@ const parameters = z.object({ command: z.string().describe("The command that triggered this task").optional(), }) -export const TaskTool = Tool.define("task", async (ctx) => { - const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) +export const TaskTool = Tool.defineEffect( + "task", + Effect.gen(function* () { + const agent = yield* Agent.Service + const config = yield* Config.Service - // Filter agents by permissions if agent provided - const caller = ctx?.agent - const accessibleAgents = caller - ? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny") - : agents - const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name)) + return async (ctx) => { + const agents = await agent.list().pipe( + Effect.map((x) => x.filter((a) => a.mode !== "primary")), + Effect.runPromise, + ) - const description = DESCRIPTION.replace( - "{agents}", - list - .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) - .join("\n"), - ) - return { - description, - parameters, - async execute(params: z.infer, ctx) { - const config = await Config.get() + const caller = ctx?.agent + const accessibleAgents = caller + ? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny") + : agents + const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name)) - // Skip permission check when user explicitly invoked via @ or command subtask - if (!ctx.extra?.bypassAgentCheck) { - await ctx.ask({ - permission: "task", - patterns: [params.subagent_type], - always: ["*"], - metadata: { - description: params.description, - subagent_type: params.subagent_type, - }, - }) - } - - const agent = await Agent.get(params.subagent_type) - if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) - - const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task") - const hasTodoWritePermission = agent.permission.some((rule) => rule.permission === "todowrite") - - const session = await iife(async () => { - if (params.task_id) { - const found = await Session.get(SessionID.make(params.task_id)).catch(() => {}) - if (found) return found - } - - return await Session.create({ - parentID: ctx.sessionID, - title: params.description + ` (@${agent.name} subagent)`, - permission: [ - ...(hasTodoWritePermission - ? [] - : [ - { - permission: "todowrite" as const, - pattern: "*" as const, - action: "deny" as const, - }, - ]), - ...(hasTaskPermission - ? [] - : [ - { - permission: "task" as const, - pattern: "*" as const, - action: "deny" as const, - }, - ]), - ...(config.experimental?.primary_tools?.map((t) => ({ - pattern: "*", - action: "allow" as const, - permission: t, - })) ?? []), - ], - }) - }) - const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) - if (msg.info.role !== "assistant") throw new Error("Not an assistant message") - - const model = agent.model ?? { - modelID: msg.info.modelID, - providerID: msg.info.providerID, - } - - ctx.metadata({ - title: params.description, - metadata: { - sessionId: session.id, - model, - }, - }) - - const messageID = MessageID.ascending() - - function cancel() { - SessionPrompt.cancel(session.id) - } - ctx.abort.addEventListener("abort", cancel) - using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) - const promptParts = await SessionPrompt.resolvePromptParts(params.prompt) - - const result = await SessionPrompt.prompt({ - messageID, - sessionID: session.id, - model: { - modelID: model.modelID, - providerID: model.providerID, - }, - agent: agent.name, - tools: { - ...(hasTodoWritePermission ? {} : { todowrite: false }), - ...(hasTaskPermission ? {} : { task: false }), - ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), - }, - parts: promptParts, - }) - - const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" - - const output = [ - `task_id: ${session.id} (for resuming to continue this task if needed)`, - "", - "", - text, - "", - ].join("\n") + const description = DESCRIPTION.replace( + "{agents}", + list + .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) + .join("\n"), + ) return { - title: params.description, - metadata: { - sessionId: session.id, - model, + description, + parameters, + async execute(params: z.infer, ctx) { + const cfg = await config.get().pipe(Effect.runPromise) + + // Skip permission check when user explicitly invoked via @ or command subtask + if (!ctx.extra?.bypassAgentCheck) { + await ctx.ask({ + permission: "task", + patterns: [params.subagent_type], + always: ["*"], + metadata: { + description: params.description, + subagent_type: params.subagent_type, + }, + }) + } + + const next = await agent + .get(params.subagent_type) + .pipe(Effect.runPromise) + .catch(() => undefined) + if (!next) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) + + const hasTaskPermission = next.permission.some((rule) => rule.permission === "task") + const hasTodoWritePermission = next.permission.some((rule) => rule.permission === "todowrite") + + const session = await iife(async () => { + if (params.task_id) { + const found = await Session.get(SessionID.make(params.task_id)).catch(() => {}) + if (found) return found + } + + return await Session.create({ + parentID: ctx.sessionID, + title: params.description + ` (@${next.name} subagent)`, + permission: [ + ...(hasTodoWritePermission + ? [] + : [ + { + permission: "todowrite" as const, + pattern: "*" as const, + action: "deny" as const, + }, + ]), + ...(hasTaskPermission + ? [] + : [ + { + permission: "task" as const, + pattern: "*" as const, + action: "deny" as const, + }, + ]), + ...(cfg.experimental?.primary_tools?.map((t) => ({ + pattern: "*", + action: "allow" as const, + permission: t, + })) ?? []), + ], + }) + }) + const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) + if (msg.info.role !== "assistant") throw new Error("Not an assistant message") + + const model = next.model ?? { + modelID: msg.info.modelID, + providerID: msg.info.providerID, + } + + ctx.metadata({ + title: params.description, + metadata: { + sessionId: session.id, + model, + }, + }) + + const messageID = MessageID.ascending() + + function cancel() { + SessionPrompt.cancel(session.id) + } + ctx.abort.addEventListener("abort", cancel) + using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) + const promptParts = await SessionPrompt.resolvePromptParts(params.prompt) + + const result = await SessionPrompt.prompt({ + messageID, + sessionID: session.id, + model: { + modelID: model.modelID, + providerID: model.providerID, + }, + agent: next.name, + tools: { + ...(hasTodoWritePermission ? {} : { todowrite: false }), + ...(hasTaskPermission ? {} : { task: false }), + ...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((t) => [t, false])), + }, + parts: promptParts, + }) + + const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" + + const output = [ + `task_id: ${session.id} (for resuming to continue this task if needed)`, + "", + "", + text, + "", + ].join("\n") + + return { + title: params.description, + metadata: { + sessionId: session.id, + model, + }, + output, + } }, - output, } - }, - } -}) + } + }), +) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 17689cf274..0c1da8a4a1 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -29,7 +29,6 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" -import { TaskTool } from "../../src/tool/task" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import { Log } from "../../src/util/log" @@ -631,7 +630,9 @@ it.live( Effect.gen(function* () { const ready = defer() const aborted = defer() - const init = spyOn(TaskTool, "init").mockImplementation(async () => ({ + const registry = yield* ToolRegistry.Service + const init = registry.named.task.init + registry.named.task.init = async () => ({ description: "task", parameters: z.object({ description: z.string(), @@ -653,8 +654,8 @@ it.live( output: "", } }, - })) - yield* Effect.addFinalizer(() => Effect.sync(() => init.mockRestore())) + }) + yield* Effect.addFinalizer(() => Effect.sync(() => void (registry.named.task.init = init))) const { prompt, chat } = yield* boot() const msg = yield* user(chat.id, "hello") diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index aae48a30ab..d00f89da75 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,49 +1,56 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" import { Agent } from "../../src/agent/agent" +import { Config } from "../../src/config/config" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Instance } from "../../src/project/instance" import { TaskTool } from "../../src/tool/task" -import { tmpdir } from "../fixture/fixture" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" afterEach(async () => { await Instance.disposeAll() }) +const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer)) + describe("tool.task", () => { - test("description sorts subagents by name and is stable across calls", async () => { - await using tmp = await tmpdir({ - config: { - agent: { - zebra: { - description: "Zebra agent", - mode: "subagent", - }, - alpha: { - description: "Alpha agent", - mode: "subagent", + it.live("description sorts subagents by name and is stable across calls", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const agent = yield* Agent.Service + const build = yield* agent.get("build") + const tool = yield* TaskTool + const first = yield* Effect.promise(() => tool.init({ agent: build })) + const second = yield* Effect.promise(() => tool.init({ agent: build })) + + expect(first.description).toBe(second.description) + + const alpha = first.description.indexOf("- alpha: Alpha agent") + const explore = first.description.indexOf("- explore:") + const general = first.description.indexOf("- general:") + const zebra = first.description.indexOf("- zebra: Zebra agent") + + expect(alpha).toBeGreaterThan(-1) + expect(explore).toBeGreaterThan(alpha) + expect(general).toBeGreaterThan(explore) + expect(zebra).toBeGreaterThan(general) + }), + { + config: { + agent: { + zebra: { + description: "Zebra agent", + mode: "subagent", + }, + alpha: { + description: "Alpha agent", + mode: "subagent", + }, }, }, }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const build = await Agent.get("build") - const first = await TaskTool.init({ agent: build }) - const second = await TaskTool.init({ agent: build }) - - expect(first.description).toBe(second.description) - - const alpha = first.description.indexOf("- alpha: Alpha agent") - const explore = first.description.indexOf("- explore:") - const general = first.description.indexOf("- general:") - const zebra = first.description.indexOf("- zebra: Zebra agent") - - expect(alpha).toBeGreaterThan(-1) - expect(explore).toBeGreaterThan(alpha) - expect(general).toBeGreaterThan(explore) - expect(zebra).toBeGreaterThan(general) - }, - }) - }) + ), + ) })