Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/evidence/provider-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ resolves every one against the source, so a renamed or deleted test fails the bu
| --- | --- |
| PRV-1 | `cin_3_every_codec_renders_collaboration_with_its_sender_named`, `prv_1_collaboration_bodies_cannot_forge_their_own_envelope`, `prv_1_chat_fixture_drives_a_full_stateless_tool_round_trip`, `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools`, `prv_1_both_protocols_preserve_parallel_call_and_result_order`, `prv_1_chat_refuses_cross_kind_order_its_wire_cannot_represent`, `prv_1_protocol_selection_never_falls_back_across_replay_grammars`, `native_catalog_is_exact_unique_and_advertised_by_both_protocols`, `prv_1_messages_signed_tool_round_trip_normalizes_cumulative_usage`, `prv_1_gemini_tool_round_trip_preserves_signatures_and_counts_thoughts` |
| PRV-2 | Both fixture round trips, `prv_2_production_tool_argument_limit_remains_64_kibibytes`, `parallel_calls_share_one_aggregate_argument_bound`, `reverse_parallel_call_completion_is_sorted_before_dispatch`, `prv_2_both_protocols_bound_tool_identity_before_emission`, `assistant_output_rechecks_aggregate_text_and_tool_identity_bounds`, `oversized_semantic_text_fails_before_canonical_commit`, `maximal_valid_assistant_output_fits_the_journal_line_envelope`, `prv_2_rejects_duplicate_chat_tool_call_ids_before_emission`, `prv_2_rejects_incremental_duplicate_responses_tool_call_ids`, `prv_2_responses_counts_incrementally_completed_calls_toward_the_step_bound`, `prv_2_rejects_tool_arguments_before_they_can_reach_admission`, `prv_2_bounds_even_empty_responses_output_items`, `prv_2_responses_text_done_confirms_deltas_or_supplies_the_only_copy`, `prv_2_stopped_is_withheld_until_the_stream_trailer_is_valid`, `prv_2_sse_framing_rejects_partial_utf8`, `prv_2_messages_rejects_unfinished_mismatched_and_oversized_blocks`, `prv_2_gemini_call_ids_are_scoped_and_parallel_signatures_keep_their_part`, `prv_2_gemini_rejects_incomplete_duplicate_and_oversized_content`, `server_tool_calls_share_the_tool_argument_bound` |
| PRV-3 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools`, `prv_3_any_of_the_four_compatibility_axes_degrades_rather_than_refusing`, `prv_3_a_degraded_call_and_its_result_keep_one_identity`, `prv_3_a_degraded_gemini_call_carries_neither_signature_nor_upstream_id`, `prv_3_an_output_that_was_only_its_replay_degrades_to_no_message`, `prv_3_an_id_every_dialect_accepts_is_carried_through_unchanged`, `prv_3_an_id_the_destination_would_reject_becomes_one_it_accepts`, `prv_3_distinct_rewritten_ids_stay_distinct`, `prv_3_replay_route_owner_encoding_is_unambiguous`, `prv_3_rejects_opaque_replay_before_step_state_can_grow`, `assistant_output_round_trips_order_and_redacts_replay`, `replay_only_wire_without_a_payload_is_rejected`, `reasoning_and_opaque_replay_survive_interrupt_without_sharing_presentation`, `provider_replay_is_named_and_bounded_before_turn_state_can_retain_it`, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_cancelled_calls_leave_no_dangling_replay`, `prv_3_chat_reasoning_aliases_round_trip_without_renaming`, `prv_3_chat_accepts_reasoning_details_that_only_restate_the_plain_delta`, `prv_3_gemini_signature_only_and_early_signature_parts_are_replayable`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation`, `prv_3_unsigned_reasoning_in_a_tool_turn_is_refused_by_messages_and_omitted_by_responses`, `prv_5_responses_web_search_call_is_carried_as_a_server_tool_call` |
| PRV-3 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools`, `prv_3_any_of_the_four_compatibility_axes_degrades_rather_than_refusing`, `prv_3_chat_keeps_carried_pieces_apart_with_a_blank_line`, `prv_3_a_degraded_call_and_its_result_keep_one_identity`, `prv_3_a_degraded_gemini_call_carries_neither_signature_nor_upstream_id`, `prv_3_an_output_that_was_only_its_replay_degrades_to_no_message`, `prv_3_an_id_every_dialect_accepts_is_carried_through_unchanged`, `prv_3_an_id_the_destination_would_reject_becomes_one_it_accepts`, `prv_3_distinct_rewritten_ids_stay_distinct`, `prv_3_replay_route_owner_encoding_is_unambiguous`, `prv_3_rejects_opaque_replay_before_step_state_can_grow`, `assistant_output_round_trips_order_and_redacts_replay`, `replay_only_wire_without_a_payload_is_rejected`, `reasoning_and_opaque_replay_survive_interrupt_without_sharing_presentation`, `provider_replay_is_named_and_bounded_before_turn_state_can_retain_it`, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_cancelled_calls_leave_no_dangling_replay`, `prv_3_chat_reasoning_aliases_round_trip_without_renaming`, `prv_3_chat_accepts_reasoning_details_that_only_restate_the_plain_delta`, `prv_3_gemini_signature_only_and_early_signature_parts_are_replayable`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation`, `prv_3_unsigned_reasoning_in_a_tool_turn_is_refused_by_messages_and_omitted_by_responses`, `prv_5_responses_web_search_call_is_carried_as_a_server_tool_call` |
| PRV-4 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools` proves stateless full-record replay without a response ID, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation` |
| PRV-5 | `http_rate_limit_is_typed_and_keeps_retry_after`, `context_error_is_classified_by_wire_code`, `prv_5_responses_done_only_refusal_is_visible_and_typed`, and both fixture completion reasons, `prv_5_chat_rejects_structured_or_conflicting_reasoning`, `prv_5_messages_context_limits_and_refusals_keep_final_usage`, `native_stream_rate_limits_are_typed`, `gemini_declared_failures_preserve_diagnostics_without_dispatching_calls`, `messages_usage_refuses_unaccounted_server_tools`, `prv_5_gemini_policy_and_unsupported_image_finishes_are_distinct`, `stream_provider_errors_keep_their_category_and_observed_usage`, `dispatched_http_failures_preserve_their_terminal_measurements`, `provider_failure_reopens_as_the_same_non_retryable_outcome`, `prv_5_empty_additive_fields_preserve_text_and_populated_fields_fail`, `prv_5_chat_admits_gateway_accounting_and_still_refuses_everything_else`, `prv_5_messages_pause_turn_is_explicitly_unsupported`, `prv_5_responses_web_search_call_is_carried_as_a_server_tool_call`, `prv_5_an_unknown_server_tool_action_fails_the_step`, `prv_5_a_server_tool_call_without_an_action_fails_the_step`, `prv_5_a_search_without_a_query_is_carried_and_a_repeated_query_is_kept_once`, `prv_5_a_server_tool_calls_outcome_is_typed_from_its_status`, `a_call_still_running_when_the_step_ends_is_shown_failed_and_kept_out_of_the_record` |
| PRV-6 | `prv_6_allowed_efforts_are_model_local_and_do_not_invent_capabilities`, `prv_6_invalid_effort_catalogs_and_defaults_fail_closed`, `prv_6_effort_catalogs_cannot_enable_unencodable_levels`, `prv_6_one_provider_resolves_two_exact_models_without_repeating_authority`, `prv_6_selection_and_every_model_fail_closed_before_network_work`, `prv_6_inline_authority_and_legacy_profiles_are_not_a_second_config_path`, `prv_6_resolution_rejects_unsafe_routes_without_echoing_them`, `prv_6_resolves_only_an_override_or_the_user_root`, `prv_6_key_resolution_is_explicit_and_redacted`, `prv_6_inline_key_is_used_when_its_environment_variable_is_absent`, `cmd_2_selected_api_key_environment_is_removed_even_without_credential_shape`, `prv_6_standard_requests_omit_unspecified_reasoning_and_encode_instructions`, `prv_6_native_thinking_options_are_explicit_and_validated`, `prv_6_gemini_38_requests_use_only_frontier_thinking_controls`, `prv_6_messages_cache_and_thinking_match_native_request_contract`, `prv_6_provider_example_selects_each_documented_dialect`, `native_messages_http_uses_api_key_headers_and_terminal_usage`, `completed_messages_without_thinking_breakdown_keep_final_cost`, `native_gemini_http_uses_versioned_endpoint_and_header_authority` ; [SKL-1](./agent-skills.md), `prv_6_hosted_tools_are_declared_per_model_and_absent_by_default`, `prv_6_hosted_tool_declarations_fail_closed`, `prv_6_hosted_tool_declarations_need_a_dialect_spelling`, `prv_6_declared_hosted_tools_are_spelled_by_the_dialect_and_absent_otherwise`, `the_tool_name_is_its_configuration_spelling` |
Expand Down
12 changes: 8 additions & 4 deletions .agents/specs/provider-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,15 @@ readable by the next model; an interrupted one is left out, half a sentence attr
saying what the model never said, and a sidecar is what tells the two apart; a call keeps its name
and arguments, drops every shape legal only beside the replay that authenticates it — Gemini's
`thought` flag, any upstream id — and takes an id every dialect accepts, applied to the call and to
its result or to neither; a replay-only block is dropped, having been nothing but its sidecar. A
reply carrying nothing is not sent as an empty one. Sidecars stay in the record, so the model that
wrote them replays them exactly when selected again, and occupancy is measured for what ships.
its result or to neither; a replay-only block is dropped, having been nothing but its sidecar.
Carried pieces stay apart: each is its own block where the dialect has blocks, and Chat, whose
assistant content is one string, puts a blank line between them, so a thought never reads as the
start of the answer. A reply carrying nothing is not sent as an empty one. Sidecars stay in the
record, so the model that wrote them replays them exactly when selected again, and occupancy is
measured for what ships.
Rejected: dropping foreign reasoning outright, cheaper and free of the unsigned-thought hazard, but
discarding a finished chain of reasoning the next model could have used.
discarding a finished chain of reasoning the next model could have used; and Chat content as a list
of text parts, which some compatible gateways refuse on an assistant message.

