mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-29 10:13:33 +00:00
fix(core): isolate tool hook outcomes (#38571)
This commit is contained in:
parent
02f2725154
commit
e7ecee5df2
6 changed files with 40 additions and 37 deletions
|
|
@ -29,7 +29,7 @@ export type JsonSchema = {
|
|||
/** Either a validating Effect Schema or a render-only JSON Schema document. */
|
||||
export type SchemaType = Schema.Decoder<unknown> | JsonSchema
|
||||
|
||||
/** Executable tool tool exposed through CodeMode's `tools` object. */
|
||||
/** Executable tool exposed through CodeMode's `tools` object. */
|
||||
export type Tool<R = never> = {
|
||||
readonly _tag: "CodeModeTool"
|
||||
readonly description: string
|
||||
|
|
|
|||
|
|
@ -394,19 +394,15 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
})
|
||||
}
|
||||
return toolHooks.hook.after((event) => {
|
||||
// JS plugin boundary: marshal the canonical outcome out, copy mutations back.
|
||||
const output: Record<string, unknown> = {
|
||||
// Decode first so plugin mutations cannot alias the canonical outcome.
|
||||
const output = {
|
||||
tool: event.tool,
|
||||
sessionID: event.sessionID,
|
||||
agent: event.agent,
|
||||
messageID: event.messageID,
|
||||
callID: event.callID,
|
||||
input: event.input,
|
||||
status: event.status,
|
||||
content: event.content,
|
||||
metadata: event.metadata,
|
||||
outputPaths: event.outputPaths,
|
||||
...(event.status === "error" ? { error: event.error } : {}),
|
||||
...Schema.decodeUnknownSync(Tool.ExecuteAfterOutcome)(event),
|
||||
}
|
||||
return Reflect.apply(callback, undefined, [output]).pipe(
|
||||
Effect.tap(() => {
|
||||
|
|
@ -417,16 +413,16 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
return Effect.logWarning("ignoring execute.after tool status change", { tool: event.tool })
|
||||
return Effect.sync(() => {
|
||||
if (event.status === "completed" && decoded.value.status === "completed") {
|
||||
if (output.content !== event.content) event.content = decoded.value.content
|
||||
if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata
|
||||
if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths
|
||||
event.content = decoded.value.content
|
||||
event.metadata = decoded.value.metadata
|
||||
event.outputPaths = decoded.value.outputPaths
|
||||
return
|
||||
}
|
||||
if (event.status === "error" && decoded.value.status === "error") {
|
||||
if (output.error !== event.error) event.error = decoded.value.error
|
||||
if (output.content !== event.content) event.content = decoded.value.content
|
||||
if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata
|
||||
if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths
|
||||
event.error = decoded.value.error
|
||||
event.content = decoded.value.content
|
||||
event.metadata = decoded.value.metadata
|
||||
event.outputPaths = decoded.value.outputPaths
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ const layer = Layer.effect(
|
|||
// Durable publishes are serialized so tool fibers and step settlement never interleave
|
||||
// mid-event.
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||
const publish = (event: LLMEvent) => serialized(publisher.publish(event))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(prepared.request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { type LLMEvent, type ProviderMetadata, type ToolContent, type ToolResultValue } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
|
||||
import { Effect } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { SessionEvent } from "../event"
|
||||
|
|
@ -12,7 +12,6 @@ import { Snapshot } from "../../snapshot"
|
|||
import { RelativePath } from "../../schema"
|
||||
import { SessionUsage } from "../usage"
|
||||
import { Tool } from "../../tool/tool"
|
||||
import { MAX_BYTES } from "../../tool-output-store"
|
||||
import type { ToolRegistry } from "../../tool/registry"
|
||||
|
||||
type Input = {
|
||||
|
|
@ -28,9 +27,11 @@ const record = (value: unknown): Record<string, unknown> =>
|
|||
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
|
||||
|
||||
/** Derives canonical model content from a provider-hosted tool result. */
|
||||
const hostedContent = (result: ToolResultValue): readonly [ToolContent, ...ToolContent[]] => {
|
||||
if (result.type === "content" && result.value.length > 0)
|
||||
return result.value as unknown as readonly [ToolContent, ...ToolContent[]]
|
||||
const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
|
||||
if (result.type === "content") {
|
||||
const content = Tool.nonEmpty(result.value)
|
||||
if (content !== undefined) return content
|
||||
}
|
||||
return [{ type: "text", text: Tool.stringify(result.value) }]
|
||||
}
|
||||
|
||||
|
|
@ -47,11 +48,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
progress?: ToolRegistry.Progress
|
||||
}
|
||||
>()
|
||||
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => {
|
||||
if (!tool.progress) return {}
|
||||
const metadata = Tool.jsonMetadata(tool.progress, MAX_BYTES)
|
||||
return metadata === undefined ? {} : { metadata }
|
||||
}
|
||||
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) =>
|
||||
tool.progress === undefined ? {} : { metadata: tool.progress }
|
||||
let assistantMessageID = input.assistantMessageID
|
||||
let stepStarted = false
|
||||
let stepFailed = false
|
||||
|
|
@ -292,7 +290,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
|
||||
}
|
||||
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent, error?: SessionError.Error) {
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
|
||||
switch (event.type) {
|
||||
case "step-start":
|
||||
yield* startAssistant()
|
||||
|
|
@ -405,12 +403,12 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
tool.settled = true
|
||||
const executed = event.providerExecuted === true || tool.providerExecuted
|
||||
const resultState = providerState(event.providerMetadata)
|
||||
if (error !== undefined || event.result.type === "error") {
|
||||
if (event.result.type === "error") {
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
error: error ?? { type: "tool.execution", message: Tool.stringify(event.result.value) },
|
||||
error: { type: "tool.execution", message: Tool.stringify(event.result.value) },
|
||||
...failureSnapshot(tool),
|
||||
executed,
|
||||
resultState,
|
||||
|
|
@ -471,13 +469,12 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||
const tool = tools.get(callID)
|
||||
if (!tool?.called || tool.settled)
|
||||
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
|
||||
const current = { ...update }
|
||||
tool.progress = current
|
||||
tool.progress = update
|
||||
yield* events.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
metadata: current,
|
||||
metadata: update,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -365,7 +365,7 @@ describe("PluginV2", () => {
|
|||
yield* ctx.tool
|
||||
.hook("execute.after", (event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.status === "completed") event.content = [] as never
|
||||
if (event.status === "completed") (event.content as unknown as unknown[]).splice(0)
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.asVoid)
|
||||
|
|
|
|||
|
|
@ -118,9 +118,7 @@ test("provider-executed success derives content and retains provider result stat
|
|||
test("interrupted progress metadata remains in the terminal failure snapshot", async () => {
|
||||
const { published, publisher } = capture("anthropic", { interruptProgress: true })
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
const exit = await Effect.runPromiseExit(
|
||||
publisher.progress(call.id, { phase: "visible" }),
|
||||
)
|
||||
const exit = await Effect.runPromiseExit(publisher.progress(call.id, { phase: "visible" }))
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
|
||||
|
||||
|
|
@ -129,6 +127,18 @@ test("interrupted progress metadata remains in the terminal failure snapshot", a
|
|||
})
|
||||
})
|
||||
|
||||
test("failure snapshot retains canonical progress above the default byte limit", async () => {
|
||||
const { published, publisher } = capture("anthropic", { interruptProgress: true })
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
const detail = "x".repeat(60 * 1024)
|
||||
await Effect.runPromiseExit(publisher.progress(call.id, { detail }))
|
||||
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
|
||||
|
||||
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
|
||||
metadata: { detail },
|
||||
})
|
||||
})
|
||||
|
||||
test("failure before progress omits partial output fields", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue