refactor(tools): unify tool APIs and result handling (#38367)

This commit is contained in:
Kit Langton 2026-07-23 17:13:31 -04:00 committed by GitHub
commit 79c1544072
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
133 changed files with 3602 additions and 2770 deletions

View file

@ -23,7 +23,7 @@ Generated clients follow the assembled public `HttpApi`. GitHub issues own activ
| Document | Job |
| ----------------------- | --------------------------------------------------------------------------------------- |
| [Session](./session.md) | Explain prompt admission, execution, instructions, compaction, and recovery boundaries. |
| [Tools](./tools.md) | Explain tool construction, registration, execution, and settlement laws. |
| [Tools](./tools.md) | Explain tool construction, registration, execution, and outcome laws. |
## Decisions And Proposals

View file

@ -2,6 +2,19 @@
Status: **Historical pre-release compatibility ledger.** Older entries retain the names and behavior that were accurate when written; current contracts live in Protocol, Schema, Core, and the indexed specifications.
## 2026-07-22: Canonical Tool Results
- Bump `session.tool.success` and `session.tool.failed` to version 2. Success stores exactly non-empty model `content` plus optional JSON `metadata`; failure stores one `error` plus the final bounded partial snapshot (`content?`, `metadata?`). The generic `structured` and `result` fields are removed.
- Rename the ephemeral `session.tool.progress` field `structured` to `metadata`; progress is one metadata replacement snapshot. Model content belongs exclusively to terminal outcomes.
- Change projected `SessionMessage.ToolState`: completed is `{ input, content, metadata? }` with non-empty content, error is `{ input, error, content?, metadata? }`, running renames `structured` to `metadata`. Provider replay derives wire values from canonical content.
- Move provider-hosted result payloads into provider-owned result state (`providerResultState.result`); Anthropic server-tool round-trips read it during lowering. OpenAI continues replaying from item references.
- Public Plugin API: remove `structured`, projection callbacks, the `Structured` generic, `Tool.Failure.metadata`, and the exported `Tool.settle`; tool responses carry schema-validated `output`, model-visible `content`, and optional JSON `metadata`. Code Mode receives the validated encoded output.
Compatibility:
- `20260722170000_canonical_tool_results` rewrites projected assistant tool rows in place: terminal content is preserved (or synthesized once from the old `structured`/`result`), compact values move to `metadata` only where tools now declare projections, and hosted payloads are copied into `providerResultState.result`. Old-version tool events fall out of the durable manifest and are skipped on read; no event rows are deleted.
- Promise and Effect client surfaces are regenerated. The legacy JavaScript SDK regenerates on the branch where the V1 package exists.
## 2026-07-10: Replace Instruction Checkpoints With Value Deltas
- Replace rendered `session.instructions.updated.1` prose with `session.instructions.updated.2 { delta }`, where values are SHA-256 hashes and the literal `"removed"` means removal.
@ -74,7 +87,7 @@ Compatibility:
- No stored event row, database, or runtime publish behavior change; runtime already attaches the envelope only after durable commit/replay.
- Generated clients now model the existing invariant: durable events carry `durable`, live-only events do not.
## 2026-07-03: Declare Event Durability At Definition Level
## 2026-07-03: Declare Event Durability At Tool Level
- Add explicit `Event.durable(...)` and `Event.ephemeral(...)` definition constructors.
- Preserve the existing durable and live-only event classifications while deriving durable inventories from definition metadata instead of hand-maintained lists.

View file

@ -43,13 +43,13 @@ The managed server provides graceful restart continuity through private Session
Before each Step, the runner reloads Session History, resolves the selected agent and model, prepares instructions, and materializes tools. Most Steps make one Physical Attempt; overflow-triggered compaction recovery may rebuild the same Step for one additional provider request.
Each complete local tool call is durable before side effects begin. Local calls start eagerly and may run concurrently, but settlement publication remains serialized. Every local and hosted call reaches durable success or failure before the Step publishes its single terminal ended or failed event.
Each complete local tool call is durable before side effects begin. Local calls start eagerly and may run concurrently, but terminal outcome publication remains serialized. Every local and hosted call reaches durable success or failure before the Step publishes its single terminal ended or failed event.
Tool calls belong to their assistant message. `callID` is unique only within that Step, so durable tool events also carry `assistantMessageID`.
Before `runStep` assembles its provider request, orphan reconciliation fails tool calls still projected as streaming or running from an earlier process. It preserves the original assistant attribution and never replays ambiguous side effects.
After local settlement, continuation reloads projected history and begins a new Step. The runner never delegates orchestration to an in-memory tool loop.
After a local outcome, continuation reloads projected history and begins a new Step. The runner never delegates orchestration to an in-memory tool loop.
## Retry Is Narrow And Observable

View file

@ -1,28 +1,28 @@
# V2 Tools
Status: **Current semantic overview.** The Plugin package owns the public tool type; Core owns registration, settlement, and generic output bounding.
Status: **Current semantic overview.** The Plugin package owns the public tool type; Core owns registration, execution, and generic output bounding.
## Tool Declarations
## Tools
V2 has one structural declaration for locally executable tools. Typed tools declare schemas, execution, and optional model-facing projection together:
V2 has one structural tool value for locally executable tools. Typed tools declare schemas and execution together:
```ts
const read = Tool.make({
description: "Read a file",
input: Schema.Struct({ path: Schema.String }),
output: Schema.Struct({ content: Schema.String }),
execute: ({ path }, context) => readFile(path, context),
toModelOutput: ({ output }) => [{ type: "text", text: output.content }],
execute: ({ path }, context) =>
readFile(path, context).pipe(Effect.map((output) => ({ output, content: output.content }))),
})
```
`structured` and `toStructuredOutput` may expose a smaller validated result than the complete execution output. Dynamic MCP and manifest tools use the same declaration with runtime JSON Schema.
One tool response may carry three values: the declared, schema-validated `output` is the ephemeral machine value Code Mode receives; `content` is the model-facing value stored durably; and optional `metadata` is compact JSON for tool-specific UI. A tool without `output` intentionally returns only model-visible `content` and optional `metadata`. Dynamic MCP and manifest tools use the same tool shape with runtime JSON Schema.
Built-ins and statically authored plugin tools use this same constructor and execution contract.
`Tool.Definition` is a transparent structural value with exactly one executor. Effect schemas and schemas implementing both Standard Schema V1 and Standard JSON Schema V1 are accepted. The Tool module derives model definitions and interprets invocations for the registry; callers normally rely on `Tool.make` inference rather than naming the declaration type.
`Tool.Tool` is a transparent structural value with exactly one `execute` function. Effect schemas and schemas implementing both Standard Schema V1 and Standard JSON Schema V1 are accepted. The Tool module derives inert model-facing `LLM.ToolDefinition` values and executes tools for the registry; callers normally rely on `Tool.make` inference rather than naming the nested type.
Standard input schemas validate model input into the handler value. Standard output schemas validate the handler result into the model-facing value. Effect codecs retain their native decode-input and encode-output directions.
Standard input schemas validate model input into the tool input. Standard output schemas validate the tool response's `output` into the Code Mode machine value. Effect codecs retain their native decode-input and encode-output directions.
Input and output codecs are self-contained. Schema conversion cannot require services. Tool dependencies are acquired during construction and captured by `execute`.
@ -34,14 +34,13 @@ Every local tool receives the same concrete invocation context:
interface Tool.Context {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string
readonly messageID: SessionMessage.ID
readonly callID: string
readonly progress: (update: Progress) => Effect.Effect<void>
}
```
`assistantMessageID` is the durable ID of the assistant message containing the call. The Session runner owns this association and supplies the complete context to the registry; the registry does not infer it.
Durable events call the invocation identifier `callID`; `Tool.Context.toolCallID` is the same value at the executor boundary.
`messageID` is the durable ID of the assistant message containing the call. The Session runner owns this association and supplies the complete context to the registry; the registry does not infer it. `callID` carries the same invocation identifier durable events use.
Decoded tool input is passed separately to `execute`. Raw provider input and domain services do not belong in the invocation context.
@ -65,7 +64,8 @@ The record key is the authored name. Registration normalizes it before deriving
```ts
interface Tools {
readonly register: (
tools: Readonly<Record<string, Tool.AnyTool>>,
tools: Readonly<Record<string, Tool.Any>>,
options?: Tool.RegisterOptions,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
}
```
@ -105,8 +105,8 @@ yield *
agent: context.agent,
source: {
type: "tool",
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
},
action: "grep",
resources: [input.pattern],
@ -126,30 +126,32 @@ Sharing a tool type does not imply equal authority. Built-ins and trusted Locati
## Requests Capture Tool Values
The Location-scoped registry owns effective lookup and settlement. For each local call it:
The Location-scoped registry owns effective lookup and execution through one request-scoped snapshot pairing advertised LLM definitions with captured tools. For each local call it:
1. Resolves one effective named registration.
2. Decodes provider input with the input codec.
3. Invokes the tool with the runner-supplied context.
4. Encodes the returned output with the output codec.
5. Projects encoded output into model-facing content.
6. Bounds the complete model-facing output.
7. Runs `execute.after` hooks with the bounded settlement.
8. Returns the settlement to the runner for durable publication.
3. Executes the tool with the runner-supplied context.
4. Encodes the returned output with the output codec; the encoded value is the ephemeral machine output for Code Mode.
5. Normalizes the tool response into canonical non-empty model content and optional JSON metadata.
6. Bounds the model content; validates metadata, dropping invalid or oversized values with a warning rather than failing the call.
7. Runs `execute.after` hooks with the canonical outcome and managed output paths.
8. Returns one `ToolOutcome` — completed with output, content, and optional metadata, or an error with an optional final partial snapshot — to the runner for durable publication.
Invalid input never invokes the tool. Invalid output never produces a successful settlement.
Invalid input never executes the tool. Invalid output never produces a successful execution.
`toModelOutput` is pure and total. When omitted, the encoded output remains structured output; an encoded string is also projected as text. Projection does not receive invocation identity because presentation depends only on validated input and output.
When an output-bearing tool omits `content`, an encoded string becomes one text item and any other encoded JSON is serialized once. A tool without `output` must provide non-empty model content.
Each model request captures the effective registered `Tool` value for every advertised name. Settlement executes those captured values; later registration changes affect later requests.
Each model request captures the effective registration for every advertised name. Execution uses those captured tools; later registration changes affect later requests. Unknown, hook-removed, and final-Step calls fail individually through the same execution seam; the final Step retains tool definitions with `toolChoice: "none"` where the provider supports it so the cached prompt prefix survives.
Durable terminal events are self-contained: success stores exactly the non-empty model content plus optional metadata; failure stores one error plus the final bounded snapshot of partial progress. Provider replay derives its wire value from canonical content; provider-hosted payloads that a protocol requires verbatim live in provider-owned result state, never in a generic result field.
## Producers And The Registry Own Different Limits
Producers may cap capture or spool data before a complete tool result exists. For example, a process tool may retain output it cannot keep in memory. Producer limits must report their own loss accurately; they are separate from registry bounding and cannot claim to reconstruct bytes already discarded.
After projection, the registry bounds the channel sent to the provider. When content exists, only its textual parts are measured; structured metadata is retained unchanged without being double-counted, and native media remains unchanged under producer-owned limits. When content is empty, the structured output is measured. Oversized provider-facing text or structured output is retained in managed storage and replaced with a bounded text preview while structured metadata and media are preserved; if complete retention fails, settlement fails operationally rather than publishing lossy success. Managed paths never appear in `Tool.make`, tool output schemas, or projection callbacks solely for retention bookkeeping.
After tool execution, the registry bounds the model content sent to the provider: only textual parts are measured, native media remains unchanged under producer-owned limits, and the default cut keeps a head-plus-tail split with the omission marker in the middle. Oversized text is retained in managed storage and replaced with a bounded preview; if complete retention fails, execution fails operationally rather than publishing lossy success. Metadata is validated and measured independently and never becomes an unbounded side channel. Managed paths never appear in `Tool.make` or tool output schemas solely for retention bookkeeping.
`execute.after` hooks receive the bounded settlement and its internal managed paths. Hooks may deliberately transform that settlement; the registry does not apply a second bounding pass afterward.
`execute.after` hooks receive the canonical bounded outcome and its internal managed paths. Hooks may deliberately transform that outcome; changed content is normalized and bounded again before publication.
## Failures Preserve Interruptions
@ -158,15 +160,18 @@ Outcomes remain distinct:
- `ToolFailure` is an expected model-visible failure.
- Interruption cancels the invocation and is not a tool result.
- Unexpected typed errors and defects follow the runner's operational failure policy.
- Unknown and invalid calls become explicit model-visible settlement errors without invoking a handler.
- Unknown and invalid calls become explicit model-visible execution errors without executing a tool.
Leaf tools translate only errors they deliberately classify as recoverable. Broad cause-catching around an executor is invalid because it consumes interruption and defects.
Tools translate only errors they deliberately classify as recoverable. Broad cause-catching around `execute` is invalid because it consumes interruption and defects.
## Laws
- **Single executor:** `Tool.make(config)` can invoke only `config.execute`.
- **Codec boundary:** execution observes decoded input; projection observes encoded output.
- **Single execution:** `Tool.make(config)` can execute only `config.execute`.
- **Codec boundary:** a tool observes decoded input; Code Mode observes the validated encoded output; model content and metadata come from the tool response.
- **Canonical representation:** a completed call has exactly one stored model representation; a failed call has exactly one stored error plus at most one final partial snapshot. Every other view is derived at a named boundary.
- **Metadata opt-in:** absent response metadata produces absent metadata, never a copied output.
- **Durable identity:** invocation-owned records use the exact Session, agent, assistant message, and call IDs supplied by the runner.
- **Scoped registration:** closing a Scope removes exactly its registration and reveals any prior active overlay.
- **Captured execution:** a call executes the registered `Tool` value advertised in its model request.
- **Captured execution:** a call executes the registered tool advertised in its model request.
- **Per-call rejection:** rejecting one unavailable call cannot fail another call.
- **Storage encapsulation:** domain output does not change according to model-output bounding or retention policy.