diff --git a/.agents/evidence/provider-adapter.md b/.agents/evidence/provider-adapter.md index 15661111..5e14caa7 100644 --- a/.agents/evidence/provider-adapter.md +++ b/.agents/evidence/provider-adapter.md @@ -7,9 +7,9 @@ resolves every one against the source, so a renamed or deleted test fails the bu | Invariant | Proven by | | --- | --- | | 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` | -| 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-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-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-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`, `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-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` | | PRV-7 | `event_guard_rejects_an_unterminated_event_at_the_bound`, `event_guard_rejects_one_oversized_transport_chunk`, `prv_2_and_prv_7_reject_unbounded_or_incomplete_provider_input`, `prv_7_model_config_cannot_widen_runtime_output_memory`, `interrupt_and_shutdown_cancel_and_join_the_exact_provider_task`, `native_stream_cancellation_preserves_observed_usage` | diff --git a/.agents/evidence/transcript-entry.md b/.agents/evidence/transcript-entry.md index 9ba7bacf..53a84840 100644 --- a/.agents/evidence/transcript-entry.md +++ b/.agents/evidence/transcript-entry.md @@ -7,9 +7,14 @@ resolves every one against the source, so a renamed or deleted test fails the bu | Invariant | Proven by | | --- | --- | | ENT-1 | `every_transcript_identity_is_new_and_names_its_agent`, `every_transcript_category_enters_one_ordered_projection`, `cancelled_submit_publishes_staged_events_before_projected_delegation`, `retained_projection_stays_with_its_root_and_applies_once`, `retained_projection_rejects_a_replacement_for_the_same_conversation`, `a_letter_reaches_both_conversations_attributed_to_its_sender`, `each_side_of_one_item_names_the_other_end`, `copying_a_conversation_preserves_interleaved_entry_sources`, `an_entry_identity_cannot_move_between_agents`, `selected_branches_place_or_suffix_one_delegated_reference_without_duplication`, `a_direct_link_wins_over_a_later_fallback_anchor`, `durable_links_place_shared_rows_and_legacy_rows_keep_a_stable_suffix`, `real_writers_reopen_durable_shared_rows_in_their_live_order_twice`, `later_child_activation_skips_replayed_entries_and_accepts_new_work`, `passive_child_activation_applies_a_real_same_item_recovery_revision_once`, `orphaned_root_ingress_persists_one_link_before_live_projection`, `selected_session_placement_rejects_a_link_from_another_announced_agent`, `shuffled_tool_completions_update_their_original_entries`, `interleaved_text_and_tools_keep_their_positions_when_tools_finish_out_of_order`, `non_chat_text_roles_have_distinct_named_treatments`, `reasoning_terminal_newlines_share_measurement_and_paint`, `reasoning_spacing_keeps_pointer_copy_visible_and_message_copy_exact`, `reasoning_and_opaque_replay_survive_interrupt_without_sharing_presentation`, `the_remaining_transcript_grammar_frames_match_their_fixtures` with the `transcript-grammar-*` frames; `scripts/smoke-delegate.py` proves ordered live/reopen task, mail and display-label rows | -| ENT-2 | `tool_transitions_do_not_reuse_stale_prepared_status`, `a_step_that_asked_for_tools_dispatches_them_and_waits`, `production_tool_lifecycles_replay_as_one_entry_each`, `tool_lifecycle_allows_only_forward_declared_transitions`, `tool_updates_refuse_revision_gaps_and_invalid_transitions`, `results_are_assembled_in_the_order_the_model_asked_and_not_the_order_they_finished`, `every_tool_status_is_one_named_logical_line`, `a_tool_transition_remeasures_only_its_original_entry` | -| ENT-3 | `every_event_variant_survives_a_json_round_trip`, `two_fresh_projections_of_the_same_envelopes_are_equal`, `rejected_event_does_not_block_the_rest_of_the_stream`, crate-graph gate | -| ENT-4 | `command_tool_copy_keeps_the_complete_invocation_context`, `cmd_1_command_detail_preserves_multiline_source_and_context`, `command_modal_copies_exact_source_and_returns_to_the_pending_approval`, `tool_disclosure_never_creates_a_copy_range_or_reverses_detail_at_three_widths`, `selected_diff_keeps_semantic_colors_and_reuses_prepared_rows_at_three_widths`, Presentation production/bounds: `bounded_presentation_text_carries_exact_omission_metadata`, `tool_status_updates_accumulate_invocation_and_outcome_presentation`, `cancellation_before_admission_has_outcome_without_invocation`, `maximum_valid_edit_retains_a_complete_bounded_patch`, `file_observation_survives_the_runtime_boundary_into_an_approved_edit`, `maximal_command_result_stays_bounded_in_the_next_model_request`; disclosure/copy: `every_tool_status_can_disclose_the_same_typed_detail`, `a_long_letter_is_one_row_until_ctrl_o_opens_it`, `a_heading_spends_columns_and_never_more_than_it_has`, `every_ellipsis_is_a_promise_disclosure_keeps`, `ctrl_o_opens_the_selections_focus_entry_in_place_at_each_drawn_width`, `pointer_and_ctrl_o_toggle_the_same_item_while_drag_cancels_disclosure` and, for the half of that parity a letter depends on, `a_click_opens_a_letter_the_same_way_ctrl_o_does`, `tool_completion_preserves_the_users_open_state`, `tool_copy_preserves_every_retained_source_in_producer_order`, `tool_copy_is_identical_when_compact_open_resized_scrolled_and_repainted`, `the_open_tool_frames_match_their_fixtures` with the `tool-open-*` frames; diff treatment: `canonical_diff_lines_keep_markers_and_receive_bounded_semantic_roles`, `opaque_maximum_diff_line_degrades_to_exact_plain_text`, `the_remaining_transcript_grammar_frames_match_their_fixtures` | +| ENT-2 | `tool_transitions_do_not_reuse_stale_prepared_status`, `a_step_that_asked_for_tools_dispatches_them_and_waits`, `production_tool_lifecycles_replay_as_one_entry_each`, `tool_lifecycle_allows_only_forward_declared_transitions`, `tool_updates_refuse_revision_gaps_and_invalid_transitions`, `results_are_assembled_in_the_order_the_model_asked_and_not_the_order_they_finished`, `every_tool_status_is_one_named_logical_line`, `a_tool_transition_remeasures_only_its_original_entry`, `a_server_tool_call_runs_at_revision_zero_and_finishes_once`, `a_reopened_server_tool_call_appears_finished_at_revision_zero`, `a_call_still_running_when_the_step_ends_is_shown_failed_and_kept_out_of_the_record`, `a_running_server_tool_is_the_current_work`, `a_server_tool_row_keeps_the_tool_grammar_and_claims_only_what_the_route_reported`, `a_running_server_tool_row_is_named_and_wears_the_server_tool_colour`, `the_server_tool_frames_match_their_fixtures` with the `server-tool-*` frames; `scripts/smoke-server-tool.py` proves live placement, the running state and reopen order through a real PTY | +| ENT-3 | `every_event_variant_survives_a_json_round_trip`, `two_fresh_projections_of_the_same_envelopes_are_equal`, `rejected_event_does_not_block_the_rest_of_the_stream`, crate-graph gate, `ent_3_a_server_tool_call_is_the_same_event_live_and_on_replay` | +| ENT-4 | `command_tool_copy_keeps_the_complete_invocation_context`, `cmd_1_command_detail_preserves_multiline_source_and_context`, `command_modal_copies_exact_source_and_returns_to_the_pending_approval`, `tool_disclosure_never_creates_a_copy_range_or_reverses_detail_at_three_widths`, `selected_diff_keeps_semantic_colors_and_reuses_prepared_rows_at_three_widths`, Presentation production/bounds: `bounded_presentation_text_carries_exact_omission_metadata`, `tool_status_updates_accumulate_invocation_and_outcome_presentation`, `cancellation_before_admission_has_outcome_without_invocation`, `maximum_valid_edit_retains_a_complete_bounded_patch`, `file_observation_survives_the_runtime_boundary_into_an_approved_edit`, `maximal_command_result_stays_bounded_in_the_next_model_request`; disclosure/copy: `every_tool_status_can_disclose_the_same_typed_detail`, `a_long_letter_is_one_row_until_ctrl_o_opens_it`, `a_heading_spends_columns_and_never_more_than_it_has`, `every_ellipsis_is_a_promise_disclosure_keeps`, `ctrl_o_opens_the_selections_focus_entry_in_place_at_each_drawn_width`, `pointer_and_ctrl_o_toggle_the_same_item_while_drag_cancels_disclosure` and, for the half of that parity a letter depends on, `a_click_opens_a_letter_the_same_way_ctrl_o_does`, `tool_completion_preserves_the_users_open_state`, `tool_copy_preserves_every_retained_source_in_producer_order`, `tool_copy_is_identical_when_compact_open_resized_scrolled_and_repainted`, `the_open_tool_frames_match_their_fixtures` with the `tool-open-*` frames; diff treatment: `canonical_diff_lines_keep_markers_and_receive_bounded_semantic_roles`, `opaque_maximum_diff_line_degrades_to_exact_plain_text`, `the_remaining_transcript_grammar_frames_match_their_fixtures`, `a_server_tool_row_discloses_what_the_route_reported`, `server_tool_copy_is_what_the_route_reported` | + +The user chose the server tool row's grammar and colour on 2026-09-21 from rendered candidates and +reviewed the real frames at [120](../../crates/plexmaton-tui/frames/server-tool-120.svg), +[88](../../crates/plexmaton-tui/frames/server-tool-88.svg) and +[60](../../crates/plexmaton-tui/frames/server-tool-60.svg). The user approved disclosure-only tool clicks on 2026-09-06. Reviewed real frames at [120](../../crates/plexmaton-tui/frames/tool-disclosure-120.svg), diff --git a/.agents/phases/phase-04-product-polish.md b/.agents/phases/phase-04-product-polish.md index 7d7b2c06..f53208a9 100644 --- a/.agents/phases/phase-04-product-polish.md +++ b/.agents/phases/phase-04-product-polish.md @@ -2,7 +2,7 @@ | Field | Value | | --- | --- | -| Status | Active; stages 1–9, 11–15, 17–27, 29, 31 and 32 complete; stages 16 and 28 await the user's terminal test; stage 30 automatic-compaction visibility unstarted; stage 10 branding remains | +| Status | Active; stages 1–9, 11–15, 17–27, 29 and 31–33 complete; stages 16 and 28 await the user's terminal test; stage 30 automatic-compaction visibility unstarted; stage 10 branding remains | | Parent roadmap | [Plexmaton Roadmap](../roadmap.md) | | Product contract | [UI/UX](../ui-ux.md) | | Depends on | Phase 01 interaction ownership; Phase 02 journal/accounting/budget projections; Phase 03 durable collaboration, owned child scheduling, Handoff/Stop, passive recovery and canonical Attention | @@ -376,6 +376,14 @@ JRN-3/PRV-3 prove JSONL save, reopen and exact compatible replay only; an export and its replay disclosure policy remain unproven. MCP remains optional later integration. Rejected: treating JSONL round trips as independent export/import acceptance. +33. **Provider-side tools — complete.** A model declares the hosted tools its route accepts + (PRV-6), the harness forwards the declaration, and a provider-run search is a transcript row in + its own colour that appears where the provider began it and finishes once (PRV-5, ENT-2). The + harness never runs the search. The [spike](../spikes/provider-side-tools/README.md) keeps the + live measurements of the one route this was proven on, including that the route delivers a + whole turn at once. Where hosted-tool spend belongs in the cost surface stays open in + [ui-ux](../ui-ux.md) §open questions. + Other roadmap work in performance, math and extensibility receives a stage when its evidence is ready; this phase opening does not claim those capabilities have started. diff --git a/.agents/roadmap.md b/.agents/roadmap.md index 737378d8..9d418172 100644 --- a/.agents/roadmap.md +++ b/.agents/roadmap.md @@ -34,7 +34,7 @@ table in `AGENTS.md` allows. | 01 | One real agent in the workspace: a thin loop over one provider, and the transcript grammar against its output | Closed 2026-09-03: delivered one live OpenAI-compatible agent, five bounded native tools with explicit approval and cancellation, and the reviewed responsive transcript grammar | | 02 | Canonical session state, persistence, durable permission policy, provider transports, context projection and project instructions | Closed 2026-09-12 by scoped evidence: delivered JSONL sessions and recovery, four provider dialects, compaction, durable permissions, AGENTS.md and conversation tree/rewind; unproven recovery/readmission acceptance inherited by Phase 03, independent export/import by Phase 04; MCP optional | | 03 | Durable multi-agent mailbox and runtime ownership | Closed 2026-09-16: delivered ordered durable delegation/mail, owned child scheduling, Stop and Handoff control, passive, process-kill and in-app switch recovery, canonical Attention and the accepted responsive agent workspace | -| 04 | Product polish, performance hardening, math in production, and extensibility | Active; stages 1–9, 11–15, 17–27, 29, 31 and 32 complete; stage 16 effort selection awaits user testing; stage 28 model-switch degradation in progress; stage 30 automatic-compaction visibility unstarted; stage 10 branding remains | +| 04 | Product polish, performance hardening, math in production, and extensibility | Active; stages 1–9, 11–15, 17–27, 29 and 31–33 complete; stage 16 effort selection awaits user testing; stage 28 model-switch degradation in progress; stage 30 automatic-compaction visibility unstarted; stage 10 branding remains | ## Locked @@ -72,6 +72,7 @@ Tracks run beside phases because their exit conditions are comparisons, not deli | [Agent Skills spike](./spikes/agent-skills/README.md) | Compare skill discovery, project configuration and durable activation | Implemented and verified offline; live model behavior unverified | | [Permission policy spike](./spikes/permission-policy/README.md) | Compare scoped grants, rule precedence and durable authority | Source comparison retained; PER-1–PER-10 and PGR-1–PGR-5 implemented and verified locally | | [Compaction spike](./spikes/compaction/README.md) | Constrain compaction, branch-local context and recovery | Source/probe evidence retained; CPL-1–CPL-8 own implementation and regression evidence | +| [Provider-side tools spike](./spikes/provider-side-tools/README.md) | Compare how harnesses surface a tool the provider runs, and measure one live hosted-search route | Live measurement retained; implemented as phase 04 stage 33, whose behaviour PRV-5, PRV-6 and ENT-2 own | Gates without a track yet, each opened with a prototype, a comparison corpus, and a decision criterion: diff --git a/.agents/specs/provider-adapter.md b/.agents/specs/provider-adapter.md index e398cb6a..9299e275 100644 --- a/.agents/specs/provider-adapter.md +++ b/.agents/specs/provider-adapter.md @@ -32,7 +32,11 @@ a semantic stop is released only after the stream trailer passes validation. **PRV-3 — Semantics and replay are exact but separate.** Every exposed reasoning artifact is retained in output order. Bounded replay sidecars attach to text, reasoning or calls; a replay-only block retains a part with no visible content. Responses retains item identity/status, message phase -and content grouping as well as encrypted reasoning. Messages retains complete thinking/signature and redacted +and content grouping, a server-tool call's identity and status, as well as encrypted reasoning. A +server-tool call replays with its status and without its identity: the route in daily use turns a +replayed id into a result block the upstream refuses on an assistant message, and Meta documents the +id as optional on replay. Rejected: replaying the id, which is the shape Codex sends and the one +that fails here. Messages retains complete thinking/signature and redacted blocks; Gemini retains signatures on their original parts, signature-only text-field absence and optional upstream call IDs. Chat retains recognized reasoning field identity and refuses unsupported structured reasoning; its @@ -88,25 +92,45 @@ that was: Chat admits `provider_metadata`, the gateway accounting — cost, cach attempts — that rides the final delta beside `finish_reason`, and admits it nowhere else. Rejected: refusing it as unexamined, which was right in principle and in practice discarded a whole completed answer at its last chunk, on every gateway that sends it. -A tool the provider ran on its own side falls the other way: a `server_tool_use` block and a -non-zero server-tool count are content this harness never admitted, never bounded and cannot show, -and a turn that absorbs them quietly reports work its own record does not contain. Rejected: -admitting them as opaque replay, which round-trips correctly and leaves the reader an answer whose -sources appear nowhere in the conversation. +A tool the provider ran on its own side is carried, not refused. Responses' `web_search_call` +becomes a server-tool call in the record: what the provider did, a search, an opened page or a find +within one, bounded the way tool arguments are, and how it ended, completed or failed, typed from +the item's status; a done item that still claims to be running fails the step. The provider's own +item identity and wire status stay replay metadata. It reaches no admission and no scheduler, +because the call already happened inside the model call the owner authorised by declaring the route +accepts it. The item's first appearance on the stream places the call, so the row sits where the +provider put it and not where a route chose to finish it: one live route finishes every search at +the end of the stream. A call still running when the step ends is shown as failed and keeps no +block, because there is nothing to replay. An action kind this harness cannot name fails the step; a +search that arrived with no query is carried as it arrived; the progress markers a route streams +while searching carry nothing the finished item lacks and are ignored. Messages' `server_tool_use` +block and a non-zero server-tool count remain refused until a Messages route is in daily use to +prove a decoder against. Rejected: keeping the call as opaque replay alone, which round-trips and +leaves the record unable to say a search happened; and a catch-all action kind, which would carry +content the record cannot name. **PRV-6 — Configuration names data, never authority.** `~/.plexmaton/config.toml` separates named provider routes from their named models and selects one exact provider/model pair. A route owns its -base URL, credential environment and default API; a model owns its wire/display identity, optional -API override, optional reasoning controls and a declared allowed-effort subset, stable instructions, cache intent, context/output/reserve +base URL, credential environment, optional inline bearer token, and default API; a model owns its +wire/display identity, optional API override, optional reasoning controls, a declared allowed-effort +subset, a declared hosted-tool subset, stable instructions, cache intent, context/output/reserve limits, compaction retention target, estimator and optional price snapshot. Resolution yields one immutable credential-blind value: omitted estimators become an explicit versioned default, while omitted pricing remains unavailable. Selection never uses fuzzy -names or URL inference. `PLEXMATON_HOME` redirects the whole root for isolated development; keys -never enter the file, diagnostics, repository or a native command's environment, and invalid input -fails before network work begins. SKL-1 permits a narrow project model-selection layer without +names or URL inference. `PLEXMATON_HOME` redirects the whole root for isolated development. A +route may keep its bearer token in `api_key`; the resolved model stays credential-blind, diagnostics +redact the token, and a set environment variable still overrides the file. Keys never enter +diagnostics, the repository, or a native command's environment, and invalid input fails before +network work begins. SKL-1 permits a narrow project model-selection layer without project provider definitions or credential changes. An allowed-effort declaration must be nonempty, -unique and encodable by the dialect; an explicit configured effort must belong to it. Rejected: a combined provider/model profile, -inline keys, and untyped merging of project configuration into provider authority. +unique and encodable by the dialect; an explicit configured effort must belong to it. A hosted-tool +declaration names capabilities the owner knows their route accepts; it must be nonempty, unique and +spellable by the dialect, and nothing infers one from a provider or model name. A route that accepts +a spelling and ignores it is the declaration being wrong, which the owner corrects, never something +the harness guesses around. Rejected: a combined provider/model profile, and untyped merging of +project configuration into provider authority. Rejected: requiring the shell to export the named +variable, which kept a local route from starting when the token already belonged in the user-owned +file. [AGI-3/AGI-4](./agent-instructions.md) add a bounded workspace snapshot to that immutable request environment as user context, separately from configured system instructions and tool authority. diff --git a/.agents/specs/transcript-entry.md b/.agents/specs/transcript-entry.md index 81e87e2d..bcf2bdcb 100644 --- a/.agents/specs/transcript-entry.md +++ b/.agents/specs/transcript-entry.md @@ -37,7 +37,14 @@ streaming and after finalization; internal line breaks and SEL-2's original sour each accepted lifecycle transition advances exactly one revision on the original entry. Display order is first appearance, execution completion order is event order, and model result order stays the batch's model-call order (APV-5). Rejected: moving a completed call to the tail or using its -completion position as model order. +completion position as model order. A server tool call is one entry too, with two states rather +than seven: it appears running at revision zero where the provider placed it, and finishes once at +revision one with what the provider did. A reopened conversation holds only the finished call and +shows it finished at revision zero. Nothing here queued, admitted or dispatched it, and a finished +call never changes again, so a second finish is a duplicate the notice log keeps. It is drawn in +the tool row's grammar, and the row and its colour are the contract's (ui-ux §transcript grammar). +Rejected: placing the row when the call finishes, which on one live route put every search after +the answer it informed, and showed a reopened conversation in a different order from the live one. **ENT-3 — Replay is pure reduction.** Feeding the same ordered envelopes to a fresh projection produces the same state and can only mutate that projection; it cannot contact a provider, consult diff --git a/.agents/spikes/provider-side-tools/README.md b/.agents/spikes/provider-side-tools/README.md new file mode 100644 index 00000000..29047e8e --- /dev/null +++ b/.agents/spikes/provider-side-tools/README.md @@ -0,0 +1,96 @@ +# Provider-side tools spike + +| Field | Value | +| --- | --- | +| Status | Implemented as phase 04 stage 33; PRV-5, PRV-6 and ENT-2 own the behaviour. Live measurements retained as evidence for the next hosted tool | +| Read when | Adding web search, or any tool a provider runs on its own side rather than handing to this harness | +| Question | Can a Session reach provider-hosted search without the harness losing sight of what was searched? | +| Contract | [provider-adapter](../../specs/provider-adapter.md) PRV-1/PRV-3/PRV-5; [tool-admission](../../specs/tool-admission.md); [permission-policy](../../specs/permission-policy.md) PER-2 | + +## Why this exists + +A provider that runs a search on its own side is the one category the fence position does not +cover: not something we execute, not something we hand to a subprocess, but something the provider +performs inside a call the owner already authorised. PRV-5 once refused the block outright; this +spike is why it now carries the call and types its outcome. + +## Corpus + +Local source read 2026-09-20; no reference suite was run. Plexmaton base `c7bdf0a`, branch +`feat/provider-side-tools`. + +| Directory | Revision | Source root for references below | +| --- | --- | --- | +| `codex` | `9f70e348e0227980de97e361cce830236fb18317` | `codex-rs` | +| `pi-arcweld` | `26cb4bf5cf7e7c4bb5fe948863264ed3cf899048` | `extensions/claude-web-search` | +| `grok-build` | `72a61251fcffb464bcc687aeb5a998e5a98ec0c9` | `crates/codegen/xai-grok-pager/src` | +| `claude-code` | `2.1.88` mapped | `cc/2.1.88` | +| `kimi-code` | `f12d59e089e2531a33fbca30b26ffeabd5862b45` | — | + +## Two shapes, and the one chosen + +Codex carries a provider-run search as a first-class protocol item, matched at every site that +already matches its response enum. pi wraps it as an ordinary client tool that issues its own +isolated Messages request, so its main conversation never holds a `server_tool_use` block. Codex's +shape was taken, narrowed by a declaration. pi's was rejected because a tool that issues its own +model request needs a provider route that native tools here do not have, and because it spends a +second model call per search that the harness, not the owner, chose. grok-build sits nearer Codex; +claude-code's only readable server-tool surface is the usage counter this harness already parses. + +## Configuration declares, the dialect spells + +PRV-6 already decides the half that matters: a model owns its declared capabilities, and +**selection never uses fuzzy names or URL inference**. A harness that switches hosted search on +because a model id looks familiar has put authority in a name, which is the thing that invariant +exists to refuse. + +`allowed_reasoning_efforts` is the precedent to copy, not a new pattern to invent. The model +declares a subset, the declaration must be encodable by that model's dialect, and an invalid one +fails before any network work. A hosted-tool declaration is the same shape: + +```toml +[providers.local.models.muse] +api = "openai_responses" +id = "muse-spark-1.3" +server_tools = ["web_search"] +``` + +Config names the capability. The adapter owns its spelling, because the spelling is dialect +property and nothing else: Messages wants `{"type":"web_search_20250305","name":"web_search"}` and +Responses wants `{"type":"web_search"}` or its `web_search_preview` alias. That is the compatibility +work, and it belongs behind the declaration rather than in front of it. A tool declared on a dialect +that cannot encode it fails at config load, the way an unencodable effort already does. + +The failure mode of a declaration is known and cheap: it can be wrong. The live config claimed +`max` among muse's efforts and the route refused it. The fix was to correct the declaration, never +to add an inference that would have guessed around it. + +Rejected: gating on the model's identity, which is how every harness read here decides it. pi's +`getHostedWebSearchRoute` matches provider names and `id.startsWith("claude-")`, so every new route +and every renamed model is a code change, and a model the owner knows supports search cannot be +told so. A declaration costs one line in a file the owner already edits and answers both. + +## What this costs in Plexmaton + +The chosen shape is Codex's, narrowed by the declaration. Cost lands in the provider adapter and the +transcript, and Codex's own use sites measure the blast radius honestly: a response variant is +matched wherever the enum is already matched, so compaction, persisted state, timing and replay each +have to say what they do with it. Plexmaton's equivalent is one new semantic event, and the sites +that match its event vocabulary are the ones to count before writing any of it. + +What the declaration buys is that none of this reaches a model that did not ask for it. A route +with no hosted tools encodes nothing new and decodes nothing new, so the existing four dialects keep +their current behaviour exactly. + +## Open + +- Is strict cloaking what strips search results on the local gateway, or does the upstream never + send them? `claude_executor_cloaking.go` handles `server_tool_use` and is the place to look. +- Codex's standalone path populates `results`. Worth reading before assuming the findings are + always unavailable. + +## Limits + +Reference behaviour is read from source, not observed at run time. The gateway measurements in +[gateway evidence](./gateway-evidence.md) are live and reproducible, and they describe one route on +one machine, not Anthropic's or Meta's own service. diff --git a/.agents/spikes/provider-side-tools/gateway-evidence.md b/.agents/spikes/provider-side-tools/gateway-evidence.md new file mode 100644 index 00000000..62deba11 --- /dev/null +++ b/.agents/spikes/provider-side-tools/gateway-evidence.md @@ -0,0 +1,176 @@ +# Gateway evidence + +Measured 2026-09-20 against the local gateway, not read from documentation. Every row below was +observed; nothing here is inferred from a vendor document. + +Route under test: `cli-proxy-api` on `127.0.0.1:5233`, built and configured from +`~/dev/ai/cpa-local-stack`. Reproduce with the key in `PLEXMATON_LOCAL_API_KEY`: + +```sh +curl -s -X POST http://127.0.0.1:5233/v1/messages \ + -H "content-type: application/json" \ + -H "x-api-key: $PLEXMATON_LOCAL_API_KEY" -H "anthropic-version: 2023-06-01" \ + -d '{"model":"muse-spark-1.3","max_tokens":1024, + "tools":[{"type":"web_search_20250305","name":"web_search"}], + "messages":[{"role":"user","content":"Search: latest stable Rust release."}]}' +``` + +## The model is not what its route says + +`muse-spark-1.3` resolves to `muse-spark-1.3-contributor` at `https://api.meta.ai`, exposed through +the gateway's Claude-compatible route. It is a **Meta** model speaking the Messages protocol. The +`owned_by: anthropic` in `/v1/models` names the protocol, not the vendor, and the `msg_` ids, +`redacted_thinking` blocks and Anthropic SSE sequence are all wire format. + +A test resting on this route proves an adapter speaks Messages correctly. It proves nothing about +Anthropic's own service, and must say so rather than borrowing the vendor's name. + +## Reasoning effort + +The endpoint declares `[minimal, low, medium, high, xhigh, max]` in its own error text. The route +accepts four of them. + +| Level | `/v1/chat/completions` | `/v1/messages` | +| --- | --- | --- | +| `none`, `minimal` | 400 | 400 | +| `low`, `medium`, `high`, `xhigh` | 200 | 200 | +| `max` | 400 | 400 | + +Sending `reasoning_effort: "minimal"` to the OpenAI facade returns an error naming +`` unsupported `output_config.effort` value ``, so that facade rewrites the OpenAI field into the +Messages one. Both routes land on a single parameter, and changing wire format does not unlock a +level. + +## Hosted search + +Works, on the Messages route only. + +| Tool type | Result | +| --- | --- | +| `web_search_20250305` | accepted; `max_uses` is rejected as a field on this route | +| `web_search_20251022` | `tool type not supported` | +| `web_fetch_20250910` | `tool type not supported` | + +One tool covers both actions: `{"type":"search","query":…}` and `{"type":"open_page","url":…}`. +A single observed turn issued three searches and one page open, narrating between them. + +pi declares `max_uses: 8` for its `anthropic` and `cli-proxy-api-anthropic` routes, so that field's +availability is route-specific rather than absent from the tool. + +## Both dialects offer one search tool, and no fetch tool + +Responses on this gateway, model `gpt-5.6-luna`: + +| Tool type | Result | +| --- | --- | +| `web_search` | 200; output items `reasoning`, `web_search_call`, `message` | +| `web_search_preview` | 200; same shape, legacy alias | +| `web_fetch`, `fetch` | 400 `Unsupported tool type` | +| `code_interpreter`, `file_search` | 400 `Unsupported tool type` | + +**Fetching a page is an action inside the search tool, not a tool of its own,** and both dialects +agree on that. Responses returns `action: {"type":"search","queries":[…]}`; Codex models exactly +`Search`, `OpenPage`, `FindInPage` and a serde catch-all, so a new action needs no release. Messages +on `muse-spark-1.3` issued three `search` actions and one `open_page` in a single observed turn. + +So a harness modelling hosted search has one shape to carry, not one per vendor. Only the wrapper +differs: Responses reports the queries as a `web_search_call` output item, Messages as +`server_tool_use` blocks. Neither reports the findings. + +Those 400s are this gateway's vocabulary and do not establish what a vendor's own API offers. +Codex, which talks to OpenAI directly, likewise models no fetch tool — two independent signals +rather than one. + +Hosted search is the only server-side tool this gateway accepts on either dialect, which bounds +what a first implementation has to handle. + +## Chat Completions carries neither the tool nor the thinking + +Measured 2026-09-21 on `muse-spark-1.3` through `/v1/chat/completions`, streaming, `max_tokens` +4096, one prompt asking for the latest stable Rust release. + +| Request | Completion tokens | Answer | +| --- | --- | --- | +| no tools | 1733 | 1.98.0 | +| `{"type":"web_search_20250305","name":"web_search"}` | 2684 | 1.98.0 | +| `{"type":"web_search"}` | 2163 | 1.98.0 | +| `web_search_options: {}` | exhausted an 800 budget | empty | +| same prompt over `/v1/messages` with the hosted tool | 950 | **1.98.1** | + +1.98.1 was published after the model's knowledge, so only the Messages run searched. Over Chat +Completions every spelling returned HTTP 200, produced no `tool_calls` delta and no field beyond +`role` and `content`, and the model answered from memory after thinking longer. **A hosted-tool +declaration on this route is swallowed silently.** The wire will not report it, which is the reason +the declaration has to fail at config load rather than at run time. + +Reasoning is discarded on the same route. A one-word answer at `reasoning_effort: high` cost 257 +completion tokens, and the response carried `role` and `content` only, in both streaming and +non-streaming form: no `reasoning`, no `reasoning_content`, no `reasoning_details`, no reasoning +count in `usage`. Over Messages the same model returns `redacted_thinking` blocks and a +`thinking_tokens` count. So on this gateway Muse's reasoning is reachable through Messages as +opaque replay, and through Chat Completions not at all. + +The mechanism is in the gateway's translator, read at upstream snapshot v7.3.9. Toward Claude, +`claude_openai_request.go:349` translates only `tools[].type == "function"` and skips every other +type without an error; nothing in the gateway reads `web_search_options`. Back toward OpenAI, the +block switch in `claude_openai_response.go:146` and `:378` has branches for `tool_use` and +`thinking` and none for `server_tool_use` or `redacted_thinking`, so both fall through and are +dropped. Reasoning reaches `reasoning_content` only from `thinking_delta`, and Muse sends +`redacted_thinking`, which has `data` and no delta. So the Chat facade's two holes are one missing +request branch and one missing response branch, and the second has no OpenAI-standard shape to +fill it with: `annotations[].url_citation` needs URLs, and the route never returns any. + +## Findings are a property of the surface + +**On the Messages route the gateway forwards the queries and never the findings.** Verified on both +transports: a streaming census of one complete search turn showed 4 `text` blocks, 3 +`server_tool_use` blocks, zero `web_search_tool_result` blocks, and no `server_tool_use` counter in +`usage`. Whether strict cloaking strips them or Meta's Messages surface never sends them is +unexamined; `claude_executor_cloaking.go` handles `server_tool_use` and is where to look. + +Meta's Responses surface documents citations and, on request, the results themselves. Through the +gateway's existing translation neither arrives, while the queries and encrypted reasoning do. +[Meta route evidence](./meta-route.md) carries both the observed and the documented halves. + +## Replay has no structural blocker + +Both arms return 200 and answer a follow-up correctly. + +| Assistant turn sent back | Result | +| --- | --- | +| verbatim, including `server_tool_use` blocks | 200, follow-up correct | +| with `server_tool_use` stripped | 200 | + +This route accepts a `server_tool_use` carrying no paired result block. A stricter endpoint would be +expected to reject that, so the leniency is a property of this gateway and not something a design +may rely on. + +## Config corrected while measuring + +The live dev config claimed `max` among muse's allowed efforts. It is rejected by the route, and the +claim was removed. luna's `none` and `max`, grok's `xhigh` and deepseek's `none` were each probed +and are honest. + +Measured again 2026-09-21 through the branch binary and a direct stream: every `web_search_call` +arrived as `search` with an empty `query`; every `done` came after the last message finished, while +each `added` came at its true position, which is why the row is placed at `added`. + +## The whole turn arrives at once + +Measured 2026-09-21 on a live turn with six searches through the branch binary, from the session +journal's request timing: headers came back 0.9 s after dispatch, then nothing for 31 s; the first +event and the terminal event arrived 50 ms apart, carrying five reasoning items, six text items, six +`web_search_call` items and about 4,200 output tokens. The direct stream measured above did the same +over 19 s. Every other route on this gateway streams through the same adapter, luna's first event at +10 s of a 17 s turn and deepseek's at 1.2 s of 3 s, so the hold is not in Plexmaton. Nor is it in the +gateway, read at snapshot v7.3.11: toward a Claude-compatible upstream other than `api.anthropic.com` +it asks for `text/event-stream` with `Accept-Encoding: identity` (`applyTransportNegotiation` in +`claude_executor_request.go`), scans the body line by line and translates each event as it is read +(`claude_executor_stream.go`): text deltas at `content_block_delta`, a search's `added` at +`content_block_start`; only a search's `done` waits for `message_stop`, because this route sends no +result block. What remains is `api.meta.ai`, which on the Messages surface releases the reply when +the turn ends, at least with the hosted tool declared. Whether the same route streams without +`server_tools` is unmeasured: the journals' only other muse attempt failed at the gateway, and the +Chat Completions runs above recorded content, not arrival times. The native `meta-api-key` route in +[Meta route evidence](./meta-route.md) is the untried variable, and the one that would also restore +queries and citations. diff --git a/.agents/spikes/provider-side-tools/meta-route.md b/.agents/spikes/provider-side-tools/meta-route.md new file mode 100644 index 00000000..8d513cfd --- /dev/null +++ b/.agents/spikes/provider-side-tools/meta-route.md @@ -0,0 +1,117 @@ +# Meta route evidence + +Two kinds of fact here, kept apart. **Observed** rows were measured on this host. **Documented** +rows come from Meta's own protocol pages, read 2026-09-21, and have not yet been observed here: a +direct probe of `api.meta.ai` was refused by the session's permission classifier as credential +extraction, and the gateway has no native Meta route configured to probe through. + +## Meta's three surfaces, from its documentation + +Base URL `https://api.meta.ai/v1`. Both `muse-spark-1.3` and `muse-spark-1.3-contributor` are +served on all three. + +| Surface | Hosted search | Reasoning across turns | +| --- | --- | --- | +| Chat Completions `/chat/completions` | **none**: "Search grounding is not available on Chat Completions" | **none**: "Chat Completions does not carry reasoning across turns"; the response carries `message.content` only | +| Responses `/responses` | `tools: [{"type":"web_search"}]`, **observed** through the gateway's existing route | `include: ["reasoning.encrypted_content"]`, replayed as `reasoning` output items, `store: false`; encrypted content **observed** arriving through the gateway | +| Messages `/messages` | observed working through the gateway with `web_search_20250305` | `redacted_thinking` blocks, observed | + +So the Chat Completions facade's two holes measured in [gateway evidence](./gateway-evidence.md) +are not the gateway's translator alone: Meta's own Chat Completions has neither. The translation +layer removes what the protocol would carry, and the protocol carries neither of the two things +this stage needs. + +## What hosted search returns on Responses, documented + +A `web_search_call` output item with `id`, `type`, `status`, and the answer in a `message` whose +`output_text` carries `annotations` of type `url_citation` with `url`, `title`, `start_index`, +`end_index`. Raw results are off by default and requested with +`include: ["web_search_call.results"]`, after which the item carries `results[]` of +`{type: "text_result", title, url, snippet}`. `search_context_size` takes `low`, `medium`, `high`. +Adding the tool does not force a search. On replay a `web_search_call` may be sent with `id` null. + +This reverses the earlier reading that hosted search returns queries and never findings. That was +true of the Messages surface as observed here, and of OpenAI's inline path as Codex models it. It +is not true of Meta's Responses surface, which is the one route on this host that would return +sources a transcript could show. + +## Responses through the existing route, observed + +Measured 2026-09-21 through `127.0.0.1:5233/v1/responses` with the gateway still routing Muse via +`claude-api-key`, so every request below was translated to Messages and back. `store: false`, +`include: ["reasoning.encrypted_content"]`, `reasoning.effort: high`, `max_output_tokens: 2048`. + +| Request | Output items | Answer | +| --- | --- | --- | +| `tools: [{"type":"web_search"}]` | 5 `reasoning` with `encrypted_content`, 5 `web_search_call`, 6 `message` | **1.98.1** | +| same, plus `include: web_search_call.results` | 2 `reasoning`, 1 `web_search_call`, 2 `message` | **1.98.1** | +| no tools, 2048 budget | none; `status: incomplete`, `incomplete_details.reason: max_output_tokens` | none | +| no tools, 8192 budget | `reasoning`, `message` | 1.98.0 from memory | +| `Say: ok`, 2048 budget | `reasoning`, `message`; 16 output tokens | ok | + +So a Plexmaton Session on `openai_responses` reaches hosted search on Muse today, with no gateway +change: the tool declaration crosses the translator, `web_search_call` items come back with +`action: {"type":"search","query":…}`, and encrypted reasoning is present for replay. The empty +control run is a budget exhausted while thinking, reported as the typed incomplete state PRV-5 +already maps, not a route defect. + +What the translated route loses, each observed: `annotations` is `[]` on every text part and no +`web_search_call` carries `results`, with or without the include; two of five calls arrived as +`search` with an empty `query`, where the Messages surface had shown `open_page` actions, so the +translator flattens action types; and `output_tokens_details.reasoning_tokens` is `0` while the +Messages surface reports `thinking_tokens`. A decoder must accept an empty query rather than fail +on it. + +The live dev config now runs muse on `openai_responses`, switched 2026-09-21 and verified through +Plexmaton itself rather than curl: main's binary at `c7bdf0a`, an ephemeral conversation, muse +chosen through `/model`, two turns. "17 times 23" answered 391 in 2.2 s; "add 9 to your previous +answer" answered 400 in 1.0 s, so the first turn's output replayed through the gateway's +translation and back intact; usage reported 2.1k in, 123 out; nothing on screen read as an error. +Whether the encrypted reasoning items survived that replay is not observable from the screen and +is untested. + +## A replayed search item must not carry its id, observed + +Measured 2026-09-21 through the same route, replaying one `web_search_call` in `input` ahead of the +assistant's answer, then asking a follow-up. + +| Replayed item | Result | +| --- | --- | +| none | 200 | +| `id` + `status` + `action` with `query` and `queries` | 400, "content block `web_search_tool_result` is not supported on `assistant` messages" | +| same without `id` | **200**, answered | +| `id` present, any action shape or none | 400, same message | + +The gateway's Responses-to-Claude translator, in `convertResponsesWebSearchCallToClaudeBlocks`, +rebuilds a replayed item that carries an id as a `server_tool_use` and `web_search_tool_result` +pair on the assistant message, and the upstream refuses the result half there. Without an id it +returns nothing, so the item is dropped before Meta sees it, which is harmless: the answer text +already carries what the search found. Meta documents the id as optional on replay, and its native +route would receive the item itself. So the encoder replays the item with its status and action and +keeps the id in the sidecar only. Found by driving this branch's binary through a search turn and a follow-up: the first +answered 1.98.1, the second returned "provider returned HTTP 400" until the id was dropped. + +## The gateway already has a native Meta route + +`cli-proxy-api` declares `meta-api-key` as a provider kind. `MetaKey` is a type alias of `CodexKey`, +so it takes `api-key`, optional `base-url` (default `https://api.meta.ai/v1`), `models[]` with +`name`/`alias`, and `excluded-models`. Its executor speaks Responses to the upstream, translating +inbound Chat Completions, Messages or Responses into it and back. On the way out it deletes only +`generate`, `prompt_cache_retention`, `safety_identifier`, `stream_options` and `client_metadata`, +and strips reasoning items whose ids the upstream would not know when `store` is false. `tools`, +`include` and `store` pass through untouched. + +The stack routes Muse through `claude-api-key` instead, so today every surface except Messages is a +translation. A `meta-api-key` fragment with the same alias would make Responses the native surface +and Messages the translated one, with nothing downstream changing. That switch is the owner's to +make and is not required by this stage: it upgrades citations, results, exact action types and +reasoning counts, and changes nothing about which items a decoder has to accept. + +## Plexmaton's Responses adapter, as it stands + +The encoder already sends `store: false` and `include: ["reasoning.encrypted_content"]`, so +encrypted reasoning replay on Meta needs no change. The decoder retains any `annotations` array on +a text part as replay metadata, so `url_citation` survives a round trip already and is simply not +rendered. A `web_search_call` output item is a typed `UnsupportedEvent` on both `output_item.added` +and `output_item.done`, so a Responses turn that searched fails the step today, which is the +refusal PRV-5 records. diff --git a/.agents/standards/quality-gates.md b/.agents/standards/quality-gates.md index f4c8f752..a9bcece8 100644 --- a/.agents/standards/quality-gates.md +++ b/.agents/standards/quality-gates.md @@ -43,6 +43,7 @@ Supply-chain and corpus lanes, selected by dependency or document changes: | `python3 scripts/smoke-tree.py` | Tree aliases, rewind/edit/send, original branch and selected-head restart through a real PTY; exact loopback context and no repeated command effect | | `python3 scripts/smoke-permissions.py` | Project trust, real command execution, a remembered prefix across restart, revoke/deny and three widths in a real PTY; eight bounded local fixture requests | | `python3 scripts/smoke-delegate.py` | Delegation/mail/task update, Handoff/User input, Stop/root continuation, three-width resume, CLI process cuts, and process-dead approval followed by fresh current-policy work. Acceptance evidence; binds a local fixture port, so it needs no sandbox | +| `python3 scripts/smoke-server-tool.py` | A provider-run search placed where the provider put it, running until finished, at three widths and after reopening, against one loopback Responses burst shaped like a live gateway's; binds a local fixture port | | `PLEXMATON_WRITE_FRAMES=1 cargo test -p plexmaton-tui frames` | Rewrites the frames under `crates/plexmaton-tui/frames/`; the diff is the review | | `cargo run --release -p plexmaton-cli --bin plexmaton-measure` | What a frame costs. Reports only; its work counts are asserted by the test suite, and its timings belong to the machine that ran it ([frame-loop](../specs/frame-loop.md) FR-4) | diff --git a/.agents/ui-ux.md b/.agents/ui-ux.md index 833eb6e3..45a6c016 100644 --- a/.agents/ui-ux.md +++ b/.agents/ui-ux.md @@ -61,6 +61,7 @@ These terms are used identically in product copy, architecture, code, and tests. | Composer menu | The popup above the composer: Skills for `$`, Commands for `/`. The draft is its query | | Drawer | The workspace's own surface, pulled from the top edge by `Ctrl-P`. Its title, `Workspace`, is the addressee; it holds what outlives the process, as pages, never Commands | | Page | A view inside the Drawer: Configuration, Permissions, opened in place | +| Server tool | A tool the provider runs on its own side inside one model call, declared per model in configuration. It is reported once it has ended; nothing here queues, admits or dispatches it | An alias such as `B` or `reviewer` is a display label, never durable identity. A pane is a layout presentation, not a Conversation. A widget is a Rust rendering component, not a synonym for an entry @@ -255,8 +256,8 @@ terminals with the fewest rows. finds the one row that needs them without reading the rest. Rejected: restrained colour with hierarchy carried by spacing alone, which made every row look equally important and left nothing to navigate by. -- Agent, mail, tool, reasoning, artifact, warning, and error content are distinguishable by - colour, and carry a marker or name as well where that marker forms a scannable column. +- Agent, mail, tool, server tool, reasoning, artifact, warning, and error content are + distinguishable by colour, and carry a marker or name as well where that marker forms a scannable column. Rejected: requiring every distinction to survive without colour, which capped the design at what a colourless terminal could express. - Explicit plaintext reasoning is named and visually quiet; system text is named and muted; @@ -269,7 +270,7 @@ terminals with the fewest rows. selection treatment without erasing those roles; unknown diff text remains undecorated source. - Typeset math is the primary presentation; source is an interaction layer for inspect and copy, and a clear failure representation. -- Workspace colour is sixteen semantic roles; widgets name a role, never a terminal colour, and +- Workspace colour is seventeen semantic roles; widgets name a role, never a terminal colour, and a palette is a complete assignment of them. Colour says what a thing is and weight says what reads first; the row `Enter` acts on carries both (`Chosen`), and what stays quiet is a low-saturation hue with no weight on it. Document italic is a separate vocabulary with its own @@ -278,7 +279,7 @@ terminals with the fewest rows. - **A palette is data, and colour is two layers.** Below, twelve slots named the way a terminal names them: a ground ramp — `ground`, `line`, `muted`, `text` — and eight hues around a closed wheel — `red`, `orange`, `yellow`, `green`, `cyan`, `blue`, `purple`, `magenta`. A theme assigns - those twelve and nothing else. Above, the sixteen roles map onto slots and carry their weight; + those twelve and nothing else. Above, the seventeen roles map onto slots and carry their weight; that mapping is the product's, so no theme can make a failure read as a success. - A surface holding a conversation, or the roster of them, carries its own hue on its border: muted for the roster, blue for the user's own, cyan for a delegate's. Focus is that hue at full @@ -512,6 +513,15 @@ full of concurrent agents stays navigable: - Reasoning summary, named `reasoning` and visually quiet - Tool call: `[ ] queued`, `[~] running`, `[?] approval required`, `[+] succeeded`, `[!] failed`, `[x] denied`, or `[-] cancelled`; retained invocation and outcome disclose beneath the same row +- Server tool call: the tool row's grammar, with the tool's name after the marker and what the + route reported after the `·`. It appears `[~] running` where the provider began the call and + finishes once: the query, `opened` and the page, or the pattern and the page it was sought `in`, + and nothing further; a search reported without a query is the name alone. The marker wears the + server-tool colour while running and when the call ended well, and failure's when it did not. + What the route reported discloses beneath the row. The user chose the grammar and its colour + from rendered candidates on 2026-09-21. Rejected: a row family of its own, which gave up the + marker column and the disclosure tools already have; the plain succeeded colour, which read as + a tool run inside the fence - Diff with original `+`/`-` markers, and artifact - Agent mail, and Main-authored task updates, each entering both conversations it names and saying in a word which side its row is: `sent to`, `received from`, `assigned to`, `assigned by`, naming @@ -550,5 +560,7 @@ counts are asserted by tests; wall-clock time is only reported, beside the machi - How much tool activity remains visible in a collapsed transcript block. - Notification treatment for mail that arrives while its sender's window is open. - Whether ten rows is the right primary-conversation guarantee in real use. +- Where hosted-tool spend belongs in the cost surface: provider-side work the owner selected no + model for, counted by the route in requests rather than tokens. An answered question moves into the section that owns the answer. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c270eed..0064e2b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,7 @@ jobs: - run: python3 scripts/smoke-input-queue.py - run: python3 scripts/smoke-tree.py - run: python3 scripts/smoke-delegate.py + - run: python3 scripts/smoke-server-tool.py ci: name: macOS Apple Silicon diff --git a/README.md b/README.md index 8a7615d9..b8ae7657 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Plexmaton is App Automaton's Rust AI coding assistant for the terminal. Choose a ## Why Plexmaton? - **Keep the work in view.** Streaming answers, tool activity and approvals share a responsive workspace with expandable details. -- **Choose your models.** Use OpenAI Responses or Chat Completions, both exercised daily. The Anthropic Messages and Gemini adapters are implemented and fixture-tested, not yet run against a live endpoint. +- **Choose your models.** Use OpenAI Responses or Chat Completions, both exercised daily. The Anthropic Messages and Gemini adapters are implemented and fixture-tested, not yet run against a live endpoint. A model may declare the provider's own web search, and the transcript shows each search the provider ran, in its own colour. - **Stay in control.** Approve an action once, remember a Session or Project permission, and review or revoke it in the Drawer. - **Carry your context forward.** Saved conversations and automatic compaction preserve source history. Reusable `SKILL.md` instructions bring your workflows into the conversation. - **Read comfortably.** Pastel Markdown, [syntax-highlighted code](.agents/specs/markdown.md#code-theme), and native math with hats and Chinese labels. Reasoning, tool groups and messages stay compact; copy follows selection. @@ -37,7 +37,7 @@ mkdir -p .local/plexmaton cp examples/providers.toml .local/plexmaton/config.toml ``` -Select your provider/model in that file, set its `api_key_env` variable, then launch: +Select your provider/model in that file. Put the credential in that variable, or set `api_key` on the route, then launch: ```sh PLEXMATON_HOME="$PWD/.local/plexmaton" cargo run -p plexmaton-cli --bin plexmaton diff --git a/crates/plexmaton-agent/src/compaction.rs b/crates/plexmaton-agent/src/compaction.rs index 6d31ec39..2387c62a 100644 --- a/crates/plexmaton-agent/src/compaction.rs +++ b/crates/plexmaton-agent/src/compaction.rs @@ -514,7 +514,9 @@ fn validate_complete_output( .ok_or(CompactionPlanError::SummaryTooLarge)?; has_summary_text |= !text.trim().is_empty(); } - AssistantBlock::Reasoning { .. } | AssistantBlock::ReplayOnly { .. } => {} + AssistantBlock::Reasoning { .. } + | AssistantBlock::ReplayOnly { .. } + | AssistantBlock::ServerToolCall { .. } => {} AssistantBlock::ToolCall { .. } => unreachable!("tool calls were rejected above"), } } diff --git a/crates/plexmaton-agent/src/journal/projection/assistant.rs b/crates/plexmaton-agent/src/journal/projection/assistant.rs index 249b374c..9b50e998 100644 --- a/crates/plexmaton-agent/src/journal/projection/assistant.rs +++ b/crates/plexmaton-agent/src/journal/projection/assistant.rs @@ -68,6 +68,16 @@ impl Projector { ); calls.push(call_id); } + // The record holds only the finished call, so here it is the whole entry: the + // same row the live step finished at revision one, at revision zero (ENT-2). + AssistantBlock::ServerToolCall { item_id, call } => { + self.emit(ConversationEvent::ServerToolCalled { + agent_id: agent_id.clone(), + item_id: item_id.clone(), + item_revision: 0, + call: call.clone(), + })?; + } AssistantBlock::Text { .. } | AssistantBlock::Reasoning { .. } | AssistantBlock::ReplayOnly { .. } => {} diff --git a/crates/plexmaton-agent/src/journal/projection/live_tests.rs b/crates/plexmaton-agent/src/journal/projection/live_tests.rs index 96851484..10adfa9c 100644 --- a/crates/plexmaton-agent/src/journal/projection/live_tests.rs +++ b/crates/plexmaton-agent/src/journal/projection/live_tests.rs @@ -40,3 +40,158 @@ fn jrn_5_canonical_live_turn_and_journal_replay_have_equal_model_context() { assert_eq!(projection.request().atoms, live.record()); } + +/// ENT-2: live, a call the provider ran appears running where the provider placed it and finishes +/// at the next revision; the journal holds only the finished call, so replay says the same finished +/// row at revision zero, and a reopened conversation shows what the live one ended with. +#[test] +fn ent_3_a_server_tool_call_is_the_same_event_live_and_on_replay() { + use plexmaton_core::{ + ConversationEvent, ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus, + }; + let agent_id = agent(); + let mut live = Agent::new(agent_id.clone()); + let _announcement = live.announce("Plexmaton"); + let submitted = live.handle(Input::Submitted { + text: "latest rust?".to_owned(), + }); + let step_id = match submitted.effects.as_slice() { + [Effect::CallModel(call)] => call.step_id.clone(), + other => panic!("expected one model call, got {other:?}"), + }; + let call = ServerToolCall { + tool: ServerTool::WebSearch, + action: ServerToolAction::Search { + queries: vec!["latest stable Rust release".to_owned()], + }, + status: ServerToolStatus::Failed, + }; + let started = live.handle(Input::Streamed { + step_id: step_id.clone(), + event: ModelEvent::ServerToolStarted { + position: ModelOutputPosition::new(0, 0), + tool: ServerTool::WebSearch, + }, + }); + assert!( + matches!( + started.events.as_slice(), + [envelope] if matches!( + &envelope.event, + ConversationEvent::ServerToolStarted { tool: ServerTool::WebSearch, .. } + ) + ), + "{started:?}" + ); + let searched = live.handle(Input::Streamed { + step_id: step_id.clone(), + event: ModelEvent::ServerToolCall { + position: ModelOutputPosition::new(0, 0), + call: call.clone(), + }, + }); + let _answer = live.handle(Input::Streamed { + step_id: step_id.clone(), + event: ModelEvent::TextDelta { + position: ModelOutputPosition::new(0, 1), + delta: "1.98.1".to_owned(), + }, + }); + let _stopped = live.handle(Input::Streamed { + step_id, + event: ModelEvent::Stopped(StopReason::EndOfTurn), + }); + let live_events: Vec<_> = searched + .events + .iter() + .map(|envelope| envelope.event.clone()) + .filter(|event| matches!(event, ConversationEvent::ServerToolCalled { .. })) + .collect(); + let [ + ConversationEvent::ServerToolCalled { + agent_id: live_agent, + item_id: live_item, + item_revision: 1, + call: live_call, + }, + ] = live_events.as_slice() + else { + panic!("{live_events:?}"); + }; + assert_eq!((live_agent, live_call), (&agent_id, &call)); + + let projection = live + .journal() + .project(&head("main")) + .unwrap_or_else(|error| panic!("project live journal: {error:?}")); + let replayed: Vec<_> = projection + .events() + .iter() + .map(|envelope| envelope.event.clone()) + .filter(|event| matches!(event, ConversationEvent::ServerToolCalled { .. })) + .collect(); + assert!( + matches!( + replayed.as_slice(), + [ConversationEvent::ServerToolCalled { agent_id, item_id, item_revision: 0, call: replayed_call }] + if agent_id == live_agent && item_id == live_item && replayed_call == live_call + ), + "{replayed:?}" + ); +} + +/// ENT-2/PRV-5: a call still running when the step ends did not finish. The row says so, and the +/// record keeps no block for it, because there is nothing to replay. +#[test] +fn a_call_still_running_when_the_step_ends_is_shown_failed_and_kept_out_of_the_record() { + use plexmaton_core::{ConversationEvent, ServerTool, ServerToolAction, ServerToolStatus}; + let agent_id = agent(); + let mut live = Agent::new(agent_id.clone()); + let _announcement = live.announce("Plexmaton"); + let submitted = live.handle(Input::Submitted { + text: "latest rust?".to_owned(), + }); + let step_id = match submitted.effects.as_slice() { + [Effect::CallModel(call)] => call.step_id.clone(), + other => panic!("expected one model call, got {other:?}"), + }; + let _started = live.handle(Input::Streamed { + step_id: step_id.clone(), + event: ModelEvent::ServerToolStarted { + position: ModelOutputPosition::new(0, 0), + tool: ServerTool::WebSearch, + }, + }); + let _answer = live.handle(Input::Streamed { + step_id: step_id.clone(), + event: ModelEvent::TextDelta { + position: ModelOutputPosition::new(0, 1), + delta: "1.98.1".to_owned(), + }, + }); + let stopped = live.handle(Input::Streamed { + step_id, + event: ModelEvent::Stopped(StopReason::EndOfTurn), + }); + assert!( + stopped.events.iter().any(|envelope| matches!( + &envelope.event, + ConversationEvent::ServerToolCalled { item_revision: 1, call, .. } + if call.status == ServerToolStatus::Failed + && call.action == ServerToolAction::Search { queries: Vec::new() } + )), + "{stopped:?}" + ); + let projection = live + .journal() + .project(&head("main")) + .unwrap_or_else(|error| panic!("project live journal: {error:?}")); + assert!( + projection.events().iter().all(|envelope| !matches!( + envelope.event, + ConversationEvent::ServerToolStarted { .. } + | ConversationEvent::ServerToolCalled { .. } + )), + "the record holds no call that never finished" + ); +} diff --git a/crates/plexmaton-agent/src/journal/tree_source.rs b/crates/plexmaton-agent/src/journal/tree_source.rs index 519d548d..cb8434d3 100644 --- a/crates/plexmaton-agent/src/journal/tree_source.rs +++ b/crates/plexmaton-agent/src/journal/tree_source.rs @@ -137,7 +137,7 @@ impl ConversationJournal { } } } - AssistantBlock::ReplayOnly { .. } => {} + AssistantBlock::ReplayOnly { .. } | AssistantBlock::ServerToolCall { .. } => {} } } Ok(()) diff --git a/crates/plexmaton-agent/src/model.rs b/crates/plexmaton-agent/src/model.rs index d1d77641..ba87dc6d 100644 --- a/crates/plexmaton-agent/src/model.rs +++ b/crates/plexmaton-agent/src/model.rs @@ -6,6 +6,7 @@ //! adapter-side. A shared event type carrying one dialect's concerns leaves every other adapter //! fabricating fields it does not have. +use plexmaton_core::{ServerTool, ServerToolCall}; use plexmaton_core::{TokenUsage, TurnId}; use serde::{Deserialize, Serialize}; @@ -120,6 +121,22 @@ pub enum ModelEvent { position: ModelOutputPosition, call: ToolCall, }, + /// The provider ran one of the model's declared server tools and reported what it did. + /// + /// Arrives complete, after the action finished. It asks this harness to run nothing, so it + /// reaches no admission and no scheduler: the call already happened inside the model call the + /// owner authorised by configuring the route (PRV-6). + ServerToolCall { + position: ModelOutputPosition, + call: ServerToolCall, + }, + /// The provider began a call on its own side at this position; the finished call follows at + /// the same position as [`Self::ServerToolCall`]. Emitted so the row appears where the provider + /// placed the call rather than where a route chose to finish it. + ServerToolStarted { + position: ModelOutputPosition, + tool: ServerTool, + }, /// Provider-reported token consumption retained by the runtime in an attempt terminal. /// Direct delivery through `Input::Streamed` is refused (TIM-3). Usage(TokenUsage), diff --git a/crates/plexmaton-agent/src/model/context.rs b/crates/plexmaton-agent/src/model/context.rs index 77463b0b..9a89e1ec 100644 --- a/crates/plexmaton-agent/src/model/context.rs +++ b/crates/plexmaton-agent/src/model/context.rs @@ -1,3 +1,4 @@ +use plexmaton_core::ServerToolCall; use std::collections::BTreeSet; use plexmaton_core::{ConversationEntryId, ToolCallId, TranscriptItemId}; @@ -72,6 +73,12 @@ pub enum AssistantBlock { item_id: TranscriptItemId, call: ToolCall, }, + /// A call the provider ran on its own side and reported. Never dispatched here; recorded so + /// the transcript can say what was searched and the next request can replay it. + ServerToolCall { + item_id: TranscriptItemId, + call: ServerToolCall, + }, } impl AssistantBlock { @@ -81,7 +88,8 @@ impl AssistantBlock { Self::Text { item_id, .. } | Self::Reasoning { item_id, .. } | Self::ReplayOnly { item_id } - | Self::ToolCall { item_id, .. } => item_id, + | Self::ToolCall { item_id, .. } + | Self::ServerToolCall { item_id, .. } => item_id, } } @@ -89,7 +97,10 @@ impl AssistantBlock { pub const fn tool_call(&self) -> Option<&ToolCall> { match self { Self::ToolCall { call, .. } => Some(call), - Self::Text { .. } | Self::Reasoning { .. } | Self::ReplayOnly { .. } => None, + Self::Text { .. } + | Self::Reasoning { .. } + | Self::ReplayOnly { .. } + | Self::ServerToolCall { .. } => None, } } } @@ -283,6 +294,19 @@ impl AssistantOutput { return Err(ContextError::ToolArgumentsTooLarge); } } + if let AssistantBlock::ServerToolCall { call, .. } = block { + // Provider-authored text, bounded the way tool arguments are and counted with them. + let bytes = call.action.text_bytes(); + if bytes > crate::MAX_REQUESTED_TOOL_ARGUMENT_BYTES { + return Err(ContextError::ToolArgumentsTooLarge); + } + tool_argument_bytes = tool_argument_bytes + .checked_add(bytes) + .ok_or(ContextError::ToolArgumentsTooLarge)?; + if tool_argument_bytes > MAX_ASSISTANT_TOOL_ARGUMENT_BYTES { + return Err(ContextError::ToolArgumentsTooLarge); + } + } } if let Some(replay) = &replay { for attachment in replay.attachments() { diff --git a/crates/plexmaton-agent/src/model/context/tests.rs b/crates/plexmaton-agent/src/model/context/tests.rs index 05e8d66f..eef6e26d 100644 --- a/crates/plexmaton-agent/src/model/context/tests.rs +++ b/crates/plexmaton-agent/src/model/context/tests.rs @@ -250,3 +250,45 @@ fn tool_batch_rechecks_persisted_result_bounds() { } } } + +/// A server-tool call's provider-authored text is bounded like tool arguments and counted with them. +#[test] +fn server_tool_calls_share_the_tool_argument_bound() { + use plexmaton_core::{ServerTool, ServerToolAction, ServerToolCall}; + let search = |name: &str, bytes: usize| AssistantBlock::ServerToolCall { + item_id: item(name), + call: ServerToolCall { + tool: ServerTool::WebSearch, + action: ServerToolAction::Search { + queries: vec!["q".repeat(bytes)], + }, + status: plexmaton_core::ServerToolStatus::Completed, + }, + }; + assert!( + AssistantOutput::new( + vec![search("at-bound", crate::MAX_REQUESTED_TOOL_ARGUMENT_BYTES)], + None + ) + .is_ok() + ); + assert_eq!( + AssistantOutput::new( + vec![search( + "over-bound", + crate::MAX_REQUESTED_TOOL_ARGUMENT_BYTES + 1 + )], + None + ), + Err(ContextError::ToolArgumentsTooLarge) + ); + let each = 60 * 1024; + let blocks: Vec<_> = (0..9) + .map(|index| search(&format!("aggregate-{index}"), each)) + .collect(); + assert!(each * 9 > MAX_ASSISTANT_TOOL_ARGUMENT_BYTES); + assert_eq!( + AssistantOutput::new(blocks, None), + Err(ContextError::ToolArgumentsTooLarge) + ); +} diff --git a/crates/plexmaton-agent/src/step.rs b/crates/plexmaton-agent/src/step.rs index 4d9b53c3..3bcf73d0 100644 --- a/crates/plexmaton-agent/src/step.rs +++ b/crates/plexmaton-agent/src/step.rs @@ -1,5 +1,6 @@ //! One model step's ordered, bounded output assembly. +use plexmaton_core::{ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus}; use std::collections::{BTreeMap, btree_map::Entry}; use plexmaton_core::{ConversationEvent, TranscriptItemId, TranscriptRole, TurnId}; @@ -37,6 +38,14 @@ enum PendingOutput { item_id: TranscriptItemId, call: ToolCall, }, + ServerToolCall { + item_id: TranscriptItemId, + call: ServerToolCall, + }, + ServerToolStarted { + item_id: TranscriptItemId, + tool: ServerTool, + }, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -248,6 +257,113 @@ impl Step { } } + /// Places a call the provider has begun. The row appears running where the provider put the + /// call, so a route that finishes every call at the end of the stream cannot move it there; + /// [`Self::note_server_tool`] finishes it at the next revision. + pub(crate) fn note_server_tool_started( + &mut self, + record: &mut Record, + reaction: &mut Reaction, + position: ModelOutputPosition, + tool: ServerTool, + ) -> Result<(), StepAssemblyError> { + self.reserve_output_position(position)?; + let item_id = Record::stream_item_id( + &self.turn_id, + self.index, + TranscriptRole::Assistant, + position, + ); + let pending = PendingOutput::ServerToolStarted { + item_id: item_id.clone(), + tool, + }; + match self.outputs.entry(position) { + Entry::Vacant(entry) => { + entry.insert(pending); + } + Entry::Occupied(mut entry) + if matches!(entry.get(), PendingOutput::ReplayOnly { .. }) => + { + entry.insert(pending); + } + Entry::Occupied(_) => return Err(StepAssemblyError::ConflictingPosition), + } + record.emit( + reaction, + ConversationEvent::ServerToolStarted { + agent_id: record.agent_id().clone(), + item_id, + tool, + }, + ); + Ok(()) + } + + /// Records a call the provider already ran. It takes no call identity of ours and joins no + /// batch: nothing here will be dispatched, so nothing here can be reused or left undone. The + /// workspace hears of it now, finishing the running row when one was placed and otherwise as + /// the whole entry, which is how replay will say it. + pub(crate) fn note_server_tool( + &mut self, + record: &mut Record, + reaction: &mut Reaction, + position: ModelOutputPosition, + call: ServerToolCall, + ) -> Result<(), StepAssemblyError> { + let bytes = call.action.text_bytes(); + if bytes > crate::MAX_REQUESTED_TOOL_ARGUMENT_BYTES { + return Err(StepAssemblyError::ToolArgumentsTooLarge); + } + let tool_argument_bytes = self + .tool_argument_bytes + .checked_add(bytes) + .filter(|&total| total <= MAX_ASSISTANT_TOOL_ARGUMENT_BYTES) + .ok_or(StepAssemblyError::ToolArgumentsTooLarge)?; + self.reserve_output_position(position)?; + let item_id = Record::stream_item_id( + &self.turn_id, + self.index, + TranscriptRole::Assistant, + position, + ); + let pending = PendingOutput::ServerToolCall { + item_id: item_id.clone(), + call: call.clone(), + }; + let item_revision = match self.outputs.entry(position) { + Entry::Vacant(entry) => { + entry.insert(pending); + 0 + } + Entry::Occupied(mut entry) => match entry.get() { + // The adapter's replay for the same item may have landed first and reserved the + // position as replay-only; the call it belongs to takes the position over. + PendingOutput::ReplayOnly { .. } => { + entry.insert(pending); + 0 + } + // The call was placed when it began; this finishes that row. + PendingOutput::ServerToolStarted { tool, .. } if *tool == call.tool => { + entry.insert(pending); + 1 + } + _ => return Err(StepAssemblyError::ConflictingPosition), + }, + }; + self.tool_argument_bytes = tool_argument_bytes; + record.emit( + reaction, + ConversationEvent::ServerToolCalled { + agent_id: record.agent_id().clone(), + item_id, + item_revision, + call, + }, + ); + Ok(()) + } + pub(crate) fn contains_call(&self, call_id: &plexmaton_core::ToolCallId) -> bool { self.outputs.values().any(|output| { matches!(output, PendingOutput::ToolCall { call, .. } if &call.call_id == call_id) @@ -335,6 +451,30 @@ impl Step { PendingOutput::ToolCall { item_id, call } => { AssistantBlock::ToolCall { item_id, call } } + PendingOutput::ServerToolCall { item_id, call } => { + AssistantBlock::ServerToolCall { item_id, call } + } + // The step ended with the call still running, so it did not finish: the row says + // so, and the record keeps no block, because there is nothing to replay. The tool + // is web search, so a search it is; the route reported no query. + PendingOutput::ServerToolStarted { item_id, tool } => { + record.emit( + reaction, + ConversationEvent::ServerToolCalled { + agent_id: record.agent_id().clone(), + item_id, + item_revision: 1, + call: ServerToolCall { + tool, + action: ServerToolAction::Search { + queries: Vec::new(), + }, + status: ServerToolStatus::Failed, + }, + }, + ); + continue; + } }; let block_index = u16::try_from(blocks.len()) .unwrap_or_else(|_| unreachable!("provider output block count is bounded")); diff --git a/crates/plexmaton-agent/src/turn/model_input.rs b/crates/plexmaton-agent/src/turn/model_input.rs index 96a41cc6..cd1e3bfe 100644 --- a/crates/plexmaton-agent/src/turn/model_input.rs +++ b/crates/plexmaton-agent/src/turn/model_input.rs @@ -105,6 +105,38 @@ impl Agent { ); } } + ModelEvent::ServerToolStarted { position, tool } => { + let result = match &mut self.turn { + Turn::Streaming { step, .. } => { + step.note_server_tool_started(&mut self.record, reaction, position, tool) + } + Turn::Idle | Turn::Working { .. } => return, + }; + if let Err(error) = result { + self.fail( + &crate::ModelError::Malformed { + message: error.message().to_owned(), + }, + reaction, + ); + } + } + ModelEvent::ServerToolCall { position, call } => { + let result = match &mut self.turn { + Turn::Streaming { step, .. } => { + step.note_server_tool(&mut self.record, reaction, position, call) + } + Turn::Idle | Turn::Working { .. } => return, + }; + if let Err(error) = result { + self.fail( + &crate::ModelError::Malformed { + message: error.message().to_owned(), + }, + reaction, + ); + } + } ModelEvent::Usage(_) => { if let Some(step_id) = self.active_model_step() { reaction.undelivered_model.push(UndeliveredModelInput { diff --git a/crates/plexmaton-cli/src/collaboration.rs b/crates/plexmaton-cli/src/collaboration.rs index 408f15fe..ccd31ae4 100644 --- a/crates/plexmaton-cli/src/collaboration.rs +++ b/crates/plexmaton-cli/src/collaboration.rs @@ -492,6 +492,8 @@ fn item_of(event: &ConversationEvent) -> Option { | ConversationEvent::TranscriptDelta { item_id, .. } | ConversationEvent::TranscriptItemFinalized { item_id, .. } | ConversationEvent::ToolCallChanged { item_id, .. } + | ConversationEvent::ServerToolStarted { item_id, .. } + | ConversationEvent::ServerToolCalled { item_id, .. } | ConversationEvent::TaskAssigned { item_id, .. } | ConversationEvent::MailDelivered { item_id, .. } | ConversationEvent::HandoffCompleted { item_id, .. } @@ -518,6 +520,8 @@ const fn forwarded(event: &ConversationEvent) -> bool { | ConversationEvent::TranscriptDelta { .. } | ConversationEvent::TranscriptItemFinalized { .. } | ConversationEvent::ToolCallChanged { .. } + | ConversationEvent::ServerToolStarted { .. } + | ConversationEvent::ServerToolCalled { .. } | ConversationEvent::AttentionRequested { .. } | ConversationEvent::AttentionResolved { .. } | ConversationEvent::ArtifactAnnounced { .. } diff --git a/crates/plexmaton-cli/src/interaction/model.rs b/crates/plexmaton-cli/src/interaction/model.rs index 2e07c45d..b263116a 100644 --- a/crates/plexmaton-cli/src/interaction/model.rs +++ b/crates/plexmaton-cli/src/interaction/model.rs @@ -6,7 +6,6 @@ //! admission. use crate::session_picker::ConversationPicker; -use plexmaton_provider::resolve_api_key; use plexmaton_runtime::{LiveRuntime, ModelReplacement}; use plexmaton_tui::{ModelChange, Workspace}; @@ -63,7 +62,9 @@ fn apply_model( .models() .model(&change.identity.provider, &change.identity.model) .ok_or_else(|| "This configured model is no longer available.".to_owned())?; - let key = resolve_api_key(model, std::env::var_os(model.api_key_env())) + let key = picker + .models() + .api_key_for(model, std::env::var_os(model.api_key_env())) .map_err(|_| "The selected provider credential is missing or invalid.".to_owned())?; runtime .set_model(&change.agent, model.clone(), key) diff --git a/crates/plexmaton-cli/src/main.rs b/crates/plexmaton-cli/src/main.rs index c8f9f392..4409c452 100644 --- a/crates/plexmaton-cli/src/main.rs +++ b/crates/plexmaton-cli/src/main.rs @@ -10,6 +10,7 @@ use crossterm::event::EventStream; #[cfg(test)] use plexmaton_agent::Input; use plexmaton_core::AgentId; +#[cfg(test)] use plexmaton_provider::resolve_api_key; use plexmaton_runtime::{ConversationRecovery, DispatchReport, LiveRuntime, NativeToolCatalog}; use plexmaton_tui::{ConfigurationSummary, Palette, Workspace}; diff --git a/crates/plexmaton-cli/src/preparation/wire.rs b/crates/plexmaton-cli/src/preparation/wire.rs index a0a005ef..2a594d8d 100644 --- a/crates/plexmaton-cli/src/preparation/wire.rs +++ b/crates/plexmaton-cli/src/preparation/wire.rs @@ -459,3 +459,99 @@ mod tests { )); } } + +#[cfg(test)] +mod server_tool_tests { + use super::*; + use plexmaton_core::{ + AgentId, AgentStatus, ConversationEvent, ConversationEventEnvelope, EventSequence, + ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus, TranscriptItemId, + }; + use plexmaton_tui::Workspace; + use ratatui::{Terminal, backend::TestBackend}; + + fn screen(terminal: &Terminal) -> String { + let buffer = terminal.backend().buffer(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") + } + + /// ENT-2/PRE-1: a running and a finished server tool row survive the same-build wire the + /// live workspace prepares through, and paint once the reply is admitted. + #[test] + fn server_tool_rows_prepare_through_the_wire_and_paint() { + let mut workspace = Workspace::default(); + let agent = AgentId::new("primary").expect("agent"); + let mut sequence = 0; + let mut emit = |workspace: &mut Workspace, event| { + sequence += 1; + workspace.emit(vec![ConversationEventEnvelope { + sequence: EventSequence::new(sequence), + event, + }]); + }; + emit( + &mut workspace, + ConversationEvent::AgentCreated { + agent_id: agent.clone(), + label: "Plexmaton".into(), + status: AgentStatus::Running, + }, + ); + emit( + &mut workspace, + ConversationEvent::ServerToolCalled { + agent_id: agent.clone(), + item_id: TranscriptItemId::new("done").expect("item"), + item_revision: 0, + call: ServerToolCall { + tool: ServerTool::WebSearch, + action: ServerToolAction::Search { + queries: vec!["latest stable Rust release".into()], + }, + status: ServerToolStatus::Completed, + }, + }, + ); + emit( + &mut workspace, + ConversationEvent::ServerToolStarted { + agent_id: agent, + item_id: TranscriptItemId::new("running").expect("item"), + tool: ServerTool::WebSearch, + }, + ); + let mut terminal = Terminal::new(TestBackend::new(88, 24)).expect("terminal"); + for round in 0..8 { + workspace.draw(&mut terminal).expect("draw"); + let Some(work) = workspace.take_preparation() else { + if !workspace.needs_draw() { + break; + } + continue; + }; + let pending = Pending::new(Ticket(round), work.requests).expect("admitted"); + let bytes = prepare(&pending.bytes).expect("child prepared"); + let reply = pending.decode_reply(&bytes).expect("reply matches request"); + let results = reply.result.expect("prepared"); + assert!( + workspace.complete_preparation(work.token, results), + "round {round}: the reply was not adopted" + ); + } + workspace.draw(&mut terminal).expect("settled draw"); + let text = screen(&terminal); + assert!( + text.contains("[+] web_search · latest stable Rust release"), + "{text}" + ); + assert!(text.contains("[~] web_search · running"), "{text}"); + assert!(!text.contains("Preparing text"), "{text}"); + } +} diff --git a/crates/plexmaton-cli/src/session_picker.rs b/crates/plexmaton-cli/src/session_picker.rs index ab3fc293..dd9f8359 100644 --- a/crates/plexmaton-cli/src/session_picker.rs +++ b/crates/plexmaton-cli/src/session_picker.rs @@ -408,7 +408,9 @@ impl Launcher { cancel: JobCancellation, ) -> anyhow::Result { anyhow::ensure!(!cancel.is_cancelled(), "session load cancelled"); - let key = resolve_api_key(&self.model, std::env::var_os(self.model.api_key_env()))?; + let key = self + .models + .api_key_for(&self.model, std::env::var_os(self.model.api_key_env()))?; self.open_with_key(selection, agent, cancel, key).await } diff --git a/crates/plexmaton-cli/src/startup.rs b/crates/plexmaton-cli/src/startup.rs index 04d57726..1f8d5af3 100644 --- a/crates/plexmaton-cli/src/startup.rs +++ b/crates/plexmaton-cli/src/startup.rs @@ -4,7 +4,7 @@ use std::{ffi::OsString, fs, path::PathBuf}; use anyhow::Context as _; use plexmaton_core::AgentId; -use plexmaton_provider::{resolve_api_key, resolve_home}; +use plexmaton_provider::resolve_home; use plexmaton_runtime::NativeToolCatalog; use crate::{ @@ -47,7 +47,9 @@ pub(super) async fn live_runtime_from_process( &plexmaton_file_tools::FileCancellation::new(), ) .context("load AGENTS.md instructions")?; - let key = resolve_api_key(&model, std::env::var_os(model.api_key_env())) + let key = config + .models + .api_key_for(&model, std::env::var_os(model.api_key_env())) .context("resolve provider API key")?; let ripgrep = resolve_path_executable("rg", std::env::var_os("PATH").as_deref())?; let driver = std::env::current_exe() diff --git a/crates/plexmaton-cli/src/statusline/snapshot/context.rs b/crates/plexmaton-cli/src/statusline/snapshot/context.rs index ac471499..adcb5c80 100644 --- a/crates/plexmaton-cli/src/statusline/snapshot/context.rs +++ b/crates/plexmaton-cli/src/statusline/snapshot/context.rs @@ -67,6 +67,7 @@ impl Context { Err(ContextBudgetError::Encoding(error)) => match error { EncodeError::MissingThinkingSignature | EncodeError::OpaqueReplayInChat + | EncodeError::ServerToolCallInChat | EncodeError::UnrepresentableChatOrder => Reason::HistoryIncompatible, EncodeError::UnresolvedCollaboration | EncodeError::InvalidToolArguments diff --git a/crates/plexmaton-cli/src/statusline/snapshot/tests.rs b/crates/plexmaton-cli/src/statusline/snapshot/tests.rs index 6e8e8c2c..b5e2d40e 100644 --- a/crates/plexmaton-cli/src/statusline/snapshot/tests.rs +++ b/crates/plexmaton-cli/src/statusline/snapshot/tests.rs @@ -307,6 +307,7 @@ fn status_context_refusals_are_typed_and_content_free() { use plexmaton_provider::{ContextBudgetError, EncodeError}; for (error, reason) in [ (EncodeError::OpaqueReplayInChat, "history_incompatible"), + (EncodeError::ServerToolCallInChat, "history_incompatible"), ( EncodeError::MissingThinkingSignature, "history_incompatible", diff --git a/crates/plexmaton-core/src/lib.rs b/crates/plexmaton-core/src/lib.rs index d3d299d2..009975c2 100644 --- a/crates/plexmaton-core/src/lib.rs +++ b/crates/plexmaton-core/src/lib.rs @@ -15,6 +15,7 @@ use thiserror::Error; mod conversation_tree; mod permissions; mod reasoning; +mod server_tool; mod transcript; mod tree_edit; mod tree_snapshot; @@ -39,6 +40,7 @@ pub use tree_snapshot::{ pub use tree_source::{MAX_TREE_SOURCE_BYTES, TreeSourceError, TreeSourceRequest}; pub use reasoning::ReasoningEffort; +pub use server_tool::{ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus}; pub use transcript::{ CommandInvocation, ToolCallStatus, ToolDetail, ToolPresentation, TranscriptRole, }; @@ -60,6 +62,8 @@ impl ConversationEvent { | Self::TranscriptDelta { agent_id, .. } | Self::TranscriptItemFinalized { agent_id, .. } | Self::ToolCallChanged { agent_id, .. } + | Self::ServerToolStarted { agent_id, .. } + | Self::ServerToolCalled { agent_id, .. } | Self::AttentionRequested { agent_id, .. } | Self::AttentionResolved { agent_id, .. } | Self::TaskAssigned { agent_id, .. } @@ -374,6 +378,33 @@ pub enum ConversationEvent { /// Bounded semantic detail used by open and copy presentations. presentation: ToolPresentation, }, + /// The provider began running a tool on its own side inside one model call. + /// + /// Nothing here was queued, admitted or dispatched: the row appears running where the provider + /// placed the call, and [`Self::ServerToolCalled`] finishes it at the next revision. + ServerToolStarted { + /// Agent whose model call the provider is running the tool inside. + agent_id: AgentId, + /// Transcript position assigned when the call was first reported. + item_id: TranscriptItemId, + /// The tool the provider began running. + tool: ServerTool, + }, + /// A tool the provider ran on its own side ended, and this is what it did. + /// + /// At revision one it finishes the entry [`Self::ServerToolStarted`] opened; at revision zero + /// it is the whole entry, which is how a reopened conversation, holding only the finished + /// call, shows it. It says what the route reported and claims nothing further. + ServerToolCalled { + /// Agent whose model call the provider ran the tool inside. + agent_id: AgentId, + /// Transcript position the call holds. + item_id: TranscriptItemId, + /// Zero for a first appearance, one when it finishes a call that appeared running. + item_revision: u64, + /// The tool, what it did with it, and how it ended. + call: ServerToolCall, + }, /// A background agent needs a user decision. /// /// Delivery must never move focus or open a modal surface; the item joins the Attention queue. @@ -488,15 +519,20 @@ pub struct ConversationEventEnvelope { mod tests { use super::{ AgentId, AgentStatus, ApprovalDecision, ApprovalId, AttentionId, AttentionRequest, - ConversationEvent, ConversationEventEnvelope, EventSequence, IdError, MailId, TokenCounts, - TokenUsage, ToolCallId, ToolCallStatus, ToolCapability, ToolDetail, ToolPresentation, - TranscriptItemId, TranscriptRole, TurnId, + ConversationEvent, ConversationEventEnvelope, EventSequence, IdError, MailId, ServerTool, + ServerToolAction, ServerToolCall, ServerToolStatus, TokenCounts, TokenUsage, ToolCallId, + ToolCallStatus, ToolCapability, ToolDetail, ToolPresentation, TranscriptItemId, + TranscriptRole, TurnId, }; fn agent(value: &str) -> AgentId { AgentId::new(value).unwrap_or_else(|error| panic!("invalid fixture: {error}")) } + fn item_id(value: &str) -> TranscriptItemId { + TranscriptItemId::new(value).unwrap_or_else(|error| panic!("invalid fixture: {error}")) + } + #[test] fn stable_id_rejects_whitespace() { assert_eq!( @@ -589,6 +625,24 @@ mod tests { }), }, }, + ConversationEvent::ServerToolStarted { + agent_id: agent("agent-a"), + item_id: item_id("item-search-1"), + tool: ServerTool::WebSearch, + }, + ConversationEvent::ServerToolCalled { + agent_id: agent("agent-a"), + item_id: item_id("item-search-1"), + item_revision: 1, + call: ServerToolCall { + tool: ServerTool::WebSearch, + action: ServerToolAction::FindInPage { + url: "https://example.test/δ".into(), + pattern: "汉字".into(), + }, + status: ServerToolStatus::Failed, + }, + }, ConversationEvent::AttentionRequested { agent_id: agent("agent-b"), attention_id: AttentionId::new("attention-1") diff --git a/crates/plexmaton-core/src/server_tool.rs b/crates/plexmaton-core/src/server_tool.rs new file mode 100644 index 00000000..3c105f91 --- /dev/null +++ b/crates/plexmaton-core/src/server_tool.rs @@ -0,0 +1,146 @@ +//! Tools a provider runs on its own side, inside the model call the owner already authorised. + +use serde::{Deserialize, Serialize}; + +/// One capability a model's route may run for it, named by what it does rather than by any +/// dialect's spelling. +/// +/// Configuration declares a subset per model and the selected dialect spells each one (PRV-6). +/// Nothing infers a declaration from a provider or model name: that would put authority in a +/// name, which is what PRV-6 exists to refuse. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ServerTool { + /// Search the public web, and open or search within a page it found. + WebSearch, +} + +impl ServerTool { + /// The one spelling this tool has: what configuration declares and the transcript shows. + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::WebSearch => "web_search", + } + } +} + +/// How a server tool call ended, as the provider reported it. +/// +/// A call reaches the record only after it has ended: the provider ran it inside the model call +/// and reports the result, so there is no queued or running state here for anything to project. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ServerToolStatus { + /// The provider finished the call and the model went on with what it found. + Completed, + /// The provider gave the call up and the model went on without its result. + Failed, +} + +/// What a server tool did, as the provider reported it. +/// +/// Findings are not here: a route returns them or it does not, and a transcript shows what +/// arrived. A search may carry no query, because one route flattens an opened page into a search +/// with an empty one, and an action that arrived is carried rather than refused (PRV-5). +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum ServerToolAction { + /// A web search. Routes spell the query once or as a list; the record keeps each once. + Search { + /// The queries the provider searched for, in the order reported; may be empty. + queries: Vec, + }, + /// The provider opened a page it had found. + OpenPage { + /// The page the provider opened. + url: String, + }, + /// The provider searched within a page it had opened. + FindInPage { + /// The page the provider searched within. + url: String, + /// The text it looked for. + pattern: String, + }, +} + +impl ServerToolAction { + /// Provider-authored text this action carries, bounded by the record the way tool arguments + /// are: a query or a URL is the provider's to make long and ours to keep bounded. + #[must_use] + pub fn text_bytes(&self) -> usize { + match self { + Self::Search { queries } => queries + .iter() + .fold(0_usize, |total, query| total.saturating_add(query.len())), + Self::OpenPage { url } => url.len(), + Self::FindInPage { url, pattern } => url.len().saturating_add(pattern.len()), + } + } +} + +/// One call the provider made on its own side and finished before reporting it. +/// +/// It is never a request for this harness to run anything, so it reaches no admission and no +/// scheduler: the call already happened inside the model call the owner authorised by +/// configuring the route. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ServerToolCall { + /// The declared capability the provider used. + pub tool: ServerTool, + /// What it did with it. + pub action: ServerToolAction, + /// How it ended. + pub status: ServerToolStatus, +} + +#[cfg(test)] +mod tests { + use super::{ServerTool, ServerToolAction}; + + /// PRV-6: the name configuration declares is the name the record and the screen use. + #[test] + fn the_tool_name_is_its_configuration_spelling() { + let declared = serde_json::to_value(ServerTool::WebSearch) + .unwrap_or_else(|error| panic!("serialize: {error}")); + assert_eq!( + declared, + serde_json::Value::from(ServerTool::WebSearch.name()) + ); + } + + #[test] + fn action_text_bytes_count_every_provider_authored_string() { + assert_eq!( + ServerToolAction::Search { + queries: vec!["ab".to_owned(), "cde".to_owned()] + } + .text_bytes(), + 5 + ); + assert_eq!( + ServerToolAction::Search { + queries: Vec::new() + } + .text_bytes(), + 0 + ); + assert_eq!( + ServerToolAction::OpenPage { + url: "https://x".to_owned() + } + .text_bytes(), + 9 + ); + assert_eq!( + ServerToolAction::FindInPage { + url: "u".to_owned(), + pattern: "pat".to_owned() + } + .text_bytes(), + 4 + ); + } +} diff --git a/crates/plexmaton-provider/src/chat/request.rs b/crates/plexmaton-provider/src/chat/request.rs index 32ab9ce3..fd31e060 100644 --- a/crates/plexmaton-provider/src/chat/request.rs +++ b/crates/plexmaton-provider/src/chat/request.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use super::{ChatReplay, ReasoningField}; use crate::{ - FunctionTool, ResolvedModel, + FunctionTool, ResolvedModel, ServerTool, codec::{EncodeError, tool_output}, degrade::{self, Carried}, }; @@ -50,6 +50,13 @@ pub(crate) fn encode( body["tools"] = Value::Array(tools); body["tool_choice"] = Value::String("auto".to_owned()); } + // PRV-6: Chat Completions spells hosted search as the request field OpenAI's own search + // models take, not as a tool type, which is where this dialect's schema puts it. + for tool in model.server_tools().unwrap_or_default() { + match tool { + ServerTool::WebSearch => body["web_search_options"] = json!({}), + } + } if let Some(limit) = max_output_tokens { body["max_completion_tokens"] = Value::from(limit); } @@ -229,6 +236,9 @@ fn encode_assistant( }, })); } + // Reached only with a compatible replay, which this dialect never produced: a foreign + // model's search is degraded away before encoding (PRV-3). + AssistantBlock::ServerToolCall { .. } => return Err(EncodeError::ServerToolCallInChat), } } Ok(pending.into_message()) diff --git a/crates/plexmaton-provider/src/codec.rs b/crates/plexmaton-provider/src/codec.rs index 498acdc0..e2cf1662 100644 --- a/crates/plexmaton-provider/src/codec.rs +++ b/crates/plexmaton-provider/src/codec.rs @@ -136,6 +136,8 @@ pub enum EncodeError { InvalidToolName, #[error("opaque provider replay cannot be sent through Chat Completions")] OpaqueReplayInChat, + #[error("a call the provider ran on its own side has no Chat Completions representation")] + ServerToolCallInChat, #[error("ordered assistant blocks cannot be represented by Chat Completions")] UnrepresentableChatOrder, #[error("stored provider replay is not valid JSON: {0}")] diff --git a/crates/plexmaton-provider/src/config.rs b/crates/plexmaton-provider/src/config.rs index 9722e2ff..4007ea0c 100644 --- a/crates/plexmaton-provider/src/config.rs +++ b/crates/plexmaton-provider/src/config.rs @@ -1,7 +1,8 @@ //! Typed model registry and pure home/config resolution. use plexmaton_core::ReasoningEffort; -use std::{collections::BTreeMap, fmt}; +pub use plexmaton_core::ServerTool; +use std::{collections::BTreeMap, ffi::OsString, fmt}; mod environment; mod model; @@ -37,6 +38,21 @@ impl ModelApi { } } + /// Whether this dialect has a wire spelling for a hosted tool (PRV-6). + /// + /// A declaration must fail at config load, because a route that accepts a spelling it does + /// not understand may ignore it without saying so. GenerateContent has a `google_search` + /// tool that no route here has been measured with, so it is refused rather than guessed. + pub(crate) const fn spells_server_tool(self, tool: ServerTool) -> bool { + match (self, tool) { + ( + Self::OpenaiResponses | Self::OpenaiChatCompletions | Self::AnthropicMessages, + ServerTool::WebSearch, + ) => true, + (Self::GoogleGenerateContent, ServerTool::WebSearch) => false, + } + } + /// Whether this dialect can carry a collaboration atom at all (PRV-1). /// /// All four can, because all four have a turn that is not the assistant's, which is where an @@ -136,10 +152,12 @@ impl ModelSelection { pub struct ModelRegistry { active: ModelSelection, models: BTreeMap<(String, String), ResolvedModel>, + /// Bearer tokens written on the route. The resolved model stays credential-blind. + api_keys: BTreeMap, } /// Provider bearer credential whose ordinary debug representation is always redacted. -#[derive(Clone)] +#[derive(Clone, PartialEq, Eq)] pub struct ApiKey(String); impl ApiKey { @@ -172,6 +190,8 @@ struct RawModelRegistry { struct RawProvider { base_url: String, api_key_env: String, + /// Optional bearer token for this route. A set environment variable still overrides it. + api_key: Option, api: Option, models: BTreeMap, } @@ -185,6 +205,7 @@ struct RawModel { #[serde(default)] reasoning_effort: ReasoningEffort, allowed_reasoning_efforts: Option>, + server_tools: Option>, #[serde(default)] instructions: String, #[serde(default)] @@ -260,6 +281,8 @@ pub enum ConfigError { MissingApiKeyEnvironment(String), #[error("provider API key environment variable `{0}` is not a header-safe value")] InvalidApiKeyValue(String), + #[error("provider `{0}` has an API key that is not header-safe")] + InvalidInlineApiKey(String), } impl ModelRegistry { @@ -277,11 +300,16 @@ impl ModelRegistry { fn resolve(raw: RawModelRegistry) -> Result { let mut models = BTreeMap::new(); + let mut api_keys = BTreeMap::new(); for (provider_name, provider) in raw.providers { let base_url = validate_provider(&provider_name, &provider)?; + if let Some(key) = inline_api_key(&provider_name, provider.api_key.as_deref())? { + api_keys.insert(provider_name.clone(), key); + } let RawProvider { base_url: _, api_key_env, + api_key: _, api, models: provider_models, } = provider; @@ -318,9 +346,28 @@ impl ModelRegistry { Ok(Self { active: raw.active_model, models, + api_keys, }) } + /// Resolves the bearer token for `model`. + /// + /// A present environment value wins, so a shell can override the file. When that value is + /// absent, the route's `api_key` is used. The returned credential is redacted in `Debug`. + pub fn api_key_for( + &self, + model: &ResolvedModel, + environment: Option, + ) -> Result { + if environment.is_some() { + return resolve_api_key(model, environment); + } + if let Some(key) = self.api_keys.get(model.provider_name()) { + return Ok(key.clone()); + } + resolve_api_key(model, None) + } + /// Every configured model in deterministic provider/name order; no credentials are resolved. pub fn models(&self) -> impl ExactSizeIterator { self.models.values() @@ -390,6 +437,16 @@ fn validate_provider(name: &str, provider: &RawProvider) -> Result) -> Result, ConfigError> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_graphic()) { + return Err(ConfigError::InvalidInlineApiKey(provider.to_owned())); + } + Ok(Some(ApiKey(value.to_owned()))) +} + fn is_environment_name(value: &str) -> bool { let mut bytes = value.bytes(); let Some(first) = bytes.next() else { diff --git a/crates/plexmaton-provider/src/config/model.rs b/crates/plexmaton-provider/src/config/model.rs index ad7fca0e..264769fc 100644 --- a/crates/plexmaton-provider/src/config/model.rs +++ b/crates/plexmaton-provider/src/config/model.rs @@ -1,6 +1,7 @@ //! Validated model request options and replay identity, independent of registry lookup. use super::{ - ConfigError, ModelApi, ModelCost, PromptCache, RawModel, ReasoningEffort, TokenEstimator, + ConfigError, ModelApi, ModelCost, PromptCache, RawModel, ReasoningEffort, ServerTool, + TokenEstimator, }; use plexmaton_agent::{ ProviderCodecId, ProviderCodecRevision, ProviderModelFamilyId, ProviderReplayOwnerId, @@ -34,6 +35,7 @@ pub struct ResolvedModel { api_key_env: String, reasoning_effort: ReasoningEffort, allowed_reasoning_efforts: Option>, + server_tools: Option>, instructions: String, workspace_instructions: WorkspaceInstructions, prompt_cache: PromptCache, @@ -106,6 +108,7 @@ impl ResolvedModel { api_key_env: api_key_env.to_owned(), reasoning_effort: model.reasoning_effort, allowed_reasoning_efforts: model.allowed_reasoning_efforts, + server_tools: model.server_tools, instructions: model.instructions, workspace_instructions: WorkspaceInstructions::default(), prompt_cache: model.prompt_cache, @@ -155,6 +158,7 @@ impl ResolvedModel { return Err(self.invalid_option("instructions")); } self.validate_reasoning_efforts()?; + self.validate_server_tools()?; if self.api == ModelApi::GoogleGenerateContent && !self .wire_id @@ -209,6 +213,21 @@ impl ResolvedModel { Ok(()) } + fn validate_server_tools(&self) -> Result<(), ConfigError> { + // Absent means none, so an empty list is a mistake rather than a quiet no. The dialect + // decides which names it can spell; a tool it cannot is refused here, before any request + // exists, because the wire may accept the spelling and ignore it without a word. + if let Some(tools) = &self.server_tools + && (tools.is_empty() + || tools.iter().enumerate().any(|(i, tool)| { + !self.api.spells_server_tool(*tool) || tools[..i].contains(tool) + })) + { + return Err(self.invalid_option("server_tools")); + } + Ok(()) + } + fn invalid_option(&self, field: &'static str) -> ConfigError { ConfigError::InvalidRequestOption { provider: self.provider_name.clone(), @@ -281,6 +300,13 @@ impl ResolvedModel { self.allowed_reasoning_efforts.as_deref() } + /// Hosted tools the owner declared this route accepts, in configuration order; absent means + /// none. The dialect spells each one; nothing here is inferred from a name. + #[must_use] + pub fn server_tools(&self) -> Option<&[ServerTool]> { + self.server_tools.as_deref() + } + #[must_use] pub const fn context_window_tokens(&self) -> u32 { self.context_window_tokens diff --git a/crates/plexmaton-provider/src/config/tests.rs b/crates/plexmaton-provider/src/config/tests.rs index 4ea692a9..ca66f224 100644 --- a/crates/plexmaton-provider/src/config/tests.rs +++ b/crates/plexmaton-provider/src/config/tests.rs @@ -283,15 +283,39 @@ reasoning_effort = "xhigh" ModelRegistry::parse(legacy), Err(ConfigError::Toml) )); +} - let inline_key = LOCAL_CONFIG.replace( +#[test] +fn prv_6_inline_key_is_used_when_its_environment_variable_is_absent() { + let source = LOCAL_CONFIG.replace( "api_key_env = \"PLEXMATON_LOCAL_API_KEY\"", "api_key_env = \"PLEXMATON_LOCAL_API_KEY\"\napi_key = \"inline-secret\"", ); - let error = ModelRegistry::parse(&inline_key).expect_err("inline key must be rejected"); - assert!(matches!(&error, ConfigError::Toml)); + let registry = ModelRegistry::parse(&source).expect("inline key is part of the route"); + let model = registry.active_model(); + let rendered = format!("{model:?}\n{registry:?}"); + assert!(!rendered.contains("inline-secret")); + let key = registry + .api_key_for(model, None) + .expect("file token supplies the absent variable"); + assert_eq!(key.expose(), "inline-secret"); + assert_eq!(format!("{key:?}"), "ApiKey([REDACTED])"); + let overridden = registry + .api_key_for(model, Some("from-env".into())) + .expect("environment still overrides the file"); + assert_eq!(overridden.expose(), "from-env"); + + let unsafe_key = LOCAL_CONFIG.replace( + "api_key_env = \"PLEXMATON_LOCAL_API_KEY\"", + "api_key_env = \"PLEXMATON_LOCAL_API_KEY\"\napi_key = \"not header safe\"", + ); + let error = ModelRegistry::parse(&unsafe_key).expect_err("unsafe token must be rejected"); + assert!(matches!( + &error, + ConfigError::InvalidInlineApiKey(provider) if provider == "local" + )); let diagnostics = format!("{error}\n{error:?}"); - assert!(!diagnostics.contains("inline-secret")); + assert!(!diagnostics.contains("not header safe")); } #[test] @@ -478,3 +502,92 @@ fn prv_6_provider_example_selects_each_documented_dialect() { ); } } + +/// PRV-6: a hosted tool is declared per model, and absent unless declared. +#[test] +fn prv_6_hosted_tools_are_declared_per_model_and_absent_by_default() { + let source = LOCAL_CONFIG.replace( + "reasoning_effort = \"xhigh\"", + "reasoning_effort = \"xhigh\"\nserver_tools = [\"web_search\"]", + ); + let registry = ModelRegistry::parse(&source).expect("declared hosted search"); + assert_eq!( + registry.active_model().server_tools(), + Some([super::ServerTool::WebSearch].as_slice()) + ); + assert_eq!( + registry.model("local", "sol").expect("Sol").server_tools(), + None + ); +} + +/// PRV-6: a declaration fails closed at config load, on the active model and an inactive one alike. +#[test] +fn prv_6_hosted_tool_declarations_fail_closed() { + for list in ["[]", "[\"web_search\", \"web_search\"]"] { + let source = LOCAL_CONFIG.replace( + "reasoning_effort = \"xhigh\"", + &format!("reasoning_effort = \"xhigh\"\nserver_tools = {list}"), + ); + assert!( + matches!( + ModelRegistry::parse(&source), + Err(ConfigError::InvalidRequestOption { + field: "server_tools", + .. + }) + ), + "{list}" + ); + } + // A name the catalog does not know is a parse error, never a tool quietly left out. + let source = LOCAL_CONFIG.replace( + "reasoning_effort = \"xhigh\"", + "reasoning_effort = \"xhigh\"\nserver_tools = [\"web_fetch\"]", + ); + assert!(matches!( + ModelRegistry::parse(&source), + Err(ConfigError::Toml) + )); + let source = LOCAL_CONFIG.replace( + "reasoning_effort = \"high\"", + "reasoning_effort = \"high\"\nserver_tools = []", + ); + assert!( + matches!(ModelRegistry::parse(&source), Err(ConfigError::InvalidRequestOption { model, .. }) if model == "sol") + ); +} + +/// PRV-6: the gate is the dialect's spelling, not the dialect. Three spell web search today, and +/// GenerateContent's tool is unmeasured, so it is refused rather than guessed. +#[test] +fn prv_6_hosted_tool_declarations_need_a_dialect_spelling() { + for (api, accepted) in [ + ("openai_responses", true), + ("openai_chat_completions", true), + ("anthropic_messages", true), + ("google_generate_content", false), + ] { + let source = LOCAL_CONFIG + .replace("api = \"openai_responses\"", &format!("api = \"{api}\"")) + .replace( + "reasoning_effort = \"xhigh\"", + "reasoning_effort = \"high\"\nserver_tools = [\"web_search\"]", + ); + match (accepted, ModelRegistry::parse(&source)) { + (true, Ok(registry)) => assert_eq!( + registry.active_model().server_tools(), + Some([super::ServerTool::WebSearch].as_slice()), + "{api}" + ), + ( + false, + Err(ConfigError::InvalidRequestOption { + field: "server_tools", + .. + }), + ) => {} + (_, other) => panic!("{api}: unexpected {other:?}"), + } + } +} diff --git a/crates/plexmaton-provider/src/degrade.rs b/crates/plexmaton-provider/src/degrade.rs index 2f2046f0..2238cc8d 100644 --- a/crates/plexmaton-provider/src/degrade.rs +++ b/crates/plexmaton-provider/src/degrade.rs @@ -63,6 +63,9 @@ pub(crate) fn degraded<'a>( AssistantBlock::ToolCall { call, .. } => Some(Carried::Call(call)), // A replay-only block was never anything but its sidecar. AssistantBlock::ReplayOnly { .. } => None, + // A search the previous model ran. Its answer text carries what came of it, and + // the next model did not ask for the query. + AssistantBlock::ServerToolCall { .. } => None, }) .collect(), ) diff --git a/crates/plexmaton-provider/src/gemini/request.rs b/crates/plexmaton-provider/src/gemini/request.rs index 98cb40a0..c5080f3a 100644 --- a/crates/plexmaton-provider/src/gemini/request.rs +++ b/crates/plexmaton-provider/src/gemini/request.rs @@ -20,6 +20,12 @@ pub(crate) fn encode( tools: &[FunctionTool], max_output_tokens: Option, ) -> Result { + // PRV-6 refuses a hosted tool this dialect cannot spell at config load, so none can arrive + // here. Stating it keeps a future spelling from being added to one side and not the other. + assert!( + model.server_tools().is_none(), + "PRV-6: a GenerateContent model cannot declare a hosted tool" + ); let mut contents = Vec::new(); if !model.workspace_instructions().is_empty() { contents.push(json!({"role":"user", "parts":[{"text":model.workspace_instructions()}]})); diff --git a/crates/plexmaton-provider/src/lib.rs b/crates/plexmaton-provider/src/lib.rs index 3e8340b2..688bc07b 100644 --- a/crates/plexmaton-provider/src/lib.rs +++ b/crates/plexmaton-provider/src/lib.rs @@ -31,7 +31,8 @@ pub use compaction::{ }; pub use config::{ ApiKey, ConfigError, MAX_WORKSPACE_INSTRUCTION_BYTES, ModelApi, ModelCost, ModelRegistry, - ModelSelection, PromptCache, ResolvedModel, TokenEstimator, resolve_api_key, resolve_home, + ModelSelection, PromptCache, ResolvedModel, ServerTool, TokenEstimator, resolve_api_key, + resolve_home, }; pub use cost::request_cost; pub use degrade::degrades_replay; diff --git a/crates/plexmaton-provider/src/messages/request.rs b/crates/plexmaton-provider/src/messages/request.rs index 061df50d..bcb54c06 100644 --- a/crates/plexmaton-provider/src/messages/request.rs +++ b/crates/plexmaton-provider/src/messages/request.rs @@ -8,7 +8,7 @@ use plexmaton_core::ReasoningEffort; use serde_json::{Value, json}; use crate::{ - EncodeError, FunctionTool, PromptCache, ResolvedModel, + EncodeError, FunctionTool, PromptCache, ResolvedModel, ServerTool, codec::tool_output, degrade::{self, Carried}, }; @@ -33,8 +33,20 @@ pub(crate) fn encode( if !model.instructions().is_empty() { body["system"] = json!([{"type":"text","text":model.instructions()}]); } - if !tools.is_empty() { - body["tools"] = Value::Array(tools.iter().map(|tool| json!({"name":tool.name(),"description":tool.description(),"input_schema":tool.parameters()})).collect()); + // PRV-6: configuration named the capability; this dialect owns the spelling. Hosted tools + // follow the function tools so the order the owner reads in the body is the order declared. + let mut declared: Vec = tools.iter().map(|tool| json!({"name":tool.name(),"description":tool.description(),"input_schema":tool.parameters()})).collect(); + declared.extend( + model + .server_tools() + .unwrap_or_default() + .iter() + .map(|tool| match tool { + ServerTool::WebSearch => json!({"type":"web_search_20250305","name":"web_search"}), + }), + ); + if !declared.is_empty() { + body["tools"] = Value::Array(declared); body["tool_choice"] = json!({"type":"auto"}); } if model.prompt_cache() == PromptCache::Automatic { diff --git a/crates/plexmaton-provider/src/messages/stream.rs b/crates/plexmaton-provider/src/messages/stream.rs index be9d0782..f9a7770b 100644 --- a/crates/plexmaton-provider/src/messages/stream.rs +++ b/crates/plexmaton-provider/src/messages/stream.rs @@ -210,6 +210,13 @@ impl MessagesDecoder { })?; self.replay_items += 1; } + // This decoder produces none: Messages' server_tool_use keeps its refusal until a + // Messages route is in daily use to prove a decoder against (PRV-5). + ModelEvent::ServerToolStarted { .. } | ModelEvent::ServerToolCall { .. } => { + return Err(DecodeError::UnsupportedEvent( + "messages_server_tool_call".to_owned(), + )); + } ModelEvent::Called { call, .. } => { if self.calls.len() >= self.limits.max_tool_calls { return Err(DecodeError::TooManyToolCalls { diff --git a/crates/plexmaton-provider/src/responses/replay.rs b/crates/plexmaton-provider/src/responses/replay.rs index 32d57af2..05ea1fef 100644 --- a/crates/plexmaton-provider/src/responses/replay.rs +++ b/crates/plexmaton-provider/src/responses/replay.rs @@ -1,6 +1,7 @@ //! Completed message metadata, kept separately from authoritative semantic text. use plexmaton_agent::{ModelEvent, ProviderReplay}; +use plexmaton_core::{ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -19,8 +20,10 @@ pub(super) enum MessagePhase { #[serde(rename_all = "snake_case")] pub(super) enum ItemStatus { InProgress, + Searching, Completed, Incomplete, + Failed, } fn item_status(item: &Value) -> Result, DecodeError> { @@ -50,6 +53,10 @@ pub(super) enum ResponseReplay { part: MessagePartKind, annotations: Vec, }, + WebSearchCall { + id: Option, + status: Option, + }, } #[derive(Clone, Copy, Debug, Deserialize, Serialize)] @@ -109,6 +116,95 @@ impl ResponsesDecoder { Ok(events) } + /// A search the provider ran, reported whole on `output_item.done`. The action is semantic + /// content the record keeps and bounds (PRV-5); only the provider's identity and status become + /// replay metadata, the way a function call's do. + pub(super) fn web_search_call_done( + &mut self, + index: usize, + item: &Value, + ) -> Result, DecodeError> { + let action = item + .get("action") + .filter(|value| value.is_object()) + .ok_or_else(|| { + DecodeError::UnsupportedEvent("web_search_call_without_action".to_owned()) + })?; + let action = match string_field(action, "type")? { + "search" => { + // Both spellings arrive on live routes, sometimes together; a query is kept once, + // and an empty one is an action that arrived without a query rather than a query. + let mut queries: Vec = action + .get("queries") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .filter(|query| !query.is_empty()) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default(); + if let Some(query) = action.get("query").and_then(Value::as_str) + && !query.is_empty() + && !queries.iter().any(|known| known == query) + { + queries.insert(0, query.to_owned()); + } + ServerToolAction::Search { queries } + } + "open_page" => ServerToolAction::OpenPage { + url: string_field(action, "url")?.to_owned(), + }, + "find_in_page" => ServerToolAction::FindInPage { + url: string_field(action, "url")?.to_owned(), + pattern: string_field(action, "pattern")?.to_owned(), + }, + other => { + return Err(DecodeError::UnsupportedEvent(format!( + "web_search_call.action:{other}" + ))); + } + }; + retain_bytes( + &mut self.retained, + action.text_bytes(), + self.limits.max_retained_output_bytes, + )?; + // PRV-5: how the call ended is typed here, because the row that shows it says so in + // colour. A done item that still claims to be running is a wire defect, not a state. + let status = match item_status(item)? { + None | Some(ItemStatus::Completed) => ServerToolStatus::Completed, + Some(ItemStatus::Failed | ItemStatus::Incomplete) => ServerToolStatus::Failed, + Some(ItemStatus::InProgress) => { + return Err(DecodeError::UnsupportedEvent( + "web_search_call.status:in_progress".to_owned(), + )); + } + Some(ItemStatus::Searching) => { + return Err(DecodeError::UnsupportedEvent( + "web_search_call.status:searching".to_owned(), + )); + } + }; + let call = ServerToolCall { + tool: ServerTool::WebSearch, + action, + status, + }; + let metadata = ResponseReplay::WebSearchCall { + id: item.get("id").and_then(Value::as_str).map(str::to_owned), + status: item_status(item)?, + }; + let replay = self.retain_replay(serde_json::to_string(&metadata)?)?; + let position = output_position(index, 0)?; + Ok(vec![ + ModelEvent::ServerToolCall { position, call }, + ModelEvent::Replay { position, replay }, + ]) + } + pub(super) fn message_done( &mut self, index: usize, diff --git a/crates/plexmaton-provider/src/responses/request.rs b/crates/plexmaton-provider/src/responses/request.rs index 734b9a6d..25b74a7c 100644 --- a/crates/plexmaton-provider/src/responses/request.rs +++ b/crates/plexmaton-provider/src/responses/request.rs @@ -3,12 +3,13 @@ use plexmaton_agent::{ AssistantBlock, AssistantOutput, BlockReplay, ContextAtom, ContextAtomValue, ModelRequest, }; +use plexmaton_core::ServerToolAction; use serde_json::{Value, json}; use super::replay::{MessagePartKind, ResponseReplay}; use crate::{ - FunctionTool, ResolvedModel, + FunctionTool, ResolvedModel, ServerTool, codec::{EncodeError, RESPONSES_CODEC_ID, tool_output}, degrade::{self, Carried}, }; @@ -20,7 +21,7 @@ pub(crate) fn encode( max_output_tokens: Option, ) -> Result { let input = encode_input(model, request)?; - let tools: Vec<_> = tools + let mut tools: Vec<_> = tools .iter() .map(|tool| { json!({ @@ -32,6 +33,17 @@ pub(crate) fn encode( }) }) .collect(); + // PRV-6: configuration named the capability; this dialect owns the spelling. Hosted tools + // follow the function tools so the order the owner reads in the body is the order declared. + tools.extend( + model + .server_tools() + .unwrap_or_default() + .iter() + .map(|tool| match tool { + ServerTool::WebSearch => json!({"type": "web_search"}), + }), + ); let mut body = json!({ "model": model.wire_id(), "input": input, @@ -214,6 +226,32 @@ fn encode_assistant( } input.push(item); } + AssistantBlock::ServerToolCall { call, .. } => { + // The item is rebuilt from the record and the sidecar adds the provider's status. + // Its identity is retained and not sent: the one live route turns a replayed id + // into a result block the upstream refuses on an assistant message, and Meta + // documents the id as optional on replay (PRV-3). + let kind = match call.tool { + ServerTool::WebSearch => "web_search_call", + }; + let mut item = json!({ + "type": kind, + "status": "completed", + "action": web_search_action(&call.action), + }); + if let Some(replay) = replay { + let metadata: ResponseReplay = serde_json::from_str(replay.payload()) + .map_err(|_| EncodeError::InvalidReplayItem)?; + let ResponseReplay::WebSearchCall { id: _, status } = metadata else { + return Err(EncodeError::InvalidReplayItem); + }; + if let Some(status) = status { + item["status"] = + serde_json::to_value(status).map_err(EncodeError::InvalidReplayJson)?; + } + } + input.push(item); + } } } debug_assert!( @@ -255,6 +293,7 @@ fn encode_text_replay( part, annotations, } => (id, phase, status, content_index, part, annotations), + ResponseReplay::WebSearchCall { .. } => return Err(EncodeError::InvalidReplayItem), ResponseReplay::EmptyMessage { id, phase, status } => { if !text.is_empty() { return Err(EncodeError::InvalidReplayItem); @@ -314,3 +353,21 @@ fn encode_text_replay( } Ok(()) } + +/// The wire's spelling of what a search did. `query` is kept beside `queries` because both +/// spellings have been observed on live routes, and a replay should read as the item did. +fn web_search_action(action: &ServerToolAction) -> Value { + match action { + ServerToolAction::Search { queries } => { + let mut value = json!({"type": "search", "queries": queries}); + if let Some(first) = queries.first() { + value["query"] = Value::String(first.clone()); + } + value + } + ServerToolAction::OpenPage { url } => json!({"type": "open_page", "url": url}), + ServerToolAction::FindInPage { url, pattern } => { + json!({"type": "find_in_page", "url": url, "pattern": pattern}) + } + } +} diff --git a/crates/plexmaton-provider/src/responses/stream.rs b/crates/plexmaton-provider/src/responses/stream.rs index 1ef78e7f..12d38cc1 100644 --- a/crates/plexmaton-provider/src/responses/stream.rs +++ b/crates/plexmaton-provider/src/responses/stream.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use plexmaton_agent::{ModelEvent, ReplayCompatibility, StopReason}; -use plexmaton_core::ToolCallId; +use plexmaton_core::{ServerTool, ToolCallId}; use serde_json::Value; use super::call::CallAssembly; @@ -76,16 +76,18 @@ impl ResponsesDecoder { | "response.content_part.done" | "response.reasoning_summary_part.added" | "response.reasoning_summary_part.done" - | "response.reasoning_summary_text.done" => Ok(Vec::new()), + | "response.reasoning_summary_text.done" + // Progress markers for a search the provider is running; the finished item carries + // everything they do (PRV-5). + | "response.web_search_call.in_progress" + | "response.web_search_call.searching" + | "response.web_search_call.completed" => Ok(Vec::new()), "response.reasoning_summary_text.delta" => self.reasoning_delta(&event), "response.output_text.delta" => self.text_delta(&event, false), "response.output_text.done" => self.text_done(&event, "text", false), "response.refusal.delta" => self.text_delta(&event, true), "response.refusal.done" => self.text_done(&event, "refusal", true), - "response.output_item.added" => { - self.output_item_added(&event)?; - Ok(Vec::new()) - } + "response.output_item.added" => self.output_item_added(&event), "response.function_call_arguments.delta" => { self.arguments_delta(&event)?; Ok(Vec::new()) @@ -183,7 +185,7 @@ impl ResponsesDecoder { Ok(Vec::new()) } - fn output_item_added(&mut self, event: &Value) -> Result<(), DecodeError> { + fn output_item_added(&mut self, event: &Value) -> Result, DecodeError> { let index = usize_field(event, "output_index")?; let item = object_field(event, "item")?; match string_field(item, "type")? { @@ -197,9 +199,17 @@ impl ResponsesDecoder { index, limits, )?; - call.seed_arguments(optional_string(item, "arguments"), index, limits) + call.seed_arguments(optional_string(item, "arguments"), index, limits)?; + Ok(Vec::new()) } - "reasoning" | "message" => Ok(()), + // The item's first appearance is where the provider placed the call. One live route + // finishes every search at the end of the stream, so the finished item alone would + // put each row after the answer it informed (PRV-5). + "web_search_call" => Ok(vec![ModelEvent::ServerToolStarted { + position: output_position(index, 0)?, + tool: ServerTool::WebSearch, + }]), + "reasoning" | "message" => Ok(Vec::new()), other => Err(DecodeError::UnsupportedEvent(format!( "response.output_item.added:{other}" ))), @@ -244,6 +254,7 @@ impl ResponsesDecoder { "reasoning" => self.reasoning_replay(index, item), "function_call" => self.function_call_done(index, item), "message" => self.message_done(index, item), + "web_search_call" => self.web_search_call_done(index, item), other => Err(DecodeError::UnsupportedEvent(format!( "response.output_item.done:{other}" ))), diff --git a/crates/plexmaton-provider/tests/fixtures/responses_web_search.sse b/crates/plexmaton-provider/tests/fixtures/responses_web_search.sse new file mode 100644 index 00000000..3343bd85 --- /dev/null +++ b/crates/plexmaton-provider/tests/fixtures/responses_web_search.sse @@ -0,0 +1,63 @@ +event: response.created +data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_fixture_search","status":"in_progress","output":[],"store":false}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":1,"output_index":0,"item":{"id":"rs_fixture_s1","type":"reasoning","summary":[],"encrypted_content":null,"status":"in_progress"}} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":2,"output_index":0,"item":{"id":"rs_fixture_s1","type":"reasoning","summary":[{"type":"summary_text","text":""}],"encrypted_content":"enc_fixture_s1_🔐","status":"completed"}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":3,"output_index":1,"item":{"id":"ws_fixture_1","type":"web_search_call","status":"in_progress","action":{"type":"search","query":""}}} + +event: response.web_search_call.searching +data: {"type":"response.web_search_call.searching","sequence_number":4,"output_index":1,"item_id":"ws_fixture_1"} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":5,"output_index":1,"item":{"id":"ws_fixture_1","type":"web_search_call","status":"completed","action":{"type":"search","query":"latest stable Rust release","queries":["latest stable Rust release"]}}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":6,"output_index":2,"item":{"id":"msg_fixture_s1","type":"message","status":"in_progress","role":"assistant","content":[]}} + +event: response.content_part.added +data: {"type":"response.content_part.added","sequence_number":7,"item_id":"msg_fixture_s1","output_index":2,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":8,"item_id":"msg_fixture_s1","output_index":2,"content_index":0,"delta":"Checking the release page. "} + +event: response.output_text.done +data: {"type":"response.output_text.done","sequence_number":9,"item_id":"msg_fixture_s1","output_index":2,"content_index":0,"text":"Checking the release page. "} + +event: response.content_part.done +data: {"type":"response.content_part.done","sequence_number":10,"item_id":"msg_fixture_s1","output_index":2,"content_index":0,"part":{"type":"output_text","text":"Checking the release page. ","annotations":[]}} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":11,"output_index":2,"item":{"id":"msg_fixture_s1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Checking the release page. ","annotations":[]}]}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":12,"output_index":3,"item":{"id":"ws_fixture_2","type":"web_search_call","status":"in_progress"}} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":13,"output_index":3,"item":{"id":"ws_fixture_2","type":"web_search_call","status":"completed","action":{"type":"open_page","url":"https://blog.rust-lang.org/releases/"}}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":14,"output_index":4,"item":{"id":"msg_fixture_s2","type":"message","status":"in_progress","role":"assistant","content":[]}} + +event: response.content_part.added +data: {"type":"response.content_part.added","sequence_number":15,"item_id":"msg_fixture_s2","output_index":4,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":16,"item_id":"msg_fixture_s2","output_index":4,"content_index":0,"delta":"The latest stable Rust release is 1.98.1."} + +event: response.output_text.done +data: {"type":"response.output_text.done","sequence_number":17,"item_id":"msg_fixture_s2","output_index":4,"content_index":0,"text":"The latest stable Rust release is 1.98.1."} + +event: response.content_part.done +data: {"type":"response.content_part.done","sequence_number":18,"item_id":"msg_fixture_s2","output_index":4,"content_index":0,"part":{"type":"output_text","text":"The latest stable Rust release is 1.98.1.","annotations":[]}} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":19,"output_index":4,"item":{"id":"msg_fixture_s2","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"The latest stable Rust release is 1.98.1.","annotations":[]}]}} + +event: response.completed +data: {"type":"response.completed","sequence_number":20,"response":{"id":"resp_fixture_search","status":"completed","usage":{"input_tokens":120,"input_tokens_details":{"cached_tokens":0},"output_tokens":40,"output_tokens_details":{"reasoning_tokens":8},"total_tokens":160}}} + diff --git a/crates/plexmaton-provider/tests/provider_fixtures.rs b/crates/plexmaton-provider/tests/provider_fixtures.rs index fd9f4100..4dff32be 100644 --- a/crates/plexmaton-provider/tests/provider_fixtures.rs +++ b/crates/plexmaton-provider/tests/provider_fixtures.rs @@ -21,6 +21,8 @@ mod gemini; mod messages; #[path = "provider_fixtures/request_options.rs"] mod request_options; +#[path = "provider_fixtures/server_tools.rs"] +mod server_tools; mod support; #[path = "provider_fixtures/usage_boundaries.rs"] mod usage_boundaries; @@ -34,6 +36,7 @@ const CHAT_TOOL_CALL: &str = include_str!("fixtures/chat_tool_call.sse"); const CHAT_FINAL_ANSWER: &str = include_str!("fixtures/chat_final_answer.sse"); const RESPONSES_TOOL_CALL: &str = include_str!("fixtures/responses_tool_call.sse"); const RESPONSES_FINAL_ANSWER: &str = include_str!("fixtures/responses_final_answer.sse"); +const RESPONSES_WEB_SEARCH: &str = include_str!("fixtures/responses_web_search.sse"); #[tokio::test] async fn prv_1_chat_fixture_drives_a_full_stateless_tool_round_trip() { @@ -941,3 +944,139 @@ fn replay_model_family(value: &str) -> ProviderModelFamilyId { ProviderModelFamilyId::new(value) .unwrap_or_else(|error| panic!("fixture model family: {error:?}")) } + +/// PRV-5: a search the provider ran arrives as a server-tool call carrying what it did, its +/// progress markers are ignored, and the turn still ends as an answer rather than a tool request. +/// The fixture is sanitized from a live stream through the local gateway, with the second call's +/// action changed to an opened page so both observed kinds are exercised. +#[tokio::test] +async fn prv_5_responses_web_search_call_is_carried_as_a_server_tool_call() { + use plexmaton_core::{ServerTool, ServerToolAction}; + let profile = profile(ModelApi::OpenaiResponses); + let (mut agent, _) = open_agent("What is the latest stable Rust release?"); + let events = decode_fixture(&profile, RESPONSES_WEB_SEARCH, &[3, 17, 64]).await; + + let calls: Vec<_> = events + .iter() + .filter_map(|event| match event { + ModelEvent::ServerToolCall { position, call } => Some((position.item(), call.clone())), + _ => None, + }) + .collect(); + assert_eq!(calls.len(), 2, "{events:?}"); + // Each call was placed when the provider began it, before its finished item, so the row sits + // where the provider put the call and not where the route chose to finish it. + let started: Vec<_> = events + .iter() + .filter_map(|event| match event { + ModelEvent::ServerToolStarted { position, .. } => Some(position.item()), + _ => None, + }) + .collect(); + assert_eq!(started, [1, 3], "{events:?}"); + for item in [1, 3] { + let placed = events.iter().position(|event| { + matches!(event, ModelEvent::ServerToolStarted { position, .. } if position.item() == item) + }); + let finished = events.iter().position(|event| { + matches!(event, ModelEvent::ServerToolCall { position, .. } if position.item() == item) + }); + assert!( + placed < finished, + "item {item}: {placed:?} then {finished:?}" + ); + } + assert_eq!(calls[0].0, 1); + assert_eq!(calls[0].1.tool, ServerTool::WebSearch); + assert_eq!( + calls[0].1.status, + plexmaton_core::ServerToolStatus::Completed + ); + assert_eq!( + calls[0].1.action, + ServerToolAction::Search { + queries: vec!["latest stable Rust release".to_owned()] + }, + "one query, spelled twice on the wire, kept once" + ); + assert_eq!(calls[1].0, 3); + assert_eq!( + calls[1].1.action, + ServerToolAction::OpenPage { + url: "https://blog.rust-lang.org/releases/".to_owned() + } + ); + // Each call's identity and status ride beside it as replay, never as semantic text. + for item in [1_u16, 3] { + assert!( + events.iter().any(|event| matches!(event, ModelEvent::Replay { position, .. } if position.item() == item)), + "call at item {item} has a replay sidecar" + ); + } + assert!( + !events + .iter() + .any(|event| matches!(event, ModelEvent::Called { .. })), + "nothing here asks the harness to run a tool" + ); + assert_eq!( + visible_text(&events), + "Checking the release page. The latest stable Rust release is 1.98.1." + ); + assert!(matches!( + events.last(), + Some(ModelEvent::Stopped(StopReason::EndOfTurn)) + )); + // The route reports no cache-write count, so this usage is partial coverage; what the test + // cares about is that it arrived. + assert!(!matches!(reported_usage(&events), TokenUsage::Unavailable)); + // The agent records the turn: both calls become blocks beside the text, and nothing is queued. + complete_answer(&mut agent, &events); + + // PRV-3: the next request replays each call as the item it was, status from the sidecar and + // action from the record, in the order the provider produced them. The provider's id stays in + // the sidecar: the one live route turns a replayed id into a result block the upstream refuses, + // and Meta documents the id as optional. + use serde_json::{Value, json}; + let reaction = agent.handle_at( + plexmaton_agent::Input::Submitted { + text: "Repeat the version.".to_owned(), + }, + plexmaton_agent::UnixMillis::EPOCH, + ); + let [plexmaton_agent::Effect::CallModel(next)] = reaction.effects.as_slice() else { + panic!( + "follow-up should open one model request: {:?}", + reaction.effects + ); + }; + let body = encode_request(&profile, &next.request, &[], None) + .unwrap_or_else(|error| panic!("follow-up should encode: {error}")); + let input = body["input"].as_array().expect("input array"); + let kinds: Vec<&str> = input + .iter() + .map(|item| item["type"].as_str().unwrap_or("")) + .collect(); + let first_search = kinds.iter().position(|kind| *kind == "web_search_call"); + let first_message = kinds.iter().position(|kind| *kind == "message"); + assert!( + first_search < first_message, + "the search precedes the answer it informed: {kinds:?}" + ); + let searches: Vec<&Value> = input + .iter() + .filter(|item| item["type"] == "web_search_call") + .collect(); + assert_eq!(searches.len(), 2, "{kinds:?}"); + assert!(searches[0].get("id").is_none(), "{:?}", searches[0]); + assert_eq!(searches[0]["status"], "completed"); + assert_eq!( + searches[0]["action"], + json!({"type": "search", "query": "latest stable Rust release", "queries": ["latest stable Rust release"]}) + ); + assert!(searches[1].get("id").is_none(), "{:?}", searches[1]); + assert_eq!( + searches[1]["action"], + json!({"type": "open_page", "url": "https://blog.rust-lang.org/releases/"}) + ); +} diff --git a/crates/plexmaton-provider/tests/provider_fixtures/request_options.rs b/crates/plexmaton-provider/tests/provider_fixtures/request_options.rs index 46733092..851217cf 100644 --- a/crates/plexmaton-provider/tests/provider_fixtures/request_options.rs +++ b/crates/plexmaton-provider/tests/provider_fixtures/request_options.rs @@ -448,3 +448,87 @@ output_reserve_tokens = 512 ); } } + +/// PRV-6: a declared hosted tool is spelled by the dialect, after the function tools, and a model +/// that declared none carries none. The three spellings are the wire's, asserted as JSON. +#[test] +fn prv_6_declared_hosted_tools_are_spelled_by_the_dialect_and_absent_otherwise() { + use serde_json::{Value, json}; + let base = r#" +active_model = { provider = "fixture", model = "searching" } +[providers.fixture] +base_url = "http://127.0.0.1:1/v1" +api_key_env = "FIXTURE_KEY" +api = "openai_responses" +[providers.fixture.models.searching] +id = "fixture" +server_tools = ["web_search"] +context_window_tokens = 8192 +max_output_tokens = 1024 +output_reserve_tokens = 512 +[providers.fixture.models.plain] +id = "fixture" +context_window_tokens = 8192 +max_output_tokens = 1024 +output_reserve_tokens = 512 +"#; + let (_, request) = open_agent("Hello."); + let hosted = |body: &Value| -> Vec { + body.get("tools") + .and_then(Value::as_array) + .map(|tools| { + tools + .iter() + .filter(|tool| { + tool.get("type").and_then(Value::as_str) != Some("function") + && tool.get("input_schema").is_none() + }) + .cloned() + .collect() + }) + .unwrap_or_default() + }; + for (api, spelling) in [ + ("openai_responses", json!({"type": "web_search"})), + ("openai_chat_completions", json!({})), + ( + "anthropic_messages", + json!({"type": "web_search_20250305", "name": "web_search"}), + ), + ] { + let registry = ModelRegistry::parse(&base.replace("openai_responses", api)) + .unwrap_or_else(|error| panic!("{api}: {error}")); + let searching = registry.active_model(); + let plain = registry.model("fixture", "plain").expect("plain"); + let with_function = encode_request(searching, &request, &[read_tool()], None) + .unwrap_or_else(|error| panic!("{api}: {error}")); + let alone = encode_request(searching, &request, &[], None) + .unwrap_or_else(|error| panic!("{api}: {error}")); + let undeclared = encode_request(plain, &request, &[read_tool()], None) + .unwrap_or_else(|error| panic!("{api}: {error}")); + if api == "openai_chat_completions" { + assert_eq!(with_function["web_search_options"], spelling, "{api}"); + assert_eq!(alone["web_search_options"], spelling, "{api}"); + assert!(undeclared.get("web_search_options").is_none(), "{api}"); + assert!( + hosted(&with_function).is_empty(), + "{api}: not a tool type here" + ); + } else { + assert_eq!(hosted(&with_function), vec![spelling.clone()], "{api}"); + assert_eq!(hosted(&alone), vec![spelling.clone()], "{api}"); + assert!(hosted(&undeclared).is_empty(), "{api}"); + let tools = with_function["tools"].as_array().expect("tools"); + assert_eq!(tools.len(), 2, "{api}: one function tool, one hosted"); + assert_eq!( + tools[1], spelling, + "{api}: hosted follows the function tools" + ); + assert!(with_function.get("tool_choice").is_some(), "{api}"); + assert!( + alone.get("tool_choice").is_some(), + "{api}: a hosted tool alone still names a choice" + ); + } + } +} diff --git a/crates/plexmaton-provider/tests/provider_fixtures/server_tools.rs b/crates/plexmaton-provider/tests/provider_fixtures/server_tools.rs new file mode 100644 index 00000000..df5f82d4 --- /dev/null +++ b/crates/plexmaton-provider/tests/provider_fixtures/server_tools.rs @@ -0,0 +1,164 @@ +use super::*; +use std::convert::Infallible; + +use futures_util::stream; +use plexmaton_core::{ServerToolAction, ServerToolStatus}; +use plexmaton_provider::{DecodeError, DecodeLimits, SseDecodeError, drive_sse}; + +const CREATED: &str = r#"event: response.created +data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_fixture_ws","status":"in_progress","output":[],"store":false}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":1,"output_index":0,"item":{"id":"ws_fixture_x","type":"web_search_call","status":"in_progress"}} + +"#; +const COMPLETED: &str = r#"event: response.completed +data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_fixture_ws","status":"completed","usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":2,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":12}}} + +"#; + +fn search_done(action: &str) -> String { + done_with_status("completed", action) +} + +fn done_with_status(status: &str, action: &str) -> String { + format!( + "{CREATED}event: response.output_item.done\ndata: {{\"type\":\"response.output_item.done\",\"sequence_number\":2,\"output_index\":0,\"item\":{{\"id\":\"ws_fixture_x\",\"type\":\"web_search_call\",\"status\":\"{status}\"{action}}}}}\n\n{COMPLETED}" + ) +} + +/// PRV-5: a call the provider gave up on is carried with that outcome, so the row can say so, and +/// a done item that still claims to be running fails the step rather than being read as either. +#[tokio::test] +async fn prv_5_a_server_tool_calls_outcome_is_typed_from_its_status() { + for (status, expected) in [ + ("failed", ServerToolStatus::Failed), + ("incomplete", ServerToolStatus::Failed), + ("completed", ServerToolStatus::Completed), + ] { + let (result, emitted) = decode(&done_with_status( + status, + r#","action":{"type":"search","query":"rust release"}"#, + )) + .await; + assert!(result.is_ok(), "{status}: {result:?}"); + assert!( + matches!( + emitted + .iter() + .find(|event| matches!(event, ModelEvent::ServerToolCall { .. })), + Some(ModelEvent::ServerToolCall { call, .. }) if call.status == expected + ), + "{status}: {emitted:?}" + ); + } + for status in ["in_progress", "searching"] { + let (result, emitted) = decode(&done_with_status( + status, + r#","action":{"type":"search","query":"rust release"}"#, + )) + .await; + assert!( + matches!( + result, + Err(SseDecodeError::Decode(DecodeError::UnsupportedEvent(ref kind))) + if *kind == format!("web_search_call.status:{status}") + ), + "{status}: {result:?}" + ); + assert!( + emitted + .iter() + .all(|event| matches!(event, ModelEvent::ServerToolStarted { .. })), + "{status}: {emitted:?}" + ); + } +} + +async fn decode(source: &str) -> (Result<(), SseDecodeError>, Vec) { + let profile = profile(ModelApi::OpenaiResponses); + let mut emitted = Vec::new(); + let result = drive_sse( + &plexmaton_agent::RequestAttemptId::new("fixture-attempt") + .unwrap_or_else(|error| panic!("attempt: {error}")), + &profile, + stream::iter([Ok::<_, Infallible>(source.as_bytes())]), + DecodeLimits::production(), + |event| { + emitted.push(event); + std::future::ready(()) + }, + ) + .await; + (result, emitted) +} + +/// PRV-5: an action kind the record cannot name fails the step rather than being carried blind. +#[tokio::test] +async fn prv_5_an_unknown_server_tool_action_fails_the_step() { + let (result, emitted) = decode(&search_done( + r#","action":{"type":"teleport","destination":"mars"}"#, + )) + .await; + assert!( + matches!( + result, + Err(SseDecodeError::Decode(DecodeError::UnsupportedEvent(ref kind))) + if kind == "web_search_call.action:teleport" + ), + "{result:?}" + ); + assert!( + emitted + .iter() + .all(|event| matches!(event, ModelEvent::ServerToolStarted { .. })), + "nothing beyond the call's placement: {emitted:?}" + ); +} + +/// PRV-5: a finished call without an action says nothing about what the provider did. +#[tokio::test] +async fn prv_5_a_server_tool_call_without_an_action_fails_the_step() { + let (result, _) = decode(&search_done("")).await; + assert!( + matches!( + result, + Err(SseDecodeError::Decode(DecodeError::UnsupportedEvent(ref kind))) + if kind == "web_search_call_without_action" + ), + "{result:?}" + ); +} + +/// PRV-5: a search that arrived with no query is carried as it arrived, and a query spelled twice +/// is kept once. Neither refuses the step. +#[tokio::test] +async fn prv_5_a_search_without_a_query_is_carried_and_a_repeated_query_is_kept_once() { + let (result, emitted) = decode(&search_done(r#","action":{"type":"search","query":""}"#)).await; + result.unwrap_or_else(|error| panic!("empty query should decode: {error}")); + assert!( + matches!( + emitted.as_slice(), + [ModelEvent::ServerToolStarted { .. }, ModelEvent::ServerToolCall { call, .. }, ModelEvent::Replay { .. }, ModelEvent::Usage(_), ModelEvent::Stopped(StopReason::EndOfTurn)] + if call.action == ServerToolAction::Search { queries: Vec::new() } + ), + "{emitted:?}" + ); + let (result, emitted) = decode(&search_done( + r#","action":{"type":"search","query":"rust 1.98.1","queries":["rust 1.98.1","rust release"]}"#, + )) + .await; + result.unwrap_or_else(|error| panic!("two spellings should decode: {error}")); + assert!( + matches!( + emitted + .iter() + .find(|event| matches!(event, ModelEvent::ServerToolCall { .. })), + Some(ModelEvent::ServerToolCall { call, .. }) + if call.action == ServerToolAction::Search { + queries: vec!["rust 1.98.1".to_owned(), "rust release".to_owned()] + } + ), + "{emitted:?}" + ); +} diff --git a/crates/plexmaton-runtime/src/http/summary.rs b/crates/plexmaton-runtime/src/http/summary.rs index f274e12b..d67ae9da 100644 --- a/crates/plexmaton-runtime/src/http/summary.rs +++ b/crates/plexmaton-runtime/src/http/summary.rs @@ -1,5 +1,6 @@ //! Bounded, non-streaming semantic collection for a compaction-owned provider attempt. +use plexmaton_core::ServerToolCall; use std::{collections::BTreeMap, future}; use plexmaton_agent::{ @@ -77,6 +78,11 @@ impl SummaryCollector { .get_or_insert(CompactionFailure::ToolCallOutput); self.tool_call(position, call) } + // A summary that searched is still a summary: the text is what compaction keeps, and + // the call is recorded beside it rather than refused. + ModelEvent::ServerToolCall { position, call } => self.server_tool_call(position, call), + // A summary is read once it is whole; where a call began is a live row's concern. + ModelEvent::ServerToolStarted { .. } => Ok(()), ModelEvent::Usage(_) | ModelEvent::Stopped(_) => { unreachable!("the shared HTTP driver consumes accounting and terminal events") } @@ -183,6 +189,37 @@ impl SummaryCollector { Ok(()) } + fn server_tool_call( + &mut self, + position: ModelOutputPosition, + call: ServerToolCall, + ) -> Result<(), CompactionFailure> { + self.reserve(position)?; + let bytes = call.action.text_bytes(); + if bytes > MAX_REQUESTED_TOOL_ARGUMENT_BYTES { + return Err(CompactionFailure::OutputTooLarge); + } + let total = self + .argument_bytes + .checked_add(bytes) + .filter(|&total| total <= MAX_ASSISTANT_TOOL_ARGUMENT_BYTES) + .ok_or(CompactionFailure::OutputTooLarge)?; + let item_id = self.item_id(position); + let block = self + .blocks + .entry(position) + .or_insert_with(|| AssistantBlock::ReplayOnly { item_id }); + if !matches!(block, AssistantBlock::ReplayOnly { .. }) { + return Err(CompactionFailure::Malformed); + } + *block = AssistantBlock::ServerToolCall { + item_id: block.item_id().clone(), + call, + }; + self.argument_bytes = total; + Ok(()) + } + fn tool_call( &mut self, position: ModelOutputPosition, diff --git a/crates/plexmaton-runtime/src/runtime/model.rs b/crates/plexmaton-runtime/src/runtime/model.rs index 17e3e6d8..61c8faa0 100644 --- a/crates/plexmaton-runtime/src/runtime/model.rs +++ b/crates/plexmaton-runtime/src/runtime/model.rs @@ -39,7 +39,10 @@ impl ModelOutput { ModelEvent::TextDelta { delta, .. } | ModelEvent::ReasoningDelta { delta, .. } => { !delta.is_empty() } - ModelEvent::Replay { .. } | ModelEvent::Called { .. } => true, + ModelEvent::Replay { .. } + | ModelEvent::Called { .. } + | ModelEvent::ServerToolStarted { .. } + | ModelEvent::ServerToolCall { .. } => true, ModelEvent::Usage(_) | ModelEvent::Stopped(_) => { unreachable!("ModelOutput excludes terminal and accounting events") } diff --git a/crates/plexmaton-tui/examples/server_tool_preview.rs b/crates/plexmaton-tui/examples/server_tool_preview.rs new file mode 100644 index 00000000..103e7a4d --- /dev/null +++ b/crates/plexmaton-tui/examples/server_tool_preview.rs @@ -0,0 +1,182 @@ +//! Provider-run calls beside a local tool, painted through the real renderer for colour review. +//! cargo run -p plexmaton-tui --example server_tool_preview -- + +use std::path::Path; + +use plexmaton_core::{ + AgentId, AgentStatus, ConversationEvent, ConversationEventEnvelope, EventSequence, ServerTool, + ServerToolAction, ServerToolCall, ServerToolStatus, ToolCallId, ToolCallStatus, ToolDetail, + ToolPresentation, TranscriptItemId, TranscriptRole, +}; +use plexmaton_tui::{MarkdownTheme, Palette, Workspace}; +use ratatui::{Terminal, backend::TestBackend}; + +#[path = "support/frame_svg.rs"] +mod frame_svg; +#[path = "support/prepared_frame.rs"] +mod prepared_frame; + +type Result = std::result::Result>; + +fn main() -> Result<()> { + let directory = std::env::args() + .nth(1) + .ok_or("provide an output directory")?; + let directory = Path::new(&directory); + std::fs::create_dir_all(directory)?; + for (width, height) in [(120, 40), (88, 42), (60, 46)] { + let mut workspace = fixture()?; + let mut terminal = Terminal::new(TestBackend::new(width, height))?; + prepared_frame::draw(&mut workspace, &mut terminal)?; + let buffer = terminal.backend().buffer(); + std::fs::write( + directory.join(format!("server-tool-{}.svg", buffer.area.width)), + frame_svg::svg(buffer), + )?; + } + Ok(()) +} + +fn text( + events: &mut Vec, + agent: &AgentId, + name: &str, + role: TranscriptRole, + source: &str, +) -> Result<()> { + let item = TranscriptItemId::new(name)?; + events.push(ConversationEvent::TranscriptItemStarted { + agent_id: agent.clone(), + item_id: item.clone(), + role, + }); + events.push(ConversationEvent::TranscriptDelta { + agent_id: agent.clone(), + item_id: item.clone(), + item_revision: 1, + text: source.to_owned(), + }); + events.push(ConversationEvent::TranscriptItemFinalized { + agent_id: agent.clone(), + item_id: item, + item_revision: 2, + }); + Ok(()) +} + +fn fixture() -> Result { + let agent = AgentId::new("primary")?; + let mut events = vec![ConversationEvent::AgentCreated { + agent_id: agent.clone(), + label: "Plexmaton".into(), + status: AgentStatus::Idle, + }]; + text( + &mut events, + &agent, + "question", + TranscriptRole::User, + "What is the latest stable Rust release, and when did it ship?", + )?; + text( + &mut events, + &agent, + "reasoning", + TranscriptRole::Reasoning, + "A version number is a current fact, so I will search rather than answer from memory, then confirm the date on the release post.", + )?; + for (revision, status) in [ + (0, ToolCallStatus::Queued), + (1, ToolCallStatus::Running), + (2, ToolCallStatus::Succeeded), + ] { + events.push(ConversationEvent::ToolCallChanged { + agent_id: agent.clone(), + item_id: TranscriptItemId::new("local-read")?, + item_revision: revision, + call_id: ToolCallId::new("local-read")?, + label: "read_file".into(), + status, + presentation: ToolPresentation { + invocation: (revision > 0).then(|| ToolDetail::Text { + source: "path: Cargo.toml".into(), + omitted_bytes: 0, + }), + outcome: None, + }, + }); + } + let search = |queries: &[&str]| ServerToolAction::Search { + queries: queries.iter().map(|query| (*query).to_owned()).collect(), + }; + for (name, action, status) in [ + ( + "search", + search(&["latest stable Rust release"]), + ServerToolStatus::Completed, + ), + ( + "search-two", + search(&["Rust 1.98.1 release date", "Rust 1.98.1 point release"]), + ServerToolStatus::Completed, + ), + ("search-bare", search(&[]), ServerToolStatus::Completed), + ( + "open", + ServerToolAction::OpenPage { + url: "blog.rust-lang.org/releases/".into(), + }, + ServerToolStatus::Completed, + ), + ( + "find", + ServerToolAction::FindInPage { + url: "releases.rs/docs/1.98.1".into(), + pattern: "1.98.1".into(), + }, + ServerToolStatus::Completed, + ), + ( + "failed", + search(&["Rust nightly changelog"]), + ServerToolStatus::Failed, + ), + ] { + events.push(ConversationEvent::ServerToolCalled { + agent_id: agent.clone(), + item_id: TranscriptItemId::new(name)?, + item_revision: 0, + call: ServerToolCall { + tool: ServerTool::WebSearch, + action, + status, + }, + }); + } + events.push(ConversationEvent::ServerToolStarted { + agent_id: agent.clone(), + item_id: TranscriptItemId::new("running")?, + tool: ServerTool::WebSearch, + }); + text( + &mut events, + &agent, + "answer", + TranscriptRole::Assistant, + "Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the release post on the Rust blog lists what it fixes.", + )?; + let mut workspace = + Workspace::with_palette(Palette::pastel().with_markdown_theme(MarkdownTheme::Pastel)); + workspace.emit( + events + .into_iter() + .enumerate() + .map(|(index, event)| ConversationEventEnvelope { + sequence: EventSequence::new(index as u64 + 1), + event, + }) + .collect(), + ); + workspace.set_working_directory("~/plexmaton".into()); + Ok(workspace) +} diff --git a/crates/plexmaton-tui/frames/server-tool-120.svg b/crates/plexmaton-tui/frames/server-tool-120.svg new file mode 100644 index 00000000..0aa74f3b --- /dev/null +++ b/crates/plexmaton-tui/frames/server-tool-120.svg @@ -0,0 +1 @@ +┌ Plexmaton 󱌣 8 ──────────────────────────────────────────────────────────────────────────────────────────────────────┐│││││││││││││││││││││││││││││││││▌What is the latest stable Rust release, and when did it ship?││││reasoning││A version number is a current fact, so I will search rather than answer from memory, then confirm the date on the││release post.││││[+] read_file · succeeded││[+] web_search · latest stable Rust release││[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release││[+] web_search││[+] web_search · opened blog.rust-lang.org/releases/││[+] web_search · "1.98.1" in releases.rs/docs/1.98.1││[!] web_search · Rust nightly changelog · failed││[~] web_search · running││││Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the release post on the Rust││blog lists what it fixes.││││· Running web_search…│── Message Plexmaton ───────────────────────────────────────────────────────────────────────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/server-tool-60.svg b/crates/plexmaton-tui/frames/server-tool-60.svg new file mode 100644 index 00000000..8701da08 --- /dev/null +++ b/crates/plexmaton-tui/frames/server-tool-60.svg @@ -0,0 +1 @@ +┌ Plexmaton 󱌣 8 ──────────────────────────────────────────┐│││││││││││││││││││││││││││││││││││││▌What is the latest stable Rust release, and when did││▌it ship?││││reasoning││A version number is a current fact, so I will search││rather than answer from memory, then confirm the date││on the release post.││││[+] read_file · succeeded││[+] web_search · latest stable Rust release││[+] web_search · Rust 1.98.1 release date, Rust 1.98.1││point release││[+] web_search││[+] web_search · opened blog.rust-lang.org/releases/││[+] web_search · "1.98.1" in releases.rs/docs/1.98.1││[!] web_search · Rust nightly changelog · failed││[~] web_search · running││││Rust 1.98.1 is the latest stable release. It is a││point release over 1.98.0, and the release post on││the Rust blog lists what it fixes.││││· Running web_search…│── Message Plexmaton ───────────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/server-tool-88.svg b/crates/plexmaton-tui/frames/server-tool-88.svg new file mode 100644 index 00000000..1bb488ea --- /dev/null +++ b/crates/plexmaton-tui/frames/server-tool-88.svg @@ -0,0 +1 @@ +┌ Plexmaton 󱌣 8 ──────────────────────────────────────────────────────────────────────┐│││││││││││││││││││││││││││││││││││││▌What is the latest stable Rust release, and when did it ship?││││reasoning││A version number is a current fact, so I will search rather than answer from││memory, then confirm the date on the release post.││││[+] read_file · succeeded││[+] web_search · latest stable Rust release││[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release││[+] web_search││[+] web_search · opened blog.rust-lang.org/releases/││[+] web_search · "1.98.1" in releases.rs/docs/1.98.1││[!] web_search · Rust nightly changelog · failed││[~] web_search · running││││Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and││the release post on the Rust blog lists what it fixes.││││· Running web_search…│── Message Plexmaton ───────────────────────────────────────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/server-tool-medium.txt b/crates/plexmaton-tui/frames/server-tool-medium.txt new file mode 100644 index 00000000..e11a11ad --- /dev/null +++ b/crates/plexmaton-tui/frames/server-tool-medium.txt @@ -0,0 +1,40 @@ +┌ Plexmaton 󱌣 8 ─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│▌What is the latest stable Rust release, and when did it ship? │ +│ │ +│reasoning │ +│A version number is a current fact, so I will search rather than answer from memory, │ +│then confirm the date on the release post. │ +│ │ +│[+] read_file · succeeded │ +│[+] web_search · latest stable Rust release │ +│[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release │ +│[+] web_search │ +│[+] web_search · opened blog.rust-lang.org/releases/ │ +│[+] web_search · "1.98.1" in releases.rs/docs/1.98.1 │ +│[!] web_search · Rust nightly changelog · failed │ +│[~] web_search · running │ +│ │ +│Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the │ +│release post on the Rust blog lists what it fixes. │ +│ │ +│· Running web_search… │ +── Message Plexmaton ────────────────────────────────────────────────────────────────────────── + Type a message · ⇥ to focus +─────────────────────────────────────────────────────────────────────────────────────────────── + ~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/server-tool-narrow.txt b/crates/plexmaton-tui/frames/server-tool-narrow.txt new file mode 100644 index 00000000..31be06ca --- /dev/null +++ b/crates/plexmaton-tui/frames/server-tool-narrow.txt @@ -0,0 +1,40 @@ +┌ Plexmaton 󱌣 8 ──────────────────────────────────────────┐ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│▌What is the latest stable Rust release, and when did │ +│▌it ship? │ +│ │ +│reasoning │ +│A version number is a current fact, so I will search │ +│rather than answer from memory, then confirm the date │ +│on the release post. │ +│ │ +│[+] read_file · succeeded │ +│[+] web_search · latest stable Rust release │ +│[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 │ +│point release │ +│[+] web_search │ +│[+] web_search · opened blog.rust-lang.org/releases/ │ +│[+] web_search · "1.98.1" in releases.rs/docs/1.98.1 │ +│[!] web_search · Rust nightly changelog · failed │ +│[~] web_search · running │ +│ │ +│Rust 1.98.1 is the latest stable release. It is a │ +│point release over 1.98.0, and the release post on │ +│the Rust blog lists what it fixes. │ +│ │ +│· Running web_search… │ +── Message Plexmaton ─────────────────────────────────────── + Type a message · ⇥ to focus +──────────────────────────────────────────────────────────── + ~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/server-tool-open-wide.txt b/crates/plexmaton-tui/frames/server-tool-open-wide.txt new file mode 100644 index 00000000..e7bcbbbc --- /dev/null +++ b/crates/plexmaton-tui/frames/server-tool-open-wide.txt @@ -0,0 +1,40 @@ +┌ Plexmaton 󱌣 8 ──────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│▌What is the latest stable Rust release, and when did it ship? │ +│ │ +│reasoning │ +│A version number is a current fact, so I will search rather than answer from memory, then confirm the date on the │ +│release post. │ +│ │ +│[+] read_file · succeeded │ +│[+] web_search · latest stable Rust release │ +│[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release │ +│[+] web_search │ +│[+] web_search · opened blog.rust-lang.org/releases/ │ +│[+] web_search · "1.98.1" in releases.rs/docs/1.98.1 │ +│ action │ +│ │ url: releases.rs/docs/1.98.1 │ +│ │ pattern: 1.98.1 │ +│[!] web_search · Rust nightly changelog · failed │ +│[~] web_search · running │ +│ │ +│Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the release post on the Rust │ +│blog lists what it fixes. │ +│ │ +│· Running web_search… │ +── Message Plexmaton ─────────────────────────────────────────────────────────────────────────────────────────────────── + Type a message · ⇥ to focus +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/server-tool-wide.txt b/crates/plexmaton-tui/frames/server-tool-wide.txt new file mode 100644 index 00000000..91d62800 --- /dev/null +++ b/crates/plexmaton-tui/frames/server-tool-wide.txt @@ -0,0 +1,40 @@ +┌ Plexmaton 󱌣 8 ──────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│▌What is the latest stable Rust release, and when did it ship? │ +│ │ +│reasoning │ +│A version number is a current fact, so I will search rather than answer from memory, then confirm the date on the │ +│release post. │ +│ │ +│[+] read_file · succeeded │ +│[+] web_search · latest stable Rust release │ +│[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release │ +│[+] web_search │ +│[+] web_search · opened blog.rust-lang.org/releases/ │ +│[+] web_search · "1.98.1" in releases.rs/docs/1.98.1 │ +│[!] web_search · Rust nightly changelog · failed │ +│[~] web_search · running │ +│ │ +│Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the release post on the Rust │ +│blog lists what it fixes. │ +│ │ +│· Running web_search… │ +── Message Plexmaton ─────────────────────────────────────────────────────────────────────────────────────────────────── + Type a message · ⇥ to focus +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/src/content.rs b/crates/plexmaton-tui/src/content.rs index 81c9ce6c..58140a15 100644 --- a/crates/plexmaton-tui/src/content.rs +++ b/crates/plexmaton-tui/src/content.rs @@ -84,7 +84,9 @@ fn count_entries(agent: &crate::AgentView) -> EntryCounts { .entries() .fold(EntryCounts::default(), |counts, entry| match entry { TranscriptEntryView::Text(_) | TranscriptEntryView::Handoff(_) => counts, - TranscriptEntryView::Tool(_) => EntryCounts { + // A call the provider ran is still a tool the model reached for; the row's colour, + // not the tally, says where it ran. + TranscriptEntryView::Tool(_) | TranscriptEntryView::ServerTool(_) => EntryCounts { tools: counts.tools.saturating_add(1), ..counts }, diff --git a/crates/plexmaton-tui/src/content/tool.rs b/crates/plexmaton-tui/src/content/tool.rs index 2473305d..364a86ed 100644 --- a/crates/plexmaton-tui/src/content/tool.rs +++ b/crates/plexmaton-tui/src/content/tool.rs @@ -1,11 +1,11 @@ //! Compact and disclosed presentation for one typed tool entry. use crate::text_layout::paint::{Line, Span, Treatment}; -use plexmaton_core::{ToolCallStatus, ToolDetail}; +use plexmaton_core::{ServerToolAction, ServerToolStatus, ToolCallStatus, ToolDetail}; use crate::{ content::transcript_presentation::{Row, append_source}, - state::{EntryAppearance, ToolCallView}, + state::{EntryAppearance, ServerToolView, ToolCallView}, theme::{Role, tool_role}, }; @@ -32,6 +32,74 @@ pub(super) fn prepared_entry(tool: &ToolCallView, appearance: EntryAppearance) - lines } +/// The provider's call in the tool row's grammar, with colour saying who ran it. +/// +/// Same marker column, same name, same `·`, same disclosure, so a reader scans one column for +/// everything the model reached for. The marker's colour is what differs: a call that ended well +/// wears the server-tool role rather than a lifecycle's, because there was no lifecycle here to +/// colour, and one that failed wears failure like any other (ui-ux §transcript grammar). After the +/// `·` is what the route reported and nothing it did not: the query, an opened page, or a pattern +/// and where it was sought. A search the route reported without a query is the name alone. +/// Rejected: a row family of its own, which gave up the marker column and the disclosure tools +/// already have; and the plain succeeded colour, which read as a tool this harness ran inside the +/// fence. The user chose the grammar and the colour on 2026-09-21 from rendered candidates. +pub(super) fn prepared_server_tool(view: &ServerToolView, appearance: EntryAppearance) -> Vec { + let Some(call) = &view.call else { + // Placed where the provider began the call, and still running: the tool row's word for + // it, in the colour that says whose work it is. + let running = ToolCallStatus::Running; + let mut compact = Line::from(vec![ + Span::styled(format!("{} ", marker(running)), Role::ServerTool), + Span::styled(view.tool.name().to_owned(), Role::Body), + Span::styled(format!(" · {}", status_label(running)), Role::ServerTool), + ]); + compact.treatment = Treatment::EntryHeading; + return vec![Row::heading(compact)]; + }; + let (status, role) = match call.status { + ServerToolStatus::Completed => (ToolCallStatus::Succeeded, Role::ServerTool), + ServerToolStatus::Failed => (ToolCallStatus::Failed, Role::Failure), + }; + let mut spans = vec![ + Span::styled(format!("{} ", marker(status)), role), + Span::styled(view.tool.name().to_owned(), Role::Body), + ]; + match &call.action { + ServerToolAction::Search { queries } if queries.is_empty() => {} + ServerToolAction::Search { queries } => { + spans.push(Span::styled(" · ", role)); + spans.push(Span::styled(queries.join(", "), Role::Body)); + } + ServerToolAction::OpenPage { url } => { + spans.push(Span::styled(" · ", role)); + spans.push(Span::styled("opened ", Role::Muted)); + spans.push(Span::styled(url.clone(), Role::Body)); + } + ServerToolAction::FindInPage { url, pattern } => { + spans.push(Span::styled(" · ", role)); + spans.push(Span::styled(format!("\"{pattern}\""), Role::Body)); + spans.push(Span::styled(" in ", Role::Muted)); + spans.push(Span::styled(url.clone(), Role::Body)); + } + } + if status == ToolCallStatus::Failed { + spans.push(Span::styled(format!(" · {}", status_label(status)), role)); + } + let mut compact = Line::from(spans); + compact.treatment = Treatment::EntryHeading; + let mut lines = vec![Row::heading(compact)]; + if appearance.open + && let Some(source) = view.action_source() + { + lines.push(Row::heading(Line::styled( + " action".to_owned(), + Role::Muted, + ))); + append_source(&mut lines, &source, Treatment::Content, |_| Role::Body); + } + lines +} + fn append_detail(lines: &mut Vec, heading: &str, detail: &ToolDetail) { let omitted = match detail { ToolDetail::Text { omitted_bytes, .. } if *omitted_bytes > 0 => { @@ -106,15 +174,188 @@ const fn status_label(status: ToolCallStatus) -> &'static str { #[cfg(test)] mod tests { use plexmaton_core::{ - ToolCallId, ToolCallStatus, ToolDetail, ToolPresentation, TranscriptItemId, + ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus, ToolCallId, ToolCallStatus, + ToolDetail, ToolPresentation, TranscriptItemId, }; - use super::{marker, prepared_entry}; + use super::{marker, prepared_entry, prepared_server_tool}; use crate::{ - state::{EntryAppearance, ToolCallView}, + state::{EntryAppearance, ServerToolView, ToolCallView}, theme::{Palette, Role}, }; + fn server_tool(action: ServerToolAction, status: ServerToolStatus) -> ServerToolView { + ServerToolView { + entry_id: TranscriptItemId::new("search") + .unwrap_or_else(|error| panic!("fixture: {error}")), + tool: ServerTool::WebSearch, + call: Some(ServerToolCall { + tool: ServerTool::WebSearch, + action, + status, + }), + revision: 0, + } + } + + /// ENT-2: a call the provider has begun is one running row in the tool grammar, in the colour + /// that says whose work it is, with nothing to disclose yet. + #[test] + fn a_running_server_tool_row_is_named_and_wears_the_server_tool_colour() { + let palette = Palette::pastel(); + let running = ServerToolView { + entry_id: TranscriptItemId::new("search") + .unwrap_or_else(|error| panic!("fixture: {error}")), + tool: ServerTool::WebSearch, + call: None, + revision: 0, + }; + let open = EntryAppearance { + selected: false, + open: true, + hovered: false, + copy_hovered: false, + }; + assert_eq!( + server_entry(&running, &palette, EntryAppearance::compact(false)), + ["[~] web_search · running"] + ); + assert_eq!( + server_entry(&running, &palette, open), + ["[~] web_search · running"] + ); + assert_eq!( + marker_colour(&running, &palette), + palette.style(Role::ServerTool).fg + ); + assert_eq!(running.action_source(), None); + } + + fn server_entry( + view: &ServerToolView, + palette: &Palette, + appearance: EntryAppearance, + ) -> Vec { + let colors = crate::text_layout::paint::Colors::new(palette); + prepared_server_tool(view, appearance) + .into_iter() + .map(|row| { + row.flattened() + .paint_entry(&colors, appearance) + .to_string() + .trim_end() + .to_owned() + }) + .collect() + } + + fn marker_colour(view: &ServerToolView, palette: &Palette) -> Option { + let colors = crate::text_layout::paint::Colors::new(palette); + let appearance = EntryAppearance::compact(false); + let rows = prepared_server_tool(view, appearance); + let line = rows + .into_iter() + .next() + .unwrap_or_else(|| panic!("a compact row")) + .flattened() + .paint_entry(&colors, appearance); + line.spans.first().and_then(|span| span.style.fg) + } + + /// ui-ux §transcript grammar: the provider's call keeps the tool row's grammar and says what + /// the route reported and nothing further; colour says who ran it, and failure stays failure. + #[test] + fn a_server_tool_row_keeps_the_tool_grammar_and_claims_only_what_the_route_reported() { + let palette = Palette::pastel(); + let search = |queries: &[&str]| ServerToolAction::Search { + queries: queries.iter().map(|query| (*query).to_owned()).collect(), + }; + for (action, expected) in [ + ( + search(&["latest stable Rust release"]), + "[+] web_search · latest stable Rust release", + ), + (search(&["a", "b"]), "[+] web_search · a, b"), + (search(&[]), "[+] web_search"), + ( + ServerToolAction::OpenPage { + url: "blog.rust-lang.org/releases/".to_owned(), + }, + "[+] web_search · opened blog.rust-lang.org/releases/", + ), + ( + ServerToolAction::FindInPage { + url: "releases.rs/docs/1.98.1".to_owned(), + pattern: "1.98.1".to_owned(), + }, + "[+] web_search · \"1.98.1\" in releases.rs/docs/1.98.1", + ), + ] { + let view = server_tool(action, ServerToolStatus::Completed); + let lines = server_entry(&view, &palette, EntryAppearance::compact(false)); + assert_eq!(lines, [expected]); + assert_eq!( + marker_colour(&view, &palette), + palette.style(Role::ServerTool).fg, + "{expected}: the marker wears the server-tool colour" + ); + } + let failed = server_tool( + search(&["Rust nightly changelog"]), + ServerToolStatus::Failed, + ); + assert_eq!( + server_entry(&failed, &palette, EntryAppearance::compact(false)), + ["[!] web_search · Rust nightly changelog · failed"] + ); + assert_eq!( + marker_colour(&failed, &palette), + palette.style(Role::Failure).fg, + "a failed call wears failure, wherever it ran" + ); + assert_ne!( + palette.style(Role::ServerTool).fg, + palette.style(Role::NewInformation).fg, + "the provider's marker is not the local tool's green" + ); + } + + /// ENT-4: what the route reported discloses beneath the row, one fact per line, and a search + /// the route reported without a query has nothing to disclose. + #[test] + fn a_server_tool_row_discloses_what_the_route_reported() { + let palette = Palette::pastel(); + let open = EntryAppearance { + selected: false, + open: true, + hovered: false, + copy_hovered: false, + }; + let sought = server_tool( + ServerToolAction::FindInPage { + url: "releases.rs/docs/1.98.1".to_owned(), + pattern: "1.98.1".to_owned(), + }, + ServerToolStatus::Completed, + ); + assert_eq!( + server_entry(&sought, &palette, open), + [ + "[+] web_search · \"1.98.1\" in releases.rs/docs/1.98.1", + " action", + " │ url: releases.rs/docs/1.98.1", + " │ pattern: 1.98.1", + ] + ); + let bare = server_tool( + ServerToolAction::Search { + queries: Vec::new(), + }, + ServerToolStatus::Completed, + ); + assert_eq!(server_entry(&bare, &palette, open), ["[+] web_search"]); + } + fn entry( tool: &ToolCallView, palette: &Palette, diff --git a/crates/plexmaton-tui/src/content_transcript.rs b/crates/plexmaton-tui/src/content_transcript.rs index b134428e..f4f57c3b 100644 --- a/crates/plexmaton-tui/src/content_transcript.rs +++ b/crates/plexmaton-tui/src/content_transcript.rs @@ -65,6 +65,7 @@ pub(crate) fn transcript_layout_with_prefix( return transcript_text_with_prefix(item, width, math, prefix); } TranscriptEntryView::Tool(tool) => tool::prepared_entry(tool, appearance), + TranscriptEntryView::ServerTool(view) => tool::prepared_server_tool(view, appearance), TranscriptEntryView::Artifact(artifact) => { let line = Line::from(vec![ Span::styled("@ ", Role::NewInformation), @@ -244,6 +245,7 @@ pub(crate) fn discloses(entry: &TranscriptEntryView) -> bool { TranscriptEntryView::Tool(tool) => { tool.presentation.invocation.is_some() || tool.presentation.outcome.is_some() } + TranscriptEntryView::ServerTool(view) => view.action_source().is_some(), TranscriptEntryView::Mail(mail) => !mail.summary.is_empty(), TranscriptEntryView::Task(task) => !task.task.is_empty(), TranscriptEntryView::Text(_) diff --git a/crates/plexmaton-tui/src/frames.rs b/crates/plexmaton-tui/src/frames.rs index b5f607c3..5eeb3b1a 100644 --- a/crates/plexmaton-tui/src/frames.rs +++ b/crates/plexmaton-tui/src/frames.rs @@ -98,6 +98,13 @@ mod tests { ("tool-open-narrow", 60, 40), ]; + /// Stage 33's provider-run calls beside a local tool, at the three product widths. + const SERVER_TOOL_FRAMES: [(&str, u16, u16); 3] = [ + ("server-tool-wide", 120, 40), + ("server-tool-medium", 95, 40), + ("server-tool-narrow", 60, 40), + ]; + /// Slice 6's remaining transcript roles and a disclosed canonical patch. const GRAMMAR_FRAMES: [(&str, u16, u16); 3] = [ ("transcript-grammar-wide", 120, 40), @@ -515,6 +522,212 @@ mod tests { ); } + /// ui-ux §transcript grammar: a provider-run call reads in the tool row's grammar, in its own + /// colour when it ended well and in failure's when it did not; the row says what the route + /// reported and nothing further, and the title counts it among the tools. + #[test] + fn the_server_tool_frames_match_their_fixtures() { + let state = server_tool_state(); + for (name, width, height) in SERVER_TOOL_FRAMES { + let drawn = draw(&state, width, height); + for row in [ + "[+] read_file · succeeded", + "[+] web_search · latest stable Rust release", + "[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release", + "[+] web_search · opened blog.rust-lang.org/releases/", + "[!] web_search · Rust nightly changelog · failed", + "[~] web_search · running", + ] { + let fits = row.len() <= usize::from(width) - 2; + assert!( + drawn.contains(row) || !fits, + "{name}: the row is absent or truncated where it fits: {row:?}" + ); + } + assert!( + drawn + .lines() + .any(|line| line.trim_end().ends_with("│[+] web_search") + || line.contains("│[+] web_search ")), + "{name}: a search the route reported without a query is the name alone" + ); + assert!( + drawn.contains(" 8 "), + "{name}: every call counts among the tools" + ); + crate::test_support::assert_frame(name, &drawn); + } + // The disclosed row, at the width a reviewer reads first. + let mut state = server_tool_state(); + let agent = state + .primary_agent() + .map(|agent| agent.id.clone()) + .unwrap_or_else(|| panic!("the frame has a primary agent")); + let item = TranscriptItemId::new("server-tool-find") + .unwrap_or_else(|error| panic!("fixture: {error}")); + let index = state + .primary_agent() + .and_then(|agent| agent.entries().position(|entry| entry.id() == &item)) + .unwrap_or_else(|| panic!("the find is in the transcript")); + let (surfaces, _) = draw_frame(&state, &Palette::pastel(), 120, 40); + state.toggle_entry( + &surfaces, + &TranscriptMetrics::default(), + EntryTarget { + surface: SurfaceId::Transcript, + agent, + item, + index, + }, + ); + let drawn = draw(&state, 120, 40); + assert!(drawn.contains(" │ pattern: 1.98.1"), "{drawn}"); + crate::test_support::assert_frame("server-tool-open-wide", &drawn); + } + + fn server_tool_state() -> ViewState { + use plexmaton_core::{ + ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus, TranscriptRole, + }; + + let agent = + AgentId::new("agent-primary").unwrap_or_else(|error| panic!("fixture: {error}")); + let mut state = ViewState::default(); + let mut sequence = 0_u64; + apply_frame_event( + &mut state, + &mut sequence, + ConversationEvent::AgentCreated { + agent_id: agent.clone(), + label: "Plexmaton".to_owned(), + status: AgentStatus::Idle, + }, + ); + append_frame_text( + &mut state, + &mut sequence, + &agent, + "user-turn", + TranscriptRole::User, + "What is the latest stable Rust release, and when did it ship?", + ); + append_frame_text( + &mut state, + &mut sequence, + &agent, + "visible-reasoning", + TranscriptRole::Reasoning, + "A version number is a current fact, so I will search rather than answer from memory, then confirm the date on the release post.", + ); + let item = + TranscriptItemId::new("local-read").unwrap_or_else(|error| panic!("fixture: {error}")); + let call = ToolCallId::new("local-read").unwrap_or_else(|error| panic!("fixture: {error}")); + for (revision, status) in [ + (0, ToolCallStatus::Queued), + (1, ToolCallStatus::Running), + (2, ToolCallStatus::Succeeded), + ] { + apply_frame_event( + &mut state, + &mut sequence, + ConversationEvent::ToolCallChanged { + agent_id: agent.clone(), + item_id: item.clone(), + item_revision: revision, + call_id: call.clone(), + label: "read_file".to_owned(), + status, + presentation: ToolPresentation { + invocation: (revision > 0).then(|| ToolDetail::Text { + source: "path: Cargo.toml".to_owned(), + omitted_bytes: 0, + }), + outcome: None, + }, + }, + ); + } + let search = |queries: &[&str]| ServerToolAction::Search { + queries: queries.iter().map(|query| (*query).to_owned()).collect(), + }; + // The shapes one live route produced, in its order: a query, two at once, a search it + // reported without one, an opened page, a pattern sought in a page, and a call it gave up. + for (name, action, status) in [ + ( + "server-tool-search", + search(&["latest stable Rust release"]), + ServerToolStatus::Completed, + ), + ( + "server-tool-search-two", + search(&["Rust 1.98.1 release date", "Rust 1.98.1 point release"]), + ServerToolStatus::Completed, + ), + ( + "server-tool-search-bare", + search(&[]), + ServerToolStatus::Completed, + ), + ( + "server-tool-open", + ServerToolAction::OpenPage { + url: "blog.rust-lang.org/releases/".to_owned(), + }, + ServerToolStatus::Completed, + ), + ( + "server-tool-find", + ServerToolAction::FindInPage { + url: "releases.rs/docs/1.98.1".to_owned(), + pattern: "1.98.1".to_owned(), + }, + ServerToolStatus::Completed, + ), + ( + "server-tool-failed", + search(&["Rust nightly changelog"]), + ServerToolStatus::Failed, + ), + ] { + apply_frame_event( + &mut state, + &mut sequence, + ConversationEvent::ServerToolCalled { + agent_id: agent.clone(), + item_id: TranscriptItemId::new(name) + .unwrap_or_else(|error| panic!("fixture: {error}")), + item_revision: 0, + call: ServerToolCall { + tool: ServerTool::WebSearch, + action, + status, + }, + }, + ); + } + // One the provider has begun and not yet reported. + apply_frame_event( + &mut state, + &mut sequence, + ConversationEvent::ServerToolStarted { + agent_id: agent.clone(), + item_id: TranscriptItemId::new("server-tool-running") + .unwrap_or_else(|error| panic!("fixture: {error}")), + tool: ServerTool::WebSearch, + }, + ); + append_frame_text( + &mut state, + &mut sequence, + &agent, + "assistant-turn", + TranscriptRole::Assistant, + "Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the release post on the Rust blog lists what it fixes.", + ); + state.set_working_directory("~/plexmaton".to_owned()); + state + } + fn transcript_grammar_state(width: u16, height: u16) -> ViewState { use plexmaton_core::TranscriptRole; diff --git a/crates/plexmaton-tui/src/lib.rs b/crates/plexmaton-tui/src/lib.rs index 4d12d112..000df7f3 100644 --- a/crates/plexmaton-tui/src/lib.rs +++ b/crates/plexmaton-tui/src/lib.rs @@ -42,9 +42,10 @@ pub use state::{ CopyReceipt, CopyRequest, Drawer, HandoffView, InspectorView, Listing, MAX_CONVERSATION_CHOICES, MailView, ModelChoice, ModelIdentity, NoticeView, Page, PermissionRequest, PersistenceNotice, QueuedBoundary, QueuedInput, ReduceError, RetryAction, - RetryActions, RetrySubmission, RetryTarget, ScrollPosition, Selection, SkillChoice, - SkillChoiceSource, Submission, SubmissionKind, SwitchRefusal, TaskView, ToolCallView, - TranscriptEntryView, TranscriptItemView, TranscriptTextKind, ViewRevision, ViewState, + RetryActions, RetrySubmission, RetryTarget, ScrollPosition, Selection, ServerToolView, + SkillChoice, SkillChoiceSource, Submission, SubmissionKind, SwitchRefusal, TaskView, + ToolCallView, TranscriptEntryView, TranscriptItemView, TranscriptTextKind, ViewRevision, + ViewState, }; pub use state::{ApprovalChoice, ApprovalFeedback, ApprovalStage}; pub use statusline::{StatusLineText, StatusLineTextError}; diff --git a/crates/plexmaton-tui/src/state/agent.rs b/crates/plexmaton-tui/src/state/agent.rs index 9a595136..41d19482 100644 --- a/crates/plexmaton-tui/src/state/agent.rs +++ b/crates/plexmaton-tui/src/state/agent.rs @@ -1,6 +1,6 @@ use plexmaton_core::{ - AgentId, AgentStatus, ArtifactId, MailId, TokenUsage, ToolCallId, ToolCallStatus, - ToolPresentation, TranscriptItemId, TranscriptRole, TurnId, + AgentId, AgentStatus, ArtifactId, MailId, ServerTool, ServerToolCall, TokenUsage, ToolCallId, + ToolCallStatus, ToolPresentation, TranscriptItemId, TranscriptRole, TurnId, }; use super::{ @@ -72,6 +72,7 @@ impl AgentView { self.entries.iter().filter_map(|entry| match entry { TranscriptEntryView::Text(item) => Some(item), TranscriptEntryView::Tool(_) + | TranscriptEntryView::ServerTool(_) | TranscriptEntryView::Artifact(_) | TranscriptEntryView::Mail(_) | TranscriptEntryView::Task(_) @@ -302,6 +303,69 @@ impl AgentView { ) } + /// Places a call the provider began, running at revision zero (ENT-2). + pub(super) fn note_server_tool_started( + &mut self, + entry_id: TranscriptItemId, + tool: ServerTool, + ) -> Result { + self.insert_terminal( + entry_id.clone(), + TranscriptEntryView::ServerTool(super::ServerToolView { + entry_id, + tool, + call: None, + revision: 0, + }), + ) + } + + /// Finishes a call the provider ran. At revision one it finishes the row + /// [`Self::note_server_tool_started`] placed; at revision zero it is the whole entry, which is + /// how a reopened conversation shows it. A finished call never changes again, so a second + /// report is a duplicate rather than an update (ENT-2). + pub(super) fn note_server_tool( + &mut self, + entry_id: TranscriptItemId, + item_revision: u64, + call: ServerToolCall, + ) -> Result { + if let Some(entry) = self.entries.get_mut(&entry_id) { + let TranscriptEntryView::ServerTool(view) = entry else { + return Err(ReduceError::EntryKindChanged(entry_id)); + }; + if view.call.is_some() { + return Err(ReduceError::DuplicateTranscriptItem(entry_id)); + } + let expected = view.revision.saturating_add(1); + if item_revision != expected { + return Err(ReduceError::ItemRevisionGap { + item_id: entry_id, + expected, + received: item_revision, + }); + } + if view.tool != call.tool { + return Err(ReduceError::ToolCorrelationChanged(entry_id)); + } + view.call = Some(call); + view.revision = item_revision; + return Ok(true); + } + if item_revision != 0 { + return Err(ReduceError::UnknownTranscriptItem(entry_id)); + } + self.insert_terminal( + entry_id.clone(), + TranscriptEntryView::ServerTool(super::ServerToolView { + entry_id, + tool: call.tool, + call: Some(call), + revision: 0, + }), + ) + } + /// Adds one outgoing mail item to its producer's transcript. pub(super) fn deliver_mail( &mut self, @@ -417,6 +481,7 @@ impl AgentView { match entry { TranscriptEntryView::Text(item) => Ok(item), TranscriptEntryView::Tool(_) + | TranscriptEntryView::ServerTool(_) | TranscriptEntryView::Artifact(_) | TranscriptEntryView::Mail(_) | TranscriptEntryView::Task(_) diff --git a/crates/plexmaton-tui/src/state/current_work.rs b/crates/plexmaton-tui/src/state/current_work.rs index d5f7e267..2a37d2df 100644 --- a/crates/plexmaton-tui/src/state/current_work.rs +++ b/crates/plexmaton-tui/src/state/current_work.rs @@ -48,6 +48,12 @@ impl<'a> CurrentWork<'a> { { running_tool = Some(tool.label.as_str()); } + // A call the provider is still running is work in progress like any tool's. + TranscriptEntryView::ServerTool(view) + if view.call.is_none() && running_tool.is_none() => + { + running_tool = Some(view.tool.name()); + } TranscriptEntryView::Text(item) if item.role == TranscriptRole::Assistant && !item.finalized => { @@ -55,6 +61,7 @@ impl<'a> CurrentWork<'a> { } TranscriptEntryView::Text(_) | TranscriptEntryView::Tool(_) + | TranscriptEntryView::ServerTool(_) | TranscriptEntryView::Artifact(_) | TranscriptEntryView::Mail(_) | TranscriptEntryView::Task(_) @@ -184,6 +191,38 @@ mod tests { } /// The current-work label is a priority projection, not a second lifecycle. + /// A call the provider is running is the current work, named the way a local tool's is, and + /// stops being it once the provider reports what it did. + #[test] + fn a_running_server_tool_is_the_current_work() { + use plexmaton_core::{ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus}; + let mut searching = agent(AgentStatus::Running); + searching + .note_server_tool_started(item("search"), ServerTool::WebSearch) + .unwrap_or_else(|error| panic!("fixture: {error}")); + assert_eq!( + CurrentWork::derive(&searching, std::iter::empty(), false), + Some(CurrentWork::RunningTool("web_search")) + ); + searching + .note_server_tool( + item("search"), + 1, + ServerToolCall { + tool: ServerTool::WebSearch, + action: ServerToolAction::Search { + queries: Vec::new(), + }, + status: ServerToolStatus::Completed, + }, + ) + .unwrap_or_else(|error| panic!("fixture: {error}")); + assert_ne!( + CurrentWork::derive(&searching, std::iter::empty(), false), + Some(CurrentWork::RunningTool("web_search")) + ); + } + #[test] fn current_work_priority_is_derived_from_semantic_facts() { let idle = agent(AgentStatus::Idle); diff --git a/crates/plexmaton-tui/src/state/entry.rs b/crates/plexmaton-tui/src/state/entry.rs index 6b7b620c..3a10db7e 100644 --- a/crates/plexmaton-tui/src/state/entry.rs +++ b/crates/plexmaton-tui/src/state/entry.rs @@ -1,8 +1,8 @@ //! Typed entries in one agent's ordered transcript projection. use plexmaton_core::{ - AgentId, ArtifactId, MailId, ToolCallId, ToolCallStatus, ToolPresentation, TranscriptItemId, - TranscriptRole, + AgentId, ArtifactId, MailId, ServerTool, ServerToolAction, ServerToolCall, ToolCallId, + ToolCallStatus, ToolPresentation, TranscriptItemId, TranscriptRole, }; use super::ReduceError; @@ -56,6 +56,8 @@ pub enum TranscriptEntryView { Text(TranscriptItemView), /// One tool call throughout its lifecycle. Tool(ToolCallView), + /// One tool the provider ran on its own side, in the state it ended in. + ServerTool(ServerToolView), /// One announced durable work product. Artifact(ArtifactView), /// One delivered mail summary. @@ -73,6 +75,7 @@ impl TranscriptEntryView { match self { Self::Text(item) => &item.id, Self::Tool(tool) => &tool.entry_id, + Self::ServerTool(view) => &view.entry_id, Self::Artifact(artifact) => &artifact.entry_id, Self::Mail(mail) => &mail.entry_id, Self::Task(task) => &task.entry_id, @@ -86,6 +89,7 @@ impl TranscriptEntryView { match self { Self::Text(item) => item.revision, Self::Tool(tool) => tool.revision, + Self::ServerTool(view) => view.revision, Self::Artifact(artifact) => artifact.revision, Self::Mail(mail) => mail.revision, Self::Task(task) => task.revision, @@ -107,6 +111,43 @@ pub struct ToolCallView { pub revision: u64, } +/// One call the provider ran inside a model call: running until the provider reports what it did. +/// +/// Drawn in [`ToolCallView`]'s grammar and unlike it underneath: there is no call identity of ours +/// to correlate, no admission, and two states rather than seven. It appears where the provider +/// placed the call and finishes once; a reopened conversation holds only the finished call and +/// shows it finished from the start (ENT-2). +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ServerToolView { + pub entry_id: TranscriptItemId, + pub tool: ServerTool, + /// What the provider did and how it ended; absent while it is still running. + pub call: Option, + pub revision: u64, +} + +impl ServerToolView { + /// What the route reported, one fact per line, for disclosure and copy alike; `None` when it + /// reported nothing beyond that a call happened, which is one live route's search today. + #[must_use] + pub fn action_source(&self) -> Option { + match &self.call.as_ref()?.action { + ServerToolAction::Search { queries } if queries.is_empty() => None, + ServerToolAction::Search { queries } => Some( + queries + .iter() + .map(|query| format!("query: {query}")) + .collect::>() + .join("\n"), + ), + ServerToolAction::OpenPage { url } => Some(format!("url: {url}")), + ServerToolAction::FindInPage { url, pattern } => { + Some(format!("url: {url}\npattern: {pattern}")) + } + } + } +} + /// Durable work product announced by an agent, referenced by pointer rather than copied inline. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct ArtifactView { diff --git a/crates/plexmaton-tui/src/state/ingest.rs b/crates/plexmaton-tui/src/state/ingest.rs index bdbb8fd4..6ff8634d 100644 --- a/crates/plexmaton-tui/src/state/ingest.rs +++ b/crates/plexmaton-tui/src/state/ingest.rs @@ -161,13 +161,9 @@ impl ViewState { agent_id, item_id, role, - } => { - self.validate_entry_owner(&agent_id, &item_id)?; - self.agent_mut(&agent_id)? - .start_item(item_id.clone(), role)?; - self.remember_entry_owner(item_id, agent_id); - true - } + } => self.apply_entry(agent_id, item_id, |agent, item_id| { + agent.start_item(item_id, role).map(|_| true) + })?, ConversationEvent::TranscriptDelta { agent_id, item_id, @@ -197,19 +193,24 @@ impl ViewState { label, status, presentation, - } => { - self.validate_entry_owner(&agent_id, &item_id)?; - let changed = self.agent_mut(&agent_id)?.set_tool_entry( - item_id.clone(), - item_revision, - call_id, - label, - status, - presentation, - )?; - self.remember_entry_owner(item_id, agent_id); - changed - } + } => self.apply_entry(agent_id, item_id, |agent, item_id| { + agent.set_tool_entry(item_id, item_revision, call_id, label, status, presentation) + })?, + ConversationEvent::ServerToolStarted { + agent_id, + item_id, + tool, + } => self.apply_entry(agent_id, item_id, |agent, item_id| { + agent.note_server_tool_started(item_id, tool) + })?, + ConversationEvent::ServerToolCalled { + agent_id, + item_id, + item_revision, + call, + } => self.apply_entry(agent_id, item_id, |agent, item_id| { + agent.note_server_tool(item_id, item_revision, call) + })?, ConversationEvent::AttentionRequested { agent_id, attention_id, @@ -402,6 +403,20 @@ impl ViewState { Ok(changed) } + /// One entry-bearing fact, in the shape every kind shares: the entry belongs to the agent, the + /// agent's projection files it, and the owner is remembered for the revisions to come. + fn apply_entry( + &mut self, + agent_id: AgentId, + item_id: TranscriptItemId, + file: impl FnOnce(&mut AgentView, TranscriptItemId) -> Result, + ) -> Result { + self.validate_entry_owner(&agent_id, &item_id)?; + let changed = file(self.agent_mut(&agent_id)?, item_id.clone())?; + self.remember_entry_owner(item_id, agent_id); + Ok(changed) + } + fn apply_artifact( &mut self, agent_id: AgentId, @@ -524,6 +539,144 @@ mod tests { } } + fn server_tool_started_event(agent: &AgentId, entry: &str) -> ConversationEvent { + ConversationEvent::ServerToolStarted { + agent_id: agent.clone(), + item_id: item_id(entry), + tool: plexmaton_core::ServerTool::WebSearch, + } + } + + fn server_tool_event(agent: &AgentId, entry: &str, queries: &[&str]) -> ConversationEvent { + server_tool_finished(agent, entry, 0, queries) + } + + fn server_tool_finished( + agent: &AgentId, + entry: &str, + item_revision: u64, + queries: &[&str], + ) -> ConversationEvent { + ConversationEvent::ServerToolCalled { + agent_id: agent.clone(), + item_id: item_id(entry), + item_revision, + call: plexmaton_core::ServerToolCall { + tool: plexmaton_core::ServerTool::WebSearch, + action: plexmaton_core::ServerToolAction::Search { + queries: queries.iter().map(|query| (*query).to_owned()).collect(), + }, + status: plexmaton_core::ServerToolStatus::Completed, + }, + } + } + + /// ENT-2: a call the provider began appears running at revision zero and finishes once at + /// revision one; a second finish is a duplicate the notice log keeps, never an update. + #[test] + fn a_server_tool_call_runs_at_revision_zero_and_finishes_once() { + let mut state = ViewState::default(); + let agent = agent_id("agent-a"); + state.apply(envelope(1, created("agent-a"))); + assert_eq!( + state.apply(envelope(2, server_tool_started_event(&agent, "search"))), + ApplyOutcome::Accepted + ); + let running: Vec<_> = state + .primary_agent() + .unwrap_or_else(|| panic!("agent was projected")) + .entries() + .cloned() + .collect(); + assert!( + matches!( + running.as_slice(), + [TranscriptEntryView::ServerTool(view)] if view.call.is_none() && view.revision == 0 + ), + "{running:?}" + ); + let gap = state.apply(envelope( + 3, + server_tool_finished(&agent, "search", 2, &["rust"]), + )); + assert!( + matches!( + gap, + ApplyOutcome::Rejected(ReduceError::ItemRevisionGap { + expected: 1, + received: 2, + .. + }) + ), + "{gap:?}" + ); + assert_eq!( + state.apply(envelope( + 4, + server_tool_finished(&agent, "search", 1, &["rust"]) + )), + ApplyOutcome::Accepted + ); + let again = state.apply(envelope( + 5, + server_tool_finished(&agent, "search", 2, &["again"]), + )); + assert!( + matches!( + again, + ApplyOutcome::Rejected(ReduceError::DuplicateTranscriptItem(ref item)) if item == &item_id("search") + ), + "{again:?}" + ); + let entries: Vec<_> = state + .primary_agent() + .unwrap_or_else(|| panic!("agent was projected")) + .entries() + .collect(); + assert!( + matches!( + entries.as_slice(), + [TranscriptEntryView::ServerTool(view)] + if view.revision == 1 + && view.call.as_ref().map(|call| &call.action) + == Some(&plexmaton_core::ServerToolAction::Search { queries: vec!["rust".to_owned()] }) + ), + "{entries:?}" + ); + } + + /// ENT-2: a reopened conversation holds only the finished call, which appears finished at + /// revision zero; it too finishes once. + #[test] + fn a_reopened_server_tool_call_appears_finished_at_revision_zero() { + let mut state = ViewState::default(); + let agent = agent_id("agent-a"); + state.apply(envelope(1, created("agent-a"))); + assert_eq!( + state.apply(envelope(2, server_tool_event(&agent, "search", &["rust"]))), + ApplyOutcome::Accepted + ); + let outcome = state.apply(envelope(3, server_tool_event(&agent, "search", &["again"]))); + assert!( + matches!( + outcome, + ApplyOutcome::Rejected(ReduceError::DuplicateTranscriptItem(ref item)) if item == &item_id("search") + ), + "{outcome:?}" + ); + let unplaced = state.apply(envelope( + 4, + server_tool_finished(&agent, "other", 1, &["x"]), + )); + assert!( + matches!( + unplaced, + ApplyOutcome::Rejected(ReduceError::UnknownTranscriptItem(_)) + ), + "{unplaced:?}" + ); + } + #[test] fn every_step_of_the_canonical_scenario_is_accepted() { // The shared fixture ignores the outcome so a degraded projection is still constructible. @@ -624,11 +777,12 @@ mod tests { state.apply(envelope( 8, ConversationEvent::RuntimeError { - agent_id: agent, + agent_id: agent.clone(), item_id: item_id("error"), message: "failed".to_owned(), }, )); + state.apply(envelope(9, server_tool_event(&agent, "search", &["rust"]))); let entries: Vec<_> = state .primary_agent() @@ -641,6 +795,7 @@ mod tests { TranscriptTextKind::Error => "error", }, TranscriptEntryView::Tool(_) => "tool", + TranscriptEntryView::ServerTool(_) => "server_tool", TranscriptEntryView::Artifact(_) => "artifact", TranscriptEntryView::Mail(_) => "mail", TranscriptEntryView::Task(_) => "task", @@ -649,7 +804,15 @@ mod tests { .collect(); assert_eq!( entries, - ["text", "tool", "artifact", "mail", "warning", "error"] + [ + "text", + "tool", + "artifact", + "mail", + "warning", + "error", + "server_tool" + ] ); assert_eq!( state.notices().count(), diff --git a/crates/plexmaton-tui/src/state/mod.rs b/crates/plexmaton-tui/src/state/mod.rs index 5ca4fe99..6a44c8fe 100644 --- a/crates/plexmaton-tui/src/state/mod.rs +++ b/crates/plexmaton-tui/src/state/mod.rs @@ -59,8 +59,8 @@ pub(crate) use disclosure::{DisclosureState, EntryAppearance, EntryTarget}; pub(crate) use drawer::Shown; pub use drawer::{Drawer, Page}; pub use entry::{ - ArtifactView, HandoffView, MailView, TaskView, ToolCallView, TranscriptEntryView, - TranscriptItemView, TranscriptTextKind, + ArtifactView, HandoffView, MailView, ServerToolView, TaskView, ToolCallView, + TranscriptEntryView, TranscriptItemView, TranscriptTextKind, }; pub use ingest::{ApplyOutcome, ReduceError}; pub(crate) use input_queue::{QUEUE_RULE_ROWS, queued_lines}; diff --git a/crates/plexmaton-tui/src/state/selection.rs b/crates/plexmaton-tui/src/state/selection.rs index bba1af37..30646b62 100644 --- a/crates/plexmaton-tui/src/state/selection.rs +++ b/crates/plexmaton-tui/src/state/selection.rs @@ -327,6 +327,8 @@ fn entry_source(entry: &TranscriptEntryView) -> Option { match entry { TranscriptEntryView::Text(item) => Some(item.source.clone()), TranscriptEntryView::Tool(tool) => tool_source(&tool.presentation), + // The same lines disclosure shows: what the route reported, and nothing it did not. + TranscriptEntryView::ServerTool(view) => view.action_source(), // The pointer, not the human label: the pointer is the stable artifact value (SEL-2). TranscriptEntryView::Artifact(artifact) => Some(artifact.pointer.clone()), // The opposite endpoint's display label travels with the addressed body (ENT-1). @@ -433,6 +435,50 @@ mod tests { assert!(!copied.contains("outcome:")); } + /// ENT-4 and SEL-2: a server tool's copy is what the route reported, one fact per line, the + /// same lines disclosure shows; a search reported without a query has no source to copy. + #[test] + fn server_tool_copy_is_what_the_route_reported() { + use super::TranscriptEntryView; + use plexmaton_core::{ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus}; + let view = |action| crate::ServerToolView { + entry_id: TranscriptItemId::new("search") + .unwrap_or_else(|error| panic!("fixture: {error}")), + tool: ServerTool::WebSearch, + call: Some(ServerToolCall { + tool: ServerTool::WebSearch, + action, + status: ServerToolStatus::Completed, + }), + revision: 0, + }; + assert_eq!( + super::entry_source(&TranscriptEntryView::ServerTool(view( + ServerToolAction::Search { + queries: vec!["a".to_owned(), "b".to_owned()], + } + ))), + Some("query: a\nquery: b".to_owned()) + ); + assert_eq!( + super::entry_source(&TranscriptEntryView::ServerTool(view( + ServerToolAction::FindInPage { + url: "releases.rs".to_owned(), + pattern: "1.98".to_owned(), + } + ))), + Some("url: releases.rs\npattern: 1.98".to_owned()) + ); + assert_eq!( + super::entry_source(&TranscriptEntryView::ServerTool(view( + ServerToolAction::Search { + queries: Vec::new(), + } + ))), + None + ); + } + /// ENT-4: copying a whole tool entry retains cwd and timeout; only modal copy extracts shell source. #[test] fn command_tool_copy_keeps_the_complete_invocation_context() { diff --git a/crates/plexmaton-tui/src/theme.rs b/crates/plexmaton-tui/src/theme.rs index 22688a1b..28b80484 100644 --- a/crates/plexmaton-tui/src/theme.rs +++ b/crates/plexmaton-tui/src/theme.rs @@ -2,7 +2,7 @@ //! //! A [`Slots`] is twelve colours named for where each sits — a ground ramp and eight hues around //! the wheel — and is the only thing a theme supplies. A [`Role`] is what a colour *means* here, -//! and [`Palette::from_slots`] is the one place the sixteen roles are spent on those twelve slots. +//! and [`Palette::from_slots`] is the one place the seventeen roles are spent on those twelve slots. //! Widgets name a role and never a colour, so a theme changes how the product looks and never what //! it says. [`Palette::pastel`] is the shipped assignment, not a closed set. @@ -68,8 +68,8 @@ impl Slots { cyan: Color::Rgb(120, 210, 205), blue: Color::Rgb(130, 180, 240), // The value two separate places arrived at on their own while this slot did not exist. - purple: Color::Rgb(0xC8, 0xB3, 0xEA), - magenta: Color::Rgb(0xE7, 0x8D, 0xCC), + purple: Color::Rgb(0x8B, 0x5C, 0xF6), + magenta: Color::Rgb(0xD9, 0x46, 0xEF), } } @@ -133,6 +133,10 @@ pub enum Role { ActionRequired, /// A failed agent, or a producer that broke the event contract. Failure, + /// Work a provider did on its own side, outside the fence: the marker of a server tool call + /// that ended well. One that failed wears `Failure`, because a failure is one wherever it ran; + /// the tool's name beside the marker still says which it was. + ServerTool, /// Content the user has selected for copying. Selection, /// The row the next `Enter` acts on: a bar, weight and a hue together, so it is found at a @@ -142,7 +146,7 @@ pub enum Role { impl Role { /// Every role, used by tests and by palette completeness checks. - pub const ALL: [Self; 16] = [ + pub const ALL: [Self; 17] = [ Self::Body, Self::Muted, Self::Border, @@ -157,6 +161,7 @@ impl Role { Self::NewInformation, Self::ActionRequired, Self::Failure, + Self::ServerTool, Self::Selection, Self::Chosen, ]; @@ -254,12 +259,13 @@ pub struct Palette { new_information: Style, action_required: Style, failure: Style, + server_tool: Style, selection: Style, chosen: Style, } impl Palette { - /// Spends the sixteen roles on a theme's twelve slots. + /// Spends the seventeen roles on a theme's twelve slots. /// /// This mapping is the product's half of the palette and lives in exactly one place. A theme /// supplies colours and reaches no further, so no colourway can make a failure read as a @@ -291,6 +297,11 @@ impl Palette { new_information: Style::new().fg(slots.green), action_required: Style::new().fg(slots.orange).add_modifier(Modifier::BOLD), failure: Style::new().fg(slots.red).add_modifier(Modifier::BOLD), + // Purple was the wheel's unspent hue, so what the provider did on its side gets a + // colour no other meaning shares; no weight, because it is finished work, not a call + // on the user. The user chose the shade on 2026-09-21 against violet, magenta and the + // slot's earlier lavender. + server_tool: Style::new().fg(slots.purple), selection: SELECTION, chosen: Style::new() .fg(slots.yellow) @@ -324,18 +335,19 @@ impl Palette { new_information: style(Role::NewInformation), action_required: style(Role::ActionRequired), failure: style(Role::Failure), + server_tool: style(Role::ServerTool), selection: style(Role::Selection), chosen: style(Role::Chosen), } } - /// The shipped palette: [`Slots::designed`] spent on the sixteen roles. + /// The shipped palette: [`Slots::designed`] spent on the seventeen roles. /// /// A role says what a thing is — blue for where you are, cyan for work in progress, green for /// what finished, orange for what needs you, red for what failed, yellow for what `Enter` acts - /// on. Weight makes titles and the chosen row read first; a low-saturation hue with no weight - /// on it is what keeps work in progress quiet, because a slant would draw the eye `Ambient` - /// exists to spare. + /// on, purple for what the provider did on its own side. Weight makes titles and the chosen + /// row read first; a low-saturation hue with no weight on it is what keeps work in progress + /// quiet, because a slant would draw the eye `Ambient` exists to spare. /// It differs from any other assignment of the same slots in one way only: its Markdown is /// designed for reading rather than inherited from the roles (MD-5). /// @@ -408,6 +420,7 @@ impl Palette { Role::NewInformation => self.new_information, Role::ActionRequired => self.action_required, Role::Failure => self.failure, + Role::ServerTool => self.server_tool, Role::Selection => self.selection, Role::Chosen => self.chosen, } diff --git a/crates/plexmaton-tui/src/transcript/spacing.rs b/crates/plexmaton-tui/src/transcript/spacing.rs index dd2de4e3..3b17db7a 100644 --- a/crates/plexmaton-tui/src/transcript/spacing.rs +++ b/crates/plexmaton-tui/src/transcript/spacing.rs @@ -23,14 +23,13 @@ impl Spacing { before_feedback: 1, after_feedback: 0, }, - (TranscriptEntryView::Tool(_), Some(TranscriptEntryView::Text(_))) - if !has_after_feedback => - { - Self { - before_feedback: 0, - after_feedback: 1, - } - } + ( + TranscriptEntryView::Tool(_) | TranscriptEntryView::ServerTool(_), + Some(TranscriptEntryView::Text(_)), + ) if !has_after_feedback => Self { + before_feedback: 0, + after_feedback: 1, + }, _ => Self::default(), } } diff --git a/crates/plexmaton-tui/src/workspace/preparation.rs b/crates/plexmaton-tui/src/workspace/preparation.rs index f969bed3..9a48bd0f 100644 --- a/crates/plexmaton-tui/src/workspace/preparation.rs +++ b/crates/plexmaton-tui/src/workspace/preparation.rs @@ -282,6 +282,13 @@ fn snapshot_bytes(entry: &crate::TranscriptEntryView) -> usize { + tool.presentation.invocation.as_ref().map_or(0, detail) + tool.presentation.outcome.as_ref().map_or(0, detail) } + Entry::ServerTool(view) => { + view.tool.name().len() + + view + .call + .as_ref() + .map_or(0, |call| call.action.text_bytes()) + } Entry::Artifact(artifact) => { artifact.id.as_str().len() + artifact.label.len() + artifact.pointer.len() } diff --git a/examples/providers.toml b/examples/providers.toml index ca49c801..b0fea09b 100644 --- a/examples/providers.toml +++ b/examples/providers.toml @@ -1,6 +1,6 @@ # Current frontier series; select the exact route/model pair your account can access. # These conservative local budgets are below the models' published ceilings. -# Credentials come only from the named environment variables. +# A route names the environment variable that holds its credential, or sets api_key itself. # Native Astra tool conversations require Responses. active_model = { provider = "openai", model = "responses" } @@ -16,6 +16,7 @@ context_window_tokens = 32768 max_output_tokens = 4096 output_reserve_tokens = 4096 instructions = "You are a coding assistant. Preserve tool results accurately." +# server_tools = ["web_search"] # A search the route runs itself; declare it only if the route accepts it. # Example only, USD per million tokens; replace with the selected model's actual rates: # cost = { input = 1.0, output = 2.0, cache_read = 0.1, cache_write = 1.25 } diff --git a/scripts/provider_fixture.py b/scripts/provider_fixture.py index c923c4e0..e72c5bd0 100644 --- a/scripts/provider_fixture.py +++ b/scripts/provider_fixture.py @@ -71,6 +71,9 @@ def asked(body): class LoopbackProvider: """One owned server thread; fixed replies, bounded requests, no outbound connection.""" + # The dialect a journey speaks; a Responses journey sets the other endpoint before entering. + endpoint = "/v1/chat/completions" + def __init__(self, responses): assert 0 < len(responses) <= MAX_RESPONSES assert all(len(self.payload(entry)) <= MAX_REQUEST_BYTES for entry in responses) @@ -100,7 +103,7 @@ def log_message(self, *_args): def do_POST(self): try: - assert self.path == "/v1/chat/completions", "unexpected endpoint" + assert self.path == owner.endpoint, "unexpected endpoint" assert self.headers.get("Authorization") == "Bearer fixture-only", "unexpected credential" assert not self.headers.get("Transfer-Encoding"), "chunked request refused" count = int(self.headers.get("Content-Length", "0")) diff --git a/scripts/smoke-server-tool.py b/scripts/smoke-server-tool.py new file mode 100755 index 00000000..bb29f252 --- /dev/null +++ b/scripts/smoke-server-tool.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""ENT-2/PRV-5: a provider-run search lands where the provider placed it, live and on reopen. + +One loopback Responses stream with the shape a live gateway produced on 2026-09-21: encrypted +reasoning before the messages, every `web_search_call` added at its true position between them, +every one of them finished only after the last message, and the whole stream in one burst. The rows must +still sit between the narration lines they belong to, run while unfinished, finish without a +query when the route reported none, and read the same after the conversation is reopened. +""" +import json +from pathlib import Path +import subprocess +import tempfile + +from provider_fixture import ScriptedProvider +from smoke_support import ROOT, Terminal, fixture_environment + + +def event(payload): + return f"event: {payload['type']}\ndata: {json.dumps(payload)}\n\n".encode() + + +def message(index, item, text): + return b"".join([ + event({"type": "response.output_item.added", "output_index": index, + "item": {"id": item, "type": "message", "status": "in_progress", "role": "assistant", "content": []}}), + event({"type": "response.output_text.delta", "output_index": index, "content_index": 0, "item_id": item, "delta": text}), + event({"type": "response.output_text.done", "output_index": index, "content_index": 0, "item_id": item, "text": text}), + event({"type": "response.output_item.done", "output_index": index, + "item": {"id": item, "type": "message", "status": "completed", "role": "assistant", + "content": [{"type": "output_text", "text": text, "annotations": []}]}}), + ]) + + +def search_added(index, item): + return event({"type": "response.output_item.added", "output_index": index, + "item": {"id": item, "type": "web_search_call", "status": "in_progress", + "action": {"type": "search", "query": ""}}}) + + +def reasoning(index, item): + """An encrypted reasoning item, as the route sends before each message; replay only.""" + return b"".join([ + event({"type": "response.output_item.added", "output_index": index, + "item": {"id": item, "type": "reasoning", "status": "in_progress", "summary": []}}), + event({"type": "response.output_item.done", "output_index": index, + "item": {"id": item, "type": "reasoning", "status": "completed", "summary": [], + "encrypted_content": "c21va2Utb3BhcXVlLXJlYXNvbmluZw=="}}), + ]) + + +def search_done(index, item, query): + action = {"type": "search", "query": query} + return event({"type": "response.output_item.done", "output_index": index, + "item": {"id": item, "type": "web_search_call", "status": "completed", "action": action}}) + + +def burst(): + usage = {"input_tokens": 40, "input_tokens_details": {"cached_tokens": 0}, "output_tokens": 30, + "output_tokens_details": {"reasoning_tokens": 0}, "total_tokens": 70} + return b"".join([ + event({"type": "response.created", "response": {"id": "resp_smoke", "status": "in_progress", "output": []}}), + reasoning(0, "rs_0"), + message(1, "msg_1", "SMOKE_FIRST checking the release page."), + search_added(2, "ws_2"), + reasoning(3, "rs_3"), + message(4, "msg_4", "SMOKE_SECOND results point to 1.98.1."), + search_added(5, "ws_5"), + message(6, "msg_6", "SMOKE_ANSWER 1.98.1"), + search_done(2, "ws_2", ""), + search_done(5, "ws_5", "Rust 1.98.1 release"), + event({"type": "response.completed", "response": {"id": "resp_smoke", "status": "completed", "usage": usage}}), + ]) + + +def rows(screen): + return [row.rstrip("│ ").lstrip("│").strip() for row in screen.splitlines()] + + +def order(screen, *needles): + positions = [] + for needle in needles: + index = next((i for i, row in enumerate(rows(screen)) if row.startswith(needle)), None) + assert index is not None, f"{needle!r} is not on screen:\n{screen}" + positions.append(index) + assert positions == sorted(positions), f"rows out of order {positions}:\n{screen}" + + +def run_smoke(provider): + with tempfile.TemporaryDirectory(prefix="plexmaton-server-tool-smoke-", dir="/tmp") as folder: + home, project = Path(folder) / "home", Path(folder) / "project" + home.mkdir() + project.mkdir() + (home / "config.toml").write_text(f'''active_model = {{ provider = "loop", model = "searcher" }} +[providers.loop] +base_url = "{provider.base_url}" +api_key_env = "LOOP_LOGIN" +api = "openai_responses" +[providers.loop.models.searcher] +id = "searcher-wire" +display_name = "Searcher" +reasoning_effort = "low" +allowed_reasoning_efforts = ["low", "high"] +server_tools = ["web_search"] +context_window_tokens = 32768 +max_output_tokens = 4096 +output_reserve_tokens = 4096 +''') + environment = dict(fixture_environment(), PLEXMATON_HOME=str(home), LOOP_LOGIN="fixture-only") + with Terminal(project, environment, "server-tool", "placement") as terminal: + terminal.wait("Message Plexmaton") + screen = terminal.prompt("What is the latest stable Rust release?", "SMOKE_ANSWER", "[+] web_search · Rust 1.98.1 release", + absent=("Preparing text", "Running web_search", "· Thinking")) + # Placed where the provider put them: between the narration lines, not after the answer. + order(screen, "SMOKE_FIRST", "[+] web_search", "SMOKE_SECOND", "[+] web_search · Rust 1.98.1 release", "SMOKE_ANSWER") + assert "󱌣 2" in screen, screen + terminal.widths("placement", "SMOKE_ANSWER", "[+] web_search") + terminal.quit() + sessions = list((home / "sessions").glob("*.jsonl")) + assert len(sessions) == 1, sessions + with Terminal(project, environment, "server-tool", "reopen", arguments=("resume", sessions[0].stem)) as terminal: + screen = terminal.wait("SMOKE_ANSWER", "[+] web_search · Rust 1.98.1 release", absent=("Preparing text",)) + order(screen, "SMOKE_FIRST", "[+] web_search", "SMOKE_SECOND", "[+] web_search · Rust 1.98.1 release", "SMOKE_ANSWER") + terminal.quit() + requests, errors = provider.snapshot() + assert not errors, errors + assert {"type": "web_search"} in requests[0]["tools"], "the declaration crossed the wire" + + +def main(): + subprocess.run(["cargo", "build", "--locked", "-p", "plexmaton-cli", "--bin", "plexmaton", "--quiet"], cwd=ROOT, check=True) + ScriptedProvider.endpoint = "/v1/responses" + with ScriptedProvider([burst()]) as provider: + run_smoke(provider) + print("server-tool smoke passed: rows placed where the provider put them, live and on reopen") + + +if __name__ == "__main__": + main()