refactor(effect): build task tool from agent services
parent
baff53b759
commit
78f7258c6d
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<typeof parameters>, 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)`,
|
||||
"",
|
||||
"<task_result>",
|
||||
text,
|
||||
"</task_result>",
|
||||
].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<typeof parameters>, 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)`,
|
||||
"",
|
||||
"<task_result>",
|
||||
text,
|
||||
"</task_result>",
|
||||
].join("\n")
|
||||
|
||||
return {
|
||||
title: params.description,
|
||||
metadata: {
|
||||
sessionId: session.id,
|
||||
model,
|
||||
},
|
||||
output,
|
||||
}
|
||||
},
|
||||
output,
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<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,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)
|
||||
},
|
||||
})
|
||||
})
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue