RFD D31: Provider Response Artifacts
- Status: Draft
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-07-31
- Required by: RFD D71
Summary
LLM providers return response artifacts JP cannot represent: activity executed on the provider's own infrastructure, citations attached to assistant text, and usage counters for that activity. This RFD adds a normalized hosted activity event, a typed provider payload envelope for exact same-provider replay, and typed citations on assistant content. Hosted-activity usage counters are parsed and reported as diagnostics, not persisted.
It adds no user-visible feature on its own. It is the response-model prerequisite for exposing any provider-hosted tool.
Motivation
JP currently discards these artifacts
The OpenAI adapter drops three output-item kinds outright — FileSearch, WebSearchResults, and ComputerToolCall — in three places (crates/jp_llm/src/provider/openai.rs:282, :1445, :1482), along with the WebSearchCallInitiated / Searching / Completed stream events. The comment above the last of these is explicit that the variants are listed rather than wildcarded "so a new variant in the upstream event enum fails to compile until someone decides whether JP needs it." This RFD is that decision.
The Anthropic path cannot even parse the artifacts. async_anthropic::types::MessageContent is an internally-tagged enum over six variants with no catch-all, so a server_tool_use content block fails deserialization and takes the stream down with it.
The response model cannot carry attribution
ChatResponse::Message { message: String } is a bare string. Anthropic, OpenAI, and Google all impose attribution or display obligations on search-backed output, and Anthropic's citation payload carries a URL, title, cited text, and an encrypted_index that must round-trip. None of that fits in a String.
This is a deeper gap than any configuration question: a feature that renders search-backed text without its citations is incomplete and potentially non-compliant, and the shape of ChatResponse is what makes it impossible.
Waiting makes it more expensive
RFD D45 proposes publishing the normalized streaming event model as the public API of a standalone elelem crate, and names hosted tools as a reason to prefer OpenAI's Responses API. Freezing an event model that cannot represent hosted activity or attribution means paying for this twice.
If nothing is done
Provider-hosted tools cannot be exposed at all, because there is nowhere to put what comes back. The dropped OpenAI artifacts stay dropped, and the next provider adds a fourth site that discards them.
Design
Hosted activity is not a Tool Call
JP already has a precise concept for "a callable operation whose request JP must fulfill": the Tool Call. Its contract is that JP resolves policy, dispatches a runtime, records a ToolCallResponse, and returns the result to the provider. Every consumer relies on it — turn_loop.rs prepares each committed ToolCallRequest, ConversationStream::sanitize_orphaned_tool_calls injects a response for any request lacking one, and the restart path scans for unresponded requests.
Provider-hosted activity satisfies none of that. The provider runs the work inside its own agent loop and continues generating. JP observes it.
Recording it as a ToolCallRequest would make the durable stream assert that JP dispatched work it never dispatched, and would put every one of those consumers in the position of needing an exception. It gets its own event kind instead.
EventKind::HostedActivity
HostedActivity
id provider-assigned activity id (correlation key)
name JP-facing capability name (e.g. "web_search")
state started | completed | failed
input normalized invocation input (e.g. the search query)
summary short human-readable description for display
payload ProviderPayload (see below), optionalState changes are append-only transitions linked by id, not one event mutated in place. "Started with no terminal transition" therefore has a defined meaning: the activity began and never finished, which a reader can detect and a provider adapter can act on.
Append-only is the established principle (RFD 064). Provider-issued repairs already follow it: jp_llm::event::record_patches appends an overlay rather than rewriting the events it targets. Hosted-activity transitions follow the same rule.
sanitize_orphaned_tool_calls matches on EventKind::ToolCallResponse and as_tool_call_request(), so a distinct kind is invisible to it. Orphan repair stays exclusive to JP-dispatched Tool Calls with no new condition to remember.
ProviderPayload
A typed envelope, not scattered metadata keys:
ProviderPayload
provider ProviderId of the originating provider
format provider-defined payload discriminator
version format version selected when the activity started
value the native payloadContract:
- Only the originating provider interprets it. Every other adapter preserves it and ignores it.
- Immutable. No consumer rewrites it. Compaction is non-destructive (RFD 064) and adds overlays, so this holds by construction.
- Unknown versions round-trip unmodified. A payload written by a newer JP is carried, not rejected.
versionrecords the wire contract selected at start. Anthropic's web search alone has three versions with different fields and defaults; without a recorded selection, a JP upgrade continues an activity under a contract it did not begin under.- Untrusted content. The payload may contain arbitrary web content and is never rendered or logged directly (see RFD 096).
- A present-but-undecodable payload is an error, not a silent downgrade. This is distinct from a payload whose continuation requirements cannot be met, which is the consuming RFD's concern.
value is inline for now. Anthropic's search payloads are large, so the envelope leaves room for a blob reference once RFD 066 exists; nothing here depends on that.
Citations on assistant content
Citations annotate assistant text, so they attach to the assistant content event, not to hosted activity. A typed attribution keeps four things apart, because they answer different questions:
Attribution
source URL and title of the cited source
excerpt text quoted from the source, optional
range span within the event's stored text, optional
replay ProviderPayload carrying the provider's opaque replay valueThe content event itself is the answer-side anchor. Anthropic emits each cited passage as its own text block, which becomes its own event, so the block is the anchor and range is absent. OpenAI's url_citation carries offsets into the response text, which become range. excerpt is always source-side text and never stands in for range.
range addresses the event's final stored text. An adapter that rewrites text after citations arrive (the Anthropic max-token continuation trims overlapping text) remaps every range it keeps, or drops it. A stale range is never persisted.
Streaming lifecycle
Citations stream inside a text block: Anthropic sends one citations_delta per citation before content_block_stop. The adapter forwards each citation with its block's index as it arrives, EventBuilder appends it to that block's attribution list, and Flush finalizes the event.
Flush is not the only point where citations reach storage. When a stream is interrupted or retried, EventBuilder::peek_partial_events commits unflushed message text as its own event. That event carries the citations received so far, marked incomplete, so a reader can tell a block that cites nothing from a block cut off before its attribution finished. Partial commits drop all other accumulated metadata, which is correct for thinking signatures because partial reasoning is resent unsigned; citations are the exception.
Usage
Anthropic reports hosted-activity counters (usage.server_tool_use.web_search_requests), billed separately from tokens.
JP persists no token usage today. No HTTP provider adapter reads its usage counters, and the ACP adapter keeps usage out of event metadata and reports it as a debug diagnostic (anthropic/acp/usage.rs). Hosted counters take the same path: the adapter parses and reports them, and they do not enter the conversation stream.
The spend is still visible. Every hosted search is recorded as a HostedActivity event, so a consumer can count searches from the durable stream. That count approximates billed requests; whether a failed search is billed is unverified. Persisting usage belongs with provider-response identity; see Non-Goals.
Two projections
The result is that a conversation carrying hosted activity has two readings:
- Portable — activity name, state, input, summary, plus assistant text and citations. Survives export, search, rendering, and switching provider.
- Provider-native — the payload, meaningful only to the originating provider, used for exact replay.
Drawbacks
A new EventKind variant touches every exhaustive match. At least seven that I verified: jp_attachment_internal/src/lib.rs, jp_cli/src/cmd/query/tool/inquiry.rs, jp_cli/src/editor.rs, jp_cli/src/render/turn.rs, two in jp_cli/src/shared/search.rs, and jp_conversation/src/storage.rs. Provider convert_event implementations and the serve-web plugin's renderer also need arms. This is the cost of not overloading Tool Call, and it is paid once.
Citations change a type every consumer of assistant text reads. Adding attribution to ChatResponse::Message means every reader that pattern-matches message content sees a changed shape, even readers with no interest in citations.
The portable projection is lossy by design. Switching provider mid-conversation preserves the final assistant text, citations, and normalized activity, but not the native state. That is a deliberate limit, not an oversight — see Alternatives.
Nothing user-visible ships. This RFD cannot be validated by a user-facing behavior on its own; its first real test is its consumer.
Alternatives
Record hosted activity as a ToolCallRequest / ToolCallResponse pair, with a marker. Rejected. It requires JP to fabricate a response for work it never dispatched, and needs coordinated exceptions in the turn loop, the execution plan, and orphan sanitization. Explored at length before this RFD; every iteration added another exception, which is the signal that the model was wrong rather than incomplete.
Produce a portable textual digest of native results so they survive a provider switch. Rejected. Anthropic's encrypted_content is useful only to Anthropic, so any digest is fabricated rather than translated: it cannot reproduce the original context, and it risks surfacing content the provider deliberately kept opaque. Preserve the final assistant text and normalized activity instead.
Store the native payload as loose metadata keys, as thinking signatures do today. Rejected for a payload that must carry provider identity, format, and version — three fields whose relationship matters. The signature precedent is a single opaque string; this is a structured envelope with a contract.
Extend ChatResponse with a hosted-activity variant rather than adding an EventKind. Rejected: hosted activity is not assistant-authored content, and folding it in means every consumer of assistant text handles a variant that has no text.
Non-Goals
Provider-response identity and durable usage. A single ChatRequest produces many provider responses (max-token chaining, retries, paused-turn continuations, tool cycles), and JP has no way to say which response produced which events. Usage is a property of a provider response, so persisting it needs that identity first; attaching counters to whichever event happened to be last records them against the wrong thing. Both are deferred on scope: the first consumer needs neither. RFD 097 is Implemented and supplies the stable-identifier primitive a provider-response record would build on, so a later RFD can add both without a dependency gate.
Rendering. This RFD defines the data a renderer needs. Terminal and web rendering, including the citation-display obligations, belong to the consuming RFD, where they gate a user-visible feature.
Any specific provider's hosted tools. No configuration surface, no request translation, no provider capability list.
Portable native state. See Alternatives.
Risks and Open Questions
- This constrains RFD D45. D45 should not publish
elelem::Eventas public API before accounting for hosted activity and attribution, or the new crate breaks immediately or routes hosted artifacts through generic metadata — recreating the problem this RFD removes. async-anthropicneeds extending before any of this is observable. The fork'sMessageContentcannot parseserver_tool_use,ContentBlockDeltahas nocitations_delta,Texthas nocitations, andUsagehas noserver_tool_usecounters. Whether serde can express a catch-all arm on that internally-tagged enum needs verifying —#[serde(other)]covers unit variants only, so an untaggedUnknown(Map)arm is the likely shape, mirroring whattypes::Toolalready does.- The normalized activity shape is derived from two providers. Anthropic pairs a call block with a result block; OpenAI emits a call item plus an annotated message; Google may expose only completed grounding metadata with no call/result pair at all.
started | completed | failedmay not survive contact with Google. Validating against a second provider is the consuming RFD's job. - Citation shape across providers is unverified. Anthropic's
web_search_result_locationis the only one modelled here. rangeneeds one coordinate unit. OpenAI'surl_citationoffsets are the only source of ranges, and whether they count bytes or code points is unverified. The unitrangestores is chosen once that is known.
Implementation Plan
Phase 1: async-anthropic support
Extend the fork: server_tool_use and web_search_tool_result content blocks, an unknown-block arm, Text::citations, the citations_delta stream delta, Usage::server_tool_use, and the pause_turn stop reason. No JP changes. Independently mergeable, and a prerequisite for observing anything below.
Phase 2: ProviderPayload and EventKind::HostedActivity
Add both types in jp_conversation, with serialization, sanitize behavior (none), and the exhaustive-match arms listed under Drawbacks. Includes the append-only transition rule. Independently mergeable; nothing produces the event yet.
Phase 3: Streaming path
An EventPart variant carrying hosted activity, plus EventBuilder accumulation and flush. Independently mergeable; no provider emits it yet.
Phase 4: Citations
Typed attribution on assistant content: accumulation while the block streams, finalization on flush, and preservation, marked incomplete, on a partial commit. Independently mergeable.
Phases 2 through 4 are all inert until a provider adapter produces the artifacts, which is the consuming RFD's first phase.
References
crates/jp_llm/src/provider/openai.rs— the discarded output itemscrates/jp_conversation/src/event.rs—EventKind,ChatResponsecrates/jp_llm/src/event_builder.rs— flush-metadata accumulation and partial commitscrates/jp_llm/src/provider/anthropic/acp/usage.rs— usage reported as a diagnostic- https://platform.claude.com/docs/en/agents-and-tools/tool-use/server-tools