@oneciel-ai/ciel-runtime 0.2.22 → 0.2.23

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +150 -57
  3. package/ciel-runtime-stop.cmd +1 -0
  4. package/ciel-runtime.cmd +1 -0
  5. package/ciel-runtimectl.cmd +1 -0
  6. package/ciel_runtime.py +56 -53
  7. package/ciel_runtime_support/anthropic_tool_turns.py +81 -0
  8. package/ciel_runtime_support/architecture.py +17 -0
  9. package/ciel_runtime_support/channel_injection.py +113 -3
  10. package/ciel_runtime_support/channel_message_prompt.py +15 -0
  11. package/ciel_runtime_support/channel_pending_injection.py +28 -4
  12. package/ciel_runtime_support/channel_pending_poll.py +2 -0
  13. package/ciel_runtime_support/channel_terminal_context.py +6 -0
  14. package/ciel_runtime_support/channel_terminal_dispatch.py +2 -0
  15. package/ciel_runtime_support/channel_terminal_proxy.py +8 -2
  16. package/ciel_runtime_support/channel_wake_claim_repository.py +7 -0
  17. package/ciel_runtime_support/channel_wake_context.py +7 -3
  18. package/ciel_runtime_support/cli_parser.py +6 -0
  19. package/ciel_runtime_support/codex_backend_context.py +4 -0
  20. package/ciel_runtime_support/codex_turn_recovery.py +75 -12
  21. package/ciel_runtime_support/config_migrations.py +102 -10
  22. package/ciel_runtime_support/config_repository.py +9 -0
  23. package/ciel_runtime_support/event_settings_cli.py +284 -2
  24. package/ciel_runtime_support/openai_chat_passthrough.py +2 -0
  25. package/ciel_runtime_support/protocols/openai_responses.py +22 -18
  26. package/ciel_runtime_support/provider_adapters.py +7 -0
  27. package/ciel_runtime_support/provider_policy.py +10 -1
  28. package/ciel_runtime_support/provider_request_builder.py +9 -2
  29. package/ciel_runtime_support/provider_responses_passthrough.py +4 -0
  30. package/ciel_runtime_support/providers/__init__.py +2 -0
  31. package/ciel_runtime_support/providers/alibaba.py +33 -10
  32. package/ciel_runtime_support/providers/constants.py +1 -0
  33. package/ciel_runtime_support/providers/opencode.py +25 -5
  34. package/ciel_runtime_support/providers/opencode_go.py +3 -3
  35. package/ciel_runtime_support/providers/openrouter.py +76 -3
  36. package/ciel_runtime_support/providers/tabitoken.py +130 -0
  37. package/ciel_runtime_support/remote_instructions.py +20 -6
  38. package/ciel_runtime_support/remote_memory.py +515 -55
  39. package/ciel_runtime_support/responses_input_compatibility.py +14 -0
  40. package/ciel_runtime_support/router_http.py +5 -0
  41. package/ciel_runtime_support/runtime_constants.py +1 -1
  42. package/ciel_runtime_support/runtime_launch.py +9 -2
  43. package/ciel_runtime_support/runtime_paths.py +2 -0
  44. package/ciel_runtime_support/tool_side_effect_dedupe.py +7 -4
  45. package/ciel_runtime_support/transcript_delta_delivery.py +333 -0
  46. package/ciel_runtime_support/usage_events.py +45 -3
  47. package/ciel_runtime_support/usage_service.py +871 -0
  48. package/ciel_runtime_support/windows_conpty.py +108 -2
  49. package/ciel_runtime_support/workspace_mcp.py +4 -0
  50. package/docs/CLI-Reference.md +11 -0
  51. package/docs/Configuration.md +7 -2
  52. package/docs/MCP-Channels.md +7 -3
  53. package/docs/Module-Map.md +4 -0
  54. package/docs/Providers.md +32 -0
  55. package/docs/Remote-Memory.md +29 -10
  56. package/docs/Test-Suite.md +1 -0
  57. package/docs/journal/2026/08/20/kevin-wake-orphan-tool-repair.md +161 -0
  58. package/docs/journal/2026/08/20/nightly-push-local-deployment.md +59 -2
  59. package/docs/journal/2026/08/20/wing-remote-memory-replication-verification.md +107 -0
  60. package/docs/journal/2026/08/21/alibaba-singapore-qwen38-official-alignment.md +64 -0
  61. package/docs/journal/2026/08/21/codex-visible-sse-wake-submit-regression.md +85 -0
  62. package/docs/journal/2026/08/21/external-sse-wake-only-change-analysis.md +131 -0
  63. package/docs/journal/2026/08/21/hyundai-invalid-replayed-tool-name-repair.okf +88 -0
  64. package/docs/journal/2026/08/21/local-stale-temporary-runtime-pin-repair.okf +118 -0
  65. package/docs/journal/2026/08/21/transcript-compaction-external-events.okf +131 -0
  66. package/docs/journal/2026/08/21/wing-ai-net-mcp-boundary-diagnosis.okf +129 -0
  67. package/docs/journal/2026/08/21/wing-cold-start-wake-submit-repair.okf +161 -0
  68. package/docs/journal/2026/08/21/wing-powershell-memory-runaway.okf +101 -0
  69. package/docs/journal/2026/08/21/wing-repeated-reasoning-tool-turn-repair.okf +112 -0
  70. package/docs/journal/2026/08/21/wing-repeated-tool-guard-finalization.okf +81 -0
  71. package/docs/journal/2026/08/21/wing-working-directory-memory-deployment.md +110 -0
  72. package/docs/journal/2026/08/21/wing-workspace-state-memory-prompt-injection.md +88 -0
  73. package/docs/journal/2026/08/22/alibaba-singapore-dual-endpoint-wire-alignment.okf +142 -0
  74. package/docs/journal/2026/08/22/delluhiold-claude-compaction-empty-tool-name.okf +168 -0
  75. package/docs/journal/2026/08/22/remote-memory-relative-prompt-paths.okf +79 -0
  76. package/docs/journal/2026/08/22/remote-memory-root-system-prompt.okf +121 -0
  77. package/docs/journal/2026/08/23/claude-web-chat-stateless-mcp-repair.okf +97 -0
  78. package/docs/journal/2026/08/23/cross-runtime-token-usage-interfaces-research.okf +378 -0
  79. package/docs/journal/2026/08/23/main-0.2.23-readme-release.okf +81 -0
  80. package/docs/journal/2026/08/23/mia-openrouter-ox-alpha-latency-diagnosis.okf +146 -0
  81. package/docs/journal/2026/08/23/openrouter-opencode-ox-alpha-support.okf +114 -0
  82. package/docs/journal/2026/08/23/tabitoken-provider-model-catalog.okf +97 -0
  83. package/docs/journal/2026/08/23/usage-stream-snapshot-implementation.okf +134 -0
  84. package/docs/usage-observability.md +141 -0
  85. package/install.ps1 +24 -3
  86. package/package.json +2 -1
package/ciel_runtime.py CHANGED
@@ -45,7 +45,7 @@ from ciel_runtime_support.agy_installer import AgyInstaller
45
45
  from ciel_runtime_support.anthropic_response_writer import AnthropicResponseWriter
46
46
  from ciel_runtime_support.anthropic_response_writer import anthropic_text_response as project_anthropic_text_response
47
47
  from ciel_runtime_support.anthropic_response_writer import prepend_anthropic_text as project_prepend_anthropic_text
