Skip to content

Commit a9094fd

Browse files
authored
feat(core): bound v2 tool output (#30999)
1 parent 760d523 commit a9094fd

31 files changed

Lines changed: 387 additions & 552 deletions

CONTEXT.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ An expected temporary inability to observe a **Context Source** value; the runti
3939
**Safe Provider-Turn Boundary**:
4040
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
4141

42+
**Model Tool Output**:
43+
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
44+
45+
**Managed Tool Output File**:
46+
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
47+
4248
**Model Request Options**:
4349
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
4450
_Avoid_: Request body, wire options
@@ -96,6 +102,17 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
96102
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
97103
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
98104
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
105+
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
106+
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
107+
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
108+
- A truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result.
109+
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
110+
- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure.
111+
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
112+
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
113+
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
114+
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
115+
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
99116

100117
## Example dialogue
101118

packages/core/src/filesystem.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ import { ProjectReference } from "./project-reference"
1313
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
1414
import { Protected } from "./filesystem/protected"
1515
import { Ripgrep } from "./filesystem/ripgrep"
16+
import { ToolOutputStore } from "./tool-output-store"
1617

1718
export const ReadInput = Schema.Struct({
18-
path: RelativePath,
19+
path: Schema.String,
1920
reference: Schema.NonEmptyString.pipe(Schema.optional),
2021
})
2122
export type ReadInput = typeof ReadInput.Type
@@ -65,7 +66,7 @@ export class ReadTarget extends Schema.Class<ReadTarget>("FileSystem.ReadTarget"
6566
}) {}
6667

