Compare commits
12 Commits
dev
...
refactor/e
| Author | SHA1 | Date |
|---|---|---|
|
|
f4faab9bb2 | |
|
|
9a5cf96b7a | |
|
|
8616818e37 | |
|
|
babb46327f | |
|
|
78f7258c6d | |
|
|
baff53b759 | |
|
|
b15f1593c0 | |
|
|
98384cd860 | |
|
|
6a56bd5e79 | |
|
|
d9a07b5d96 | |
|
|
64f6c66984 | |
|
|
62f1421120 |
|
|
@ -33,6 +33,7 @@ import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { Env } from "../env"
|
import { Env } from "../env"
|
||||||
|
import { Agent as AgentSvc } from "../agent/agent"
|
||||||
import { Question } from "../question"
|
import { Question } from "../question"
|
||||||
import { Todo } from "../session/todo"
|
import { Todo } from "../session/todo"
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
|
|
@ -68,6 +69,7 @@ export namespace ToolRegistry {
|
||||||
| Plugin.Service
|
| Plugin.Service
|
||||||
| Question.Service
|
| Question.Service
|
||||||
| Todo.Service
|
| Todo.Service
|
||||||
|
| AgentSvc.Service
|
||||||
| LSP.Service
|
| LSP.Service
|
||||||
| FileTime.Service
|
| FileTime.Service
|
||||||
| Instruction.Service
|
| Instruction.Service
|
||||||
|
|
@ -237,6 +239,7 @@ export namespace ToolRegistry {
|
||||||
layer.pipe(
|
layer.pipe(
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
|
Layer.provide(AgentSvc.defaultLayer),
|
||||||
Layer.provide(Question.defaultLayer),
|
Layer.provide(Question.defaultLayer),
|
||||||
Layer.provide(Todo.defaultLayer),
|
Layer.provide(Todo.defaultLayer),
|
||||||
Layer.provide(LSP.defaultLayer),
|
Layer.provide(LSP.defaultLayer),
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import DESCRIPTION from "./task.txt"
|
import DESCRIPTION from "./task.txt"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { Session } from "../session"
|
import { Session } from "../session"
|
||||||
import { SessionID, MessageID } from "../session/schema"
|
import { SessionID, MessageID } from "../session/schema"
|
||||||
import { MessageV2 } from "../session/message-v2"
|
import { MessageV2 } from "../session/message-v2"
|
||||||
import { Identifier } from "../id/id"
|
|
||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
import { SessionPrompt } from "../session/prompt"
|
import { SessionPrompt } from "../session/prompt"
|
||||||
import { iife } from "@/util/iife"
|
|
||||||
import { defer } from "@/util/defer"
|
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
|
|
||||||
|
|
@ -25,31 +23,39 @@ const parameters = z.object({
|
||||||
command: z.string().describe("The command that triggered this task").optional(),
|
command: z.string().describe("The command that triggered this task").optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const TaskTool = Tool.define("task", async (ctx) => {
|
export const TaskTool = Tool.defineEffect(
|
||||||
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
|
"task",
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const agent = yield* Agent.Service
|
||||||
|
const config = yield* Config.Service
|
||||||
|
|
||||||
// Filter agents by permissions if agent provided
|
const list = Effect.fn("TaskTool.list")(function* (caller?: Tool.InitContext["agent"]) {
|
||||||
const caller = ctx?.agent
|
const items = yield* agent.list().pipe(Effect.map((items) => items.filter((item) => item.mode !== "primary")))
|
||||||
const accessibleAgents = caller
|
const filtered = caller
|
||||||
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
|
? items.filter((item) => Permission.evaluate("task", item.name, caller.permission).action !== "deny")
|
||||||
: agents
|
: items
|
||||||
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
|
return filtered.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
|
})
|
||||||
|
|
||||||
const description = DESCRIPTION.replace(
|
const desc = Effect.fn("TaskTool.desc")(function* (caller?: Tool.InitContext["agent"]) {
|
||||||
|
const items = yield* list(caller)
|
||||||
|
return DESCRIPTION.replace(
|
||||||
"{agents}",
|
"{agents}",
|
||||||
list
|
items
|
||||||
.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"),
|
.join("\n"),
|
||||||
)
|
)
|
||||||
return {
|
})
|
||||||
description,
|
|
||||||
parameters,
|
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
|
||||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
const cfg = yield* config.get()
|
||||||
const config = await Config.get()
|
|
||||||
|
|
||||||
// Skip permission check when user explicitly invoked via @ or command subtask
|
|
||||||
if (!ctx.extra?.bypassAgentCheck) {
|
if (!ctx.extra?.bypassAgentCheck) {
|
||||||
await ctx.ask({
|
yield* Effect.promise(() =>
|
||||||
|
ctx.ask({
|
||||||
permission: "task",
|
permission: "task",
|
||||||
patterns: [params.subagent_type],
|
patterns: [params.subagent_type],
|
||||||
always: ["*"],
|
always: ["*"],
|
||||||
|
|
@ -57,26 +63,33 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
||||||
description: params.description,
|
description: params.description,
|
||||||
subagent_type: params.subagent_type,
|
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 hasTask = next.permission.some((rule) => rule.permission === "task")
|
||||||
|
const hasTodo = 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 =
|
||||||
const agent = await Agent.get(params.subagent_type)
|
session ??
|
||||||
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
|
(yield* Effect.promise(() =>
|
||||||
|
Session.create({
|
||||||
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,
|
parentID: ctx.sessionID,
|
||||||
title: params.description + ` (@${agent.name} subagent)`,
|
title: params.description + ` (@${next.name} subagent)`,
|
||||||
permission: [
|
permission: [
|
||||||
...(hasTodoWritePermission
|
...(hasTodo
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
|
|
@ -85,7 +98,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
||||||
action: "deny" as const,
|
action: "deny" as const,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
...(hasTaskPermission
|
...(hasTask
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
|
|
@ -94,18 +107,19 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
||||||
action: "deny" as const,
|
action: "deny" as const,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
...(config.experimental?.primary_tools?.map((t) => ({
|
...(cfg.experimental?.primary_tools?.map((item) => ({
|
||||||
pattern: "*",
|
pattern: "*",
|
||||||
action: "allow" as const,
|
action: "allow" as const,
|
||||||
permission: t,
|
permission: item,
|
||||||
})) ?? []),
|
})) ?? []),
|
||||||
],
|
],
|
||||||
})
|
}),
|
||||||
})
|
))
|
||||||
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 ?? {
|
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,
|
modelID: msg.info.modelID,
|
||||||
providerID: msg.info.providerID,
|
providerID: msg.info.providerID,
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +127,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
||||||
ctx.metadata({
|
ctx.metadata({
|
||||||
title: params.description,
|
title: params.description,
|
||||||
metadata: {
|
metadata: {
|
||||||
sessionId: session.id,
|
sessionId: nextSession.id,
|
||||||
model,
|
model,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -121,46 +135,64 @@ export const TaskTool = Tool.define("task", async (ctx) => {
|
||||||
const messageID = MessageID.ascending()
|
const messageID = MessageID.ascending()
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
SessionPrompt.cancel(session.id)
|
SessionPrompt.cancel(nextSession.id)
|
||||||
}
|
}
|
||||||
|
return yield* Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
ctx.abort.addEventListener("abort", cancel)
|
ctx.abort.addEventListener("abort", cancel)
|
||||||
using _ = defer(() => ctx.abort.removeEventListener("abort", cancel))
|
}),
|
||||||
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt)
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
const result = await SessionPrompt.prompt({
|
const parts = yield* Effect.promise(() => SessionPrompt.resolvePromptParts(params.prompt))
|
||||||
|
const result = yield* Effect.promise(() =>
|
||||||
|
SessionPrompt.prompt({
|
||||||
messageID,
|
messageID,
|
||||||
sessionID: session.id,
|
sessionID: nextSession.id,
|
||||||
model: {
|
model: {
|
||||||
modelID: model.modelID,
|
modelID: model.modelID,
|
||||||
providerID: model.providerID,
|
providerID: model.providerID,
|
||||||
},
|
},
|
||||||
agent: agent.name,
|
agent: next.name,
|
||||||
tools: {
|
tools: {
|
||||||
...(hasTodoWritePermission ? {} : { todowrite: false }),
|
...(hasTodo ? {} : { todowrite: false }),
|
||||||
...(hasTaskPermission ? {} : { task: false }),
|
...(hasTask ? {} : { task: false }),
|
||||||
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
|
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
|
||||||
},
|
},
|
||||||
parts: promptParts,
|
parts,
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
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 {
|
return {
|
||||||
title: params.description,
|
title: params.description,
|
||||||
metadata: {
|
metadata: {
|
||||||
sessionId: session.id,
|
sessionId: nextSession.id,
|
||||||
model,
|
model,
|
||||||
},
|
},
|
||||||
output,
|
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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return async (ctx) => {
|
||||||
|
const description = await Effect.runPromise(desc(ctx?.agent))
|
||||||
|
|
||||||
|
return {
|
||||||
|
description,
|
||||||
|
parameters,
|
||||||
|
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||||
|
return Effect.runPromise(run(params, ctx))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
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 { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
@ -13,7 +13,6 @@ import { MCP } from "../../src/mcp"
|
||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
import { Plugin } from "../../src/plugin"
|
import { Plugin } from "../../src/plugin"
|
||||||
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
||||||
import type { Provider } from "../../src/provider/provider"
|
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { Question } from "../../src/question"
|
import { Question } from "../../src/question"
|
||||||
import { Todo } from "../../src/session/todo"
|
import { Todo } from "../../src/session/todo"
|
||||||
|
|
@ -29,7 +28,6 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||||
import { SessionStatus } from "../../src/session/status"
|
import { SessionStatus } from "../../src/session/status"
|
||||||
import { Shell } from "../../src/shell/shell"
|
import { Shell } from "../../src/shell/shell"
|
||||||
import { Snapshot } from "../../src/snapshot"
|
import { Snapshot } from "../../src/snapshot"
|
||||||
import { TaskTool } from "../../src/tool/task"
|
|
||||||
import { ToolRegistry } from "../../src/tool/registry"
|
import { ToolRegistry } from "../../src/tool/registry"
|
||||||
import { Truncate } from "../../src/tool/truncate"
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { Log } from "../../src/util/log"
|
import { Log } from "../../src/util/log"
|
||||||
|
|
@ -627,11 +625,13 @@ it.live(
|
||||||
"cancel finalizes subtask tool state",
|
"cancel finalizes subtask tool state",
|
||||||
() =>
|
() =>
|
||||||
provideTmpdirInstance(
|
provideTmpdirInstance(
|
||||||
(dir) =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const ready = defer<void>()
|
const ready = defer<void>()
|
||||||
const aborted = 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",
|
description: "task",
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
|
|
@ -641,6 +641,13 @@ it.live(
|
||||||
command: z.string().optional(),
|
command: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async (_args, ctx) => {
|
execute: async (_args, ctx) => {
|
||||||
|
ctx.metadata({
|
||||||
|
title: "inspect bug",
|
||||||
|
metadata: {
|
||||||
|
sessionId: SessionID.make("task"),
|
||||||
|
model: ref,
|
||||||
|
},
|
||||||
|
})
|
||||||
ready.resolve()
|
ready.resolve()
|
||||||
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
||||||
await new Promise<void>(() => {})
|
await new Promise<void>(() => {})
|
||||||
|
|
@ -653,8 +660,8 @@ it.live(
|
||||||
output: "",
|
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 { prompt, chat } = yield* boot()
|
||||||
const msg = yield* user(chat.id, "hello")
|
const msg = yield* user(chat.id, "hello")
|
||||||
|
|
@ -673,11 +680,19 @@ it.live(
|
||||||
expect(taskMsg?.info.role).toBe("assistant")
|
expect(taskMsg?.info.role).toBe("assistant")
|
||||||
if (!taskMsg || taskMsg.info.role !== "assistant") return
|
if (!taskMsg || taskMsg.info.role !== "assistant") return
|
||||||
|
|
||||||
const tool = toolPart(taskMsg.parts)
|
const tool = errorTool(taskMsg.parts)
|
||||||
expect(tool?.type).toBe("tool")
|
|
||||||
if (!tool) return
|
if (!tool) return
|
||||||
|
|
||||||
expect(tool.state.status).not.toBe("running")
|
expect(tool.state.error).toBe("Cancelled")
|
||||||
|
expect(tool.state.input).toEqual({
|
||||||
|
description: "inspect bug",
|
||||||
|
prompt: "look into the cache key path",
|
||||||
|
subagent_type: "general",
|
||||||
|
})
|
||||||
|
expect(tool.state.metadata).toEqual({
|
||||||
|
sessionId: SessionID.make("task"),
|
||||||
|
model: ref,
|
||||||
|
})
|
||||||
expect(taskMsg.info.time.completed).toBeDefined()
|
expect(taskMsg.info.time.completed).toBeDefined()
|
||||||
expect(taskMsg.info.finish).toBeDefined()
|
expect(taskMsg.info.finish).toBeDefined()
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,100 @@
|
||||||
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 { 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 { Instance } from "../../src/project/instance"
|
||||||
|
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 { TaskTool } from "../../src/tool/task"
|
import { TaskTool } from "../../src/tool/task"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("tool.task", () => {
|
const ref = {
|
||||||
test("description sorts subagents by name and is stable across calls", async () => {
|
providerID: ProviderID.make("test"),
|
||||||
await using tmp = await tmpdir({
|
modelID: ModelID.make("test-model"),
|
||||||
config: {
|
}
|
||||||
agent: {
|
|
||||||
zebra: {
|
|
||||||
description: "Zebra agent",
|
|
||||||
mode: "subagent",
|
|
||||||
},
|
|
||||||
alpha: {
|
|
||||||
description: "Alpha agent",
|
|
||||||
mode: "subagent",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await Instance.provide({
|
const it = testEffect(
|
||||||
directory: tmp.path,
|
Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, Session.defaultLayer),
|
||||||
fn: async () => {
|
)
|
||||||
const build = await Agent.get("build")
|
|
||||||
const first = await TaskTool.init({ agent: build })
|
const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
|
||||||
const second = await TaskTool.init({ agent: build })
|
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", () => {
|
||||||
|
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)
|
expect(first.description).toBe(second.description)
|
||||||
|
|
||||||
|
|
@ -43,7 +107,161 @@ describe("tool.task", () => {
|
||||||
expect(explore).toBeGreaterThan(alpha)
|
expect(explore).toBeGreaterThan(alpha)
|
||||||
expect(general).toBeGreaterThan(explore)
|
expect(general).toBeGreaterThan(explore)
|
||||||
expect(zebra).toBeGreaterThan(general)
|
expect(zebra).toBeGreaterThan(general)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
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 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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue