Skip to content

Commit eb9a683

Browse files
authored
fix(core): harden unified tool runtime (#31171)
1 parent 48c26fa commit eb9a683

35 files changed

Lines changed: 435 additions & 237 deletions

packages/core/src/public/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export { Agent } from "./agent"
33
export { Model } from "./model"
44
export { OpenCode } from "./opencode"
55
export { Session } from "./session"
6-
export * as Tool from "./tool"
6+
export { Tool } from "./tool"
77
export { Location } from "./location"
88
export { Prompt } from "../session/prompt"
99
export { AbsolutePath } from "../schema"

packages/core/src/public/tool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Effect, Scope } from "effect"
44
import type { AnyTool, RegistrationError } from "../tool/tool"
55

66
export { Failure, RegistrationError, make } from "../tool/tool"
7-
export type { AnyTool, Content, Context } from "../tool/tool"
7+
export type { AnyTool, Content, Context, Definition } from "../tool/tool"
88

99
export interface Interface {
1010
/**

packages/core/src/session/runner/llm.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,11 @@ export const layer = Layer.effect(
320320
yield* FiberSet.clear(toolFibers)
321321
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
322322
}
323+
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
324+
const failure = Cause.squash(settled.cause)
325+
const message = failure instanceof Error ? failure.message : String(failure)
326+
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
327+
}
323328
if (publisher.hasProviderError())
324329
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
325330
if (stream._tag === "Success" && !publisher.hasProviderError())

packages/core/src/state.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,30 +61,40 @@ export function create<State extends Objectish, Editor>(options: Options<State,
6161
state = next
6262
})
6363

64-
const rebuild = Effect.fn("State.rebuild")(function* () {
64+
const rebuild = Effect.fnUntraced(function* () {
6565
const next = options.initial()
6666
const api = options.editor(next as Draft<State>)
6767
for (const transform of transforms)
6868
yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {}))
6969
yield* commit(next)
70-
}, semaphore.withPermit)
70+
})
7171

7272
return {
7373
get: () => state,
7474
transform: Effect.fn("State.transform")(function* () {
75-
const transform = { update: (_editor: Editor) => {} }
76-
transforms = [...transforms, transform]
7775
const scope = yield* Scope.Scope
78-
yield* Scope.addFinalizer(
79-
scope,
80-
Effect.sync(() => {
81-
transforms = transforms.filter((item) => item !== transform)
82-
}).pipe(Effect.andThen(rebuild())),
76+
return yield* Effect.uninterruptible(
77+
Effect.gen(function* () {
78+
const transform = { update: (_editor: Editor) => {} }
79+
transforms = [...transforms, transform]
80+
yield* Scope.addFinalizer(
81+
scope,
82+
semaphore.withPermit(
83+
Effect.sync(() => {
84+
transforms = transforms.filter((item) => item !== transform)
85+
}).pipe(Effect.andThen(rebuild())),
86+
),
87+
)
88+
return (update: Transform<Editor>) =>
89+
Effect.uninterruptible(
90+
semaphore.withPermit(
91+
Effect.sync(() => {
92+
transform.update = update
93+
}).pipe(Effect.andThen(rebuild())),
94+
),
95+
)
96+
}),
8397
)
84-
return Effect.fnUntraced(function* (update: Transform<Editor>) {
85-
transform.update = update
86-
yield* rebuild()
87-
})
8898
}),
8999
update: Effect.fn("State.update")(function* (update, reason) {
90100
const api = options.editor(state as Draft<State>)

packages/core/src/tool-output-store.ts

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import type { ToolOutput } from "@opencode-ai/llm"
1111

1212
export const MAX_LINES = 2_000
1313
export const MAX_BYTES = 50 * 1024
14-
export const MAX_INLINE_MEDIA_BYTES = 5 * 1024 * 1024
1514
export const RETENTION = Duration.days(7)
1615

1716
export const MANAGED_DIRECTORY = "tool-output"
@@ -32,13 +31,7 @@ export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolO
3231
cause: Schema.Defect,
3332
}) {}
3433

35-
export class MediaLimitError extends Schema.TaggedErrorClass<MediaLimitError>()("ToolOutputStore.MediaLimitError", {
36-
mime: Schema.String,
37-
bytes: Schema.Int,
38-
limit: Schema.Int,
39-
}) {}
40-
41-
export type Error = StorageError | MediaLimitError
34+
export type Error = StorageError
4235

4336
export interface Interface {
4437
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
@@ -139,37 +132,33 @@ export const layer = Layer.effect(
139132
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
140133
const outputLimits = yield* limits()
141134
const media = input.output.content.filter((item) => item.type === "file")
142-
let mediaBytes = 0
143-
for (const item of media) {
144-
if (item.source.type !== "data") continue
145-
mediaBytes += Buffer.byteLength(item.source.data, "utf-8")
146-
if (mediaBytes > MAX_INLINE_MEDIA_BYTES)
147-
return yield* new MediaLimitError({ mime: item.mime, bytes: mediaBytes, limit: MAX_INLINE_MEDIA_BYTES })
148-
}
149-
const contextual = {
150-
structured: media.length > 0 ? {} : input.output.structured,
151-
content: input.output.content.filter((item) => item.type === "text"),
152-
}
153-
const encoded = yield* Effect.try({
154-
try: () => JSON.stringify(contextual, null, 2),
155-
catch: (cause) => new StorageError({ operation: "encode", cause }),
156-
})
157-
if (lineCount(encoded) <= outputLimits.maxLines && Buffer.byteLength(encoded, "utf-8") <= outputLimits.maxBytes)
135+
const text = input.output.content.filter((item) => item.type === "text")
136+
const contextual =
137+
input.output.content.length === 0
138+
? yield* Effect.try({
139+
try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured),
140+
catch: (cause) => new StorageError({ operation: "encode", cause }),
141+
})
142+
: text.map((item) => item.text).join("")
143+
if (
144+
lineCount(contextual) <= outputLimits.maxLines &&
145+
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
146+
)
158147
return {
159-
output: { structured: contextual.structured, content: input.output.content },
148+
output: input.output,
160149
outputPaths: [],
161150
}
162151

163-
const outputPath = yield* write(encoded)
152+
const outputPath = yield* write(contextual)
164153
const marker = `... output truncated; full content saved to ${outputPath} ...`
165154

166155
return {
167156
output: {
168-
structured: {},
157+
structured: input.output.structured,
169158
content: [
170159
{
171160
type: "text" as const,
172-
text: boundedPreview(encoded, marker, outputLimits.maxLines, outputLimits.maxBytes),
161+
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
173162
},
174163
...media,
175164
],

packages/core/src/tool/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,4 @@ Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOut
5656

5757
- Plugin boot has not been redesigned to register canonical tools through `Tools.Service`; do not redesign it as part of leaf migrations.
5858
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
59+
- The public Session result shape currently exposes managed `outputPaths`; full storage encapsulation requires a future opaque managed-output reference design.

packages/core/src/tool/apply-patch.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { Tools } from "./tools"
1212

1313
export const name = "apply_patch"
1414

15-
export const Parameters = Schema.Struct({
15+
export const Input = Schema.Struct({
1616
patchText: Schema.String.annotate({
1717
description: "The full patch text describing add, update, and delete operations",
1818
}),
@@ -24,10 +24,10 @@ export const Applied = Schema.Struct({
2424
target: Schema.String,
2525
})
2626

27-
export const Success = Schema.Struct({ applied: Schema.Array(Applied) })
28-
export type Success = typeof Success.Type
27+
export const Output = Schema.Struct({ applied: Schema.Array(Applied) })
28+
export type Output = typeof Output.Type
2929

30-
export const toModelOutput = (output: Success) =>
30+
export const toModelOutput = (output: Output) =>
3131
[
3232
"Applied patch sequentially:",
3333
...output.applied.map(
@@ -57,8 +57,8 @@ export const layer = Layer.effectDiscard(
5757
Tool.make({
5858
description:
5959
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
60-
input: Parameters,
61-
output: Success,
60+
input: Input,
61+
output: Output,
6262
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
6363
execute: (input, context) => {
6464
const applied: Array<typeof Applied.Type> = []

packages/core/src/tool/bash.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
1818
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
1919
export const MAX_CAPTURE_BYTES = 1024 * 1024
2020

21-
export const Parameters = Schema.Struct({
21+
export const Input = Schema.Struct({
2222
command: Schema.String.annotate({ description: "Shell command string to execute" }),
2323
workdir: Schema.String.pipe(Schema.optional).annotate({
2424
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
@@ -33,7 +33,7 @@ export const Parameters = Schema.Struct({
3333
}),
3434
})
3535

36-
const Success = Schema.Struct({
36+
const Output = Schema.Struct({
3737
command: Schema.String,
3838
cwd: Schema.String,
3939
exitCode: Schema.Number.pipe(Schema.optional),
@@ -46,7 +46,7 @@ const Success = Schema.Struct({
4646
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
4747
})
4848

49-
type Success = typeof Success.Type
49+
type Output = typeof Output.Type
5050

5151
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
5252

@@ -62,7 +62,7 @@ const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => {
6262
return undefined
6363
}
6464

65-
const modelOutput = (output: Success) => {
65+
const modelOutput = (output: Output) => {
6666
const warnings = output.warnings?.length
6767
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
6868
: ""
@@ -117,8 +117,8 @@ export const layer = Layer.effectDiscard(
117117
.register({
118118
[name]: Tool.make({
119119
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
120-
input: Parameters,
121-
output: Success,
120+
input: Input,
121+
output: Output,
122122
toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })],
123123
execute: (input, context) =>
124124
Effect.gen(function* () {

packages/core/src/tool/edit.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { Tools } from "./tools"
1818

1919
export const name = "edit"
2020

21-
export const Parameters = Schema.Struct({
21+
export const Input = Schema.Struct({
2222
path: Schema.String.annotate({
2323
description:
2424
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
@@ -30,14 +30,14 @@ export const Parameters = Schema.Struct({
3030
}),
3131
})
3232

33-
export const Success = Schema.Struct({
33+
export const Output = Schema.Struct({
3434
operation: Schema.Literal("write"),
3535
target: Schema.String,
3636
resource: Schema.String,
3737
existed: Schema.Boolean,
3838
replacements: Schema.Number,
3939
})
40-
export type Success = typeof Success.Type
40+
export type Output = typeof Output.Type
4141

4242
const normalizeLineEndings = (text: string) => text.replaceAll("\r\n", "\n")
4343
const detectLineEnding = (text: string): "\n" | "\r\n" => (text.includes("\r\n") ? "\r\n" : "\n")
@@ -70,7 +70,7 @@ const previewLines = (value: string, prefix: "+" | "-") => {
7070
return shown
7171
}
7272

73-
export const toModelOutput = (output: Success, oldString: string, newString: string) =>
73+
export const toModelOutput = (output: Output, oldString: string, newString: string) =>
7474
[
7575
`Edited file successfully: ${output.resource}`,
7676
`Replacements: ${output.replacements}`,
@@ -101,8 +101,8 @@ export const layer = Layer.effectDiscard(
101101
Tool.make({
102102
description:
103103
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
104-
input: Parameters,
105-
output: Success,
104+
input: Input,
105+
output: Output,
106106
toModelOutput: ({ input, output }) => [
107107
toolText({ type: "text", text: toModelOutput(output, input.oldString, input.newString) }),
108108
],
@@ -188,7 +188,7 @@ export const layer = Layer.effectDiscard(
188188
content: joinBom(next.text, source.bom || next.bom),
189189
}),
190190
)
191-
return { ...result, replacements } satisfies Success
191+
return { ...result, replacements } satisfies Output
192192
})
193193
},
194194
}),

packages/core/src/tool/glob.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Tools } from "./tools"
1010

1111
export const name = "glob"
1212

13-
export const Parameters = Schema.Struct({
13+
export const Input = Schema.Struct({
1414
pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
1515
path: LocationSearch.FilesInput.fields.path.annotate({
1616
description: "Relative directory to search. Defaults to the active Location.",
@@ -56,7 +56,7 @@ export const layer = Layer.effectDiscard(
5656
[name]: Tool.make({
5757
description:
5858
"Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
59-
input: Parameters,
59+
input: Input,
6060
output: LocationSearch.FilesResult,
6161
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
6262
execute: (input, context) =>

0 commit comments

Comments
 (0)