mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 20:05:23 +00:00
285 lines
9.7 KiB
TypeScript
285 lines
9.7 KiB
TypeScript
import * as Tool from "./tool"
|
|
import DESCRIPTION from "./task.txt"
|
|
import { Bus } from "../bus"
|
|
import { Session } from "../session"
|
|
import { SessionID, MessageID } from "../session/schema"
|
|
import { MessageV2 } from "../session/message-v2"
|
|
import { Agent } from "../agent/agent"
|
|
import type { SessionPrompt } from "../session/prompt"
|
|
import { SessionStatus } from "../session/status"
|
|
import { Config } from "../config"
|
|
import { TuiEvent } from "@/cli/cmd/tui/event"
|
|
import { Cause, Effect, Option, Schema } from "effect"
|
|
|
|
export interface TaskPromptOps {
|
|
cancel(sessionID: SessionID): void
|
|
resolvePromptParts(template: string): Effect.Effect<SessionPrompt.PromptInput["parts"]>
|
|
prompt(input: SessionPrompt.PromptInput): Effect.Effect<MessageV2.WithParts>
|
|
loop(input: SessionPrompt.LoopInput): Effect.Effect<MessageV2.WithParts>
|
|
fork(effect: Effect.Effect<void, never, never>): void
|
|
}
|
|
|
|
const id = "task"
|
|
|
|
export const Parameters = Schema.Struct({
|
|
description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }),
|
|
prompt: Schema.String.annotate({ description: "The task for the agent to perform" }),
|
|
subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
|
|
task_id: Schema.optional(SessionID).annotate({
|
|
description:
|
|
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
|
|
}),
|
|
command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }),
|
|
background: Schema.optional(Schema.Boolean).annotate({
|
|
description: "When true, launch the subagent in the background and return immediately",
|
|
}),
|
|
})
|
|
|
|
function output(sessionID: SessionID, text: string) {
|
|
return [
|
|
`task_id: ${sessionID} (for resuming to continue this task if needed)`,
|
|
"",
|
|
"<task_result>",
|
|
text,
|
|
"</task_result>",
|
|
].join("\n")
|
|
}
|
|
|
|
function backgroundOutput(sessionID: SessionID) {
|
|
return [
|
|
`task_id: ${sessionID} (for polling this task with task_status)`,
|
|
"state: running",
|
|
"",
|
|
"<task_result>",
|
|
"Background task started. Continue your current work and call task_status when you need the result.",
|
|
"</task_result>",
|
|
].join("\n")
|
|
}
|
|
|
|
function backgroundMessage(input: { sessionID: SessionID; description: string; state: "completed" | "error"; text: string }) {
|
|
const tag = input.state === "completed" ? "task_result" : "task_error"
|
|
const title =
|
|
input.state === "completed"
|
|
? `Background task completed: ${input.description}`
|
|
: `Background task failed: ${input.description}`
|
|
return [title, `task_id: ${input.sessionID}`, `state: ${input.state}`, `<${tag}>`, input.text, `</${tag}>`].join(
|
|
"\n",
|
|
)
|
|
}
|
|
|
|
function errorText(error: unknown) {
|
|
if (error instanceof Error) return error.message
|
|
return String(error)
|
|
}
|
|
|
|
export const TaskTool = Tool.define(
|
|
id,
|
|
Effect.gen(function* () {
|
|
const agent = yield* Agent.Service
|
|
const bus = yield* Bus.Service
|
|
const config = yield* Config.Service
|
|
const sessions = yield* Session.Service
|
|
const status = yield* SessionStatus.Service
|
|
|
|
const run = Effect.fn(
|
|
"TaskTool.execute",
|
|
)(function* (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) {
|
|
const cfg = yield* config.get()
|
|
|
|
if (!ctx.extra?.bypassAgentCheck) {
|
|
yield* ctx.ask({
|
|
permission: id,
|
|
patterns: [params.subagent_type],
|
|
always: ["*"],
|
|
metadata: {
|
|
description: params.description,
|
|
subagent_type: params.subagent_type,
|
|
},
|
|
})
|
|
}
|
|
|
|
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 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* sessions.get(taskID).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
|
: undefined
|
|
const nextSession =
|
|
session ??
|
|
(yield* sessions.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,
|
|
}
|
|
const parentModel = {
|
|
modelID: msg.info.modelID,
|
|
providerID: msg.info.providerID,
|
|
}
|
|
const background = params.background === true
|
|
|
|
const metadata = {
|
|
sessionId: nextSession.id,
|
|
model,
|
|
...(background ? { background: true } : {}),
|
|
}
|
|
|
|
yield* ctx.metadata({
|
|
title: params.description,
|
|
metadata,
|
|
})
|
|
|
|
const ops = ctx.extra?.promptOps as TaskPromptOps
|
|
if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra"))
|
|
|
|
const runTask = Effect.fn("TaskTool.runTask")(function* () {
|
|
const parts = yield* ops.resolvePromptParts(params.prompt)
|
|
const result = yield* ops.prompt({
|
|
messageID: MessageID.ascending(),
|
|
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 result.parts.findLast((item) => item.type === "text")?.text ?? ""
|
|
})
|
|
|
|
const continueIfIdle = Effect.fn("TaskTool.continueIfIdle")(function* (input: {
|
|
userID: MessageID
|
|
state: "completed" | "error"
|
|
}) {
|
|
if ((yield* status.get(ctx.sessionID)).type !== "idle") return
|
|
const latest = yield* sessions.findMessage(ctx.sessionID, (item) => item.info.role === "user")
|
|
if (Option.isNone(latest)) return
|
|
if (latest.value.info.id !== input.userID) return
|
|
yield* bus.publish(TuiEvent.ToastShow, {
|
|
title: input.state === "completed" ? "Background task complete" : "Background task failed",
|
|
message:
|
|
input.state === "completed"
|
|
? `Background task \"${params.description}\" finished. Resuming the main thread.`
|
|
: `Background task \"${params.description}\" failed. Resuming the main thread.`,
|
|
variant: input.state === "completed" ? "success" : "error",
|
|
duration: 5000,
|
|
})
|
|
yield* ops.loop({ sessionID: ctx.sessionID }).pipe(Effect.ignore)
|
|
})
|
|
|
|
if (background) {
|
|
const inject = Effect.fn("TaskTool.injectBackgroundResult")(function* (state: "completed" | "error", text: string) {
|
|
const message = yield* ops.prompt({
|
|
sessionID: ctx.sessionID,
|
|
noReply: true,
|
|
model: parentModel,
|
|
agent: ctx.agent,
|
|
parts: [
|
|
{
|
|
type: "text",
|
|
synthetic: true,
|
|
text: backgroundMessage({
|
|
sessionID: nextSession.id,
|
|
description: params.description,
|
|
state,
|
|
text,
|
|
}),
|
|
},
|
|
],
|
|
})
|
|
yield* continueIfIdle({ userID: message.info.id, state })
|
|
})
|
|
|
|
ops.fork(
|
|
runTask().pipe(
|
|
Effect.matchCauseEffect({
|
|
onSuccess: (text) => inject("completed", text),
|
|
onFailure: (cause) =>
|
|
inject("error", errorText(Cause.squash(cause))).pipe(Effect.catchCause(() => Effect.void)),
|
|
}),
|
|
Effect.catchCause(() => Effect.void),
|
|
Effect.asVoid,
|
|
),
|
|
)
|
|
|
|
return {
|
|
title: params.description,
|
|
metadata,
|
|
output: backgroundOutput(nextSession.id),
|
|
}
|
|
}
|
|
|
|
function cancel() {
|
|
ops.cancel(nextSession.id)
|
|
}
|
|
|
|
return yield* Effect.acquireUseRelease(
|
|
Effect.sync(() => {
|
|
ctx.abort.addEventListener("abort", cancel)
|
|
}),
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const text = yield* runTask()
|
|
return {
|
|
title: params.description,
|
|
metadata,
|
|
output: output(nextSession.id, text),
|
|
}
|
|
}),
|
|
() =>
|
|
Effect.sync(() => {
|
|
ctx.abort.removeEventListener("abort", cancel)
|
|
}),
|
|
)
|
|
}, Effect.orDie)
|
|
|
|
return {
|
|
description: DESCRIPTION,
|
|
parameters: Parameters,
|
|
execute: run,
|
|
}
|
|
}),
|
|
)
|