48
- from ciel_runtime_support.anthropic_tool_turns import AnthropicToolTurnServices, normalize_historical_anthropic_tool_turns
48
+ from ciel_runtime_support.anthropic_tool_turns import AnthropicToolTurnServices, normalize_historical_anthropic_tool_turns, sanitize_invalid_anthropic_tool_history
49
49
  from ciel_runtime_support.api_key_cooldown import API_KEY_COOLDOWN_DEFAULT_SECONDS # noqa: F401 - compatibility export
50
50
  from ciel_runtime_support.api_key_cooldown import API_KEY_COOLDOWN_MAX_SECONDS # noqa: F401 - compatibility export
51
51
  from ciel_runtime_support.api_key_cooldown import RATE_LIMIT_RESET_HEADER_NAMES as _RATE_LIMIT_RESET_HEADER_NAMES # noqa: F401 - compatibility export
@@ -73,8 +73,7 @@ from ciel_runtime_support.channel_message_policy import message_has_external_pro
73
73
  from ciel_runtime_support.channel_message_policy import message_is_web_chat_request as _channel_message_is_web_chat_request
74
74
  from ciel_runtime_support.channel_message_policy import string_list as _as_string_list
75
75
  from ciel_runtime_support.channel_message_policy import superseded_message_ids as _channel_superseded_message_ids
76
- from ciel_runtime_support.channel_message_prompt import format_llm_batch_prompt as format_channel_llm_batch_prompt
77
- from ciel_runtime_support.channel_message_prompt import format_llm_delivery_wake_prompt as format_channel_llm_delivery_wake_prompt
76
+ from ciel_runtime_support.channel_message_prompt import format_llm_batch_prompt as format_channel_llm_batch_prompt, format_llm_delivery_wake_prompt as format_channel_llm_delivery_wake_prompt, format_visible_llm_delivery_wake_prompt as format_channel_visible_llm_delivery_wake_prompt
78
77
  from ciel_runtime_support.channel_message_prompt import format_wake_batch_prompt as format_channel_wake_batch_prompt
79
78
  from ciel_runtime_support.channel_message_prompt import format_wake_prompt as format_channel_wake_prompt # noqa: F401 - compatibility export
80
79
  from ciel_runtime_support.channel_message_prompt import format_web_chat_wake_batch_prompt as format_channel_web_chat_wake_batch_prompt
@@ -103,6 +102,7 @@ from ciel_runtime_support.channel_transcript import queued_age_seconds_from_text
103
102
  from ciel_runtime_support.channel_transcript import queued_command_ids_from_text as analyze_channel_queued_ids
104
103
  from ciel_runtime_support.channel_transcript import wake_state_evidence_from_text as analyze_channel_wake_evidence
105
104
  from ciel_runtime_support.channel_transcript_repository import ChannelTranscriptRepository
105
+ from ciel_runtime_support.transcript_delta_delivery import TranscriptDeliveryPorts, TranscriptDeltaDeliveryService
106
106
  from ciel_runtime_support.channel_wake_claim_repository import ChannelWakeClaimRepository
107
107
  from ciel_runtime_support.channel_wake_claim_repository import prompt_message_ids as _channel_prompt_message_ids
108
108
  from ciel_runtime_support.channel_wake_claim_repository import prompt_references_message_id as analyze_prompt_message_reference
@@ -244,7 +244,7 @@ from ciel_runtime_support.prompt_injection import normalize_anthropic_system_rol
244
244
  from ciel_runtime_support.prompt_injection import normalize_anthropic_system_role_messages_by_strategy as project_normalize_anthropic_system_role_messages_by_strategy
245
245
  from ciel_runtime_support.remote_instructions import RemoteInstructionResult, RemoteInstructionSynchronizer, SynchronizedLaunch
246
246
  from ciel_runtime_support.remote_instructions import panel_rows as project_remote_instruction_panel_rows
247
- from ciel_runtime_support.remote_memory import RemoteMemoryResult, RemoteMemorySynchronizer, sync_all_memory_pointers as project_sync_all_memory_pointers, sync_instruction_with_memory_pointer as project_sync_instruction_with_memory_pointer, sync_launch_assets as project_sync_launch_assets
247
+ from ciel_runtime_support.remote_memory import RemoteMemoryResult, RemoteMemorySynchronizer, current_memory_prompt as project_current_memory_prompt, inject_current_memory_prompt as project_inject_current_memory_prompt, move_memory_pointer_to_system_end as project_move_memory_pointer_to_system_end, sync_all_memory_pointers as project_sync_all_memory_pointers, sync_instruction_with_memory_pointer as project_sync_instruction_with_memory_pointer, sync_launch_assets as project_sync_launch_assets, without_memory_pointer as project_without_memory_pointer
248
248
  from ciel_runtime_support.protocols import PROTOCOL_ADAPTERS
249
249
  from ciel_runtime_support.protocols.anthropic_content import content_to_text as anthropic_content_to_text
250
250
  from ciel_runtime_support.protocols.anthropic_thinking_policy import AnthropicThinkingPolicy, SuppressedThinkingRepository, ThinkingPolicyPorts
@@ -509,7 +509,8 @@ from ciel_runtime_support.upstream_stream_io import iter_lines_until_disconnect
509
509
  from ciel_runtime_support.upstream_stream_io import set_stream_read_timeout as project_set_stream_read_timeout
510
510
  from ciel_runtime_support.upstream_stream_io import sleep_until_disconnect as project_sleep_until_disconnect
511
511
  from ciel_runtime_support.upstream_stream_io import stream_idle_timeout as project_stream_idle_timeout
512
- from ciel_runtime_support.usage_events import JsonlUsageEventSink
512
+ from ciel_runtime_support.usage_events import CompositeUsageEventSink, JsonlUsageEventSink
513
+ from ciel_runtime_support.usage_service import SqliteUsageLedger, UsageApiKeyRepository, UsageHttpAdapter, UsagePushDeliveryService, UsageRuntimeServices, usage_jsonl_enabled
513
514
  from ciel_runtime_support.visible_stream_filters import VISIBLE_THINKING_MARKUP_PREFIXES # noqa: F401 - compatibility export
514
515
  from ciel_runtime_support.visible_stream_filters import VISIBLE_THINKING_MARKUP_TAG_RE # noqa: F401 - compatibility export
515
516
  from ciel_runtime_support.visible_stream_filters import VISIBLE_TOOL_CALL_ARTIFACT_HOLD_CHARS # noqa: F401 - compatibility export
@@ -586,17 +587,12 @@ _CHANNEL_COMPACT_REQUEST_LOCK = threading.Lock()
586
587
  _TOOL_SIDE_EFFECT_DEDUP_TTL_SECONDS = 10 * 60.0
587
588
  _TOOL_SIDE_EFFECT_DEDUP_LOCK = threading.Lock()
588
589
  _TOOL_SIDE_EFFECT_DEDUP_RECENT: dict[str, float] = {}
589
- EVENT_BUS = EventBus()
590
- TUI_OBSERVATION_BUS = TuiObservationBus()
591
- USAGE_EVENT_SINK = JsonlUsageEventSink(
592
- USAGE_EVENTS_PATH,
593
- enabled=lambda: str(os.environ.get("CIEL_RUNTIME_USAGE_LOG", "1")).strip().lower()
594
- not in {"0", "false", "off", "no", ""},
595
- )
590
+ EVENT_BUS, TUI_OBSERVATION_BUS = EventBus(), TuiObservationBus()
591
+ USAGE_API_KEYS = UsageApiKeyRepository(USAGE_LEDGER := SqliteUsageLedger(WORKSPACE_STATE_DIR / "usage" / "usage.sqlite3", ROUTER_WORKSPACE_ID), WORKSPACE_STATE_DIR / "usage" / "api-key.pepper", os.environ)
592
+ USAGE_EVENT_SINK = CompositeUsageEventSink(JsonlUsageEventSink(USAGE_EVENTS_PATH, enabled=lambda: usage_jsonl_enabled(load_config(), os.environ)), USAGE_LEDGER)
596
593
  # Tools Claude Code injects into every model's tool list that misfire when called
