Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton
3d7501a6a3 refactor(task): split task spec from prompt execution 2026-04-10 13:42:58 -04:00
5 changed files with 272 additions and 208 deletions

View File

@@ -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<MessageV2.WithParts> = 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)

View File

@@ -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<typeof TaskTool.parameters, TaskMetadata> = yield* TaskTool
const read = yield* ReadTool
const question = yield* QuestionTool
const todo = yield* TodoWriteTool

View File

@@ -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<typeof parameters>, 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<void>
resolvePromptParts: (template: string) => Promise<SessionPrompt.PromptInput["parts"]>
prompt: (input: SessionPrompt.PromptInput) => Promise<MessageV2.WithParts>
}
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<typeof parameters, Metadata> = {
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<typeof parameters>, 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)`,
"",
"<task_result>",
result.parts.findLast((item) => item.type === "text")?.text ?? "",
"</task_result>",
].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<typeof parameters>, 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)`,
"",
"<task_result>",
result.parts.findLast((item) => item.type === "text")?.text ?? "",
"</task_result>",
].join("\n"),
}
}),
() =>
Effect.sync(() => {
ctx.abort.removeEventListener("abort", cancel)
}),
)
})
return Tool.define(id, {
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx))
},
})
}
export const TaskTool = Object.assign(Effect.succeed(Tool.define(id, unbound)), {
id,
description: DESCRIPTION,
parameters,
build,
})

View File

@@ -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<void>()
const aborted = defer<void>()
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<void>(() => {})
const original = TaskTool.build
TaskTool.build = ((runtime: Parameters<typeof TaskTool.build>[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<void>(() => {})
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")

View File

@@ -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<typeof SessionPrompt.prompt>[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<typeof SessionPrompt.prompt>[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<typeof SessionPrompt.prompt>[0] | undefined