mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-09 07:24:51 +00:00
Compare commits
6 Commits
refactor/r
...
pr-21017
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2c4365fa0 | ||
|
|
1033d0c46c | ||
|
|
7988a76a25 | ||
|
|
5e54c54134 | ||
|
|
0f3a2a7b67 | ||
|
|
186063fbed |
@@ -600,7 +600,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
subagent_type: task.agent,
|
||||
command: task.command,
|
||||
}
|
||||
yield* plugin.trigger("tool.execute.before", { tool: "task", sessionID, callID: part.id }, { args: taskArgs })
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: TaskTool.id, sessionID, callID: part.id },
|
||||
{ args: taskArgs },
|
||||
)
|
||||
|
||||
const taskAgent = yield* agents.get(task.agent)
|
||||
if (!taskAgent) {
|
||||
@@ -679,7 +683,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{ tool: "task", sessionID, callID: part.id, args: taskArgs },
|
||||
{ tool: TaskTool.id, sessionID, callID: part.id, args: taskArgs },
|
||||
result,
|
||||
)
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ export namespace ToolRegistry {
|
||||
export interface Interface {
|
||||
readonly ids: () => Effect.Effect<string[]>
|
||||
readonly all: () => Effect.Effect<Tool.Def[]>
|
||||
readonly named: {
|
||||
task: Tool.Info
|
||||
read: Tool.Info
|
||||
}
|
||||
readonly tools: (model: {
|
||||
providerID: ProviderID
|
||||
modelID: ModelID
|
||||
@@ -67,6 +71,7 @@ export namespace ToolRegistry {
|
||||
| Plugin.Service
|
||||
| Question.Service
|
||||
| Todo.Service
|
||||
| Agent.Service
|
||||
| LSP.Service
|
||||
| FileTime.Service
|
||||
| Instruction.Service
|
||||
@@ -77,8 +82,10 @@ export namespace ToolRegistry {
|
||||
const config = yield* Config.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
|
||||
Effect.isEffect(tool) ? tool.pipe(Effect.flatMap(Tool.init)) : Tool.init(tool)
|
||||
const task = yield* TaskTool
|
||||
const read = yield* ReadTool
|
||||
const question = yield* QuestionTool
|
||||
const todo = yield* TodoWriteTool
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||
@@ -90,11 +97,11 @@ export namespace ToolRegistry {
|
||||
parameters: z.object(def.args),
|
||||
description: def.description,
|
||||
execute: async (args, toolCtx) => {
|
||||
const pluginCtx = {
|
||||
const pluginCtx: PluginToolContext = {
|
||||
...toolCtx,
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
} as unknown as PluginToolContext
|
||||
}
|
||||
const result = await def.execute(args as any, pluginCtx)
|
||||
const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent))
|
||||
return {
|
||||
@@ -132,34 +139,50 @@ export namespace ToolRegistry {
|
||||
}
|
||||
|
||||
const cfg = yield* config.get()
|
||||
const question =
|
||||
const questionEnabled =
|
||||
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
||||
|
||||
const tool = yield* Effect.all({
|
||||
invalid: Tool.init(InvalidTool),
|
||||
bash: Tool.init(BashTool),
|
||||
read: Tool.init(read),
|
||||
glob: Tool.init(GlobTool),
|
||||
grep: Tool.init(GrepTool),
|
||||
edit: Tool.init(EditTool),
|
||||
write: Tool.init(WriteTool),
|
||||
task: Tool.init(task),
|
||||
fetch: Tool.init(WebFetchTool),
|
||||
todo: Tool.init(todo),
|
||||
search: Tool.init(WebSearchTool),
|
||||
code: Tool.init(CodeSearchTool),
|
||||
skill: Tool.init(SkillTool),
|
||||
patch: Tool.init(ApplyPatchTool),
|
||||
question: Tool.init(question),
|
||||
lsp: Tool.init(LspTool),
|
||||
plan: Tool.init(PlanExitTool),
|
||||
})
|
||||
|
||||
return {
|
||||
custom,
|
||||
builtin: yield* Effect.forEach(
|
||||
[
|
||||
InvalidTool,
|
||||
BashTool,
|
||||
ReadTool,
|
||||
GlobTool,
|
||||
GrepTool,
|
||||
EditTool,
|
||||
WriteTool,
|
||||
TaskTool,
|
||||
WebFetchTool,
|
||||
TodoWriteTool,
|
||||
WebSearchTool,
|
||||
CodeSearchTool,
|
||||
SkillTool,
|
||||
ApplyPatchTool,
|
||||
...(question ? [QuestionTool] : []),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
|
||||
],
|
||||
build,
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
builtin: [
|
||||
tool.invalid,
|
||||
...(questionEnabled ? [tool.question] : []),
|
||||
tool.bash,
|
||||
tool.read,
|
||||
tool.glob,
|
||||
tool.grep,
|
||||
tool.edit,
|
||||
tool.write,
|
||||
tool.task,
|
||||
tool.fetch,
|
||||
tool.todo,
|
||||
tool.search,
|
||||
tool.code,
|
||||
tool.skill,
|
||||
tool.patch,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []),
|
||||
],
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -208,7 +231,6 @@ export namespace ToolRegistry {
|
||||
id: tool.id,
|
||||
description: [
|
||||
output.description,
|
||||
// TODO: remove this hack
|
||||
tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined,
|
||||
tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined,
|
||||
]
|
||||
@@ -223,7 +245,7 @@ export namespace ToolRegistry {
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({ ids, tools, all, fromID })
|
||||
return Service.of({ ids, all, named: { task, read }, tools, fromID })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -234,6 +256,7 @@ export namespace ToolRegistry {
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Question.defaultLayer),
|
||||
Layer.provide(Todo.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(FileTime.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
|
||||
@@ -6,96 +6,101 @@ import { SessionID, MessageID } from "../session/schema"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { SessionPrompt } from "../session/prompt"
|
||||
import { iife } from "@/util/iife"
|
||||
import { defer } from "@/util/defer"
|
||||
import { Config } from "../config/config"
|
||||
import { Permission } from "@/permission"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const TaskTool = Tool.define("task", async () => {
|
||||
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
|
||||
const list = agents.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
const agentList = list
|
||||
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
|
||||
.join("\n")
|
||||
const description = [`Available agent types and the tools they have access to:`, agentList].join("\n")
|
||||
const id = "task"
|
||||
|
||||
return {
|
||||
description,
|
||||
parameters: z.object({
|
||||
description: z.string().describe("A short (3-5 words) description of the task"),
|
||||
prompt: z.string().describe("The task for the agent to perform"),
|
||||
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
|
||||
task_id: z
|
||||
.string()
|
||||
.describe(
|
||||
"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)",
|
||||
)
|
||||
.optional(),
|
||||
command: z.string().describe("The command that triggered this task").optional(),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const config = await Config.get()
|
||||
const parameters = z.object({
|
||||
description: z.string().describe("A short (3-5 words) description of the task"),
|
||||
prompt: z.string().describe("The task for the agent to perform"),
|
||||
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
|
||||
task_id: z
|
||||
.string()
|
||||
.describe(
|
||||
"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)",
|
||||
)
|
||||
.optional(),
|
||||
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
|
||||
|
||||
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
|
||||
const cfg = yield* config.get()
|
||||
|
||||
// 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,
|
||||
},
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: id,
|
||||
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 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 hasTaskPermission = agent.permission.some((rule) => rule.permission === "task")
|
||||
const hasTodoWritePermission = agent.permission.some((rule) => rule.permission === "todowrite")
|
||||
const canTask = next.permission.some((rule) => rule.permission === id)
|
||||
const canTodo = 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
|
||||
}
|
||||
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,
|
||||
})) ?? []),
|
||||
],
|
||||
}),
|
||||
))
|
||||
|
||||
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 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 = agent.model ?? {
|
||||
const model = next.model ?? {
|
||||
modelID: msg.info.modelID,
|
||||
providerID: msg.info.providerID,
|
||||
}
|
||||
@@ -103,7 +108,7 @@ export const TaskTool = Tool.define("task", async () => {
|
||||
ctx.metadata({
|
||||
title: params.description,
|
||||
metadata: {
|
||||
sessionId: session.id,
|
||||
sessionId: nextSession.id,
|
||||
model,
|
||||
},
|
||||
})
|
||||
@@ -111,59 +116,77 @@ export const TaskTool = Tool.define("task", async () => {
|
||||
const messageID = MessageID.ascending()
|
||||
|
||||
function cancel() {
|
||||
SessionPrompt.cancel(session.id)
|
||||
SessionPrompt.cancel(nextSession.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,
|
||||
})
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
ctx.abort.addEventListener("abort", cancel)
|
||||
}),
|
||||
() =>
|
||||
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,
|
||||
}),
|
||||
)
|
||||
|
||||
const text = result.parts.findLast((x) => x.type === "text")?.text ?? ""
|
||||
return {
|
||||
title: params.description,
|
||||
metadata: {
|
||||
sessionId: nextSession.id,
|
||||
model,
|
||||
},
|
||||
output: [
|
||||
`task_id: ${nextSession.id} (for resuming to continue this task if needed)`,
|
||||
"",
|
||||
"<task_result>",
|
||||
result.parts.findLast((item) => item.type === "text")?.text ?? "",
|
||||
"</task_result>",
|
||||
].join("\n"),
|
||||
}
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
ctx.abort.removeEventListener("abort", cancel)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const output = [
|
||||
`task_id: ${session.id} (for resuming to continue this task if needed)`,
|
||||
"",
|
||||
"<task_result>",
|
||||
text,
|
||||
"</task_result>",
|
||||
].join("\n")
|
||||
|
||||
return {
|
||||
title: params.description,
|
||||
metadata: {
|
||||
sessionId: session.id,
|
||||
model,
|
||||
},
|
||||
output,
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||
return Effect.runPromise(run(params, ctx))
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
export const TaskDescription: Tool.DynamicDescription = (agent) =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Effect.promise(() => Agent.list().then((x) => x.filter((a) => a.mode !== "primary")))
|
||||
const accessibleAgents = agents.filter(
|
||||
(a) => Permission.evaluate("task", a.name, agent.permission).action !== "deny",
|
||||
const items = yield* Effect.promise(() =>
|
||||
Agent.list().then((items) => items.filter((item) => item.mode !== "primary")),
|
||||
)
|
||||
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
const filtered = items.filter((item) => Permission.evaluate(id, item.name, agent.permission).action !== "deny")
|
||||
const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
const description = list
|
||||
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
|
||||
.map(
|
||||
(item) => `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`,
|
||||
)
|
||||
.join("\n")
|
||||
return [`Available agent types and the tools they have access to:`, description].join("\n")
|
||||
return ["Available agent types and the tools they have access to:", description].join("\n")
|
||||
})
|
||||
|
||||
@@ -98,24 +98,27 @@ export namespace Tool {
|
||||
}
|
||||
}
|
||||
|
||||
export function define<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
id: string,
|
||||
export function define<Parameters extends z.ZodType, Result extends Metadata, ID extends string = string>(
|
||||
id: ID,
|
||||
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
|
||||
): Info<Parameters, Result> {
|
||||
): Info<Parameters, Result> & { id: ID } {
|
||||
return {
|
||||
id,
|
||||
init: wrap(id, init),
|
||||
}
|
||||
}
|
||||
|
||||
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R>(
|
||||
id: string,
|
||||
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
|
||||
id: ID,
|
||||
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
|
||||
): Effect.Effect<Info<Parameters, Result>, never, R> {
|
||||
return Effect.map(init, (next) => ({ id, init: wrap(id, next) }))
|
||||
): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } {
|
||||
return Object.assign(
|
||||
Effect.map(init, (next) => ({ id, init: wrap(id, next) })),
|
||||
{ id },
|
||||
)
|
||||
}
|
||||
|
||||
export function init(info: Info): Effect.Effect<Def, never, any> {
|
||||
export function init(info: Info): Effect.Effect<Def> {
|
||||
return Effect.gen(function* () {
|
||||
const init = yield* Effect.promise(() => info.init())
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { expect, spyOn } from "bun:test"
|
||||
import { expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
@@ -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"
|
||||
@@ -627,11 +626,13 @@ it.live(
|
||||
"cancel finalizes subtask tool state",
|
||||
() =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const ready = defer<void>()
|
||||
const aborted = defer<void>()
|
||||
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")
|
||||
|
||||
@@ -1,50 +1,412 @@
|
||||
import { Effect } from "effect"
|
||||
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 { TaskDescription } from "../../src/tool/task"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Session } from "../../src/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { TaskDescription, TaskTool } from "../../src/tool/task"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
const ref = {
|
||||
providerID: ProviderID.make("test"),
|
||||
modelID: ModelID.make("test-model"),
|
||||
}
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, Session.defaultLayer),
|
||||
)
|
||||
|
||||
const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
|
||||
const session = yield* Session.Service
|
||||
const chat = yield* session.create({ title })
|
||||
const user = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: chat.id,
|
||||
agent: "build",
|
||||
model: ref,
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const assistant: MessageV2.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
parentID: user.id,
|
||||
sessionID: chat.id,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
cost: 0,
|
||||
path: { cwd: "/tmp", root: "/tmp" },
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: ref.modelID,
|
||||
providerID: ref.providerID,
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
yield* session.updateMessage(assistant)
|
||||
return { chat, assistant }
|
||||
})
|
||||
|
||||
function reply(input: Parameters<typeof SessionPrompt.prompt>[0], text: string): MessageV2.WithParts {
|
||||
const id = MessageID.ascending()
|
||||
return {
|
||||
info: {
|
||||
id,
|
||||
role: "assistant",
|
||||
parentID: input.messageID ?? MessageID.ascending(),
|
||||
sessionID: input.sessionID,
|
||||
mode: input.agent ?? "general",
|
||||
agent: input.agent ?? "general",
|
||||
cost: 0,
|
||||
path: { cwd: "/tmp", root: "/tmp" },
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: input.model?.modelID ?? ref.modelID,
|
||||
providerID: input.model?.providerID ?? ref.providerID,
|
||||
time: { created: Date.now() },
|
||||
finish: "stop",
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
messageID: id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
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 first = yield* TaskDescription(build)
|
||||
const second = yield* TaskDescription(build)
|
||||
|
||||
expect(first).toBe(second)
|
||||
|
||||
const alpha = first.indexOf("- alpha: Alpha agent")
|
||||
const explore = first.indexOf("- explore:")
|
||||
const general = first.indexOf("- general:")
|
||||
const zebra = first.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 agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
|
||||
const first = await Effect.runPromise(TaskDescription(agent))
|
||||
const second = await Effect.runPromise(TaskDescription(agent))
|
||||
it.live("description hides denied subagents for the caller", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
const build = yield* agent.get("build")
|
||||
const description = yield* TaskDescription(build)
|
||||
|
||||
expect(first).toBe(second)
|
||||
|
||||
const alpha = first.indexOf("- alpha: Alpha agent")
|
||||
const explore = first.indexOf("- explore:")
|
||||
const general = first.indexOf("- general:")
|
||||
const zebra = first.indexOf("- zebra: Zebra agent")
|
||||
|
||||
expect(alpha).toBeGreaterThan(-1)
|
||||
expect(explore).toBeGreaterThan(alpha)
|
||||
expect(general).toBeGreaterThan(explore)
|
||||
expect(zebra).toBeGreaterThan(general)
|
||||
expect(description).toContain("- alpha: Alpha agent")
|
||||
expect(description).not.toContain("- zebra: Zebra agent")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
permission: {
|
||||
task: {
|
||||
"*": "allow",
|
||||
zebra: "deny",
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
zebra: {
|
||||
description: "Zebra agent",
|
||||
mode: "subagent",
|
||||
},
|
||||
alpha: {
|
||||
description: "Alpha agent",
|
||||
mode: "subagent",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
),
|
||||
)
|
||||
|
||||
it.live("execute resumes an existing task session from task_id", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
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 resolve = SessionPrompt.resolvePromptParts
|
||||
const prompt = SessionPrompt.prompt
|
||||
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
|
||||
|
||||
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
|
||||
SessionPrompt.prompt = async (input) => {
|
||||
seen = input
|
||||
return reply(input, "resumed")
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
SessionPrompt.resolvePromptParts = resolve
|
||||
SessionPrompt.prompt = prompt
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "general",
|
||||
task_id: child.id,
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const kids = yield* sessions.children(chat.id)
|
||||
expect(kids).toHaveLength(1)
|
||||
expect(kids[0]?.id).toBe(child.id)
|
||||
expect(result.metadata.sessionId).toBe(child.id)
|
||||
expect(result.output).toContain(`task_id: ${child.id}`)
|
||||
expect(seen?.sessionID).toBe(child.id)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("execute asks by default and skips checks when bypassed", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const { chat, assistant } = yield* seed()
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* Effect.promise(() => tool.init())
|
||||
const resolve = SessionPrompt.resolvePromptParts
|
||||
const prompt = SessionPrompt.prompt
|
||||
const calls: unknown[] = []
|
||||
|
||||
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
|
||||
SessionPrompt.prompt = async (input) => reply(input, "done")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
SessionPrompt.resolvePromptParts = resolve
|
||||
SessionPrompt.prompt = prompt
|
||||
}),
|
||||
)
|
||||
|
||||
const exec = (extra?: { bypassAgentCheck?: boolean }) =>
|
||||
Effect.promise(() =>
|
||||
def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "general",
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra,
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async (input) => {
|
||||
calls.push(input)
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
yield* exec()
|
||||
yield* exec({ bypassAgentCheck: true })
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toEqual({
|
||||
permission: "task",
|
||||
patterns: ["general"],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
description: "inspect bug",
|
||||
subagent_type: "general",
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("execute creates a child when task_id does not exist", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const { chat, assistant } = yield* seed()
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* Effect.promise(() => tool.init())
|
||||
const resolve = SessionPrompt.resolvePromptParts
|
||||
const prompt = SessionPrompt.prompt
|
||||
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
|
||||
|
||||
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
|
||||
SessionPrompt.prompt = async (input) => {
|
||||
seen = input
|
||||
return reply(input, "created")
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
SessionPrompt.resolvePromptParts = resolve
|
||||
SessionPrompt.prompt = prompt
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "general",
|
||||
task_id: "ses_missing",
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const kids = yield* sessions.children(chat.id)
|
||||
expect(kids).toHaveLength(1)
|
||||
expect(kids[0]?.id).toBe(result.metadata.sessionId)
|
||||
expect(result.metadata.sessionId).not.toBe("ses_missing")
|
||||
expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`)
|
||||
expect(seen?.sessionID).toBe(result.metadata.sessionId)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("execute shapes child permissions for task, todowrite, and primary tools", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const { chat, assistant } = yield* seed()
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* Effect.promise(() => tool.init())
|
||||
const resolve = SessionPrompt.resolvePromptParts
|
||||
const prompt = SessionPrompt.prompt
|
||||
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
|
||||
|
||||
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
|
||||
SessionPrompt.prompt = async (input) => {
|
||||
seen = input
|
||||
return reply(input, "done")
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
SessionPrompt.resolvePromptParts = resolve
|
||||
SessionPrompt.prompt = prompt
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "reviewer",
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const child = yield* sessions.get(result.metadata.sessionId)
|
||||
expect(child.parentID).toBe(chat.id)
|
||||
expect(child.permission).toEqual([
|
||||
{
|
||||
permission: "todowrite",
|
||||
pattern: "*",
|
||||
action: "deny",
|
||||
},
|
||||
{
|
||||
permission: "bash",
|
||||
pattern: "*",
|
||||
action: "allow",
|
||||
},
|
||||
{
|
||||
permission: "read",
|
||||
pattern: "*",
|
||||
action: "allow",
|
||||
},
|
||||
])
|
||||
expect(seen?.tools).toEqual({
|
||||
todowrite: false,
|
||||
bash: false,
|
||||
read: false,
|
||||
})
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
agent: {
|
||||
reviewer: {
|
||||
mode: "subagent",
|
||||
permission: {
|
||||
task: "allow",
|
||||
},
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
primary_tools: ["bash", "read"],
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user