Repository navigation
refactor: public items in history and one persist path (4/5) - #505
Merged
Merged
Conversation
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
…to 0009 Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
…_output_item Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
…factor/append-only-turns
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
… refactor/public-history Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
… refactor/public-history Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
maralbahari
marked this pull request as ready for review
November 5, 2026 10:08
maralbahari
requested review from
bbrowning,
franciscojavierarceo,
haoshan98,
jiahuei,
leseb,
noobHappylife,
qandrew and
tjtanaa
as code owners
November 5, 2026 10:08
Signed-off-by: maral <maralbahari.98@gmail.com>
tjtanaa
reviewed
Nov 5, 2026
tjtanaa
left a comment
Member
There was a problem hiding this comment.
One session-continuation case needs attention.
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
tjtanaa
reviewed
Nov 6, 2026
tjtanaa
left a comment
Member
There was a problem hiding this comment.
One existing-history compatibility case needs attention.
Signed-off-by: maral <maralbahari.98@gmail.com>
Collaborator
Author
|
@tjtanaa @franciscojavierarceo any remaining review can be addressed in the next PR in stack. the PR grow to 96 file changes including the new recording yaml files and test files. |
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
haoshan98
approved these changes
Nov 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of the RFC #500 (one typed path for rehydrating and persisting history).
Summary
Stored history now holds only the items the client sees. For gateway tools (web search, MCP, code interpreter) the gateway talks to the model with
function_call/function_call_outputpairs; those pairs used to be stored and leaked intoGET /v1/responses/{id}/input_items, which OpenAI never returns. They now live only in the inference input, and one function lowers every public tool item to a function call when a request goes upstream. Every gateway call has a public item: each handler must return its own, so a custom web search executor produces aweb_search_calltoo.The turn's storage path is also reduced to one entry point each way.
persist.rshas a singlepersistthat builds the turn's metadata and items and owns session checkpoints, and the storage handlers only write. The rehydrate loaders share one step for turning history into the model input.Pending client calls have one API:
CallLedgerscans an item sequence once and answers every question about its calls, on the sharedClientCallIdandClientCallKindtypes. Call IDs follow OpenAI, as the newcall-idscassettes record it: a repeated or reusedcall_idis accepted and an empty one is refused, and the llm-d split path uses the same rules as the gateway instead of its own. Every item a client sends (Responsesinput, Conversationsitems) passes one admission check,InOutItem::admit_all. Overlapping multi-agent turns in a conversation are refused like OpenAI refuses them: a multi-agent tree is the latest turn's snapshot, so a multi-agent turn stored after another turn would replace that turn's tree.function_call+function_call_output) were stored and returned byinput_itemsweb_search_call,mcp_call,code_interpreter_call, ...); the pairs exist only in the inference inputResponsesInput::normalized_model_inputis the one lowering point, usingFromconversions intypes/io/web_search.rsandtypes/io/code_interpreter.rsis_recorded_as_history,is_already_in_history,record_round_historyandmark_outputs_recordedpersist_if_needed,persist_response,persist_prepared_response,persist_turn,persist_prepared_turn)persist(ctx, TurnOutput, tool_search_metadata, exec_ctx);persist_turnandcommitare thin public wrappersResponseHandler::persistheld session checkpoints, transient-parent promotion and output selectionpersist.rsset_model_input(ctx, history, window)does the rest (a session passescanonical_session_historyas its window)pending_calls,first_unanswered_sync_call,resolved_prefix_lenand rehydration'sreject_unanswered_sync_call, each scanning the items againCallLedger::scan(items)withpending(),awaits_output(),require_answered()andresolved_prefix_len()pending_calls.rskept its ownCallKindandStringcall IDs besideClientCallKindandClientCallId; the multi-agent checkpoint mapped one enum onto the otherClientCallKindandClientCallId; the mapping and the empty-ID handling are gone400 conversation_locked, as on OpenAI (overlapping-multi-agentcassettes)commit(llm-d) checked a relayed response's call IDs against the continued history with its own loop (validate_output_call_ids), a rule the gateway path does not haveCallLedgerrefused a repeatedcall_idand a second output for a call; OpenAI accepts both (call-idscassettes)call_id, further outputs are accepted; empty and unknown IDs are still refusedinput(message ID prefix only, inresolve_input_items), Conversationsitems(every ID prefix, insideConversationStore), and nowhere for an emptycall_iduntil a later turnInOutItem::admit_all, called once per endpoint in the handlers: every kind's ID prefix and a non-emptycall_id, with OpenAI'sinvalid_value/empty_stringerrors; storage no longer validatesfunction_calland output without a statusinto_publicgives an omitted status OpenAI's value: a callin_progress, an outputcompletedpublic_output(a custom web search executor registered withwith_gateway_executor) left its call as a barefunction_call; once history held only public items, the next turn failed withMissingOutputstarted_outputandpublic_outputare required: every handler returns its own items, and a custom web search executor builds them with the publicweb_search::started_output_itemandweb_search::output_itemfile_search) stayed in the public output as a barefunction_callmax_tool_callsrefusal; the model still sees its error outputupstream_requestwrapped a privateupstream_request_with_guidanceupstream_request(ctx, stream, guidance, withheld)carry_over::public_callsare removedRedundancy removed
InOutItem::into_input_itemsandOutputItem::to_input_itemturned custom and shell calls into function calls, and the upstream projection lowered the rest. Now onlyResponsesInput::normalized_model_input, as one loop (its separate pre-scan, which repeated the same match, is gone).function_call+function_call_outputpair and as its public item. Now only the public item.record_round_history,mark_outputs_recorded,is_recorded_as_history,is_already_in_history). Now written once, at persist.should_persist. Now onepersist.ResponseHandler::persistbeside the turn logic inpersist.rs. Now only inpersist.rs; the handlers write.apply_effective_settings. Now oneset_model_inputandapply_effective_settings.pending_calls,first_unanswered_sync_call,resolved_prefix_len,reject_unanswered_sync_call) over one scan. NowCallLedger.pending_calls.rs'sCallKind(the same four kinds asClientCallKind) andStringcall IDs, the multi-agent checkpoint's variant-by-variant mapping between the two enums, andinto_steering_required_input's empty-ID branch. Now the sharedClientCallKindandClientCallId.commitread the continued history again and checked the relayed output's call IDs with its own loop, which the gateway path never ran. Now both paths rely onCallLedger.resolve_input_items(message IDs only),ConversationStore::createandcreate_items(validate_client_id), andStorageError::InvalidItemIdbeside the executor errors. NowInOutItem::admit_allwith oneClientItemError; thecall_idrule lives once, inClientCallId::validate, whichClientCallId::try_fromand the ledger use.upstream_requestonly calledupstream_request_with_guidance(ctx, stream, None, None). Now one function.gateway/history.rs(append_output_items_to_input,append_gateway_results). File removed; callers useResponsesInput: Extend<InputItem>.append_round_inputrepeatingto_input_item. Removed.carry_over::public_calls) to recover the public form of calls rehydration had already loaded. Removed.Fromconversions next to their types.Changes
OutputItem::to_input_itemkeeps every public type, including hosted tool-search calls and outputs.ResponsesInput::normalized_model_inputis one loop with one match that builds the model copy: custom and shell calls become function calls;web_search_call,mcp_callandcode_interpreter_callbecome a function call and its output; message files become text and images get a detail. It returns the copy (ResponsesInput) instead of aCow, and drops the pre-scan that decided whether a copy was needed.types/io/web_search.rs(new) andtypes/io/code_interpreter.rshold theFromconversions from a stored public item to its model-facing call and output. The web search output types and code interpreter execution types stay intool/.executor/gateway/history.rsis removed;ResponsesInputimplementsExtend<InputItem>.persist.rs::persistchecks the storage policy and the response status once, times the write, maps errors, builds the turn's metadata and items, and routes to the conversation or response handler.TurnOutput::Response(a generated response, with its snapshot when stored),TurnOutput::Empty(an empty session turn) andTurnOutput::Compaction(a standalone compaction, stored whateverstoresays soprevious_response_idcan continue it).mark_outputs_compacted); persistence drops those outputs, except MCP discovery and async calls.commitrefuses a missing response ID and an unfinished response, then persists; it no longer checks call IDs.ResponseHandler::persist(&ctx, items, snapshot, &metadata)andConversationHandler::persist(&ctx, items, snapshot, &metadata)make one store call each.from_response,from_session_responseandfrom_conversationload;set_model_inputappends the request's items, applies the window (identity, orcanonical_session_historyfor a session) and checks for unanswered calls; the session loader usesapply_effective_settingsinstead of its own copy.from_conversationsetsResponseCheckpoint::requires_latestfor a multi-agent turn, one that continues a restored tree or builds one, so storage refuses it if another turn was stored since (the check PR3 added for a second answer to a pending call). Storage is unchanged.CallLedgeris the module's one public type:scan,pending,awaits_output,require_answered,resolved_prefix_len. Its calls are keyed byClientCallId, andPendingCallcarries aClientCallIdand aClientCallKind.ClientCallKindgainscall_item_nameandoutput_item_name;CallKindis removed.call_ids follow OpenAI: a repeated call adds nothing, a call is answered once any output for it follows, and further outputs are accepted, for synchronous calls as well as async ones.InOutItem::admit_all(items, field)(types/io/item/item_id.rs) checks each item a client sends: its ID begins with its kind's prefix (every kind, as OpenAI does), and a call or an output has a non-emptycall_id(ClientCallId::validate). A refusal isClientItemError(InvalidId,EmptyString) with OpenAI's code,param(input[N].id,items[N].call_id) and message.ResponseHandler::resolve_input_items(Responsesinput) andConversationHandler::createandcreate_items(Conversationsitems).resolve_input_itemskeeps the two Responses-only rules: a duplicate itemidis refused, and anidnaming a stored item is replaced by it. OpenAI accepts duplicate item IDs in a Conversations batch.validate_client_idandStorageError::InvalidItemIdare removed.into_publiccompletes an omitted status, afunction_callin_progressand an outputcompleted, through the samecomplete_omitted_statusthatinto_canonical_itemsuses.iddecodes with an empty one;id()now reports it as having none.GatewayExecutor::started_outputandpublic_outputare required, with noNonedefault;plan_gateway_eventsdefaults to the handler'sstarted_output. Every handler implements both: web search, MCP and the code interpreter build their items; hosted tool search builds its call (its planned result still supplies the completed pair);web_fetchreturnsNone, since Messages answers it with its own result blocks.web_search::started_output_itemandweb_search::output_itemare public, so a custom web search executor implements each method in one line. The test executors do.GatewayScheduler::planomits a call with no bound executor from the public output, as it omits a hidden refusal.upstream_request(ctx, stream, guidance, withheld)is the only builder; llm-d passesNone, None.validate_output_call_ids,upstream_request_with_guidance,InOutItem::call_id,record_round_history,append_gateway_calls_to_new_input,append_gateway_results' history branch,OutputItem::is_recorded_as_historyandis_already_in_history,ResponseContinuation::mark_outputs_recorded,persist_response,persist_if_needed,persist_prepared_response,persist_prepared_turn,log_custom_tool_calls,carry_over::public_calls.overlapping_multi_agent_turns_keep_the_first_turns_pending_call(three turns rehydrate one tree; after the first commits with a pending call, a stale-tree turn and a plain turn are refused, and the client's output continues the first turn's call).session_continuation_test'sLocalSearchbuilds its items with the public web search builders, sosession_tool_choice_inherits_request_policy_after_internal_relaxation(two session turns overprevious_response_id) andsession_explicit_conversation_tool_turns_match_no_session_historyrun a custom executor end to end; both fail withMissingOutputwhen itspublic_outputreturnsNone.session_commit_rejects_reused_call_id_against_pinned_historybecomessession_continuation_accepts_a_call_id_the_pinned_history_already_used: as on OpenAI, the repeated call is stored and the session turn answering it continues.empty_call_ids_are_rejectedandrepeated_call_ids_are_acceptedreplace the duplicate-rejection test. Admission: the ID-prefix tests move from storage toitem_id.rs, plus tests for an emptycall_idon calls and outputs and for the omitted-status defaults.function_call_history_status_gets_openai_defaultreplaces the test that kept a missing call status.conversation_storage_regressionschecks the refused batch throughConversationHandler.scheduler_retains_event_slot_for_gateway_tool_without_handlerbecomesscheduler_omits_a_gateway_tool_without_handler_from_public_output.committo refuse a repeated or empty call ID (relayed_{json,sse}_tool_call_ids_are_validated_before_persistence,relayed_{json,sse}_call_id_cannot_reuse_continued_history,relayed_call_id_cannot_reuse_a_call_replayed_in_the_current_input). The strict stream checks that a call ID cannot change between events keep their tests.overlapping-multi-agentscenario inrecord_cassette.pyandrecord_conversations_api_cassettes.sh(multi-agent turns B, streamed, and C overlap on one tree; D answers C's call), recorded against OpenAI (gpt-5.6-sol,responses_multi_agent=v1beta, sent on Responses requests only, since the Conversations API rejects it) and the gateway;conversations_api_cassette_test.rschecks both. The gateway'sconcurrent-writesandcompeting-tool-outputscassettes are re-recorded with this PR's code.relayed_stream_validation_testlists both refused B streams as known non-terminal streams.call-idsscenario (RESPONSES_SCENARIO=call-idsinrecord_cassette.pyandrecord_responses_api_cassettes.sh), recorded against OpenAI (gpt-4.1) and the gateway: repeated, reused and emptycall_ids in Responses input,previous_response_idcontinuations and Conversations items, 16 turns each with every request named inx-run-id.responses_api_testreplays both through the gateway (replay_call_ids_openai,replay_call_ids_gateway), and its recording comparisons pair turns byx-run-id.ARCHITECTURE.mddescribes public-only history, the one lowering point, the persist path, the public item defaults, client item admission and the ledger's repeated-ID rules; the cassette README lists thecall-idssuite;docs/design/tool-framework.mdlists the requiredstarted_outputandpublic_output. The engineering skill asks to searchtypes/for an existing type or rule before adding a parallel one.Behaviour changes
GET /v1/responses/{id}/input_itemsno longer returns the gateway's model-facing pairs, matching OpenAI. A first turn returns only its input; a continuation after a web search returnsmessage, web_search_call, ...as OpenAI does.On a later turn the model sees each gateway call rebuilt from its public item, under the public item's ID as
call_id:max_tool_callsreplays with no results and no refusal text. OpenAI behaves the same: asked on the next turn, its model reports no results and no error for the refused search.The inference prefix after a gateway call therefore differs from the live round, so a continuation does not reuse the prompt cache past that point.
Client-supplied
web_search_callandcode_interpreter_callinput items are lowered to function calls like every other tool item.A repeated or reused
call_id(two calls, two outputs for one call, a continuation re-sending a stored call) is accepted on every route, as on OpenAI; the gateway refused it before. llm-d no longer refuses one atcommit.An empty
call_idin Responsesinput(every route,store: falseincluded) or Conversationsitemsis refused with400 empty_string,paraminput[N].call_id/items[N].call_id, as on OpenAI. Before, Responses accepted it withstore: falseand the conversation stored it, failing on a later turn.A Responses input item of any kind whose
idhas the wrong prefix is refused with400 invalid_value, as on OpenAI; before, only messages were checked.Conversations and
input_itemslistings show an omittedfunction_callstatus asin_progressand an omitted output status ascompleted, as on OpenAI.Two custom tool calls without an
idare no longer refused as duplicate items.A custom gateway executor must implement
started_outputandpublic_output; one that relied on theNonedefaults no longer compiles.A call to a gateway tool with no executor (
file_search) is no longer shown in the response output or stored; the model still receives its error output.Public API:
GatewayExecutor::started_outputandpublic_outputare required (they defaulted toNone);web_search::started_output_itemandweb_search::output_itemare public;StorageError::InvalidItemIdis replaced byExecutorError::ClientItem(ClientItemError);ConversationStore::createandcreate_itemsno longer validate items (useConversationHandler);ClientCallId::validateis new;persist_turn(ctx, exec_ctx)replacespersist_turn(ctx, output_items, conv_handler, resp_handler)(one call site in the server);persist_responseis removed (usecommit);upstream_requesttakesguidanceandwithheld(None, Nonefor llm-d).Metrics: an empty session turn is now timed as a
persiststage; a write that finds no configured storage is no longer timed on any path.A multi-agent conversation turn stored after another turn that was stored since it rehydrated fails with
400 conversation_lockedand stores nothing, as on OpenAI; the earlier turn's tree and pending client calls survive.Known limitations (planned for the next PR)
history_item_ids; it can extend the checkpoint's rows instead.Size
Measured against
main, which now includes PR3. Production code excludes integration tests (tests/,*tests.rs), cassettes, benches and docs; unit tests insidesrcfiles count as production code. Cassettes include the two recorder scripts.Stack
The cleanup is split into five stacked PRs; each builds on the previous one and passes CI on its own. This is PR4.
refactor/storage-layerrehydrateand onepersistper handler.refactor/storage-schemaitems.metadata,public_id NOT NULL, theresponses.snapshotcolumn, recreate/reject for databases from earlier releases (migration 0009).revision.refactor/append-only-turnsconversation_lockedwhen the conversation changes while they run, except a second answer to the same pending call; a turn stores the history it started from (oneResponseCheckpointfrom rehydration to persist);conversations.revisionis dropped (migration 0010);concurrent-writesandcompeting-tool-outputscassettes.refactor/public-historypersist.rs;input_itemsmatch OpenAI for gateway tools; oneCallLedgeron the shared client-call types; overlapping multi-agent turns refused as on OpenAI.refactor/typed-requestmetadata, compaction during a concurrent turn (stale-compaction).RequestContextstays a follow-up.Test Plan
scan_callsfold, which then passed clippy, the core unit tests and the shell cassettes (pre-commit, clippy,cargo test --workspace: 3018 passed, 0 failed, 20 ignored; launchers, CLI end-to-end, OpenTelemetry fixtures, embedded code interpreter clippy, build and characterization; MSRV; PostgreSQL schema 4, storage 5, tenant isolation 6)cargo test --workspace: 3013 passed, 0 failed, 20 ignored1c5bed0): workspace clippy clean; core lib 1366 passed (6 ignored); llm-dsplit_execution_integration14;relayed_stream_validation_test12;terminal_status_test9;shell_tool_test26;stateful_responses_integration5;input_file_repro41;responses_test88input_itemsfor MCP and web search return only public items; session history holdsweb_search_call; continuations find the lowered call under the public IDgpt-5.6,max_tool_calls: 1, two searches): the refused search is stored asweb_search_call(searching) and the next turn's model reports no results and no erroroverlapping-multi-agentrecorded against OpenAI (gpt-5.6-sol) and the gateway (vLLM,Qwen/Qwen3.8-27B-FP8): on both, B is refused withconversation_locked, C keeps its pending call, and D completes with the call's output (7°C). Gatewayconcurrent-writesandcompeting-tool-outputsre-recorded with this PR's code;conversations_api_cassette_test: 13 passed;relayed_stream_validation_test: 12 passedQwen/Qwen3.8-27B-FP8). The re-recordings are not committed.tool-items:input_itemsno longer lists the gateway'sfunction_call/function_call_outputpairs and has the shape of the committed OpenAI reference (message, mcp_list_tools, reasoning, web_search_call, ..., mcp_call, ...); client function, custom and shell calls and their outputs are still listed. Other differences are model variance (which tools the model chose to call, an intermediate assistant message, the order of multi-agent calls).store: false(full-history replay, stored and unstored parents, explicit conversation, conversation items, function replay; JSON and SSE): JSON matches the committed gateway cassettes on every turn; SSE differs only byresponse.reasoning_part.added/response.reasoning_part.done, whichmainemits since 474d154 (before the previous PR).Qwen/Qwen3.8-27B-FP8), not committed:responses(11/11),store_false(20/20),multi_turn_steering(5 categories; vision needs a vision model) andmulti_agent(17/17, including the 5 WebSocket scenarios). No behaviour change comes from this PR. The differences from the committed recordings are earliermainchanges (response.reasoning_part.*events,storeechoed on response objects,function_call_outputlisted withstatus: completed, OpenAI's wording for an output with an unknowncall_id, no repeatedmcp_list_toolsfor resumed subagents) or the different model (preamble messages, other tool or agent choices, output-token limits). Replay tests against the new files:responses_api_test29/29,multi_agent_contract_test,hosted_tool_search_test38/38,responses_websocket_test110/110; two tests that pin details of the old recordings (a vLLM stream fault vLLM no longer emits, and an exact response count) fail only against the new filespersist.rscleanup against vLLM (Qwen/Qwen3.8-27B-FP8), not committed:web_search(2/2),code_interpreter(3/3),responses(11/11) andconversations(12/12), all on the first attempt. No behaviour change comes from this PR; the differences from the committed recordings are earliermainchanges (storeechoed on response objects, reasoning item statuses,function_call_outputlisted withstatus: completed) or the model (multi-agent item order).competing-tool-outputsstill differs from OpenAI only in which racing request wins. Replay tests against the new files:responses_api_test29/29,conversations_api_cassette_test13/13,conversation_branch_response_test2/2,accumulator_cassette_test40/40,code_interpreter_characterization_test4/4;web_search_tool_testandrelayed_stream_validation_testfail only on pins to the old model name and the old recordings' rejected-stream count