597
594
  # by non-Anthropic models. See docs/notes from anthropics/claude-code issues
598
595
  # #25720, #29950 and Piebald-AI/claude-code-system-prompts for tool semantics.
599
-
600
596
  def positive_env_int(name: str, default: int) -> int: return runtime_primitives.positive_environment_int(os.environ, name, default)
601
597
  SUPPRESSED_THINKING_PASSBACK_MAX = positive_env_int("CIEL_RUNTIME_THINKING_PASSBACK_MAX", 4096)
602
598
  SUPPRESSED_THINKING_PASSBACK_CACHE: list[dict[str, Any]] = []
@@ -934,9 +930,9 @@ def select_provider_protocol(provider: str, pcfg: dict[str, Any], operation: Mes
934
930
  adapter = configured_provider_adapter(provider, pcfg)
935
931
  return adapter.select_protocol(operation, provider_contract_config(provider, pcfg), model)
936
932
 
937
- def apply_provider_adapter_request_policy(provider: str, pcfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]:
933
+ def apply_provider_adapter_request_policy(provider: str, pcfg: dict[str, Any], body: dict[str, Any], protocol: MessageProtocol | None = None) -> dict[str, Any]:
938
934
  adapter = configured_provider_adapter(provider, pcfg)
939
- normalized = adapter.normalize_request_options(provider_contract_config(provider, pcfg), body)
935
+ normalized = adapter.normalize_request_options_for_protocol(provider_contract_config(provider, pcfg), body, protocol)
940
936
  return dict(normalized)
941
937
 
942
938
  def provider_has_api_key(provider: str, pcfg: dict[str, Any]) -> bool: return bool(provider_config_api_keys(provider, pcfg))
@@ -1727,10 +1723,12 @@ def parse_json_body(raw: bytes) -> dict[str, Any]:
1727
1723
  except Exception:
1728
1724
  return {}
1729
1725
  return value if isinstance(value, dict) else {}
1730
-
1731
1726
  query_int = EventHttpAdapter.query_int
1732
1727
  def event_http_adapter() -> EventHttpAdapter: return EventHttpAdapter(EventHttpPorts(EVENT_BUS.recent, EVENT_BUS.wait_after, render_events_html, write_text_response, write_json, router_log))
1733
1728
  def handle_events_get(handler: BaseHTTPRequestHandler, path: str, query: dict[str, list[str]]) -> bool: return event_http_adapter().handle_get(handler, path, query)
1729
+ _USAGE_HTTP_ADAPTER = UsageHttpAdapter(USAGE_LEDGER, USAGE_API_KEYS, write_json, reject_external_router_request, load_config, router_log)
1730
+ def handle_usage_post(handler: BaseHTTPRequestHandler, path: str, body: dict[str, Any]) -> bool: return _USAGE_HTTP_ADAPTER.handle_post(handler, path, body)
1731
+ def handle_observability_get(handler: BaseHTTPRequestHandler, path: str, query: dict[str, list[str]]) -> bool: return _USAGE_HTTP_ADAPTER.handle_get(handler, path, query) or handle_events_get(handler, path, query)
1734
1732
  def tui_observation_http_adapter() -> TuiObservationHttpAdapter: return TuiObservationHttpAdapter(TuiObservationHttpPorts(TUI_OBSERVATION_BUS, write_json, write_text_response, router_log))
1735
1733
  def handle_tui_observation_get(handler: BaseHTTPRequestHandler, path: str, query: dict[str, list[str]]) -> bool: return tui_observation_http_adapter().handle_get(handler, path, query)
1736
1734
  def observe_tui_runtime_response(handler: BaseHTTPRequestHandler, path: str, provider: str, model: str, body: dict[str, Any]) -> Any: return observe_runtime_response(handler, path, provider, model, body, TUI_OBSERVATION_BUS)
@@ -1946,14 +1944,14 @@ def set_external_event_config(key: str, value: Any) -> list[str]:
1946
1944
  "External events use the private Runtime Input Gateway and are never published to Web Chat.",
1947
1945
  ]
1948
1946
 
1949
- def remote_instruction_synchronizer() -> RemoteInstructionSynchronizer: return RemoteInstructionSynchronizer(load_config=load_config, workspace=Path.cwd, state_dir=WORKSPACE_STATE_DIR, log=router_log)
1950
-
1951
- def remote_memory_synchronizer() -> RemoteMemorySynchronizer: return RemoteMemorySynchronizer(load_config=load_config, workspace=Path.cwd, state_dir=WORKSPACE_STATE_DIR, log=router_log)
1947
+ def remote_instruction_synchronizer() -> RemoteInstructionSynchronizer: return RemoteInstructionSynchronizer(load_config=load_config, workspace=lambda: Path(ROUTER_WORKSPACE), state_dir=WORKSPACE_STATE_DIR, log=router_log)
1948
+ def remote_memory_synchronizer() -> RemoteMemorySynchronizer: return RemoteMemorySynchronizer(load_config=load_config, workspace=lambda: Path(ROUTER_WORKSPACE), state_dir=WORKSPACE_STATE_DIR, log=router_log)
1949
+ def body_with_remote_memory_prompt(body: dict[str, Any], protocol: MessageProtocol) -> dict[str, Any]: return project_inject_current_memory_prompt(body, protocol, WORKSPACE_STATE_DIR, load_config(), Path(ROUTER_WORKSPACE))
1950
+ def finalized_anthropic_upstream_body(body: dict[str, Any]) -> dict[str, Any]: return body_without_ciel_runtime_internal_metadata(body_with_remote_memory_prompt(body, "anthropic_messages"))
1952
1951
  def sync_remote_instruction(runtime: str, *, reason: str) -> RemoteInstructionResult: return project_sync_instruction_with_memory_pointer(runtime, reason=reason, instruction_synchronizer=remote_instruction_synchronizer, memory_synchronizer=remote_memory_synchronizer, log=router_log)
1953
1952
  def sync_remote_memory(runtime: str, *, reason: str) -> RemoteMemoryResult: return remote_memory_synchronizer().sync(runtime, reason=reason)
1954
1953
  def sync_remote_launch_assets(runtime: str, *, reason: str) -> RemoteMemoryResult: return project_sync_launch_assets(runtime, reason=reason, instruction_sync=sync_remote_instruction, memory_sync=sync_remote_memory)
1955
1954
  def sync_all_remote_memories() -> list[str]: return project_sync_all_memory_pointers(remote_memory_synchronizer())
1956
-
1957
1955
  def remote_instruction_panel_rows(cfg: dict[str, Any]) -> tuple[list[str], list[str]]:
1958
1956
  return project_remote_instruction_panel_rows(cfg)
1959
1957
 
@@ -2006,7 +2004,7 @@ def _latest_remote_instruction(runtime: str, *, reason: str) -> str:
2006
2004
  if path is None or not path.is_file():
2007
2005
  return ""
2008
2006
  try:
2009
- return path.read_text(encoding="utf-8").strip()
2007
+ return project_without_memory_pointer(path.read_text(encoding="utf-8"))
2010
2008
  except OSError as exc:
2011
2009
  router_log("WARN", f"remote_instructions_read_failed runtime={runtime} error={type(exc).__name__}: {exc}")
2012
2010
  return ""
@@ -2047,7 +2045,7 @@ def _refresh_anthropic_compact_body(body: dict[str, Any], runtime: str = "claude
2047
2045
  _without_remote_instruction_blocks(body.get("system")),
2048
2046
  [_with_latest_instruction_text("", instruction)],
2049
2047
  )
2050
- return updated
2048
+ return body_with_remote_memory_prompt(updated, "anthropic_messages")
2051
2049
 
2052
2050
  def _refresh_chat_compact_messages(messages: list[dict[str, Any]], runtime: str = "claude") -> list[dict[str, Any]]:
2053
2051
  instruction = _latest_remote_instruction(runtime, reason="pre-compact")
@@ -2057,15 +2055,17 @@ def _refresh_chat_compact_messages(messages: list[dict[str, Any]], runtime: str
2057
2055
  for message in updated:
2058
2056
  if str(message.get("role") or "") == "system" and isinstance(message.get("content"), str):
2059
2057
  message["content"] = _with_latest_instruction_text(message.get("content"), instruction)
2060
- return updated
2061
- return [{"role": "system", "content": _with_latest_instruction_text("", instruction)}, *updated]
2058
+ break
2059
+ else:
2060
+ updated = [{"role": "system", "content": _with_latest_instruction_text("", instruction)}, *updated]
2061
+ return project_move_memory_pointer_to_system_end(updated, project_current_memory_prompt(WORKSPACE_STATE_DIR, load_config(), Path(ROUTER_WORKSPACE)))
2062
2062
 
2063
2063
  def compact_responses_with_remote_instruction(body: dict[str, Any], budget: int, **kwargs: Any) -> dict[str, Any]:
2064
2064
  instruction = _latest_remote_instruction("codex", reason="pre-compact")
2065
2065
  updated = dict(body)
2066
2066
  if instruction:
2067
2067
  updated["instructions"] = _with_latest_instruction_text(body.get("instructions"), instruction)
2068
- return run_responses_prompt_compaction(updated, budget, services=prompt_compaction_services(), **kwargs)
2068
+ return body_with_remote_memory_prompt(run_responses_prompt_compaction(updated, budget, services=prompt_compaction_services(), **kwargs), "openai_responses")
2069
2069
 
2070
2070
  def _channel_compact_request_ttl_seconds() -> float: return compact_request_ttl(os.environ.get('CIEL_RUNTIME_CHANNEL_COMPACT_REQUEST_TTL_SECONDS'))
2071
2071
 
@@ -2304,7 +2304,7 @@ def normalize_request_for_provider_wire(provider: str, pcfg: dict[str, Any], bod
2304
2304
  normalize_thinking_for_non_anthropic_provider=normalize_thinking_for_non_anthropic_provider,
2305
2305
  normalize_tool_choice_for_provider=normalize_tool_choice_for_provider,
2306
2306
  provider_wire_profile=provider_wire_profile,
2307
- sanitize_assistant_pseudo_tool_text_history=sanitize_assistant_pseudo_tool_text_history
2307
+ sanitize_assistant_pseudo_tool_text_history=sanitize_assistant_pseudo_tool_text_history, sanitize_invalid_anthropic_tool_history=lambda body: sanitize_invalid_anthropic_tool_history(body, AnthropicToolTurnServices(log=router_log))
2308
2308
  ),
2309
2309
  )
2310
2310
 
@@ -2409,8 +2409,8 @@ def chat_projection_services() -> ChatProjectionServices:
2409
2409
  ),
2410
2410
  )
2411
2411
 
2412
- def anthropic_messages_to_ollama(body: dict[str, Any]) -> list[dict[str, Any]]: return project_anthropic_messages_to_ollama(body, services=chat_projection_services())
2413
- def anthropic_messages_to_openai(body: dict[str, Any], reasoning_passback: bool = False) -> list[dict[str, Any]]: return project_anthropic_messages_to_openai(body, reasoning_passback, services=chat_projection_services())
2412
+ def anthropic_messages_to_ollama(body: dict[str, Any]) -> list[dict[str, Any]]: return project_move_memory_pointer_to_system_end(project_anthropic_messages_to_ollama(body, services=chat_projection_services()), project_current_memory_prompt(WORKSPACE_STATE_DIR, load_config(), Path(ROUTER_WORKSPACE)))
2413
+ def anthropic_messages_to_openai(body: dict[str, Any], reasoning_passback: bool = False) -> list[dict[str, Any]]: return project_move_memory_pointer_to_system_end(project_anthropic_messages_to_openai(body, reasoning_passback, services=chat_projection_services()), project_current_memory_prompt(WORKSPACE_STATE_DIR, load_config(), Path(ROUTER_WORKSPACE)))
2414
2414
  missing_openai_tool_result_message = project_missing_openai_tool_result_message
2415
2415
  orphan_openai_tool_message_to_user = project_orphan_openai_tool_message_to_user
2416
2416
  def repair_openai_tool_call_adjacency(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: return project_repair_openai_tool_call_adjacency(messages, OpenAiHistoryServices(log=router_log))
@@ -2534,7 +2534,7 @@ def provider_request_builder() -> ProviderRequestBuilder:
2534
2534
  sampling_allowed=lambda provider, config: configured_provider_adapter(provider, config).allows_sampling_overrides(provider_contract_config(provider, config)),
2535
2535
  omit_tool_choice=should_omit_openai_chat_tool_choice, tool_choice=anthropic_tool_choice_to_openai, normalize_request=apply_provider_adapter_request_policy,
2536
2536
  ),
2537
- ProviderOptionPorts(frozenset(PROVIDER_SAMPLING_OPTION_PROVIDERS), tuple(PROVIDER_SAMPLING_OPTIONS), anthropic_model_runtime_hints, router_log),
2537
+ ProviderOptionPorts(frozenset(PROVIDER_SAMPLING_OPTION_PROVIDERS), tuple(PROVIDER_SAMPLING_OPTIONS), anthropic_model_runtime_hints, router_log, lambda messages: project_move_memory_pointer_to_system_end(messages, project_current_memory_prompt(WORKSPACE_STATE_DIR, load_config(), Path(ROUTER_WORKSPACE)))),
2538
2538
  )
2539
2539
 
2540
2540
  _PROVIDER_REQUEST_API = ProviderRequestCompatibilityApi(provider_request_builder)
@@ -2843,7 +2843,7 @@ def response_collection_context() -> ResponseCollectionContext:
2843
2843
  anthropic=AnthropicCollectionServices(
2844
2844
  request=AnthropicCollectionRequest(normalize_thinking_for_non_anthropic_provider, normalize_anthropic_system_role_messages_for_provider, cap_anthropic_body_for_provider,
2845
2845
  apply_provider_request_options, rehydrate_suppressed_thinking_passback, resolve_requested_model, provider_upstream_model,
2846
- resolve_tool_model_references, normalize_anthropic_model_request_options, body_without_ciel_runtime_internal_metadata),
2846
+ resolve_tool_model_references, normalize_anthropic_model_request_options, finalized_anthropic_upstream_body),
2847
2847
  transport=AnthropicCollectionTransport(provider_native_compat_enabled, native_anthropic_base_url, provider_upstream_request_base, join_url, upstream_messages_query,
2848
2848
  provider_headers, apply_router_rate_limit, open_provider_request_with_key_retry, provider_request_timeout_seconds),
2849
2849
  projection=AnthropicCollectionProjection(normalize_response_thinking_for_non_anthropic_provider, append_synthetic_tasklist_to_message,
@@ -2881,7 +2881,8 @@ def codex_backend_context() -> CodexBackendContext:
2881
2881
  provider_projection=ProviderPassthroughProjectionPorts(provider_headers, lambda *args, **kwargs: provider_chat_headers(*args, **kwargs),
2882
2882
  lambda *args, **kwargs: provider_responses_headers(*args, **kwargs),
2883
2883
  provider_upstream_model, resolve_requested_model, apply_provider_adapter_request_policy,
2884
- lambda provider, pcfg: configured_provider_adapter(provider, pcfg).responses_request_max_bytes(provider_contract_config(provider, pcfg))),
2884
+ lambda provider, pcfg: configured_provider_adapter(provider, pcfg).responses_request_max_bytes(provider_contract_config(provider, pcfg)),
2885
+ lambda body: body_with_remote_memory_prompt(body, "openai_chat"), lambda body: body_with_remote_memory_prompt(body, "openai_responses")),
2885
2886
  provider_transport=ProviderPassthroughTransportPorts(provider_upstream_request_base, join_url, provider_urlopen,
2886
2887
  provider_request_timeout_seconds,
2887
2888
  lambda *args, **kwargs: _copy_upstream_response_headers(*args, **kwargs), write_router_activity),
@@ -2907,7 +2908,8 @@ forward_codex_backend_get = _CODEX_BACKEND_API.forward_get
2907
2908
  forward_codex_responses = _CODEX_BACKEND_API.forward_responses
2908
2909
 
2909
2910
  def body_with_codex_compat_instructions(cfg: dict[str, Any], provider: str, pcfg: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]:
2910
- return codex_turn_recovery.body_with_codex_compat_instructions(body, ROUTED_CODEX_COMPAT_PROMPT, is_native_codex=codex_routed_enabled(provider, pcfg), compat_enabled=should_append_compat_prompt(provider, pcfg, cfg))
2911
+ body = codex_turn_recovery.body_with_codex_compat_instructions(body, ROUTED_CODEX_COMPAT_PROMPT, is_native_codex=codex_routed_enabled(provider, pcfg), compat_enabled=should_append_compat_prompt(provider, pcfg, cfg))
2912
+ return body_with_remote_memory_prompt(body, "openai_responses")
2911
2913
 
2912
2914
  def _codex_turn_recovery_services() -> codex_turn_recovery.CodexTurnRecoveryServices:
2913
2915
  return codex_turn_recovery.CodexTurnRecoveryServices(should_retry=should_retry_preamble_only_turn, collect_message=collect_provider_message_for_responses, log=router_log)
@@ -2939,7 +2941,7 @@ def _router_request_context() -> RouterRequestContext:
2939
2941
  assembly.ClaudeRouterRoutingPorts(forward_ollama_api_chat, forward_openai_compatible_chat, select_provider_protocol, provider_request_policy, resolve_requested_model, PROVIDER_LABELS, write_json),
2940
2942
  assembly.ClaudeRouterNormalizationPorts(normalize_request_for_provider_wire, normalize_thinking_for_non_anthropic_provider, normalize_anthropic_system_role_messages_for_provider,
2941
2943
  cap_anthropic_body_for_provider, apply_provider_request_options, rehydrate_suppressed_thinking_passback, ncp_model_id_for_nvidia_hosted,
2942
- resolve_tool_model_references, normalize_anthropic_model_request_options, body_without_ciel_runtime_internal_metadata),
2944
+ resolve_tool_model_references, normalize_anthropic_model_request_options, finalized_anthropic_upstream_body),
2943
2945
  assembly.ClaudeRouterTransportPorts(native_anthropic_base_url, provider_native_compat_enabled, provider_upstream_request_base, join_url, upstream_messages_query,
2944
2946
  provider_headers, apply_router_rate_limit, open_provider_request_with_key_retry, provider_request_timeout_seconds, provider_stream_idle_timeout_seconds),