6768
export const ListInput = Schema.Struct({
68-
path: RelativePath.pipe(Schema.optional),
69+
path: Schema.String.pipe(Schema.optional),
6970
reference: Schema.NonEmptyString.pipe(Schema.optional),
7071
})
7172
export type ListInput = typeof ListInput.Type
@@ -181,6 +182,7 @@ export const layer = Layer.effect(
181182
Effect.gen(function* () {
182183
const fs = yield* FSUtil.Service
183184
const location = yield* Location.Service
185+
const global = yield* Effect.serviceOption(Global.Service)
184186
const references = yield* ProjectReference.Service
185187
const ripgrep = yield* Ripgrep.Service
186188
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
@@ -201,8 +203,21 @@ export const layer = Layer.effect(
201203
if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
202204
return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
203205
})
204-
const resolve = Effect.fnUntraced(function* (input?: RelativePath, reference?: string) {
205-
if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location"))
206+
const resolve = Effect.fnUntraced(function* (input?: string, reference?: string) {
207+
const managed = path.join(
208+
Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }),
209+
ToolOutputStore.MANAGED_DIRECTORY,
210+
)
211+
if (input && path.isAbsolute(input)) {
212+
if (reference) return yield* Effect.die(new Error("Absolute paths cannot use a project reference"))
213+
if (path.dirname(input) !== managed || !path.basename(input).startsWith("tool_"))
214+
return yield* Effect.die(new Error("Absolute path is not managed tool output"))
215+
const real = yield* fs.realPath(input).pipe(Effect.orDie)
216+
const managedRoot = yield* fs.realPath(managed).pipe(Effect.orDie)
217+
if (path.dirname(real) !== managedRoot || !path.basename(real).startsWith("tool_"))
218+
return yield* Effect.die(new Error("Path escapes managed tool output"))
219+
return { absolute: input, real, directory: managed, root: managedRoot }
220+
}
206221
const selected = yield* select(reference)
207222
const absolute = path.resolve(selected.directory, input ?? ".")
208223
if (!FSUtil.contains(selected.directory, absolute))

packages/core/src/location-layer.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,8 @@ import { FetchHttpClient } from "effect/unstable/http"
4646
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
4747
lookup: (ref: Location.Ref) => {
4848
const location = Location.layer(ref)
49-
const permissionsAndTools = ToolRegistry.layer.pipe(Layer.provideMerge(PermissionV2.locationLayer))
5049
const systemContext = SystemContextBuiltIns.locationLayer
51-
const services = Layer.mergeAll(
50+
const base = Layer.mergeAll(
5251
location,
5352
Policy.locationLayer,
5453
Config.locationLayer,
@@ -63,13 +62,18 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
6362
Pty.locationLayer,
6463
SkillV2.locationLayer,
6564
systemContext,
66-
permissionsAndTools,
6765
LocationMutation.locationLayer.pipe(Layer.orDie),
6866
).pipe(Layer.provideMerge(location))
67+
const resources = ToolOutputStore.layer.pipe(Layer.provide(base))
68+
const permissionsAndTools = ToolRegistry.layer.pipe(
69+
Layer.provideMerge(PermissionV2.locationLayer),
70+
Layer.provide(resources),
71+
Layer.provide(base),
72+
)
73+
const services = Layer.mergeAll(base, resources, permissionsAndTools)
6974
const commits = FileMutation.locationLayer.pipe(Layer.provide(services))
7075
const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services))
7176
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
72-
const resources = ToolOutputStore.layer.pipe(Layer.provide(services))
7377
const todos = SessionTodo.layer.pipe(Layer.provide(services))
7478
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
7579
const builtInTools = BuiltInTools.locationLayer.pipe(

packages/core/src/location-search.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const MAX_LINE_PREVIEW_LENGTH = 2_000
2525
export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESULT_LIMIT))
2626

2727
const RootInput = {
28-
path: RelativePath.pipe(Schema.optional),
28+
path: Schema.String.pipe(Schema.optional),
2929
reference: Schema.NonEmptyString.pipe(Schema.optional),
3030
}
3131

packages/core/src/session/event.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,7 @@ export namespace Tool {
373373
...ToolBase,
374374
structured: ToolOutput.Structured,
375375
content: Schema.Array(ToolOutput.Content),
376+
outputPaths: Schema.Array(Schema.String).pipe(Schema.optional),
376377
result: Schema.Unknown.pipe(Schema.optional),
377378
provider: Schema.Struct({
378379
executed: Schema.Boolean,

packages/core/src/session/message-updater.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
308308
input: match.state.input,
309309
structured: event.data.structured,
310310
content: [...event.data.content],
311+
outputPaths: event.data.outputPaths ? [...event.data.outputPaths] : [],
311312
result: event.data.result,
312313
}),
313314
)

packages/core/src/session/message.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Sessio
8686
input: Schema.Record(Schema.String, Schema.Unknown),
8787
attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
8888
content: ToolOutput.Content.pipe(Schema.Array),
89+
outputPaths: SessionEvent.Tool.Success.data.fields.outputPaths,
8990
structured: ToolOutput.Structured,
9091
result: SessionEvent.Tool.Success.data.fields.result,
9192
}) {}

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

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,8 @@ export const layer = Layer.effect(
207207
},
208208
})
209209
const withPublication = Semaphore.makeUnsafe(1).withPermit
210-
const publish = (event: LLMEvent) => withPublication(publisher.publish(event))
210+
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
211+
withPublication(publisher.publish(event, outputPaths))
211212
if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision)))
212213
return yield* Effect.die(new RetryTurn(undefined))
213214
const providerStream = llm.stream(request).pipe(
@@ -216,26 +217,29 @@ export const layer = Layer.effect(
216217
yield* publish(event)
217218
if (event.type !== "tool-call" || event.providerExecuted) return
218219
needsContinuation = true
219-
yield* tools.settle({ sessionID: session.id, agent: agent.id, call: event }).pipe(
220-
Effect.catchCause((cause) => {
221-
if (isQuestionRejected(cause)) return Effect.failCause(cause)
222-
return Effect.succeed({
223-
result: { type: "error" as const, value: String(Cause.squash(cause)) },
224-
output: undefined,
225-
})
226-
}),
227-
Effect.flatMap((settlement) =>
228-
publish(
229-
LLMEvent.toolResult({
230-
id: event.id,
231-
name: event.name,
232-
result: settlement.result,
233-
output: settlement.output,
234-
}),
220+
yield* Effect.uninterruptibleMask((restore) =>
221+
restore(tools.settle({ sessionID: session.id, agent: agent.id, call: event })).pipe(
222+
Effect.catchCause((cause) => {
223+
if (isQuestionRejected(cause) || Cause.hasInterrupts(cause)) return Effect.failCause(cause)
224+
return Effect.succeed({
225+
result: { type: "error" as const, value: String(Cause.squash(cause)) },
226+
output: undefined,
227+
outputPaths: [],
228+
})
229+
}),
230+
Effect.flatMap((settlement) =>
231+
publish(
232+
LLMEvent.toolResult({
233+
id: event.id,
234+
name: event.name,
235+
result: settlement.result,
236+
output: settlement.output,
237+
}),
238+
settlement.outputPaths ?? [],
239+
),
235240
),
236241
),
237-
FiberSet.run(toolFibers),
238-
)
242+
).pipe(FiberSet.run(toolFibers))
239243
}),
240244
),
241245
Effect.ensuring(withPublication(publisher.flush())),

packages/core/src/session/runner/publish-llm-event.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,10 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
218218
}
219219
})
220220

221-
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
221+
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
222+
event: LLMEvent,
223+
outputPaths: ReadonlyArray<string> = [],
224+
) {
222225
switch (event.type) {
223226
case "step-start":
224227
yield* startAssistant()
@@ -347,6 +350,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
347350
assistantMessageID: tool.assistantMessageID,
348351
callID: event.id,
349352
...result,
353+
outputPaths,
350354
result: event.result,
351355
provider,
352356
})

0 commit comments

Comments
 (0)