fix: propagate abort signal to inline read tool (#21584)
This commit is contained in:
@@ -559,7 +559,7 @@ 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 { task, model, lastUser, sessionID, session, msgs } = input
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
const taskTool = yield* registry.fromID(TaskTool.id)
|
const { task: taskTool } = yield* registry.named()
|
||||||
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
|
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
|
||||||
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
|
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
|
||||||
id: MessageID.ascending(),
|
id: MessageID.ascending(),
|
||||||
@@ -1080,6 +1080,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
const filepath = fileURLToPath(part.url)
|
const filepath = fileURLToPath(part.url)
|
||||||
if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory"
|
if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory"
|
||||||
|
|
||||||
|
const { read } = yield* registry.named()
|
||||||
|
const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) =>
|
||||||
|
Effect.promise((signal: AbortSignal) =>
|
||||||
|
read.execute(args, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
abort: signal,
|
||||||
|
agent: input.agent!,
|
||||||
|
messageID: info.id,
|
||||||
|
extra: { bypassCwdCheck: true, ...extra },
|
||||||
|
messages: [],
|
||||||
|
metadata: async () => {},
|
||||||
|
ask: async () => {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
if (part.mime === "text/plain") {
|
if (part.mime === "text/plain") {
|
||||||
let offset: number | undefined
|
let offset: number | undefined
|
||||||
let limit: number | undefined
|
let limit: number | undefined
|
||||||
@@ -1116,29 +1131,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
|
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
const read = yield* registry.fromID("read").pipe(
|
const exit = yield* provider.getModel(info.model.providerID, info.model.modelID).pipe(
|
||||||
Effect.flatMap((t) =>
|
Effect.flatMap((mdl) => execRead(args, { model: mdl })),
|
||||||
provider.getModel(info.model.providerID, info.model.modelID).pipe(
|
|
||||||
Effect.flatMap((mdl) =>
|
|
||||||
Effect.promise(() =>
|
|
||||||
t.execute(args, {
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
abort: new AbortController().signal,
|
|
||||||
agent: input.agent!,
|
|
||||||
messageID: info.id,
|
|
||||||
extra: { bypassCwdCheck: true, model: mdl },
|
|
||||||
messages: [],
|
|
||||||
metadata: async () => {},
|
|
||||||
ask: async () => {},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Effect.exit,
|
Effect.exit,
|
||||||
)
|
)
|
||||||
if (Exit.isSuccess(read)) {
|
if (Exit.isSuccess(exit)) {
|
||||||
const result = read.value
|
const result = exit.value
|
||||||
pieces.push({
|
pieces.push({
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -1160,7 +1158,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })
|
pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const error = Cause.squash(read.cause)
|
const error = Cause.squash(exit.cause)
|
||||||
log.error("failed to read file", { error })
|
log.error("failed to read file", { error })
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
yield* bus.publish(Session.Event.Error, {
|
yield* bus.publish(Session.Event.Error, {
|
||||||
@@ -1180,22 +1178,25 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
|
|
||||||
if (part.mime === "application/x-directory") {
|
if (part.mime === "application/x-directory") {
|
||||||
const args = { filePath: filepath }
|
const args = { filePath: filepath }
|
||||||
const result = yield* registry.fromID("read").pipe(
|
const exit = yield* execRead(args).pipe(Effect.exit)
|
||||||
Effect.flatMap((t) =>
|
if (Exit.isFailure(exit)) {
|
||||||
Effect.promise(() =>
|
const error = Cause.squash(exit.cause)
|
||||||
t.execute(args, {
|
log.error("failed to read directory", { error })
|
||||||
sessionID: input.sessionID,
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
abort: new AbortController().signal,
|
yield* bus.publish(Session.Event.Error, {
|
||||||
agent: input.agent!,
|
sessionID: input.sessionID,
|
||||||
messageID: info.id,
|
error: new NamedError.Unknown({ message }).toObject(),
|
||||||
extra: { bypassCwdCheck: true },
|
})
|
||||||
messages: [],
|
return [
|
||||||
metadata: async () => {},
|
{
|
||||||
ask: async () => {},
|
messageID: info.id,
|
||||||
}),
|
sessionID: input.sessionID,
|
||||||
),
|
type: "text",
|
||||||
),
|
synthetic: true,
|
||||||
)
|
text: `Read tool failed to read ${filepath} with the following error: ${message}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
@@ -1209,7 +1210,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
type: "text",
|
type: "text",
|
||||||
synthetic: true,
|
synthetic: true,
|
||||||
text: result.output,
|
text: exit.value.output,
|
||||||
},
|
},
|
||||||
{ ...part, messageID: info.id, sessionID: input.sessionID },
|
{ ...part, messageID: info.id, sessionID: input.sessionID },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -42,24 +42,25 @@ import { Agent } from "../agent/agent"
|
|||||||
export namespace ToolRegistry {
|
export namespace ToolRegistry {
|
||||||
const log = Log.create({ service: "tool.registry" })
|
const log = Log.create({ service: "tool.registry" })
|
||||||
|
|
||||||
|
type TaskDef = Tool.InferDef<typeof TaskTool>
|
||||||
|
type ReadDef = Tool.InferDef<typeof ReadTool>
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
custom: Tool.Def[]
|
custom: Tool.Def[]
|
||||||
builtin: Tool.Def[]
|
builtin: Tool.Def[]
|
||||||
|
task: TaskDef
|
||||||
|
read: ReadDef
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly ids: () => Effect.Effect<string[]>
|
readonly ids: () => Effect.Effect<string[]>
|
||||||
readonly all: () => Effect.Effect<Tool.Def[]>
|
readonly all: () => Effect.Effect<Tool.Def[]>
|
||||||
readonly named: {
|
readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }>
|
||||||
task: Tool.Info
|
|
||||||
read: Tool.Info
|
|
||||||
}
|
|
||||||
readonly tools: (model: {
|
readonly tools: (model: {
|
||||||
providerID: ProviderID
|
providerID: ProviderID
|
||||||
modelID: ModelID
|
modelID: ModelID
|
||||||
agent: Agent.Info
|
agent: Agent.Info
|
||||||
}) => Effect.Effect<Tool.Def[]>
|
}) => Effect.Effect<Tool.Def[]>
|
||||||
readonly fromID: (id: string) => Effect.Effect<Tool.Def>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
|
||||||
@@ -183,6 +184,8 @@ export namespace ToolRegistry {
|
|||||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
|
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
|
||||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []),
|
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []),
|
||||||
],
|
],
|
||||||
|
task: tool.task,
|
||||||
|
read: tool.read,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -192,13 +195,6 @@ export namespace ToolRegistry {
|
|||||||
return [...s.builtin, ...s.custom] as Tool.Def[]
|
return [...s.builtin, ...s.custom] as Tool.Def[]
|
||||||
})
|
})
|
||||||
|
|
||||||
const fromID: Interface["fromID"] = Effect.fn("ToolRegistry.fromID")(function* (id: string) {
|
|
||||||
const tools = yield* all()
|
|
||||||
const match = tools.find((tool) => tool.id === id)
|
|
||||||
if (!match) return yield* Effect.die(`Tool not found: ${id}`)
|
|
||||||
return match
|
|
||||||
})
|
|
||||||
|
|
||||||
const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () {
|
const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () {
|
||||||
return (yield* all()).map((tool) => tool.id)
|
return (yield* all()).map((tool) => tool.id)
|
||||||
})
|
})
|
||||||
@@ -245,7 +241,12 @@ export namespace ToolRegistry {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ ids, all, named: { task, read }, tools, fromID })
|
const named: Interface["named"] = Effect.fn("ToolRegistry.named")(function* () {
|
||||||
|
const s = yield* InstanceState.get(state)
|
||||||
|
return { task: s.task, read: s.read }
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ ids, all, named, tools })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ export namespace Tool {
|
|||||||
export type InferMetadata<T> =
|
export type InferMetadata<T> =
|
||||||
T extends Info<any, infer M> ? M : T extends Effect.Effect<Info<any, infer M>, any, any> ? M : never
|
T extends Info<any, infer M> ? M : T extends Effect.Effect<Info<any, infer M>, any, any> ? M : never
|
||||||
|
|
||||||
|
export type InferDef<T> =
|
||||||
|
T extends Info<infer P, infer M>
|
||||||
|
? Def<P, M>
|
||||||
|
: T extends Effect.Effect<Info<infer P, infer M>, any, any>
|
||||||
|
? Def<P, M>
|
||||||
|
: never
|
||||||
|
|
||||||
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||||
id: string,
|
id: string,
|
||||||
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
|
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
|
||||||
@@ -118,7 +125,7 @@ export namespace Tool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function init(info: Info): Effect.Effect<Def> {
|
export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const init = yield* Effect.promise(() => info.init())
|
const init = yield* Effect.promise(() => info.init())
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -631,31 +631,22 @@ it.live(
|
|||||||
const ready = defer<void>()
|
const ready = defer<void>()
|
||||||
const aborted = defer<void>()
|
const aborted = defer<void>()
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
const init = registry.named.task.init
|
const { task } = yield* registry.named()
|
||||||
registry.named.task.init = async () => ({
|
const original = task.execute
|
||||||
description: "task",
|
task.execute = async (_args, ctx) => {
|
||||||
parameters: z.object({
|
ready.resolve()
|
||||||
description: z.string(),
|
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
||||||
prompt: z.string(),
|
await new Promise<void>(() => {})
|
||||||
subagent_type: z.string(),
|
return {
|
||||||
task_id: z.string().optional(),
|
title: "",
|
||||||
command: z.string().optional(),
|
metadata: {
|
||||||
}),
|
sessionId: SessionID.make("task"),
|
||||||
execute: async (_args, ctx) => {
|
model: ref,
|
||||||
ready.resolve()
|
},
|
||||||
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
output: "",
|
||||||
await new Promise<void>(() => {})
|
}
|
||||||
return {
|
}
|
||||||
title: "",
|
yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
|
||||||
metadata: {
|
|
||||||
sessionId: SessionID.make("task"),
|
|
||||||
model: ref,
|
|
||||||
},
|
|
||||||
output: "",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
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")
|
||||||
@@ -1240,3 +1231,109 @@ unix(
|
|||||||
),
|
),
|
||||||
30_000,
|
30_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Abort signal propagation tests for inline tool execution
|
||||||
|
|
||||||
|
/** Override a tool's execute to hang until aborted. Returns ready/aborted defers and a finalizer. */
|
||||||
|
function hangUntilAborted(tool: { execute: (...args: any[]) => any }) {
|
||||||
|
const ready = defer<void>()
|
||||||
|
const aborted = defer<void>()
|
||||||
|
const original = tool.execute
|
||||||
|
tool.execute = async (_args: any, ctx: any) => {
|
||||||
|
ready.resolve()
|
||||||
|
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
||||||
|
await new Promise<void>(() => {})
|
||||||
|
return { title: "", metadata: {}, output: "" }
|
||||||
|
}
|
||||||
|
const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original)))
|
||||||
|
return { ready, aborted, restore }
|
||||||
|
}
|
||||||
|
|
||||||
|
it.live(
|
||||||
|
"interrupt propagates abort signal to read tool via file part (text/plain)",
|
||||||
|
() =>
|
||||||
|
provideTmpdirInstance(
|
||||||
|
(dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const { read } = yield* registry.named()
|
||||||
|
const { ready, aborted, restore } = hangUntilAborted(read)
|
||||||
|
yield* restore
|
||||||
|
|
||||||
|
const prompt = yield* SessionPrompt.Service
|
||||||
|
const sessions = yield* Session.Service
|
||||||
|
const chat = yield* sessions.create({ title: "Abort Test" })
|
||||||
|
|
||||||
|
const testFile = path.join(dir, "test.txt")
|
||||||
|
yield* Effect.promise(() => Bun.write(testFile, "hello world"))
|
||||||
|
|
||||||
|
const fiber = yield* prompt
|
||||||
|
.prompt({
|
||||||
|
sessionID: chat.id,
|
||||||
|
agent: "build",
|
||||||
|
parts: [
|
||||||
|
{ type: "text", text: "read this" },
|
||||||
|
{ type: "file", url: `file://${testFile}`, filename: "test.txt", mime: "text/plain" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.pipe(Effect.forkChild)
|
||||||
|
|
||||||
|
yield* Effect.promise(() => ready.promise)
|
||||||
|
yield* Fiber.interrupt(fiber)
|
||||||
|
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Promise.race([
|
||||||
|
aborted.promise,
|
||||||
|
new Promise<void>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
{ git: true, config: cfg },
|
||||||
|
),
|
||||||
|
30_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live(
|
||||||
|
"interrupt propagates abort signal to read tool via file part (directory)",
|
||||||
|
() =>
|
||||||
|
provideTmpdirInstance(
|
||||||
|
(dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const { read } = yield* registry.named()
|
||||||
|
const { ready, aborted, restore } = hangUntilAborted(read)
|
||||||
|
yield* restore
|
||||||
|
|
||||||
|
const prompt = yield* SessionPrompt.Service
|
||||||
|
const sessions = yield* Session.Service
|
||||||
|
const chat = yield* sessions.create({ title: "Abort Test" })
|
||||||
|
|
||||||
|
const fiber = yield* prompt
|
||||||
|
.prompt({
|
||||||
|
sessionID: chat.id,
|
||||||
|
agent: "build",
|
||||||
|
parts: [
|
||||||
|
{ type: "text", text: "read this" },
|
||||||
|
{ type: "file", url: `file://${dir}`, filename: "dir", mime: "application/x-directory" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.pipe(Effect.forkChild)
|
||||||
|
|
||||||
|
yield* Effect.promise(() => ready.promise)
|
||||||
|
yield* Fiber.interrupt(fiber)
|
||||||
|
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
Promise.race([
|
||||||
|
aborted.promise,
|
||||||
|
new Promise<void>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
{ git: true, config: cfg },
|
||||||
|
),
|
||||||
|
30_000,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user