2945
2947
  assembly.ClaudeRouterResponsePorts(_rebatch_anthropic_sse_text, preserves_anthropic_thinking_contract, should_normalize_anthropic_stream_tool_use,
@@ -2962,9 +2964,7 @@ runtime_router_capability_matrix = _ROUTER_REQUEST_API.capability_matrix
2962
2964
  runtime_router_capability_gaps = _ROUTER_REQUEST_API.capability_gaps
2963
2965
  route_runtime_get = _ROUTER_REQUEST_API.route_get
2964
2966
  route_runtime_post = _ROUTER_REQUEST_API.route_post
2965
-
2966
2967
  _ROUTER_REQUEST_BODY_POLICY: RouterRequestBodyPolicy | None = None
2967
-
2968
2968
  def router_request_body_policy() -> RouterRequestBodyPolicy:
2969
2969
  global _ROUTER_REQUEST_BODY_POLICY
2970
2970
  if _ROUTER_REQUEST_BODY_POLICY is None:
@@ -2975,17 +2975,21 @@ def router_request_body_policy() -> RouterRequestBodyPolicy:
2975
2975
  for warning in _ROUTER_REQUEST_BODY_POLICY.configuration_warnings:
2976
2976
  router_log("WARN", f"router_request_limit_configuration {warning}")
2977
2977
  return _ROUTER_REQUEST_BODY_POLICY
2978
-
2979
2978
  def _router_server_context() -> RouterServerContext:
2979
+ usage_services = UsageRuntimeServices(USAGE_LEDGER, USAGE_API_KEYS, UsagePushDeliveryService(USAGE_LEDGER, load_config, os.environ, router_log), CONFIG_DIR, WORKSPACE_STATE_DIR, ROUTER_WORKSPACE_ID, USAGE_EVENTS_PATH, router_log)
2980
+ def start_router_services() -> None:
2981
+ external_event_receiver_service().start()
2982
+ usage_services.start()
2983
+ def stop_router_services() -> None:
2984
+ usage_services.stop()
2985
+ external_event_receiver_service().stop()
2980
2986
  http_services = RouterHttpServices(
2981
2987
  core=RouterHttpCore(load_config, reject_external_router_request, get_current_provider, parse_json_body, is_client_disconnect_error, router_log, observe_tui_runtime_response, router_request_body_policy()),
2982
- # Channel MCP delivery was retired in favor of the TTY/PTY bridge. Do
2983
- # not expose the legacy endpoint or register it with Claude.
2984
- get=RouterHttpGetEndpoints(handle_tui_observation_get, handle_events_get, handle_llm_config_get, lambda _handler, _path: False, handle_web_get,
2988
+ get=RouterHttpGetEndpoints(handle_tui_observation_get, handle_observability_get, handle_llm_config_get, lambda _handler, _path: False, handle_web_get,
2985
2989
  lambda handler, path: speech_http_controller().get(handler, path), handle_chat_get, handle_plan_get, route_runtime_get,
2986
2990
  handle_external_event_get),
2987
- post=RouterHttpPostEndpoints(lambda handler, path, raw, content_type: speech_http_controller().post(handler, path, raw, content_type), handle_llm_config_post, lambda _handler, _path, _body: False, handle_chat_post,
2988
- handle_plan_post, route_runtime_post, handle_external_event_raw_post, handle_external_event_config_post),
2991
+ post=RouterHttpPostEndpoints(lambda handler, path, raw, content_type: speech_http_controller().post(handler, path, raw, content_type), handle_llm_config_post, handle_channel_mcp_post, handle_chat_post,
2992
+ handle_plan_post, route_runtime_post, handle_external_event_raw_post, handle_external_event_config_post, handle_usage_post),
2989
2993
  presentation=RouterHttpPresentation(render_router_home_html, router_health_payload, write_text_response, write_json, list_model_objects_for_request,
2990
2994
  resolve_requested_model, model_object),
2991
2995
  errors=RouterHttpErrors(write_openai_responses_error, try_write_json),
@@ -2997,8 +3001,8 @@ def _router_server_context() -> RouterServerContext:
2997
3001
  router_server_runtime.RouterServerEffects(os.chmod, sys.stderr, ThreadingHTTPServer, start_managed_router_lifetime_watchdog,
2998
3002
  lambda bind_host: configure_requested_web_endpoints(ROUTER_PORT, ROUTER_HOST, bind_host,
2999
3003
  config=load_config()),
3000
- external_event_receiver_service().start,
3001
- external_event_receiver_service().stop),
3004
+ start_router_services,
3005
+ stop_router_services),
3002
3006
  )
3003
3007
  return RouterServerContext(
3004
3008
  health=RouterHealthPresentationPorts(VERSION, SOURCE_FINGERPRINT, RouterHealthRuntimePorts(os.getpid, active_router_client_pids), getpass.getuser, HOME, ROUTER_INSTANCE_DIR, ROUTER_WORKSPACE, ROUTER_PORT, ROUTER_INSTANCE_ID, current_alias),
@@ -4412,7 +4416,7 @@ def channel_wake_context() -> ChannelWakeContext:
4412
4416
  pending_state=ChannelPendingStatePorts(_channel_stdin_active_tool_call, _channel_stdin_active_turn, _channel_stdin_recover_cursor_from_queued_only, _channel_pending_scan_limit,
4413
4417
  _channel_superseded_message_ids, _channel_message_is_web_chat_request, _channel_llm_message_skip_reason, _channel_message_event_identity_key,
4414
4418
  _channel_stdin_wake_state_for_message, _channel_stdin_wake_queued_is_stale_for_message),
4415
- pending_delivery=ChannelPendingDeliveryPorts(format_channel_llm_delivery_wake_prompt, format_channel_web_chat_wake_batch_prompt, format_channel_wake_batch_prompt, _channel_enter_label,
4419
+ pending_delivery=ChannelPendingDeliveryPorts(format_channel_llm_delivery_wake_prompt, format_channel_visible_llm_delivery_wake_prompt, format_channel_web_chat_wake_batch_prompt, format_channel_wake_batch_prompt, _channel_enter_label,
4416
4420
  _channel_wake_store_release_stale, _CHANNEL_WAKE_DELIVERY_REPOSITORY.mark_delivered, _channel_wake_store_record_prompts,
4417
4421
  _channel_wake_store_rollback, _commit_channel_llm_cursor_if_newer),
4418
4422
  pending_io=ChannelPendingIoPorts(_CHANNEL_STDIN_INJECT_LOCK, read_runtime_inputs, _write_channel_wake_prompt, _read_channel_compact_request, _clear_channel_compact_request, _runtime_input_storage_path(), router_log),
@@ -4429,7 +4433,7 @@ def _channel_prompt_references_message_id(text: str, message_id: int, prompt_tex
4429
4433
  def _channel_message_ids_already_in_request(body: dict[str, Any]) -> set[int]: return channel_wake_context().message_ids_already_in_request(body)
4430
4434
  def _channel_llm_commit_cursor_locked(last_id: int) -> None: channel_wake_context().commit_cursor(last_id)
4431
4435
  def _channel_llm_stdin_skip_reason(message_id: int) -> str: return channel_wake_context().stdin_skip_reason(message_id)
4432
- def body_with_pending_channel_messages(body: dict[str, Any]) -> dict[str, Any]: return channel_wake_context().body_with_pending_messages(body)
4436
+ def body_with_pending_channel_messages(body: dict[str, Any]) -> dict[str, Any]: return channel_wake_context().body_with_pending_messages(body_with_remote_memory_prompt(body, "anthropic_messages"))
4433
4437
  def _write_fd_all(fd: int, data: bytes) -> None: ChannelWakeContext.write_all(fd, data)
4434
4438
  def _channel_wake_enter_bytes(value: str | bytes | None = None) -> bytes: return channel_wake_context().enter_bytes(value)
4435
4439
  def _channel_wake_input_bytes(prompt: str, enter_bytes: bytes | None = None) -> bytes: return channel_wake_context().input_bytes(prompt, enter_bytes)
@@ -4444,8 +4448,8 @@ def _windows_channel_startup_grace_seconds() -> float:
4444
4448
  def _windows_channel_wake_max_attempts() -> int:
4445
4449
  return channel_runtime_environment_policy().windows_wake_max_attempts()
4446
4450
 
4447
- def _write_channel_wake_prompt( master_fd: int, prompt: str, enter_bytes: bytes | None = None, *, submit_retry_count: int = 1, confirm_submit: bool = False, bracketed_paste: bool = False, submit_delay_seconds: float | None = None, ) -> None:
4448
- channel_wake_context().write_prompt(
4451
+ def _write_channel_wake_prompt( master_fd: int, prompt: str, enter_bytes: bytes | None = None, *, submit_retry_count: int = 1, confirm_submit: bool = False, bracketed_paste: bool = False, submit_delay_seconds: float | None = None, ) -> bool:
4452
+ return channel_wake_context().write_prompt(
4449
4453
  master_fd, prompt, enter_bytes, submit_retry_count=submit_retry_count,
4450
4454
  confirm_submit=confirm_submit, bracketed_paste=bracketed_paste,
4451
4455
  submit_delay_seconds=submit_delay_seconds, write_all=_write_fd_all,
@@ -4458,11 +4462,9 @@ _CHANNEL_TRANSCRIPT_SCOPE: dict[str, Any] = {
4458
4462
  'session_id': '', 'bound_path': None,
4459
4463
  }
4460
4464
  _CHANNEL_STDIN_RECOVERY_CACHE: dict[str, Any] = {'checked_at': 0.0, 'last_id': None, 'marker': None, 'recovered_last_id': None}
4465
+ _TRANSCRIPT_DELIVERY_SERVICE = TranscriptDeltaDeliveryService(WORKSPACE_STATE_DIR / "transcript-event-cursors.json", ROUTER_WORKSPACE_ID, TranscriptDeliveryPorts(load_config, lambda: _latest_claude_transcript_path(ttl_seconds=0.5), lambda: dict(_CHANNEL_TRANSCRIPT_SCOPE), router_log))
4461
4466
  def channel_transcript_repository() -> ChannelTranscriptRepository: return channel_wake_context().transcript_repository()
4462
-
4463
- def _set_channel_transcript_scope(runtime: str, *, started_at: float | None = None, codex_home: Path | None = None, cwd: Path | None = None, session_id: str | None = None) -> None:
4464
- channel_wake_context().set_transcript_scope(runtime, started_at=started_at, codex_home=codex_home, cwd=cwd, session_id=session_id)
4465
-
4467
+ def _set_channel_transcript_scope(runtime: str, *, started_at: float | None = None, codex_home: Path | None = None, cwd: Path | None = None, session_id: str | None = None) -> None: return (channel_wake_context().set_transcript_scope(runtime, started_at=started_at, codex_home=codex_home, cwd=cwd, session_id=session_id), _TRANSCRIPT_DELIVERY_SERVICE.start())[1]
4466
4468
  def _channel_transcript_roots() -> tuple[tuple[Path, str], ...]: return channel_wake_context().transcript_roots()
4467
4469
  def _latest_claude_transcript_path(ttl_seconds: float = 2.0) -> Path | None: return channel_wake_context().latest_transcript_path(ttl_seconds)
4468
4470
  _read_file_tail_text = ChannelTranscriptRepository.read_tail_text
@@ -4507,7 +4509,7 @@ def _inject_pending_channel_messages(
4507
4509
  enter_bytes: bytes | None = None,
4508
4510
  *,
4509
4511
  web_chat_only: bool = False,
4510
- wake_for_llm_delivery: bool = False,
4512
+ wake_for_llm_delivery: bool = False, display_llm_delivery_body: bool = False,
4511
4513
  commit_cursor: bool = True,
4512
4514
  injected_message_ids: list[int] | None = None,
4513
4515
  submit_retry_count: int = 1,
@@ -4522,6 +4524,7 @@ def _inject_pending_channel_messages(
4522
4524
  enter_bytes,
4523
4525
  web_chat_only=web_chat_only,
4524
4526
  wake_for_llm_delivery=wake_for_llm_delivery,
4527
+ display_llm_delivery_body=display_llm_delivery_body,
4525
4528
  commit_cursor=commit_cursor,
4526
4529
  injected_message_ids=injected_message_ids,
4527
4530
  submit_retry_count=submit_retry_count,
@@ -4948,7 +4951,7 @@ def cli_parser_services() -> cli_parser.CliParserServices:
4948
4951
  return cli_assembly.CliParserAssembly(
4949
4952
  launch=cli_parser.CliParserLaunch(cmd_cli, cmd_launch, cmd_launch_codex, cmd_launch_codex_app_server, cmd_launch_agy, serve, cmd_launch_grok),
4950
4953
  runtime=cli_parser.CliParserRuntime(cmd_version, cmd_status, cmd_env, cmd_stop, cmd_test),
4951
- settings=cli_parser.CliParserSettings(cmd_language, cmd_web_search, cmd_web_fetch, cmd_log_level, *event_settings_cli.handlers(event_settings_cli.EventSettingsCliPorts(load_config, save_config, external_event_receiver_service, lambda: set_remote_instruction_config('sync', ''), sync_all_remote_memories, print))),
4954
+ settings=cli_parser.CliParserSettings(cmd_language, cmd_web_search, cmd_web_fetch, cmd_log_level, *event_settings_cli.handlers(event_settings_cli.EventSettingsCliPorts(load_config, save_config, external_event_receiver_service, lambda: set_remote_instruction_config('sync', ''), sync_all_remote_memories, print, lambda: USAGE_API_KEYS))),
4952
4955
  provider=cli_parser.CliParserProvider(cmd_ollama_native, cmd_ollama_options, cmd_provider_options, cmd_ollama_catalog, cmd_provider,
4953
4956
  cmd_api_key, cmd_set_api_key, cmd_set_api_keys, cmd_base_url, cmd_copilot_oauth),
4954
4957
  models=cli_parser.CliParserModels(cmd_model, cmd_advisor_model, cmd_models),
@@ -12,6 +12,80 @@ class AnthropicToolTurnServices:
12
12
  log: Callable[[str, str], Any]
13
13
 
14
14
 
15
+ def sanitize_invalid_anthropic_tool_history(
16
+ body: dict[str, Any],
17
+ services: AnthropicToolTurnServices,
18
+ ) -> dict[str, Any]:
19
+ """Remove schema-invalid tool uses and only their paired results.
20
+
21
+ Provider-native compaction validates retained messages again. A historical
22
+ assistant tool block with an empty name therefore makes every later
23
+ compaction fail, even when the current runtime no longer emits that block.
24
+ Preserve valid and unmatched history; remove only invalid tool blocks and
25
+ results that explicitly reference their IDs.
26
+ """
27
+ messages = body.get("messages")
28
+ if not isinstance(messages, list):
29
+ return body
30
+
31
+ invalid_ids = {
32
+ str(block.get("id") or "")
33
+ for message in messages
34
+ if isinstance(message, dict)
35
+ and str(message.get("role") or "") == "assistant"
36
+ for block in _content_blocks(message)
37
+ if isinstance(block, dict)
38
+ and block.get("type") == "tool_use"
39
+ and not str(block.get("name") or "").strip()
40
+ and str(block.get("id") or "")
41
+ }
42
+ invalid_uses = 0
43
+ invalid_results = 0
44
+ normalized_messages: list[Any] = []
45
+ for message in messages:
46
+ if not isinstance(message, dict) or not isinstance(message.get("content"), list):
47
+ normalized_messages.append(message)
48
+ continue
49
+ role = str(message.get("role") or "")
50
+ next_content: list[Any] = []
51
+ content_changed = False
52
+ for block in message["content"]:
53
+ if (
54
+ role == "assistant"
55
+ and isinstance(block, dict)
56
+ and block.get("type") == "tool_use"
57
+ and not str(block.get("name") or "").strip()
58
+ ):
59
+ invalid_uses += 1
60
+ content_changed = True
61
+ continue
62
+ if (
63
+ role == "user"
64
+ and isinstance(block, dict)
65
+ and block.get("type") == "tool_result"
66
+ and str(block.get("tool_use_id") or "") in invalid_ids
67
+ ):
68
+ invalid_results += 1
69
+ content_changed = True
70
+ continue
71
+ next_content.append(block)
72
+ if not content_changed:
73
+ normalized_messages.append(message)
74
+ elif next_content:
75
+ normalized_messages.append(_with_content(message, next_content))
76
+
77
+ if not invalid_uses and not invalid_results:
78
+ return body
79
+ out = dict(body)
80
+ out["messages"] = normalized_messages
81
+ services.log(
82
+ "WARN",
83
+ "discarded schema-invalid historical Anthropic tool blocks "
84
+ f"tool_uses={invalid_uses} tool_results={invalid_results}",
85
+ )
86
+ return out
87
+
88
+
15
89
  def normalize_historical_anthropic_tool_turns(
16
90
  provider: str,
17
91
  body: dict[str, Any],
@@ -133,3 +207,10 @@ def _with_content(message: dict[str, Any], content: list[Any]) -> dict[str, Any]
133
207
  updated = dict(message)
134
208
  updated["content"] = content
135
209
  return updated
210
+
211
+
212
+ __all__ = [
213
+ "AnthropicToolTurnServices",
214
+ "normalize_historical_anthropic_tool_turns",
215
+ "sanitize_invalid_anthropic_tool_history",
216
+ ]
@@ -699,6 +699,23 @@ class ProviderAdapter(ABC):
699
699
  del config
700
700
  return request
701
701
 
702
+ def normalize_request_options_for_protocol(
703
+ self,
704
+ config: ProviderConfig,
705
+ request: Mapping[str, Any],
706
+ protocol: MessageProtocol | None,
707
+ ) -> Mapping[str, Any]:
708
+ """Normalize request options for the selected upstream wire protocol.
709
+
710
+ Existing adapters remain protocol-neutral by default. Providers that
711
+ expose more than one wire surface can override this hook without
712
+ inferring the protocol from overlapping request fields such as
713
+ ``messages`` and ``max_tokens``.
714
+ """
715
+
716
+ del protocol
717
+ return self.normalize_request_options(config, request)
718
+
702
719
  def anthropic_system_role_strategy(
703
720
  self, config: ProviderConfig
704
721
  ) -> Literal["inline_user", "hoist_top_level"]:
@@ -23,6 +23,16 @@ class InputTransport(Protocol):
23
23
 
24
24
  def input_snapshot(self) -> str | None: ...
25
25
 
26
+ def prompt_readiness_checkpoint(self) -> object: ...
27
+
28
+ def wait_until_prompt_ready(
29
+ self,
30
+ previous_snapshot: object,
31
+ timeout_seconds: float = 2.0,
32
+ *,
33
+ expected_prompt: str | None = None,
34
+ ) -> bool | None: ...
35
+
26
36
 
27
37
  @dataclass(frozen=True)
28
38
  class RuntimeInjectionPolicy:
@@ -36,6 +46,7 @@ class RuntimeInjectionPolicy:
36
46
  confirm_submission: bool = False
37
47
  bracketed_paste: bool = False
38
48
  input_drain_timeout_seconds: float = 2.0
49
+ prompt_render_timeout_seconds: float = 30.0
39
50
 
40
51
  def __post_init__(self) -> None:
41
52
  if not self.runtime.strip():
@@ -46,6 +57,8 @@ class RuntimeInjectionPolicy:
46
57
  raise ValueError("submit_delay_seconds cannot be negative")
47
58
  if not 1 <= self.submit_attempts <= 8:
48
59
  raise ValueError("submit_attempts must be between 1 and 8")
60
+ if self.prompt_render_timeout_seconds < 0:
61
+ raise ValueError("prompt_render_timeout_seconds cannot be negative")
49
62
 
50
63
 
51
64
  @dataclass(frozen=True)
@@ -70,8 +83,18 @@ class ChannelPromptInjector:
70
83
  self._snapshot = snapshot
71
84
  self._log = log
72
85
 
73
- def inject(self, transport: InputTransport, request: PromptInjection) -> None:
86
+ def inject(self, transport: InputTransport, request: PromptInjection) -> bool:
74
87
  policy = request.policy
88
+ prompt_ready_wait = bool(
89
+ getattr(transport, "supports_prompt_ready_wait", False)
90
+ )
91
+ before_prompt = (
92
+ self._prompt_readiness_checkpoint(transport)
93
+ if prompt_ready_wait
94
+ and policy.confirm_submission
95
+ and policy.submit_attempts > 1
96
+ else None
97
+ )
75
98
  normalize = getattr(transport, "normalize_prompt", None)
76
99
  prompt_text = (
77
100
  str(normalize(request.prompt)) if callable(normalize) else request.prompt
@@ -101,10 +124,28 @@ class ChannelPromptInjector:
101
124
  ):
102
125
  self._log("WARN", "channel_input_drain_timeout")
103
126
 
127
+ if prompt_ready_wait:
128
+ prompt_ready = self._wait_until_prompt_ready(
129
+ transport,
130
+ before_prompt,
131
+ prompt_text,
132
+ policy,
133
+ )
134
+ if prompt_ready is False:
135
+ self._log(
136
+ "WARN",
137
+ "channel_input_submit_deferred reason=prompt_render_timeout",
138
+ )
139
+ return False
140
+
104
141
  if policy.submit_delay_seconds:
105
142
  self._sleep(policy.submit_delay_seconds)
106
143
 
107
- before = self._snapshot() if policy.confirm_submission and policy.submit_attempts > 1 else None
144
+ before = (
145
+ self._submission_snapshot(transport)
146
+ if policy.confirm_submission and policy.submit_attempts > 1
147
+ else None
148
+ )
108
149
  for attempt in range(policy.submit_attempts):
109
150
  if bool(getattr(transport, "separate_input_stages", False)):
110
151
  self._write_stage(
@@ -120,10 +161,52 @@ class ChannelPromptInjector:
120
161
  retry_delay = self._retry_delay_seconds()
121
162
  if retry_delay:
122
163
  self._sleep(retry_delay)
123
- after = self._snapshot()
164
+ after = self._submission_snapshot(transport)
124
165
  if after and after != before:
125
166
  self._log("INFO", f"channel_stdin_proxy_submit_confirmed attempt={attempt + 1}")
126
167
  break
168
+ return True
169
+
170
+ def _submission_snapshot(self, transport: InputTransport) -> str | None:
171
+ """Use the host snapshot when available, then the transport's own view."""
172
+
173
+ captured = self._snapshot()
174
+ if captured is not None:
175
+ return captured
176
+ snapshot = getattr(transport, "input_snapshot", None)
177
+ return snapshot() if callable(snapshot) else None
178
+
179
+ def _prompt_readiness_checkpoint(self, transport: InputTransport) -> object:
180
+ """Capture the transport's own output position before prompt input."""
181
+
182
+ checkpoint = getattr(transport, "prompt_readiness_checkpoint", None)
183
+ if callable(checkpoint):
184
+ return checkpoint()
185
+ return self._submission_snapshot(transport)
186
+
187
+ def _wait_until_prompt_ready(
188
+ self,
189
+ transport: InputTransport,
190
+ previous_snapshot: object,
191
+ prompt: str,
192
+ policy: RuntimeInjectionPolicy,
193
+ ) -> bool | None:
194
+ if not policy.confirm_submission or policy.submit_attempts <= 1:
195
+ return None
196
+ wait = getattr(transport, "wait_until_prompt_ready", None)
197
+ if not callable(wait):
198
+ return None
199
+ ready = wait(
200
+ previous_snapshot,
201
+ policy.prompt_render_timeout_seconds,
202
+ expected_prompt=prompt,
203
+ )
204
+ if ready is not None:
205
+ self._log(
206
+ "INFO" if ready else "WARN",
207
+ f"channel_input_prompt_ready result={'observed' if ready else 'timeout'}",
208
+ )
209
+ return ready
127
210
 
128
211
  def _write_stage(
129
212
  self,
@@ -188,6 +271,9 @@ class CallableInputTransport:
188
271
  self.supports_input_snapshot = callable(
189
272
  getattr(target, "input_snapshot", None)
190
273
  )
274
+ self.supports_prompt_ready_wait = callable(
275
+ getattr(target, "wait_until_prompt_ready", None)
276
+ )
191
277
 
192
278
  def write(self, data: bytes) -> None:
193
279
  self._write(self._target, data)
@@ -213,6 +299,30 @@ class CallableInputTransport:
213
299
  snapshot = getattr(self._target, "input_snapshot", None)
214
300
  return snapshot() if callable(snapshot) else None
215
301
 
302
+ def prompt_readiness_checkpoint(self) -> object:
303
+ checkpoint = getattr(self._target, "prompt_readiness_checkpoint", None)
304
+ if callable(checkpoint):
305
+ return checkpoint()
306
+ return self.input_snapshot()
307
+
308
+ def wait_until_prompt_ready(
309
+ self,
310
+ previous_snapshot: object,
311
+ timeout_seconds: float = 2.0,
312
+ *,
313
+ expected_prompt: str | None = None,
314
+ ) -> bool | None:
315
+ wait = getattr(self._target, "wait_until_prompt_ready", None)
316
+ if not callable(wait):
317
+ return None
318
+ return bool(
319
+ wait(
320
+ previous_snapshot,
321
+ timeout_seconds,
322
+ expected_prompt=expected_prompt,
323
+ )
324
+ )
325
+
216
326
 
217
327
  __all__ = [
218
328
  "CallableInputTransport",