Unsigned reasoning — a reply interrupted before its own replay existed — stays in the journal and
presentation, and wire encoders omit
Expand Down
13 changes: 9 additions & 4 deletions crates/plexmaton-provider/src/chat/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,15 @@ fn degraded_assistant(carried: &[Carried<'_>]) -> Option<Value> {
let mut pending = PendingAssistant::default();
for block in carried {
match block {
Carried::Text(text) => pending
.content
.get_or_insert_with(String::new)
.push_str(text),
// Chat's assistant content is one string, so carried pieces are kept apart by a blank
// line rather than run together: a thought must not read as the start of the answer.
Carried::Text(text) => {
let content = pending.content.get_or_insert_with(String::new);
if !content.is_empty() {
content.push_str("\n\n");
}
content.push_str(text);
}
Carried::Call(call) => pending.calls.push(json!({
"id": degrade::atom_call_id(true, &call.call_id),
"type": "function",
Expand Down
21 changes: 21 additions & 0 deletions crates/plexmaton-provider/tests/provider_fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,27 @@ fn prv_3_any_of_the_four_compatibility_axes_degrades_rather_than_refusing() {
}
}

/// PRV-3: Chat's assistant content is one string, so the pieces a degraded reply carries are kept
/// apart by a blank line. Run together, a finished thought reads as the opening of the answer.
#[test]
fn prv_3_chat_keeps_carried_pieces_apart_with_a_blank_line() {
let selected = profile(ModelApi::OpenaiChatCompletions);
let request = degradable_request(foreign_compatibility());
let encoded = encode_request(&selected, &request, &[], None)
.unwrap_or_else(|error| panic!("a foreign replay must degrade, not refuse: {error}"));
let assistant: Vec<_> = encoded["messages"]
.as_array()
.unwrap_or_else(|| panic!("Chat messages are an array"))
.iter()
.filter(|message| message["role"] == "assistant")
.collect();
assert_eq!(assistant.len(), 1, "one reply stays one message");
assert_eq!(
assistant[0]["content"], "Visible answer.\n\nA finished thought.",
"each carried piece is its own paragraph"
);
}

/// A compatibility no supported dialect owns, so every one of them has to degrade.
fn foreign_compatibility() -> ReplayCompatibility {
let expected = profile(ModelApi::OpenaiResponses).replay_compatibility();
Expand Down
Loading