@oneciel-ai/ciel-runtime 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -0
- package/ciel-runtime-menu.py +56 -6
- package/ciel_runtime.py +9379 -35909
- package/ciel_runtime_support/advisor_client.py +193 -0
- package/ciel_runtime_support/advisor_policy.py +320 -0
- package/ciel_runtime_support/advisor_refinement.py +160 -0
- package/ciel_runtime_support/advisor_request_builder.py +261 -0
- package/ciel_runtime_support/agy_installer.py +169 -0
- package/ciel_runtime_support/agy_mcp_restore.py +182 -0
- package/ciel_runtime_support/anthropic_model_policy.py +186 -0
- package/ciel_runtime_support/anthropic_response_writer.py +255 -0
- package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
- package/ciel_runtime_support/api_key_cooldown.py +159 -0
- package/ciel_runtime_support/architecture.py +488 -1
- package/ciel_runtime_support/architecture_budget.py +42 -0
- package/ciel_runtime_support/channel_backlog.py +90 -0
- package/ciel_runtime_support/channel_cli.py +119 -0
- package/ciel_runtime_support/channel_compact_injection.py +82 -0
- package/ciel_runtime_support/channel_compact_poll.py +67 -0
- package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
- package/ciel_runtime_support/channel_config_service.py +281 -0
- package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
- package/ciel_runtime_support/channel_connection_registry.py +128 -0
- package/ciel_runtime_support/channel_connection_worker.py +284 -0
- package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
- package/ciel_runtime_support/channel_cursor_repository.py +89 -0
- package/ciel_runtime_support/channel_cursor_service.py +178 -0
- package/ciel_runtime_support/channel_event_identity.py +212 -0
- package/ciel_runtime_support/channel_event_projection.py +315 -0
- package/ciel_runtime_support/channel_inflight.py +127 -0
- package/ciel_runtime_support/channel_injection.py +115 -0
- package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
- package/ciel_runtime_support/channel_launch_policy.py +180 -0
- package/ciel_runtime_support/channel_llm_context.py +156 -0
- package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
- package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
- package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
- package/ciel_runtime_support/channel_mcp_tools.py +240 -0
- package/ciel_runtime_support/channel_mcp_transport.py +394 -0
- package/ciel_runtime_support/channel_message_dedupe.py +65 -0
- package/ciel_runtime_support/channel_message_policy.py +256 -0
- package/ciel_runtime_support/channel_message_prompt.py +305 -0
- package/ciel_runtime_support/channel_message_repository.py +234 -0
- package/ciel_runtime_support/channel_notification_projection.py +217 -0
- package/ciel_runtime_support/channel_panel.py +162 -0
- package/ciel_runtime_support/channel_pending_injection.py +209 -0
- package/ciel_runtime_support/channel_pending_poll.py +109 -0
- package/ciel_runtime_support/channel_probe_cache.py +433 -0
- package/ciel_runtime_support/channel_probe_report.py +101 -0
- package/ciel_runtime_support/channel_runtime_environment.py +181 -0
- package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
- package/ciel_runtime_support/channel_session_repository.py +90 -0
- package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
- package/ciel_runtime_support/channel_terminal_input.py +277 -0
- package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
- package/ciel_runtime_support/channel_tool_context.py +166 -0
- package/ciel_runtime_support/channel_transcript.py +414 -0
- package/ciel_runtime_support/channel_transcript_repository.py +96 -0
- package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
- package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
- package/ciel_runtime_support/chat_files.py +138 -0
- package/ciel_runtime_support/chat_http_controller.py +235 -0
- package/ciel_runtime_support/claude_environment.py +375 -0
- package/ciel_runtime_support/claude_router.py +247 -193
- package/ciel_runtime_support/cli_dispatch.py +792 -0
- package/ciel_runtime_support/cli_parser.py +165 -0
- package/ciel_runtime_support/cli_usage.py +100 -0
- package/ciel_runtime_support/codex_app_server.py +20 -5
- package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
- package/ciel_runtime_support/codex_cli.py +42 -6
- package/ciel_runtime_support/codex_config.py +323 -0
- package/ciel_runtime_support/codex_launch_configuration.py +240 -0
- package/ciel_runtime_support/codex_launch_policy.py +66 -0
- package/ciel_runtime_support/codex_mcp_integration.py +195 -0
- package/ciel_runtime_support/codex_mcp_restore.py +304 -0
- package/ciel_runtime_support/codex_model_catalog.py +133 -0
- package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
- package/ciel_runtime_support/codex_router.py +147 -1
- package/ciel_runtime_support/codex_session_repository.py +115 -0
- package/ciel_runtime_support/codex_session_selection.py +114 -0
- package/ciel_runtime_support/command_asset_installer.py +103 -0
- package/ciel_runtime_support/compatibility_probe.py +295 -0
- package/ciel_runtime_support/compatibility_protocol.py +251 -0
- package/ciel_runtime_support/compatibility_runtime.py +166 -0
- package/ciel_runtime_support/compatibility_test.py +370 -0
- package/ciel_runtime_support/config_migrations.py +307 -0
- package/ciel_runtime_support/config_repository.py +175 -0
- package/ciel_runtime_support/config_value_codec.py +64 -0
- package/ciel_runtime_support/configuration_cli.py +374 -0
- package/ciel_runtime_support/context_compaction.py +280 -0
- package/ciel_runtime_support/context_setup.py +208 -0
- package/ciel_runtime_support/context_summary_policy.py +392 -0
- package/ciel_runtime_support/credential_cli.py +104 -0
- package/ciel_runtime_support/credential_management.py +261 -0
- package/ciel_runtime_support/credentials.py +269 -0
- package/ciel_runtime_support/executable_discovery.py +141 -0
- package/ciel_runtime_support/github_copilot_oauth.py +335 -0
- package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
- package/ciel_runtime_support/header_forwarding.py +73 -0
- package/ciel_runtime_support/headless_config.py +221 -0
- package/ciel_runtime_support/http_response.py +129 -0
- package/ciel_runtime_support/install_diagnostics.py +149 -0
- package/ciel_runtime_support/kimi_identity.py +123 -0
- package/ciel_runtime_support/launch_diagnostics.py +204 -0
- package/ciel_runtime_support/launch_state.py +127 -0
- package/ciel_runtime_support/live_api_key_controller.py +58 -0
- package/ciel_runtime_support/llm_config_http.py +148 -0
- package/ciel_runtime_support/llm_option_config.py +259 -0
- package/ciel_runtime_support/llm_presentation_data.py +447 -0
- package/ciel_runtime_support/llm_presets.py +773 -0
- package/ciel_runtime_support/lm_studio_runtime.py +401 -0
- package/ciel_runtime_support/managed_mcp_config.py +144 -0
- package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
- package/ciel_runtime_support/managed_service_cleanup.py +89 -0
- package/ciel_runtime_support/mcp_config_reader.py +230 -0
- package/ciel_runtime_support/mcp_http_proxy.py +607 -0
- package/ciel_runtime_support/mcp_inventory.py +59 -0
- package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
- package/ciel_runtime_support/mcp_probe_codec.py +136 -0
- package/ciel_runtime_support/mcp_probe_transport.py +328 -0
- package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
- package/ciel_runtime_support/mcp_proxy_config.py +107 -0
- package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
- package/ciel_runtime_support/mcp_proxy_process.py +560 -0
- package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
- package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
- package/ciel_runtime_support/mcp_transport.py +146 -0
- package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
- package/ciel_runtime_support/model_catalog_projection.py +61 -0
- package/ciel_runtime_support/model_context_hints.py +109 -0
- package/ciel_runtime_support/model_panel.py +147 -0
- package/ciel_runtime_support/model_registry_repository.py +231 -0
- package/ciel_runtime_support/npm_runtime.py +191 -0
- package/ciel_runtime_support/ollama_catalog.py +462 -0
- package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
- package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
- package/ciel_runtime_support/ollama_context_sync.py +87 -0
- package/ciel_runtime_support/ollama_forwarding.py +449 -0
- package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
- package/ciel_runtime_support/openai_chat_router.py +64 -0
- package/ciel_runtime_support/openai_forwarding.py +194 -0
- package/ciel_runtime_support/openai_responses_router.py +291 -0
- package/ciel_runtime_support/openai_responses_stream.py +135 -0
- package/ciel_runtime_support/output_budget.py +89 -0
- package/ciel_runtime_support/package_lifecycle.py +217 -0
- package/ciel_runtime_support/plan_artifact_controller.py +104 -0
- package/ciel_runtime_support/prelaunch.py +959 -0
- package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
- package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
- package/ciel_runtime_support/prelaunch_terminal.py +764 -0
- package/ciel_runtime_support/process_control.py +708 -0
- package/ciel_runtime_support/prompt_compaction.py +322 -0
- package/ciel_runtime_support/prompt_injection.py +176 -0
- package/ciel_runtime_support/protocols/__init__.py +24 -0
- package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
- package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
- package/ciel_runtime_support/protocols/chat_projection.py +315 -0
- package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
- package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
- package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
- package/ciel_runtime_support/protocols/ollama_response.py +231 -0
- package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
- package/ciel_runtime_support/protocols/openai_responses.py +271 -0
- package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
- package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
- package/ciel_runtime_support/provider_adapters.py +160 -0
- package/ciel_runtime_support/provider_catalog_sources.py +316 -0
- package/ciel_runtime_support/provider_choice.py +201 -0
- package/ciel_runtime_support/provider_compatibility.py +165 -0
- package/ciel_runtime_support/provider_config_mutations.py +361 -0
- package/ciel_runtime_support/provider_configuration_service.py +162 -0
- package/ciel_runtime_support/provider_context.py +308 -0
- package/ciel_runtime_support/provider_contract_projection.py +73 -0
- package/ciel_runtime_support/provider_descriptor.py +82 -0
- package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
- package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
- package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
- package/ciel_runtime_support/provider_limits.py +457 -0
- package/ciel_runtime_support/provider_model_identity.py +139 -0
- package/ciel_runtime_support/provider_model_selection.py +431 -0
- package/ciel_runtime_support/provider_model_specs.py +142 -0
- package/ciel_runtime_support/provider_models.py +263 -0
- package/ciel_runtime_support/provider_network.py +176 -0
- package/ciel_runtime_support/provider_option_cli.py +238 -0
- package/ciel_runtime_support/provider_option_panel.py +275 -0
- package/ciel_runtime_support/provider_option_status.py +192 -0
- package/ciel_runtime_support/provider_policy.py +101 -0
- package/ciel_runtime_support/provider_query_policy.py +67 -0
- package/ciel_runtime_support/provider_readiness.py +112 -0
- package/ciel_runtime_support/provider_request_access.py +131 -0
- package/ciel_runtime_support/provider_request_builder.py +250 -0
- package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
- package/ciel_runtime_support/provider_runtime_info.py +113 -0
- package/ciel_runtime_support/provider_runtime_modes.py +150 -0
- package/ciel_runtime_support/provider_sampling_policy.py +46 -0
- package/ciel_runtime_support/provider_status.py +145 -0
- package/ciel_runtime_support/provider_timeout_policy.py +184 -0
- package/ciel_runtime_support/provider_tool_policy.py +145 -0
- package/ciel_runtime_support/providers/__init__.py +65 -0
- package/ciel_runtime_support/providers/anthropic.py +160 -0
- package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
- package/ciel_runtime_support/providers/base.py +232 -0
- package/ciel_runtime_support/providers/catalog.py +326 -0
- package/ciel_runtime_support/providers/cloud.py +194 -0
- package/ciel_runtime_support/providers/constants.py +54 -0
- package/ciel_runtime_support/providers/deepseek.py +115 -0
- package/ciel_runtime_support/providers/fireworks.py +158 -0
- package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
- package/ciel_runtime_support/providers/kimi.py +297 -0
- package/ciel_runtime_support/providers/lm_studio.py +76 -0
- package/ciel_runtime_support/providers/meta.py +257 -0
- package/ciel_runtime_support/providers/native.py +194 -0
- package/ciel_runtime_support/providers/nim.py +69 -0
- package/ciel_runtime_support/providers/nvidia.py +158 -0
- package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
- package/ciel_runtime_support/providers/ollama.py +181 -0
- package/ciel_runtime_support/providers/ollama_context.py +195 -0
- package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
- package/ciel_runtime_support/providers/opencode.py +220 -0
- package/ciel_runtime_support/providers/opencode_go.py +37 -0
- package/ciel_runtime_support/providers/openrouter.py +57 -0
- package/ciel_runtime_support/providers/vllm.py +65 -0
- package/ciel_runtime_support/providers/zai.py +119 -0
- package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
- package/ciel_runtime_support/rate_limit_policy.py +117 -0
- package/ciel_runtime_support/rate_limit_repository.py +154 -0
- package/ciel_runtime_support/registry.py +46 -0
- package/ciel_runtime_support/request_shortcuts.py +253 -0
- package/ciel_runtime_support/request_trace.py +323 -0
- package/ciel_runtime_support/response_collection.py +209 -0
- package/ciel_runtime_support/router_access.py +238 -0
- package/ciel_runtime_support/router_client_lifecycle.py +366 -0
- package/ciel_runtime_support/router_health_policy.py +101 -0
- package/ciel_runtime_support/router_http.py +513 -0
- package/ciel_runtime_support/router_process_lifecycle.py +401 -0
- package/ciel_runtime_support/router_rate_limit_service.py +285 -0
- package/ciel_runtime_support/router_server_runtime.py +103 -0
- package/ciel_runtime_support/router_shortcuts.py +201 -0
- package/ciel_runtime_support/routing_fallback.py +73 -0
- package/ciel_runtime_support/runtime_activity_repository.py +143 -0
- package/ciel_runtime_support/runtime_adapters.py +104 -0
- package/ciel_runtime_support/runtime_command_factory.py +73 -0
- package/ciel_runtime_support/runtime_compatibility.py +50 -0
- package/ciel_runtime_support/runtime_constants.py +178 -0
- package/ciel_runtime_support/runtime_launch.py +1602 -0
- package/ciel_runtime_support/runtime_llm_options.py +312 -0
- package/ciel_runtime_support/runtime_logging.py +161 -0
- package/ciel_runtime_support/runtime_paths.py +157 -0
- package/ciel_runtime_support/runtime_restart.py +84 -0
- package/ciel_runtime_support/runtime_upgrade.py +149 -0
- package/ciel_runtime_support/secure_json_repository.py +55 -0
- package/ciel_runtime_support/session_import.py +356 -0
- package/ciel_runtime_support/settings_repository.py +8 -0
- package/ciel_runtime_support/slash_command_assets.py +211 -0
- package/ciel_runtime_support/sse_stream.py +57 -0
- package/ciel_runtime_support/sse_trace.py +225 -0
- package/ciel_runtime_support/statusline_script.py +593 -0
- package/ciel_runtime_support/statusline_settings.py +53 -0
- package/ciel_runtime_support/stream_chunk_policy.py +18 -0
- package/ciel_runtime_support/streaming_anthropic.py +1955 -0
- package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
- package/ciel_runtime_support/terminal_platform_io.py +127 -0
- package/ciel_runtime_support/timeout_profile.py +196 -0
- package/ciel_runtime_support/tool_dialects.py +85 -0
- package/ciel_runtime_support/tool_exposure_policy.py +63 -0
- package/ciel_runtime_support/tool_guard_hooks.py +218 -0
- package/ciel_runtime_support/tool_request_projection.py +96 -0
- package/ciel_runtime_support/tool_schema.py +483 -0
- package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
- package/ciel_runtime_support/ui_text.py +266 -0
- package/ciel_runtime_support/upstream_error_policy.py +104 -0
- package/ciel_runtime_support/upstream_retry.py +419 -0
- package/ciel_runtime_support/upstream_stream_io.py +106 -0
- package/ciel_runtime_support/usage_events.py +96 -0
- package/ciel_runtime_support/visible_stream_filters.py +130 -0
- package/ciel_runtime_support/web_endpoints.py +447 -0
- package/ciel_runtime_support/web_ui.py +915 -0
- package/ciel_runtime_support/web_ui_controller.py +189 -0
- package/ciel_runtime_support/windows_console_input.py +137 -0
- package/ciel_runtime_support/windows_console_mode.py +112 -0
- package/docs/Architecture.md +54 -0
- package/docs/Configuration.md +17 -0
- package/docs/Module-Map.md +1093 -26
- package/docs/Providers.md +46 -1
- package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
- package/npm-bin/run-ciel-runtime.js +22 -2
- package/package.json +9 -2
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Anthropic thinking and tool-choice compatibility policies.
|
|
2
|
+
|
|
3
|
+
The policy owns protocol-level message transformations. Provider discovery and
|
|
4
|
+
logging remain injected ports so this module does not depend on the runtime
|
|
5
|
+
facade or on concrete provider adapters.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
THINKING_BLOCK_TYPES: tuple[str, ...] = ("thinking", "redacted_thinking")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def message_content_blocks(message: dict[str, Any]) -> list[Any]:
|
|
20
|
+
content = message.get("content")
|
|
21
|
+
if isinstance(content, list):
|
|
22
|
+
return content
|
|
23
|
+
if isinstance(content, str):
|
|
24
|
+
return [{"type": "text", "text": content}]
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def thinking_requested(body: dict[str, Any]) -> bool:
|
|
29
|
+
thinking = body.get("thinking")
|
|
30
|
+
if isinstance(thinking, dict):
|
|
31
|
+
thinking_type = str(thinking.get("type") or "").strip().lower()
|
|
32
|
+
return thinking_type not in ("", "disabled", "none", "off", "false")
|
|
33
|
+
return bool(thinking)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def thinking_block_count(body: dict[str, Any]) -> int:
|
|
37
|
+
return sum(
|
|
38
|
+
1
|
|
39
|
+
for message in body.get("messages") or []
|
|
40
|
+
if isinstance(message, dict)
|
|
41
|
+
for block in message_content_blocks(message)
|
|
42
|
+
if isinstance(block, dict) and block.get("type") in THINKING_BLOCK_TYPES
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def tool_continuation_block_count(body: dict[str, Any]) -> int:
|
|
47
|
+
count = 0
|
|
48
|
+
for message in body.get("messages") or []:
|
|
49
|
+
if not isinstance(message, dict):
|
|
50
|
+
continue
|
|
51
|
+
role = message.get("role")
|
|
52
|
+
for block in message_content_blocks(message):
|
|
53
|
+
if not isinstance(block, dict):
|
|
54
|
+
continue
|
|
55
|
+
block_type = block.get("type")
|
|
56
|
+
if (role == "assistant" and block_type == "tool_use") or (
|
|
57
|
+
role == "user" and block_type == "tool_result"
|
|
58
|
+
):
|
|
59
|
+
count += 1
|
|
60
|
+
return count
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def assistant_history_count(body: dict[str, Any]) -> int:
|
|
64
|
+
return sum(
|
|
65
|
+
1
|
|
66
|
+
for message in body.get("messages") or []
|
|
67
|
+
if isinstance(message, dict) and message.get("role") == "assistant"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def strip_thinking_blocks(body: dict[str, Any]) -> dict[str, Any]:
|
|
72
|
+
messages = body.get("messages")
|
|
73
|
+
if not isinstance(messages, list):
|
|
74
|
+
return body
|
|
75
|
+
changed = False
|
|
76
|
+
projected: list[Any] = []
|
|
77
|
+
for message in messages:
|
|
78
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
79
|
+
if not isinstance(content, list):
|
|
80
|
+
projected.append(message)
|
|
81
|
+
continue
|
|
82
|
+
filtered = [
|
|
83
|
+
block
|
|
84
|
+
for block in content
|
|
85
|
+
if not (
|
|
86
|
+
isinstance(block, dict)
|
|
87
|
+
and block.get("type") in THINKING_BLOCK_TYPES
|
|
88
|
+
)
|
|
89
|
+
]
|
|
90
|
+
if len(filtered) == len(content):
|
|
91
|
+
projected.append(message)
|
|
92
|
+
continue
|
|
93
|
+
projected.append({**message, "content": filtered})
|
|
94
|
+
changed = True
|
|
95
|
+
return {**body, "messages": projected} if changed else body
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def has_synthetic_tool_use(body: dict[str, Any]) -> bool:
|
|
99
|
+
return any(
|
|
100
|
+
str(block.get("id") or "").startswith("toolu_ciel_runtime_")
|
|
101
|
+
for message in body.get("messages") or []
|
|
102
|
+
if isinstance(message, dict) and message.get("role") == "assistant"
|
|
103
|
+
for block in message_content_blocks(message)
|
|
104
|
+
if isinstance(block, dict) and block.get("type") == "tool_use"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def copy_thinking_blocks(blocks: Any) -> list[dict[str, Any]]:
|
|
109
|
+
if not isinstance(blocks, list):
|
|
110
|
+
return []
|
|
111
|
+
return [
|
|
112
|
+
dict(block)
|
|
113
|
+
for block in blocks
|
|
114
|
+
if isinstance(block, dict) and block.get("type") in THINKING_BLOCK_TYPES
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class ThinkingPolicyPorts:
|
|
120
|
+
preserves_contract: Callable[[str, dict[str, Any]], bool]
|
|
121
|
+
reasoning_passback_enabled: Callable[[str, dict[str, Any], dict[str, Any]], bool]
|
|
122
|
+
suggestion_mode: Callable[[dict[str, Any]], bool]
|
|
123
|
+
log: Callable[[str, str], None]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, slots=True)
|
|
127
|
+
class ToolChoicePorts:
|
|
128
|
+
normalize: Callable[[str, dict[str, Any], dict[str, Any], Any], Any]
|
|
129
|
+
supports: Callable[[str, dict[str, Any], dict[str, Any]], bool]
|
|
130
|
+
log: Callable[[str, str], None]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class SuppressedThinkingRepository:
|
|
134
|
+
"""Bounded in-memory repository for passback blocks hidden from clients."""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
records: list[dict[str, Any]],
|
|
139
|
+
capacity: Callable[[], int],
|
|
140
|
+
) -> None:
|
|
141
|
+
self._records = records
|
|
142
|
+
self._capacity = capacity
|
|
143
|
+
|
|
144
|
+
def clear(self) -> None:
|
|
145
|
+
self._records.clear()
|
|
146
|
+
|
|
147
|
+
def remember(self, provider: str, model: str, blocks: list[Any]) -> int:
|
|
148
|
+
copied = copy_thinking_blocks(blocks)
|
|
149
|
+
if not copied:
|
|
150
|
+
return 0
|
|
151
|
+
self._records.append(
|
|
152
|
+
{
|
|
153
|
+
"time": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
154
|
+
"provider": provider,
|
|
155
|
+
"model": model,
|
|
156
|
+
"blocks": copied,
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
capacity = max(1, self._capacity())
|
|
160
|
+
del self._records[:-capacity]
|
|
161
|
+
return len(copied)
|
|
162
|
+
|
|
163
|
+
def recent_for(self, provider: str, limit: int) -> list[dict[str, Any]]:
|
|
164
|
+
if limit <= 0:
|
|
165
|
+
return []
|
|
166
|
+
matches = [record for record in self._records if record.get("provider") == provider]
|
|
167
|
+
return matches[-limit:]
|
|
168
|
+
|
|
169
|
+
def __len__(self) -> int:
|
|
170
|
+
return len(self._records)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class AnthropicThinkingPolicy:
|
|
174
|
+
def __init__(
|
|
175
|
+
self,
|
|
176
|
+
ports: ThinkingPolicyPorts,
|
|
177
|
+
repository: SuppressedThinkingRepository,
|
|
178
|
+
) -> None:
|
|
179
|
+
self._ports = ports
|
|
180
|
+
self._repository = repository
|
|
181
|
+
|
|
182
|
+
def normalize_request(
|
|
183
|
+
self, provider: str, config: dict[str, Any], body: dict[str, Any]
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
requested = thinking_requested(body)
|
|
186
|
+
block_count = thinking_block_count(body)
|
|
187
|
+
if (not requested and block_count <= 0) or self._ports.preserves_contract(
|
|
188
|
+
provider, config
|
|
189
|
+
):
|
|
190
|
+
return body
|
|
191
|
+
if self._ports.reasoning_passback_enabled(provider, config, body):
|
|
192
|
+
if not requested:
|
|
193
|
+
return body
|
|
194
|
+
projected = dict(body)
|
|
195
|
+
projected.pop("thinking", None)
|
|
196
|
+
self._ports.log(
|
|
197
|
+
"INFO",
|
|
198
|
+
"removed top-level Anthropic thinking request but preserved thinking blocks "
|
|
199
|
+
f"for OpenAI-chat reasoning passback provider={provider} "
|
|
200
|
+
f"thinking_blocks={block_count}",
|
|
201
|
+
)
|
|
202
|
+
return projected
|
|
203
|
+
projected = dict(strip_thinking_blocks(body))
|
|
204
|
+
projected.pop("thinking", None)
|
|
205
|
+
self._ports.log(
|
|
206
|
+
"WARN",
|
|
207
|
+
"removed Anthropic thinking request and thinking content blocks for "
|
|
208
|
+
f"non-Anthropic provider provider={provider} "
|
|
209
|
+
f"synthetic_tool={has_synthetic_tool_use(body)} "
|
|
210
|
+
f"continuation_blocks={tool_continuation_block_count(body)} "
|
|
211
|
+
f"assistant_history={assistant_history_count(body)} "
|
|
212
|
+
f"thinking_blocks={block_count}",
|
|
213
|
+
)
|
|
214
|
+
return projected
|
|
215
|
+
|
|
216
|
+
def normalize_response(
|
|
217
|
+
self,
|
|
218
|
+
provider: str,
|
|
219
|
+
config: dict[str, Any],
|
|
220
|
+
message: dict[str, Any],
|
|
221
|
+
model: str | None = None,
|
|
222
|
+
) -> dict[str, Any]:
|
|
223
|
+
if self._ports.preserves_contract(provider, config):
|
|
224
|
+
return message
|
|
225
|
+
content = message.get("content")
|
|
226
|
+
if not isinstance(content, list):
|
|
227
|
+
return message
|
|
228
|
+
filtered = [
|
|
229
|
+
block
|
|
230
|
+
for block in content
|
|
231
|
+
if not (
|
|
232
|
+
isinstance(block, dict)
|
|
233
|
+
and block.get("type") in THINKING_BLOCK_TYPES
|
|
234
|
+
)
|
|
235
|
+
]
|
|
236
|
+
if len(filtered) == len(content):
|
|
237
|
+
return message
|
|
238
|
+
self.remember(provider, str(model or message.get("model") or ""), content)
|
|
239
|
+
self._ports.log(
|
|
240
|
+
"WARN",
|
|
241
|
+
"removed Anthropic thinking response blocks for non-Anthropic provider "
|
|
242
|
+
f"provider={provider} thinking_blocks={len(content) - len(filtered)}",
|
|
243
|
+
)
|
|
244
|
+
return {**message, "content": filtered or [{"type": "text", "text": ""}]}
|
|
245
|
+
|
|
246
|
+
def remember(self, provider: str, model: str, blocks: list[Any]) -> None:
|
|
247
|
+
copied = self._repository.remember(provider, model, blocks)
|
|
248
|
+
if copied:
|
|
249
|
+
self._ports.log(
|
|
250
|
+
"WARN",
|
|
251
|
+
"stored suppressed Anthropic thinking passback blocks "
|
|
252
|
+
f"provider={provider} model={model} blocks={copied} "
|
|
253
|
+
f"cache={len(self._repository)}",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def rehydrate(
|
|
257
|
+
self, provider: str, config: dict[str, Any], body: dict[str, Any]
|
|
258
|
+
) -> dict[str, Any]:
|
|
259
|
+
if (
|
|
260
|
+
self._ports.preserves_contract(provider, config)
|
|
261
|
+
or self._ports.suggestion_mode(body)
|
|
262
|
+
or not len(self._repository)
|
|
263
|
+
):
|
|
264
|
+
return body
|
|
265
|
+
messages = body.get("messages")
|
|
266
|
+
if not isinstance(messages, list):
|
|
267
|
+
return body
|
|
268
|
+
indices = [
|
|
269
|
+
index
|
|
270
|
+
for index, message in enumerate(messages)
|
|
271
|
+
if isinstance(message, dict)
|
|
272
|
+
and message.get("role") == "assistant"
|
|
273
|
+
and not thinking_block_count({"messages": [message]})
|
|
274
|
+
and (
|
|
275
|
+
(isinstance(message.get("content"), list) and bool(message["content"]))
|
|
276
|
+
or (isinstance(message.get("content"), str) and bool(message["content"]))
|
|
277
|
+
)
|
|
278
|
+
]
|
|
279
|
+
records = self._repository.recent_for(provider, len(indices))
|
|
280
|
+
if not records:
|
|
281
|
+
return body
|
|
282
|
+
selected = indices[-len(records) :]
|
|
283
|
+
projected = list(messages)
|
|
284
|
+
inserted = 0
|
|
285
|
+
for index, record in zip(selected, records):
|
|
286
|
+
message = projected[index]
|
|
287
|
+
blocks = copy_thinking_blocks(record.get("blocks"))
|
|
288
|
+
if not isinstance(message, dict) or not blocks:
|
|
289
|
+
continue
|
|
290
|
+
content = message.get("content")
|
|
291
|
+
if isinstance(content, list):
|
|
292
|
+
new_content = blocks + list(content)
|
|
293
|
+
elif isinstance(content, str):
|
|
294
|
+
new_content = blocks + [{"type": "text", "text": content}]
|
|
295
|
+
else:
|
|
296
|
+
continue
|
|
297
|
+
projected[index] = {**message, "content": new_content}
|
|
298
|
+
inserted += len(blocks)
|
|
299
|
+
if not inserted:
|
|
300
|
+
return body
|
|
301
|
+
self._ports.log(
|
|
302
|
+
"WARN",
|
|
303
|
+
"rehydrated suppressed Anthropic thinking passback blocks for upstream "
|
|
304
|
+
f"provider={provider} blocks={inserted} "
|
|
305
|
+
f"assistant_messages={len(selected)} cache={len(self._repository)}",
|
|
306
|
+
)
|
|
307
|
+
return {**body, "messages": projected}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def normalize_tool_choice(
|
|
311
|
+
provider: str,
|
|
312
|
+
config: dict[str, Any],
|
|
313
|
+
body: dict[str, Any],
|
|
314
|
+
ports: ToolChoicePorts,
|
|
315
|
+
) -> dict[str, Any]:
|
|
316
|
+
tool_choice = body.get("tool_choice")
|
|
317
|
+
if tool_choice is None:
|
|
318
|
+
return body
|
|
319
|
+
normalized = ports.normalize(provider, config, body, tool_choice)
|
|
320
|
+
if normalized != tool_choice:
|
|
321
|
+
ports.log(
|
|
322
|
+
"WARN",
|
|
323
|
+
f"normalized unsupported forced tool_choice for provider={provider}: "
|
|
324
|
+
f"model={body.get('model')} tool_choice={tool_choice}",
|
|
325
|
+
)
|
|
326
|
+
return {**body, "tool_choice": normalized}
|
|
327
|
+
if ports.supports(provider, config, body):
|
|
328
|
+
return body
|
|
329
|
+
projected = dict(body)
|
|
330
|
+
removed = projected.pop("tool_choice", None)
|
|
331
|
+
ports.log(
|
|
332
|
+
"WARN",
|
|
333
|
+
f"removed unsupported tool_choice for {provider}: "
|
|
334
|
+
f"model={body.get('model')} tool_choice={removed}",
|
|
335
|
+
)
|
|
336
|
+
return projected
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Anthropic Messages projections for chat-style provider protocols."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class ChatProjectionText:
|
|
12
|
+
system_messages: Callable[..., list[dict[str, Any]]]
|
|
13
|
+
execution_reminder: Callable[..., dict[str, str]]
|
|
14
|
+
state_messages: Callable[..., list[dict[str, Any]]]
|
|
15
|
+
content_to_text: Callable[..., str]
|
|
16
|
+
compact_text: Callable[..., str]
|
|
17
|
+
skip_message: Callable[..., bool]
|
|
18
|
+
attachment_only: Callable[..., bool]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class ChatProjectionTools:
|
|
23
|
+
collect_result_context: Callable[..., tuple[dict[str, str], set[str]]]
|
|
24
|
+
plan_mode_active: Callable[..., bool]
|
|
25
|
+
input_for_prompt: Callable[..., str]
|
|
26
|
+
persisted_output: Callable[..., bool]
|
|
27
|
+
truncate_for_prompt: Callable[..., str]
|
|
28
|
+
canonical_signature: Callable[..., str]
|
|
29
|
+
format_result: Callable[..., tuple[str, str]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class ChatProjectionPolicy:
|
|
34
|
+
thinking_block_types: frozenset[str] | set[str] | tuple[str, ...]
|
|
35
|
+
tool_result_limit: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class ChatProjectionServices:
|
|
40
|
+
text: ChatProjectionText
|
|
41
|
+
tools: ChatProjectionTools
|
|
42
|
+
policy: ChatProjectionPolicy
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class OpenAiHistoryServices:
|
|
47
|
+
log: Callable[[str, str], None]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def anthropic_messages_to_ollama(body: dict[str, Any], *, services: ChatProjectionServices) -> list[dict[str, Any]]:
|
|
51
|
+
text = services.text
|
|
52
|
+
tools = services.tools
|
|
53
|
+
messages = text.system_messages(body.get("system"))
|
|
54
|
+
messages.append(text.execution_reminder())
|
|
55
|
+
messages.extend(text.state_messages(body))
|
|
56
|
+
prior_tool_results, latest_noop_result_ids = tools.collect_result_context(body)
|
|
57
|
+
in_plan_mode = tools.plan_mode_active(body)
|
|
58
|
+
tool_names_by_id: dict[str, str] = {}
|
|
59
|
+
tool_inputs_by_id: dict[str, Any] = {}
|
|
60
|
+
for message in body.get("messages", []) or []:
|
|
61
|
+
if not isinstance(message, dict) or text.attachment_only(message) or text.skip_message(message):
|
|
62
|
+
continue
|
|
63
|
+
role = message.get("role", "user")
|
|
64
|
+
content = message.get("content", "")
|
|
65
|
+
if role == "user" and isinstance(content, list):
|
|
66
|
+
text_blocks: list[Any] = []
|
|
67
|
+
tool_blocks: list[dict[str, Any]] = []
|
|
68
|
+
for block in content:
|
|
69
|
+
if isinstance(block, dict) and block.get("type") == "tool_result":
|
|
70
|
+
tool_blocks.append(block)
|
|
71
|
+
else:
|
|
72
|
+
text_blocks.append(block)
|
|
73
|
+
user_text = text.content_to_text(text_blocks)
|
|
74
|
+
if user_text:
|
|
75
|
+
messages.append({"role": "user", "content": text.compact_text(user_text)})
|
|
76
|
+
for block in tool_blocks:
|
|
77
|
+
tool_use_id = str(block.get("tool_use_id") or "")
|
|
78
|
+
tool_name = tool_names_by_id.get(tool_use_id, "tool")
|
|
79
|
+
tool_input = tool_inputs_by_id.get(tool_use_id)
|
|
80
|
+
tool_input_text = tools.input_for_prompt(tool_input)
|
|
81
|
+
raw_result_text = text.content_to_text(block.get("content", ""))
|
|
82
|
+
if tools.persisted_output(raw_result_text):
|
|
83
|
+
result_text = raw_result_text
|
|
84
|
+
tool_text = raw_result_text
|
|
85
|
+
tool_summary = ""
|
|
86
|
+
else:
|
|
87
|
+
result_text = tools.truncate_for_prompt(raw_result_text, services.policy.tool_result_limit)
|
|
88
|
+
signature = tools.canonical_signature(tool_name, tool_input)
|
|
89
|
+
tool_text, tool_summary = tools.format_result(
|
|
90
|
+
tool_name=tool_name,
|
|
91
|
+
tool_input_text=tool_input_text,
|
|
92
|
+
result_text=result_text,
|
|
93
|
+
is_error=bool(block.get("is_error")),
|
|
94
|
+
prior_success_text=prior_tool_results.get(signature, ""),
|
|
95
|
+
include_prior_success=tool_use_id in latest_noop_result_ids,
|
|
96
|
+
in_plan_mode=in_plan_mode,
|
|
97
|
+
)
|
|
98
|
+
if not block.get("is_error") and tool_name == "TaskList":
|
|
99
|
+
tool_summary = (
|
|
100
|
+
f"The task list is current:\n{result_text}\n\n"
|
|
101
|
+
"If any task is in_progress and the user's request is not finished, your next response "
|
|
102
|
+
"must call a concrete work tool such as Write, Edit, Read, or Bash. Do not respond with "
|
|
103
|
+
"another progress announcement like 'I will write the files now'. If everything is "
|
|
104
|
+
"actually complete, provide the final answer."
|
|
105
|
+
)
|
|
106
|
+
messages.append({"role": "tool", "tool_name": tool_name, "content": tool_text})
|
|
107
|
+
if tool_summary:
|
|
108
|
+
messages.append({"role": "user", "content": tool_summary})
|
|
109
|
+
continue
|
|
110
|
+
message_text = text.content_to_text(content)
|
|
111
|
+
out: dict[str, Any] = {"role": role, "content": text.compact_text(message_text)}
|
|
112
|
+
if role == "assistant" and isinstance(content, list):
|
|
113
|
+
calls = []
|
|
114
|
+
for block in content:
|
|
115
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
116
|
+
name = str(block.get("name") or "tool")
|
|
117
|
+
tool_id = str(block.get("id") or "")
|
|
118
|
+
if tool_id:
|
|
119
|
+
tool_names_by_id[tool_id] = name
|
|
120
|
+
tool_inputs_by_id[tool_id] = block.get("input") or {}
|
|
121
|
+
calls.append({"function": {"name": name, "arguments": block.get("input") or {}}})
|
|
122
|
+
if calls:
|
|
123
|
+
out["tool_calls"] = calls
|
|
124
|
+
messages.append(out)
|
|
125
|
+
return messages
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def anthropic_messages_to_openai(
|
|
129
|
+
body: dict[str, Any],
|
|
130
|
+
reasoning_passback: bool = False,
|
|
131
|
+
*,
|
|
132
|
+
services: ChatProjectionServices,
|
|
133
|
+
) -> list[dict[str, Any]]:
|
|
134
|
+
text = services.text
|
|
135
|
+
tools = services.tools
|
|
136
|
+
messages = text.system_messages(body.get("system"))
|
|
137
|
+
messages.append(text.execution_reminder())
|
|
138
|
+
messages.extend(text.state_messages(body))
|
|
139
|
+
prior_tool_results, latest_noop_result_ids = tools.collect_result_context(body)
|
|
140
|
+
in_plan_mode = tools.plan_mode_active(body)
|
|
141
|
+
tool_names_by_id: dict[str, str] = {}
|
|
142
|
+
tool_inputs_by_id: dict[str, Any] = {}
|
|
143
|
+
for message in body.get("messages", []) or []:
|
|
144
|
+
if not isinstance(message, dict) or text.attachment_only(message) or text.skip_message(message):
|
|
145
|
+
continue
|
|
146
|
+
role = message.get("role", "user")
|
|
147
|
+
content = message.get("content", "")
|
|
148
|
+
if role == "assistant" and isinstance(content, list):
|
|
149
|
+
text_blocks: list[Any] = []
|
|
150
|
+
tool_calls: list[dict[str, Any]] = []
|
|
151
|
+
reasoning_seen = False
|
|
152
|
+
reasoning_parts: list[str] = []
|
|
153
|
+
for block in content:
|
|
154
|
+
if isinstance(block, dict) and block.get("type") in services.policy.thinking_block_types:
|
|
155
|
+
reasoning_seen = True
|
|
156
|
+
if block.get("type") == "thinking":
|
|
157
|
+
reasoning_parts.append(str(block.get("thinking") or ""))
|
|
158
|
+
continue
|
|
159
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
160
|
+
tool_id = str(block.get("id") or f"call_{len(tool_calls) + 1}")
|
|
161
|
+
name = str(block.get("name") or "tool")
|
|
162
|
+
tool_input = block.get("input") if isinstance(block.get("input"), dict) else {}
|
|
163
|
+
tool_names_by_id[tool_id] = name
|
|
164
|
+
tool_inputs_by_id[tool_id] = tool_input
|
|
165
|
+
tool_calls.append({
|
|
166
|
+
"id": tool_id,
|
|
167
|
+
"type": "function",
|
|
168
|
+
"function": {"name": name, "arguments": json.dumps(tool_input, ensure_ascii=False)},
|
|
169
|
+
})
|
|
170
|
+
else:
|
|
171
|
+
text_blocks.append(block)
|
|
172
|
+
out: dict[str, Any] = {"role": "assistant", "content": text.compact_text(text.content_to_text(text_blocks))}
|
|
173
|
+
if reasoning_seen or reasoning_passback:
|
|
174
|
+
out["reasoning_content"] = "\n".join(reasoning_parts)
|
|
175
|
+
if tool_calls:
|
|
176
|
+
out["tool_calls"] = tool_calls
|
|
177
|
+
messages.append(out)
|
|
178
|
+
continue
|
|
179
|
+
if role == "user" and isinstance(content, list):
|
|
180
|
+
text_blocks = []
|
|
181
|
+
for block in content:
|
|
182
|
+
if isinstance(block, dict) and block.get("type") == "tool_result":
|
|
183
|
+
tool_id = str(block.get("tool_use_id") or "call_tool")
|
|
184
|
+
raw_result_text = text.content_to_text(block.get("content", ""))
|
|
185
|
+
if tool_id not in tool_names_by_id:
|
|
186
|
+
result_text = raw_result_text if tools.persisted_output(raw_result_text) else tools.truncate_for_prompt(raw_result_text, services.policy.tool_result_limit)
|
|
187
|
+
if tools.persisted_output(result_text):
|
|
188
|
+
text_blocks.append({"type": "text", "text": result_text})
|
|
189
|
+
else:
|
|
190
|
+
text_blocks.append({"type": "text", "text": f"Historical tool result without a retained assistant tool call ({tool_id}):\n{result_text}"})
|
|
191
|
+
continue
|
|
192
|
+
tool_name = tool_names_by_id.get(tool_id, "tool")
|
|
193
|
+
tool_input = tool_inputs_by_id.get(tool_id)
|
|
194
|
+
if tools.persisted_output(raw_result_text):
|
|
195
|
+
tool_text = raw_result_text
|
|
196
|
+
else:
|
|
197
|
+
result_text = tools.truncate_for_prompt(raw_result_text, services.policy.tool_result_limit)
|
|
198
|
+
tool_text, _ = tools.format_result(
|
|
199
|
+
tool_name=tool_name,
|
|
200
|
+
tool_input_text=tools.input_for_prompt(tool_input),
|
|
201
|
+
result_text=result_text,
|
|
202
|
+
is_error=bool(block.get("is_error")),
|
|
203
|
+
prior_success_text=prior_tool_results.get(tools.canonical_signature(tool_name, tool_input), ""),
|
|
204
|
+
include_prior_success=tool_id in latest_noop_result_ids,
|
|
205
|
+
in_plan_mode=in_plan_mode,
|
|
206
|
+
)
|
|
207
|
+
messages.append({"role": "tool", "tool_call_id": tool_id, "id": tool_id, "content": tool_text})
|
|
208
|
+
else:
|
|
209
|
+
text_blocks.append(block)
|
|
210
|
+
user_text = text.content_to_text(text_blocks)
|
|
211
|
+
if user_text:
|
|
212
|
+
messages.append({"role": "user", "content": text.compact_text(user_text)})
|
|
213
|
+
continue
|
|
214
|
+
out = {"role": role, "content": text.compact_text(text.content_to_text(content))}
|
|
215
|
+
if role == "assistant" and reasoning_passback:
|
|
216
|
+
out["reasoning_content"] = ""
|
|
217
|
+
messages.append(out)
|
|
218
|
+
return messages
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def missing_openai_tool_result_message(tool_call: dict[str, Any]) -> dict[str, Any]:
|
|
222
|
+
tool_id = str(tool_call.get("id") or "call_tool")
|
|
223
|
+
function = tool_call.get("function") if isinstance(tool_call.get("function"), dict) else {}
|
|
224
|
+
name = str(function.get("name") or "tool")
|
|
225
|
+
return {
|
|
226
|
+
"role": "tool",
|
|
227
|
+
"tool_call_id": tool_id,
|
|
228
|
+
"id": tool_id,
|
|
229
|
+
"content": (
|
|
230
|
+
f"Tool result for historical tool call `{name}` was not present in the retained "
|
|
231
|
+
"Claude Code transcript. Treat this as missing historical context, not as a "
|
|
232
|
+
"successful tool execution."
|
|
233
|
+
),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def orphan_openai_tool_message_to_user(message: dict[str, Any]) -> dict[str, str]:
|
|
238
|
+
tool_id = str(message.get("tool_call_id") or message.get("id") or "unknown")
|
|
239
|
+
content = str(message.get("content") or "")
|
|
240
|
+
return {
|
|
241
|
+
"role": "user",
|
|
242
|
+
"content": (
|
|
243
|
+
f"Historical tool message without a retained assistant tool call ({tool_id}):\n"
|
|
244
|
+
f"{content}"
|
|
245
|
+
),
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def repair_openai_tool_call_adjacency(
|
|
250
|
+
messages: list[dict[str, Any]],
|
|
251
|
+
services: OpenAiHistoryServices,
|
|
252
|
+
) -> list[dict[str, Any]]:
|
|
253
|
+
"""Repair OpenAI's immediate tool-result adjacency invariant."""
|
|
254
|
+
repaired: list[dict[str, Any]] = []
|
|
255
|
+
missing_count = 0
|
|
256
|
+
orphan_count = 0
|
|
257
|
+
index = 0
|
|
258
|
+
while index < len(messages):
|
|
259
|
+
message = messages[index]
|
|
260
|
+
tool_calls = message.get("tool_calls") if isinstance(message, dict) else None
|
|
261
|
+
if not (message.get("role") == "assistant" and isinstance(tool_calls, list) and tool_calls):
|
|
262
|
+
if message.get("role") == "tool":
|
|
263
|
+
repaired.append(orphan_openai_tool_message_to_user(message))
|
|
264
|
+
orphan_count += 1
|
|
265
|
+
else:
|
|
266
|
+
repaired.append(message)
|
|
267
|
+
index += 1
|
|
268
|
+
continue
|
|
269
|
+
repaired.append(message)
|
|
270
|
+
index += 1
|
|
271
|
+
immediate_tools: list[dict[str, Any]] = []
|
|
272
|
+
while (
|
|
273
|
+
index < len(messages)
|
|
274
|
+
and isinstance(messages[index], dict)
|
|
275
|
+
and messages[index].get("role") == "tool"
|
|
276
|
+
):
|
|
277
|
+
immediate_tools.append(messages[index])
|
|
278
|
+
index += 1
|
|
279
|
+
by_id: dict[str, list[dict[str, Any]]] = {}
|
|
280
|
+
for tool_message in immediate_tools:
|
|
281
|
+
tool_id = str(tool_message.get("tool_call_id") or tool_message.get("id") or "")
|
|
282
|
+
by_id.setdefault(tool_id, []).append(tool_message)
|
|
283
|
+
required_ids: list[str] = []
|
|
284
|
+
for call in tool_calls:
|
|
285
|
+
if not isinstance(call, dict):
|
|
286
|
+
continue
|
|
287
|
+
tool_id = str(call.get("id") or "")
|
|
288
|
+
if not tool_id:
|
|
289
|
+
continue
|
|
290
|
+
required_ids.append(tool_id)
|
|
291
|
+
matches = by_id.get(tool_id) or []
|
|
292
|
+
if matches:
|
|
293
|
+
repaired.append(matches.pop(0))
|
|
294
|
+
else:
|
|
295
|
+
repaired.append(missing_openai_tool_result_message(call))
|
|
296
|
+
missing_count += 1
|
|
297
|
+
required = set(required_ids)
|
|
298
|
+
for tool_message in immediate_tools:
|
|
299
|
+
tool_id = str(tool_message.get("tool_call_id") or tool_message.get("id") or "")
|
|
300
|
+
if tool_id in required:
|
|
301
|
+
remaining = by_id.get(tool_id) or []
|
|
302
|
+
if tool_message in remaining:
|
|
303
|
+
remaining.remove(tool_message)
|
|
304
|
+
repaired.append(orphan_openai_tool_message_to_user(tool_message))
|
|
305
|
+
orphan_count += 1
|
|
306
|
+
else:
|
|
307
|
+
repaired.append(orphan_openai_tool_message_to_user(tool_message))
|
|
308
|
+
orphan_count += 1
|
|
309
|
+
if missing_count or orphan_count:
|
|
310
|
+
services.log(
|
|
311
|
+
"WARN",
|
|
312
|
+
f"openai_tool_call_adjacency_repaired missing_tool_results={missing_count} "
|
|
313
|
+
f"orphan_tool_messages={orphan_count}",
|
|
314
|
+
)
|
|
315
|
+
return repaired
|