diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 33be6b9c58..67e2ce4a4c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -47,6 +47,7 @@ import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { TaskTool } from "@/tool/task" +import { Config } from "@/config/config" import { SessionRunState } from "./run-state" // @ts-ignore @@ -88,6 +89,7 @@ export namespace SessionPrompt { const compaction = yield* SessionCompaction.Service const plugin = yield* Plugin.Service const commands = yield* Command.Service + const config = yield* Config.Service const permission = yield* Permission.Service const fsys = yield* AppFileSystem.Service const mcp = yield* MCP.Service @@ -140,6 +142,17 @@ export namespace SessionPrompt { return parts }) + let prompt!: Interface["prompt"] + + const taskTool = () => + TaskTool.build({ + agent: agents, + config, + cancel: SessionPrompt.cancel, + resolvePromptParts: SessionPrompt.resolvePromptParts, + prompt: SessionPrompt.prompt, + }) + const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: { session: Session.Info history: MessageV2.WithParts[] @@ -391,6 +404,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: input.model.providerID, agent: input.agent, })) { + const toolDef = item.id === TaskTool.id ? yield* Tool.init(taskTool()) : item const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ id: item.id as any, @@ -405,7 +419,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, { args }, ) - const result = yield* Effect.promise(() => item.execute(args, ctx)) + const result = yield* Effect.promise(() => toolDef.execute(args, ctx)) const output = { ...result, attachments: result.attachments?.map((attachment) => ({ @@ -521,7 +535,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) { const { task, model, lastUser, sessionID, session, msgs } = input const ctx = yield* InstanceState.context - const { task: taskTool } = yield* registry.named() const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({ id: MessageID.ascending(), @@ -578,8 +591,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the } let error: Error | undefined + const taskDef = yield* Tool.init(taskTool()) const result = yield* Effect.promise((signal) => - taskTool + taskDef .execute(taskArgs, { agent: task.agent, messageID: assistantMessage.id, @@ -1267,26 +1281,24 @@ NOTE: At any point in time through this workflow you should feel free to ask the return { info, parts } }, Effect.scoped) - const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")( - function* (input: PromptInput) { - const session = yield* sessions.get(input.sessionID) - yield* revert.cleanup(session) - const message = yield* createUserMessage(input) - yield* sessions.touch(input.sessionID) + prompt = Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput) { + const session = yield* sessions.get(input.sessionID) + yield* revert.cleanup(session) + const message = yield* createUserMessage(input) + yield* sessions.touch(input.sessionID) - const permissions: Permission.Ruleset = [] - for (const [t, enabled] of Object.entries(input.tools ?? {})) { - permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) - } - if (permissions.length > 0) { - session.permission = permissions - yield* sessions.setPermission({ sessionID: session.id, permission: permissions }) - } + const permissions: Permission.Ruleset = [] + for (const [t, enabled] of Object.entries(input.tools ?? {})) { + permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) + } + if (permissions.length > 0) { + session.permission = permissions + yield* sessions.setPermission({ sessionID: session.id, permission: permissions }) + } - if (input.noReply === true) return message - return yield* loop({ sessionID: input.sessionID }) - }, - ) + if (input.noReply === true) return message + return yield* loop({ sessionID: input.sessionID }) + }) const lastAssistant = (sessionID: SessionID) => Effect.promise(async () => { @@ -1667,28 +1679,30 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(SessionRunState.defaultLayer), - Layer.provide(SessionStatus.defaultLayer), - Layer.provide(SessionCompaction.defaultLayer), - Layer.provide(SessionProcessor.defaultLayer), - Layer.provide(Command.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(MCP.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(FileTime.defaultLayer), - Layer.provide(ToolRegistry.defaultLayer), - Layer.provide(Truncate.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionRevert.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - ), + layer + .pipe( + Layer.provide(SessionRunState.defaultLayer), + Layer.provide(SessionStatus.defaultLayer), + Layer.provide(SessionCompaction.defaultLayer), + Layer.provide(SessionProcessor.defaultLayer), + Layer.provide(Command.defaultLayer), + Layer.provide(Permission.defaultLayer), + Layer.provide(MCP.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(FileTime.defaultLayer), + Layer.provide(ToolRegistry.defaultLayer), + Layer.provide(Truncate.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Instruction.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Bus.layer), + ) + .pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)), ) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 7a70c77298..87c376bae2 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -43,6 +43,7 @@ import { AppFileSystem } from "../filesystem" import { Agent } from "../agent/agent" import { Skill } from "../skill" import { Permission } from "@/permission" +import type { TaskMetadata } from "./task" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -94,7 +95,7 @@ export namespace ToolRegistry { const agents = yield* Agent.Service const skill = yield* Skill.Service - const task = yield* TaskTool + const task: Tool.Info = yield* TaskTool const read = yield* ReadTool const question = yield* QuestionTool const todo = yield* TodoWriteTool diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 900938f0d3..8f9fdf5489 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -5,10 +5,9 @@ import { Session } from "../session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" -import { SessionPrompt } from "../session/prompt" import { Config } from "../config/config" +import type { SessionPrompt } from "../session/prompt" import { Effect } from "effect" -import { Log } from "@/util/log" const id = "task" @@ -25,153 +24,180 @@ const parameters = z.object({ command: z.string().describe("The command that triggered this task").optional(), }) -export const TaskTool = Tool.defineEffect( - id, - Effect.gen(function* () { - const agent = yield* Agent.Service - const config = yield* Config.Service +type Metadata = { + sessionId: SessionID + model: { + modelID: MessageV2.Assistant["modelID"] + providerID: MessageV2.Assistant["providerID"] + } +} - const run = Effect.fn("TaskTool.execute")(function* (params: z.infer, ctx: Tool.Context) { - const cfg = yield* config.get() +export type TaskMetadata = Metadata - if (!ctx.extra?.bypassAgentCheck) { - yield* Effect.promise(() => - ctx.ask({ - permission: id, - patterns: [params.subagent_type], - always: ["*"], - metadata: { - description: params.description, - subagent_type: params.subagent_type, - }, - }), - ) - } +type Runtime = { + agent: Agent.Interface + config: Config.Interface + cancel: (sessionID: SessionID) => Promise + resolvePromptParts: (template: string) => Promise + prompt: (input: SessionPrompt.PromptInput) => Promise +} - const next = yield* agent.get(params.subagent_type) - if (!next) { - return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) - } +const unbound: Tool.DefWithoutID = { + description: DESCRIPTION, + parameters, + async execute() { + throw new Error("Task tool execution is only available from the prompt runtime") + }, +} - const canTask = next.permission.some((rule) => rule.permission === id) - const canTodo = next.permission.some((rule) => rule.permission === "todowrite") +const build = (runtime: Runtime) => { + const run = Effect.fn("TaskTool.execute")(function* (params: z.infer, ctx: Tool.Context) { + const cfg = yield* runtime.config.get() - const taskID = params.task_id - const session = taskID - ? yield* Effect.promise(() => { - const id = SessionID.make(taskID) - return Session.get(id).catch(() => undefined) - }) - : undefined - const nextSession = - session ?? - (yield* Effect.promise(() => - Session.create({ - parentID: ctx.sessionID, - title: params.description + ` (@${next.name} subagent)`, - permission: [ - ...(canTodo - ? [] - : [ - { - permission: "todowrite" as const, - pattern: "*" as const, - action: "deny" as const, - }, - ]), - ...(canTask - ? [] - : [ - { - permission: id, - pattern: "*" as const, - action: "deny" as const, - }, - ]), - ...(cfg.experimental?.primary_tools?.map((item) => ({ - pattern: "*", - action: "allow" as const, - permission: item, - })) ?? []), - ], - }), - )) - - const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })) - if (msg.info.role !== "assistant") return yield* Effect.fail(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: nextSession.id, - model, - }, - }) - - const messageID = MessageID.ascending() - - function cancel() { - SessionPrompt.cancel(nextSession.id) - } - - return yield* Effect.acquireUseRelease( - Effect.sync(() => { - ctx.abort.addEventListener("abort", cancel) + if (!ctx.extra?.bypassAgentCheck) { + yield* Effect.promise(() => + ctx.ask({ + permission: id, + patterns: [params.subagent_type], + always: ["*"], + metadata: { + description: params.description, + subagent_type: params.subagent_type, + }, }), - () => - Effect.gen(function* () { - const parts = yield* Effect.promise(() => SessionPrompt.resolvePromptParts(params.prompt)) - const result = yield* Effect.promise(() => - SessionPrompt.prompt({ - messageID, - sessionID: nextSession.id, - model: { - modelID: model.modelID, - providerID: model.providerID, - }, - agent: next.name, - tools: { - ...(canTodo ? {} : { todowrite: false }), - ...(canTask ? {} : { task: false }), - ...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])), - }, - parts, - }), - ) - - return { - title: params.description, - metadata: { - sessionId: nextSession.id, - model, - }, - output: [ - `task_id: ${nextSession.id} (for resuming to continue this task if needed)`, - "", - "", - result.parts.findLast((item) => item.type === "text")?.text ?? "", - "", - ].join("\n"), - } - }), - () => - Effect.sync(() => { - ctx.abort.removeEventListener("abort", cancel) - }), ) + } + + const next = yield* runtime.agent.get(params.subagent_type) + if (!next) { + return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) + } + + const canTask = next.permission.some((rule) => rule.permission === id) + const canTodo = next.permission.some((rule) => rule.permission === "todowrite") + + const taskID = params.task_id + const session = taskID + ? yield* Effect.promise(() => { + const id = SessionID.make(taskID) + return Session.get(id).catch(() => undefined) + }) + : undefined + const nextSession = + session ?? + (yield* Effect.promise(() => + Session.create({ + parentID: ctx.sessionID, + title: params.description + ` (@${next.name} subagent)`, + permission: [ + ...(canTodo + ? [] + : [ + { + permission: "todowrite" as const, + pattern: "*" as const, + action: "deny" as const, + }, + ]), + ...(canTask + ? [] + : [ + { + permission: id, + pattern: "*" as const, + action: "deny" as const, + }, + ]), + ...(cfg.experimental?.primary_tools?.map((item) => ({ + pattern: "*", + action: "allow" as const, + permission: item, + })) ?? []), + ], + }), + )) + + const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })) + if (msg.info.role !== "assistant") return yield* Effect.fail(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: nextSession.id, + model, + }, }) - return { - description: DESCRIPTION, - parameters, - async execute(params: z.infer, ctx) { - return Effect.runPromise(run(params, ctx)) - }, + const messageID = MessageID.ascending() + + function cancel() { + return runtime.cancel(nextSession.id) } - }), -) + + return yield* Effect.acquireUseRelease( + Effect.sync(() => { + ctx.abort.addEventListener("abort", cancel) + }), + () => + Effect.gen(function* () { + const parts = yield* Effect.promise(() => runtime.resolvePromptParts(params.prompt)) + const result = yield* Effect.promise(() => + runtime.prompt({ + messageID, + sessionID: nextSession.id, + model: { + modelID: model.modelID, + providerID: model.providerID, + }, + agent: next.name, + tools: { + ...(canTodo ? {} : { todowrite: false }), + ...(canTask ? {} : { task: false }), + ...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])), + }, + parts, + }), + ) + + return { + title: params.description, + metadata: { + sessionId: nextSession.id, + model, + }, + output: [ + `task_id: ${nextSession.id} (for resuming to continue this task if needed)`, + "", + "", + result.parts.findLast((item) => item.type === "text")?.text ?? "", + "", + ].join("\n"), + } + }), + () => + Effect.sync(() => { + ctx.abort.removeEventListener("abort", cancel) + }), + ) + }) + + return Tool.define(id, { + description: DESCRIPTION, + parameters, + async execute(params: z.infer, ctx) { + return Effect.runPromise(run(params, ctx)) + }, + }) +} + +export const TaskTool = Object.assign(Effect.succeed(Tool.define(id, unbound)), { + id, + description: DESCRIPTION, + parameters, + build, +}) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index c114af6510..4469d07777 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -33,6 +33,7 @@ import { SessionStatus } from "../../src/session/status" import { Skill } from "../../src/skill" 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" @@ -727,23 +728,31 @@ it.live( Effect.gen(function* () { const ready = defer() const aborted = defer() - const registry = yield* ToolRegistry.Service - const { task } = yield* registry.named() - const original = task.execute - task.execute = async (_args, ctx) => { - ready.resolve() - ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) - await new Promise(() => {}) + const original = TaskTool.build + TaskTool.build = ((runtime: Parameters[0]) => { + const base = original(runtime) return { - title: "", - metadata: { - sessionId: SessionID.make("task"), - model: ref, + id: base.id, + async init() { + const next = await base.init() + next.execute = async (_args: any, ctx: any) => { + ready.resolve() + ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) + await new Promise(() => {}) + return { + title: "", + metadata: { + sessionId: SessionID.make("task"), + model: ref, + }, + output: "", + } + } + return next }, - output: "", } - } - yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original))) + }) as typeof TaskTool.build + yield* Effect.addFinalizer(() => Effect.sync(() => void (TaskTool.build = original))) 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 e3e6d58d3c..2bb92e4c37 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -10,6 +10,7 @@ import { SessionPrompt } from "../../src/session/prompt" import { MessageID, PartID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { TaskTool } from "../../src/tool/task" +import { Tool } from "../../src/tool/tool" import { ToolRegistry } from "../../src/tool/registry" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -23,6 +24,15 @@ const ref = { modelID: ModelID.make("test-model"), } +const bindTask = (agent: Agent.Interface, config: Config.Interface) => + TaskTool.build({ + agent, + config, + cancel: (sessionID) => SessionPrompt.cancel(sessionID), + resolvePromptParts: (template) => SessionPrompt.resolvePromptParts(template), + prompt: (input) => SessionPrompt.prompt(input), + }) + const it = testEffect( Layer.mergeAll( Agent.defaultLayer, @@ -175,11 +185,12 @@ describe("tool.task", () => { it.live("execute resumes an existing task session from task_id", () => provideTmpdirInstance(() => Effect.gen(function* () { + const agent = yield* Agent.Service + const config = yield* Config.Service const sessions = yield* Session.Service const { chat, assistant } = yield* seed() const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) - const tool = yield* TaskTool - const def = yield* Effect.promise(() => tool.init()) + const def = yield* Tool.init(bindTask(agent, config)) const resolve = SessionPrompt.resolvePromptParts const prompt = SessionPrompt.prompt let seen: Parameters[0] | undefined @@ -229,9 +240,10 @@ describe("tool.task", () => { it.live("execute asks by default and skips checks when bypassed", () => provideTmpdirInstance(() => Effect.gen(function* () { + const agent = yield* Agent.Service + const config = yield* Config.Service const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* Effect.promise(() => tool.init()) + const def = yield* Tool.init(bindTask(agent, config)) const resolve = SessionPrompt.resolvePromptParts const prompt = SessionPrompt.prompt const calls: unknown[] = [] @@ -288,10 +300,11 @@ describe("tool.task", () => { it.live("execute creates a child when task_id does not exist", () => provideTmpdirInstance(() => Effect.gen(function* () { + const agent = yield* Agent.Service + const config = yield* Config.Service const sessions = yield* Session.Service const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* Effect.promise(() => tool.init()) + const def = yield* Tool.init(bindTask(agent, config)) const resolve = SessionPrompt.resolvePromptParts const prompt = SessionPrompt.prompt let seen: Parameters[0] | undefined @@ -342,10 +355,11 @@ describe("tool.task", () => { provideTmpdirInstance( () => Effect.gen(function* () { + const agent = yield* Agent.Service + const config = yield* Config.Service const sessions = yield* Session.Service const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* Effect.promise(() => tool.init()) + const def = yield* Tool.init(bindTask(agent, config)) const resolve = SessionPrompt.resolvePromptParts const prompt = SessionPrompt.prompt let seen: Parameters[0] | undefined