@oneciel-ai/ciel-runtime 0.2.37 → 0.2.38

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 (140) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +5 -2
  3. package/ciel_runtime.py +94 -96
  4. package/ciel_runtime_support/anthropic_model_policy.py +20 -3
  5. package/ciel_runtime_support/architecture.py +5 -0
  6. package/ciel_runtime_support/channel_inflight.py +4 -4
  7. package/ciel_runtime_support/channel_injection.py +56 -1
  8. package/ciel_runtime_support/channel_mcp_tools.py +34 -0
  9. package/ciel_runtime_support/channel_message_policy.py +9 -1
  10. package/ciel_runtime_support/channel_message_prompt.py +23 -0
  11. package/ciel_runtime_support/channel_pending_injection.py +86 -32
  12. package/ciel_runtime_support/channel_pending_poll.py +6 -2
  13. package/ciel_runtime_support/channel_runtime_environment.py +0 -11
  14. package/ciel_runtime_support/channel_terminal_context.py +0 -6
  15. package/ciel_runtime_support/channel_terminal_proxy.py +3 -42
  16. package/ciel_runtime_support/channel_transcript_repository.py +16 -1
  17. package/ciel_runtime_support/channel_wake_claim_repository.py +0 -19
  18. package/ciel_runtime_support/channel_wake_context.py +6 -13
  19. package/ciel_runtime_support/channel_wake_delivery_repository.py +50 -0
  20. package/ciel_runtime_support/chat_http_controller.py +88 -5
  21. package/ciel_runtime_support/claude_environment.py +100 -3
  22. package/ciel_runtime_support/claude_launch_assembly.py +4 -0
  23. package/ciel_runtime_support/claude_session_socket.py +181 -0
  24. package/ciel_runtime_support/cli_application_context.py +18 -10
  25. package/ciel_runtime_support/cli_dispatch.py +19 -3
  26. package/ciel_runtime_support/cli_parser.py +2 -0
  27. package/ciel_runtime_support/cli_usage.py +3 -2
  28. package/ciel_runtime_support/codex_backend_context.py +8 -3
  29. package/ciel_runtime_support/codex_completion_gate.py +198 -0
  30. package/ciel_runtime_support/codex_turn_recovery.py +240 -83
  31. package/ciel_runtime_support/compatibility_protocol.py +5 -2
  32. package/ciel_runtime_support/config_migrations.py +79 -0
  33. package/ciel_runtime_support/config_repository.py +7 -0
  34. package/ciel_runtime_support/context_summary_policy.py +24 -4
  35. package/ciel_runtime_support/external_event_menu.py +107 -0
  36. package/ciel_runtime_support/external_event_receiver.py +19 -1
  37. package/ciel_runtime_support/launch_state.py +2 -0
  38. package/ciel_runtime_support/managed_tool_injection.py +33 -0
  39. package/ciel_runtime_support/muse_runtime_context.py +291 -0
  40. package/ciel_runtime_support/ollama_thinking.py +12 -26
  41. package/ciel_runtime_support/prelaunch.py +13 -0
  42. package/ciel_runtime_support/prelaunch_launch_panel.py +2 -1
  43. package/ciel_runtime_support/prompt_compaction.py +39 -0
  44. package/ciel_runtime_support/provider_files_proxy.py +248 -0
  45. package/ciel_runtime_support/provider_model_identity.py +10 -2
  46. package/ciel_runtime_support/provider_option_cli.py +1 -1
  47. package/ciel_runtime_support/provider_request_access.py +54 -1
  48. package/ciel_runtime_support/provider_responses_passthrough.py +152 -15
  49. package/ciel_runtime_support/providers/alibaba.py +34 -1
  50. package/ciel_runtime_support/providers/anthropic.py +18 -1
  51. package/ciel_runtime_support/providers/meta.py +252 -21
  52. package/ciel_runtime_support/pseudo_tool_parser.py +17 -1
  53. package/ciel_runtime_support/remote_bridge.py +4 -0
  54. package/ciel_runtime_support/remote_instructions.py +5 -0
  55. package/ciel_runtime_support/remote_memory.py +1 -1
  56. package/ciel_runtime_support/responses_cache_diagnostics.py +82 -0
  57. package/ciel_runtime_support/responses_custom_tool_bridge.py +282 -0
  58. package/ciel_runtime_support/responses_input_compatibility.py +19 -3
  59. package/ciel_runtime_support/router_http.py +301 -4
  60. package/ciel_runtime_support/router_observability_context.py +26 -0
  61. package/ciel_runtime_support/router_server_context.py +1 -0
  62. package/ciel_runtime_support/runtime_adapters.py +31 -0
  63. package/ciel_runtime_support/runtime_constants.py +23 -1
  64. package/ciel_runtime_support/runtime_input_gateway.py +85 -5
  65. package/ciel_runtime_support/runtime_input_status.py +150 -0
  66. package/ciel_runtime_support/runtime_launch.py +27 -4
  67. package/ciel_runtime_support/runtime_paths.py +1 -0
  68. package/ciel_runtime_support/speech_http_controller.py +3 -2
  69. package/ciel_runtime_support/streaming_anthropic.py +3 -0
  70. package/ciel_runtime_support/tool_call_events.py +98 -0
  71. package/ciel_runtime_support/transcript_delta_delivery.py +140 -1
  72. package/ciel_runtime_support/ui_text.py +1 -0
  73. package/ciel_runtime_support/web_search_result_events.py +130 -0
  74. package/ciel_runtime_support/web_ui.py +1 -0
  75. package/ciel_runtime_support/windows_conpty.py +92 -8
  76. package/ciel_runtime_support/workspace_mcp.py +4 -1
  77. package/ciel_runtime_support/workspace_state.py +1 -0
  78. package/docs/Configuration.md +20 -4
  79. package/docs/MCP-Channels.md +79 -6
  80. package/docs/Managed-Tool-Injection.md +25 -0
  81. package/docs/Module-Map.md +4 -0
  82. package/docs/Muse-Code.md +73 -0
  83. package/docs/Observability.md +39 -0
  84. package/docs/Providers.md +29 -2
  85. package/docs/Remote-Bridge.md +12 -0
  86. package/docs/Router.md +1 -0
  87. package/docs/journal/2026/09/01/diagnostics/anthropic/router/fable51-usage-credits.okf +70 -0
  88. package/docs/journal/2026/09/01/implementation/claude/session-socket/all-input-paths/default-delivery.okf +133 -0
  89. package/docs/journal/2026/09/01/implementation/claude/session-socket/windows-direct-input.okf +112 -0
  90. package/docs/journal/2026/09/02/diagnostics/cache/alibaba/qwen38-post-restart-hit-rate.okf +107 -0
  91. package/docs/journal/2026/09/02/diagnostics/cache/ollama-cloud/kimi-k3-codex-hit-rate.okf +91 -0
  92. package/docs/journal/2026/09/02/diagnostics/codex/tui/statusline-cache-metrics.okf +44 -0
  93. package/docs/journal/2026/09/02/diagnostics/input/transport/fallback-behavior.okf +31 -0
  94. package/docs/journal/2026/09/02/diagnostics/providers/ollama-cloud/kimi-k3-agent-turn-recovery.okf +125 -0
  95. package/docs/journal/2026/09/02/diagnostics/providers/ollama-cloud/kimi-k3-parameters.okf +128 -0
  96. package/docs/journal/2026/09/02/diagnostics/remote/mia/socket-tui-visibility.okf +47 -0
  97. package/docs/journal/2026/09/02/diagnostics/runtime/alibaba/token-plan/qwen38-latency.okf +108 -0
  98. package/docs/journal/2026/09/02/diagnostics/runtime/alibaba/token-plan/qwen38-vs-gpt56-latency.okf +92 -0
  99. package/docs/journal/2026/09/02/implementation/anthropic/context/one-million-defaults.okf +85 -0
  100. package/docs/journal/2026/09/02/implementation/cache/alibaba/qwen38-cache-hit-improvement.okf +104 -0
  101. package/docs/journal/2026/09/02/implementation/cache/alibaba/qwen38-cache-hit-improvement.png +0 -0
  102. package/docs/journal/2026/09/02/implementation/cache/alibaba/responses-session-cache.okf +97 -0
  103. package/docs/journal/2026/09/02/implementation/providers/alibaba/qwen38/0902-parameter-alignment.okf +97 -0
  104. package/docs/journal/2026/09/02/implementation/providers/meta/multimodal-tools/contributor-protocol-adoption.okf +140 -0
  105. package/docs/journal/2026/09/02/implementation/providers/meta/muse-spark-1.3-support.okf +53 -0
  106. package/docs/journal/2026/09/02/monitoring/cache/alibaba/cielarvis-qwen38-live-hit-rate.okf +858 -0
  107. package/docs/journal/2026/09/02/operations/release/main-merge-local-deploy.okf +36 -0
  108. package/docs/journal/2026/09/02/operations/release/nightly/local-cache-deployment.okf +54 -0
  109. package/docs/journal/2026/09/02/operations/release/nightly/ollama-kimi-recovery-model-audit-deployment.okf +58 -0
  110. package/docs/journal/2026/09/02/operations/release/nightly/qwen38-cache-improvement-deployment.okf +72 -0
  111. package/docs/journal/2026/09/02/research/cache/alibaba/qwen38-hit-rate-improvement.okf +233 -0
  112. package/docs/journal/2026/09/02/research/cache/codex/provider-scope.okf +77 -0
  113. package/docs/journal/2026/09/02/research/providers/ollama-cloud/desktop-model-exhaustive-audit.okf +159 -0
  114. package/docs/journal/2026/09/03/diagnostics/providers/meta/muse-spark-contributor-cache-hit.okf +148 -0
  115. package/docs/journal/2026/09/03/diagnostics/providers/meta/muse-spark-contributor-required-schema.okf +127 -0
  116. package/docs/journal/2026/09/03/diagnostics/providers/ollama-cloud/kimi-k3-resumed-session-stall.okf +160 -0
  117. package/docs/journal/2026/09/03/diagnostics/providers/ollama-cloud/kimi-k3-substantive-dangling-action.okf +94 -0
  118. package/docs/journal/2026/09/03/diagnostics/remote/kevin/codex-gpt-early-turn-completion.okf +162 -0
  119. package/docs/journal/2026/09/03/implementation/runtimes/meta/muse-code/default-yolo.okf +40 -0
  120. package/docs/journal/2026/09/03/implementation/runtimes/meta/muse-code/native-subscription-router.okf +86 -0
  121. package/docs/journal/2026/09/03/operations/deployment/local/session-socket-default-fallback.okf +41 -0
  122. package/docs/journal/2026/09/03/research/deployment/colab/tailscale/credential-storage.okf +41 -0
  123. package/docs/journal/2026/09/03/research/providers/meta/muse-code/subscription-billing-boundary.okf +64 -0
  124. package/docs/journal/2026/09/03/research/web-chat/voice/instruction-format.okf +135 -0
  125. package/docs/journal/2026/09/04/diagnostics/claude/context/early-auto-compaction.okf +75 -0
  126. package/docs/journal/2026/09/04/diagnostics/claude/context/fable-51-status-200k.okf +54 -0
  127. package/docs/journal/2026/09/04/diagnostics/runtime/codex/responses/replayed-item-id-validation.okf +74 -0
  128. package/docs/journal/2026/09/04/diagnostics/workspaces/onecieldmsui/codex/replay-stall.okf +100 -0
  129. package/docs/journal/2026/09/04/implementation/observability/tool-calls/websocket-stream.okf +49 -0
  130. package/docs/journal/2026/09/04/implementation/web-chat/input/raw-injection.okf +42 -0
  131. package/docs/journal/2026/09/05/diagnostics/tty/claude-raw-idle-render.okf +25 -0
  132. package/docs/journal/2026/09/05/implementation/events/search-response-urls.okf +23 -0
  133. package/docs/journal/2026/09/05/implementation/tools/native-injection.okf +19 -0
  134. package/docs/journal/2026/09/05/implementation/windows/conpty/prompt-delivery-lifecycle.okf +43 -0
  135. package/docs/journal/2026/09/05/release/nightly/conpty-native-tools.okf +13 -0
  136. package/docs/journal/2026/09/05/release/nightly/search-result-urls.okf +14 -0
  137. package/docs/journal/2026/09/06/implementation/codex/inherited-web-mcp.okf +22 -0
  138. package/docs/journal/2026/09/07/integration/main/nightly-merge.okf +13 -0
  139. package/docs/journal/2026/09/07/release/stable/0.2.38.okf +10 -0
  140. package/package.json +1 -1
@@ -10,6 +10,7 @@ same protection without synthesizing a tool call the Codex client never offered.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import copy
13
14
  import re
14
15
  from dataclasses import dataclass
15
16
  from typing import Any, Callable
@@ -56,11 +57,16 @@ RUNTIME_CONTROL_MESSAGE_KEY = "ciel_runtime_control"
56
57
  RUNTIME_REPEATED_TOOL_RECOVERY = "repeated_tool_call_recovery"
57
58
 
58
59
  _REASONING_RECOVERY_MIN_OUTPUT_TOKENS = 8192
60
+ _KIMI_CONTINUATION_MAX_ATTEMPTS = 6
61
+ _KIMI_STRICT_CONTINUATION_AFTER_ATTEMPT = 3
59
62
 
60
- KIMI_FOLLOWUP_PROMISE_RE = re.compile(
61
- r"(?:겠습니다|할게요|해볼게요|하겠습니다|"
62
- r"i(?:'|’)ll\b[^\n]*|i\s+will\b[^\n]*|let\s+me\b[^\n]*)[.!?。!?]?\s*$",
63
- re.IGNORECASE,
63
+ CODEX_COMPLETION_TOOL_NAME = "ciel_runtime_confirm_completion"
64
+
65
+ CODEX_STRICT_CONTINUATION_NUDGE = (
66
+ "This is a runtime completion check. Call the supplied "
67
+ f"{CODEX_COMPLETION_TOOL_NAME} tool only if every requested action is complete. "
68
+ "Otherwise call the next actual work tool now, using an exact tool name and "
69
+ "every required field from the supplied schema. Do not answer with text."
64
70
  )
65
71
 
66
72
 
@@ -304,12 +310,40 @@ def message_has_only_runtime_stall_notice(message: dict[str, Any]) -> bool:
304
310
  )
305
311
 
306
312
 
307
- def kimi_message_promises_followup(message: dict[str, Any]) -> bool:
308
- """Recognize Kimi ending a reasoning turn with an unperformed next action."""
313
+ def message_confirms_completion(message: dict[str, Any]) -> bool:
314
+ """Recognize the private, language-independent completion tool call."""
309
315
 
310
- if not message_has_reasoning(message) or message_has_tool_use(message):
316
+ return any(
317
+ isinstance(block, dict)
318
+ and block.get("type") == "tool_use"
319
+ and block.get("name") == CODEX_COMPLETION_TOOL_NAME
320
+ for block in message.get("content") or []
321
+ )
322
+
323
+
324
+ def message_requires_completion_check(
325
+ body: dict[str, Any], message: dict[str, Any]
326
+ ) -> bool:
327
+ """Gate a no-tool reasoning response without inspecting natural language."""
328
+
329
+ return bool(
330
+ body.get("tools")
331
+ and message_has_reasoning(message)
332
+ and not message_has_tool_use(message)
333
+ and message_text(message).strip()
334
+ )
335
+
336
+
337
+ def _is_kimi_turn(provider: str, body: dict[str, Any]) -> bool:
338
+ """Identify Kimi across its native and Ollama Cloud provider routes."""
339
+
340
+ provider_name = str(provider or "").strip().casefold()
341
+ if provider_name == "kimi":
342
+ return True
343
+ if provider_name not in {"ollama", "ollama-cloud"}:
311
344
  return False
312
- return bool(KIMI_FOLLOWUP_PROMISE_RE.search(message_text(message).strip()))
345
+ model = str(body.get("model") or "").strip().casefold()
346
+ return "kimi-k3" in model
313
347
 
314
348
 
315
349
  def message_without_reasoning_notice(message: dict[str, Any]) -> dict[str, Any]:
@@ -358,9 +392,26 @@ def body_with_continuation_nudge(
358
392
  """Replay the request with the stalled reply and an explicit continue turn."""
359
393
 
360
394
  messages = list(body.get("messages") or [])
361
- assistant_text = message_text(message).strip()
362
- if assistant_text:
363
- messages.append({"role": "assistant", "content": [{"type": "text", "text": assistant_text}]})
395
+ assistant_content = message.get("content")
396
+ if isinstance(assistant_content, list) and assistant_content:
397
+ # Ollama Desktop retains the complete assistant response before the
398
+ # next agent-loop pass. Kimi K3 likewise requires preserved thinking
399
+ # history, so do not reduce a stalled response to visible text only.
400
+ messages.append(
401
+ {
402
+ "role": "assistant",
403
+ "content": copy.deepcopy(assistant_content),
404
+ }
405
+ )
406
+ else:
407
+ assistant_text = message_text(message).strip()
408
+ if assistant_text:
409
+ messages.append(
410
+ {
411
+ "role": "assistant",
412
+ "content": [{"type": "text", "text": assistant_text}],
413
+ }
414
+ )
364
415
  continuation = {"role": "user", "content": [{"type": "text", "text": nudge}]}
365
416
  if control:
366
417
  continuation[RUNTIME_CONTROL_MESSAGE_KEY] = control
@@ -370,6 +421,36 @@ def body_with_continuation_nudge(
370
421
  return retried
371
422
 
372
423
 
424
+ def body_with_completion_check(
425
+ body: dict[str, Any], message: dict[str, Any]
426
+ ) -> dict[str, Any]:
427
+ """Offer a runtime-owned completion tool only to the private check call."""
428
+
429
+ projected = body_with_continuation_nudge(
430
+ body, message, CODEX_STRICT_CONTINUATION_NUDGE
431
+ )
432
+ tools = copy.deepcopy(list(projected.get("tools") or []))
433
+ if not any(
434
+ isinstance(tool, dict) and tool.get("name") == CODEX_COMPLETION_TOOL_NAME
435
+ for tool in tools
436
+ ):
437
+ tools.append(
438
+ {
439
+ "name": CODEX_COMPLETION_TOOL_NAME,
440
+ "description": "Confirm that every action requested by the user is complete.",
441
+ "input_schema": {
442
+ "type": "object",
443
+ "properties": {},
444
+ "required": [],
445
+ "additionalProperties": False,
446
+ },
447
+ }
448
+ )
449
+ projected["tools"] = tools
450
+ projected["tool_choice"] = {"type": "any"}
451
+ return projected
452
+
453
+
373
454
  def body_with_codex_compat_instructions(
374
455
  body: dict[str, Any],
375
456
  compat_prompt: str,
@@ -380,12 +461,13 @@ def body_with_codex_compat_instructions(
380
461
  """Append the routed Codex compatibility instruction to a Responses request.
381
462
 
382
463
  Codex rejects ``--append-system-prompt`` as a Claude-only flag, so the
383
- instruction has to travel in the request body. The native Codex backend is
384
- excluded: it serves OpenAI's own models, which do not need the nudge, and
385
- rewriting instructions there would only invalidate the cached prefix.
464
+ instruction has to travel in the request body. The text is constant and
465
+ appended only once to keep the upstream cached prefix stable.
386
466
  """
387
467
 
388
- if not isinstance(body, dict) or is_native_codex or not compat_enabled:
468
+ # Native Codex needs the same pre-completion contract. Its separate SSE
469
+ # completion gate validates the response structure after generation.
470
+ if not isinstance(body, dict) or (not is_native_codex and not compat_enabled):
389
471
  return body
390
472
  existing = str(body.get("instructions") or "")
391
473
  if compat_prompt in existing:
@@ -416,11 +498,12 @@ def recover_preamble_only_turn(
416
498
  message: dict[str, Any],
417
499
  services: CodexTurnRecoveryServices,
418
500
  ) -> dict[str, Any]:
419
- """Retry once when the model announced work but called no tool.
501
+ """Retry when the model announced work but called no tool.
420
502
 
421
- Bounded to a single extra upstream call. The retry only wins if it produces
422
- a tool call; prose answers keep the original reply so a model that legitimately
423
- responds without tools is never overridden or duplicated.
503
+ Ordinary providers get one extra upstream call. Kimi gets a bounded series
504
+ because observed routed Codex turns can return several consecutive progress
505
+ announcements. A retry wins if it produces a tool call or a substantive
506
+ concrete answer; repeated announcements do not replace the original reply.
424
507
  """
425
508
 
426
509
  if not isinstance(message, dict) or message_has_tool_use(message):
@@ -435,16 +518,18 @@ def recover_preamble_only_turn(
435
518
  reasoning_output_budget = (
436
519
  reasoning_only and message_exhausted_reasoning_output_budget(message)
437
520
  )
438
- kimi_promised_followup = (
439
- (provider or "").strip().lower() == "kimi"
440
- and kimi_message_promises_followup(message)
441
- and services.should_retry(body, text.replace("`", ""), [])
521
+ kimi_turn = _is_kimi_turn(provider, body)
522
+ completion_check = (
523
+ not empty_end_turn
524
+ and not repeated_tool_guard
525
+ and not reasoning_only
526
+ and message_requires_completion_check(body, message)
442
527
  )
443
528
  if (
444
529
  not empty_end_turn
445
530
  and not repeated_tool_guard
446
531
  and not reasoning_only
447
- and not kimi_promised_followup
532
+ and not completion_check
448
533
  and not services.should_retry(body, text, [])
449
534
  ):
450
535
  return message
@@ -458,8 +543,8 @@ def recover_preamble_only_turn(
458
543
  if reasoning_output_budget
459
544
  else "reasoning_only"
460
545
  if reasoning_only
461
- else "promised_followup"
462
- if kimi_promised_followup
546
+ else "completion_check"
547
+ if completion_check
463
548
  else "preamble_only"
464
549
  )
465
550
  services.log(
@@ -467,73 +552,142 @@ def recover_preamble_only_turn(
467
552
  f"codex_turn_retry provider={provider} reason={reason} "
468
553
  f"model={str(body.get('model') or '-')} chars={len(text.strip())}",
469
554
  )
470
- try:
471
- nudge = (
472
- CODEX_REPEATED_TOOL_CONTINUATION_NUDGE
473
- if repeated_tool_guard
474
- else
475
- CODEX_EMPTY_REASONING_CONTINUATION_NUDGE
476
- if empty_end_turn or reasoning_only
477
- else CODEX_CONTINUATION_NUDGE
478
- )
479
- recovery_config = dict(pcfg)
480
- if (provider or "").strip().lower() == "kimi":
481
- recovery_config["gateway_retries"] = 0
482
- retry_body = body_with_continuation_nudge(
483
- body,
484
- message_without_repeated_tool_notice(message)
485
- if repeated_tool_guard
486
- else message_without_empty_end_turn_notice(message)
487
- if empty_end_turn
488
- else message_without_reasoning_notice(message)
489
- if reasoning_only
490
- else message,
491
- nudge,
492
- control=(RUNTIME_REPEATED_TOOL_RECOVERY if repeated_tool_guard else None),
493
- )
494
- recovery_strategy = "prompt_only"
495
- if reasoning_output_budget and services.prepare_reasoning_budget_retry:
496
- try:
497
- recovery_config, retry_body, recovery_strategy = (
498
- services.prepare_reasoning_budget_retry(
499
- provider, recovery_config, retry_body
500
- )
555
+ nudge = (
556
+ CODEX_REPEATED_TOOL_CONTINUATION_NUDGE
557
+ if repeated_tool_guard
558
+ else CODEX_EMPTY_REASONING_CONTINUATION_NUDGE
559
+ if empty_end_turn or reasoning_only
560
+ else CODEX_STRICT_CONTINUATION_NUDGE
561
+ if completion_check
562
+ else CODEX_CONTINUATION_NUDGE
563
+ )
564
+ recovery_config = dict(pcfg)
565
+ if kimi_turn:
566
+ recovery_config["gateway_retries"] = 0
567
+ # Keep the original request as the stable prefix for every retry. Only the
568
+ # latest stalled assistant response is replayed below; accumulating every
569
+ # failed announcement teaches the same bad pattern back to the model and
570
+ # grows an already-large Codex request on every attempt.
571
+ replay_body = body
572
+ replay_message = (
573
+ message_without_repeated_tool_notice(message)
574
+ if repeated_tool_guard
575
+ else message_without_empty_end_turn_notice(message)
576
+ if empty_end_turn
577
+ else message_without_reasoning_notice(message)
578
+ if reasoning_only
579
+ else message
580
+ )
581
+ # Ollama Desktop keeps looping while agent work is active. Codex itself
582
+ # owns tool execution, so Ciel can only continue no-tool announcements.
583
+ # Kimi K3 gets a small bounded loop because one retry is observably not
584
+ # enough when it emits another progress announcement.
585
+ max_attempts = (
586
+ _KIMI_CONTINUATION_MAX_ATTEMPTS
587
+ if kimi_turn and not (empty_end_turn or reasoning_only or repeated_tool_guard)
588
+ else 1
589
+ )
590
+
591
+ for attempt in range(1, max_attempts + 1):
592
+ try:
593
+ attempt_nudge = (
594
+ CODEX_STRICT_CONTINUATION_NUDGE
595
+ if kimi_turn
596
+ and attempt > _KIMI_STRICT_CONTINUATION_AFTER_ATTEMPT
597
+ and not (empty_end_turn or reasoning_only or repeated_tool_guard)
598
+ else nudge
599
+ )
600
+ retry_body = (
601
+ body_with_completion_check(replay_body, replay_message)
602
+ if completion_check
603
+ else body_with_continuation_nudge(
604
+ replay_body,
605
+ replay_message,
606
+ attempt_nudge,
607
+ control=(
608
+ RUNTIME_REPEATED_TOOL_RECOVERY if repeated_tool_guard else None
609
+ ),
501
610
  )
502
- except Exception as exc: # noqa: BLE001 - retain safe prompt-only retry
611
+ )
612
+ recovery_strategy = "prompt_only"
613
+ if (
614
+ attempt == 1
615
+ and reasoning_output_budget
616
+ and services.prepare_reasoning_budget_retry
617
+ ):
618
+ try:
619
+ recovery_config, retry_body, recovery_strategy = (
620
+ services.prepare_reasoning_budget_retry(
621
+ provider, recovery_config, retry_body
622
+ )
623
+ )
624
+ except Exception as exc: # noqa: BLE001 - retain safe prompt-only retry
625
+ services.log(
626
+ "WARN",
627
+ "codex_reasoning_budget_recovery_projection_failed "
628
+ f"provider={provider} error={type(exc).__name__}: {exc}",
629
+ )
503
630
  services.log(
504
631
  "WARN",
505
- "codex_reasoning_budget_recovery_projection_failed "
506
- f"provider={provider} error={type(exc).__name__}: {exc}",
632
+ f"codex_reasoning_budget_recovery provider={provider} "
633
+ f"model={str(body.get('model') or '-')} strategy={recovery_strategy}",
507
634
  )
635
+ retried = services.collect_message(
636
+ handler,
637
+ provider,
638
+ recovery_config,
639
+ retry_body,
640
+ )
641
+ except Exception as exc: # noqa: BLE001 - recovery must never fail the turn
508
642
  services.log(
509
643
  "WARN",
510
- f"codex_reasoning_budget_recovery provider={provider} "
511
- f"model={str(body.get('model') or '-')} strategy={recovery_strategy}",
644
+ f"codex_preamble_only_turn_retry_failed attempt={attempt}/{max_attempts} "
645
+ f"error={type(exc).__name__}: {exc}",
646
+ )
647
+ return message
648
+ if not isinstance(retried, dict):
649
+ return message
650
+ if empty_end_turn or reasoning_only or repeated_tool_guard:
651
+ if message_confirms_completion(retried):
652
+ return message
653
+ if (
654
+ message_has_only_runtime_stall_notice(retried)
655
+ or (
656
+ not message_has_tool_use(retried)
657
+ and not message_text(retried).strip()
658
+ )
659
+ ):
660
+ return message
661
+ return retried
662
+ if message_confirms_completion(retried):
663
+ services.log(
664
+ "INFO",
665
+ f"codex_completion_confirmed provider={provider} "
666
+ f"attempt={attempt}/{max_attempts}",
512
667
  )
513
- retried = services.collect_message(
514
- handler,
515
- provider,
516
- recovery_config,
517
- retry_body,
668
+ return message
669
+ if message_has_tool_use(retried):
670
+ return _merged(message, retried)
671
+
672
+ retried_text = message_text(retried)
673
+ retryable = services.should_retry(retry_body, retried_text, []) or (
674
+ kimi_turn and message_requires_completion_check(retry_body, retried)
518
675
  )
519
- except Exception as exc: # noqa: BLE001 - recovery must never fail the turn
520
676
  services.log(
521
- "WARN",
522
- f"codex_preamble_only_turn_retry_failed error={type(exc).__name__}: {exc}",
677
+ "WARN" if retryable else "INFO",
678
+ f"codex_turn_retry_result provider={provider} "
679
+ f"attempt={attempt}/{max_attempts} retryable={str(retryable).lower()} "
680
+ f"chars={len(retried_text.strip())}",
523
681
  )
524
- return message
525
- if not isinstance(retried, dict):
526
- return message
527
- if empty_end_turn or reasoning_only or repeated_tool_guard:
528
- if (
529
- message_has_only_runtime_stall_notice(retried)
530
- or (not message_has_tool_use(retried) and not message_text(retried).strip())
531
- ):
682
+ if not retryable and retried_text.strip():
683
+ # A concrete no-tool answer is a valid completion. The old path
684
+ # discarded it and exposed the original announcement instead.
685
+ return retried
686
+ if attempt == max_attempts:
532
687
  return message
533
- return retried
534
- if not message_has_tool_use(retried):
535
- return message
536
- return _merged(message, retried)
688
+ replay_message = retried
689
+
690
+ return message
537
691
 
538
692
 
539
693
  def _merged(original: dict[str, Any], retried: dict[str, Any]) -> dict[str, Any]:
@@ -552,12 +706,14 @@ def _merged(original: dict[str, Any], retried: dict[str, Any]) -> dict[str, Any]
552
706
 
553
707
  __all__ = [
554
708
  "CODEX_CONTINUATION_NUDGE",
709
+ "CODEX_COMPLETION_TOOL_NAME",
555
710
  "CODEX_EMPTY_REASONING_CONTINUATION_NUDGE",
556
711
  "CODEX_REPEATED_TOOL_CONTINUATION_NUDGE",
557
712
  "RUNTIME_EMPTY_END_TURN_NOTICE_PREFIX",
558
713
  "RUNTIME_REASONING_OUTPUT_BUDGET_NOTICE_PREFIX",
559
714
  "CodexTurnRecoveryServices",
560
715
  "body_with_codex_compat_instructions",
716
+ "body_with_completion_check",
561
717
  "body_with_continuation_nudge",
562
718
  "message_has_tool_use",
563
719
  "message_exhausted_reasoning_output_budget",
@@ -566,7 +722,8 @@ __all__ = [
566
722
  "message_has_only_empty_end_turn_notice",
567
723
  "message_has_only_repeated_tool_notice",
568
724
  "message_has_only_runtime_stall_notice",
569
- "kimi_message_promises_followup",
725
+ "message_confirms_completion",
726
+ "message_requires_completion_check",
570
727
  "message_without_empty_end_turn_notice",
571
728
  "message_without_repeated_tool_notice",
572
729
  "message_without_reasoning_notice",
@@ -51,7 +51,10 @@ class CompatibilityProtocolCodec:
51
51
  def tool_request(self, model: str) -> dict[str, Any]:
52
52
  return {
53
53
  "model": model,
54
- "max_tokens": 128,
54
+ # Reasoning models spend from the same output-token budget before
55
+ # emitting a tool call. Keep the historical floor for compact
56
+ # models, while allowing model presets to reserve enough room.
57
+ "max_tokens": max(128, self.ports.max_tokens_for_model(model)),
55
58
  "stream": False,
56
59
  "messages": [
57
60
  {
@@ -76,7 +79,7 @@ class CompatibilityProtocolCodec:
76
79
  )
77
80
  return {
78
81
  "model": model,
79
- "max_tokens": 64,
82
+ "max_tokens": max(64, self.ports.max_tokens_for_model(model)),
80
83
  "stream": False,
81
84
  "messages": [
82
85
  {
@@ -8,6 +8,7 @@ from typing import Any, Callable
8
8
 
9
9
  @dataclass(frozen=True, slots=True)
10
10
  class ConfigMigrationPolicy:
11
+ anthropic_defaults_to_one_million_context: Callable[..., Any]
11
12
  default_request_timeout_ms: int
12
13
  kimi_k3_model: str
13
14
  opencode_provider_names: tuple[str, ...]
@@ -20,6 +21,9 @@ class ConfigMigrationPolicy:
20
21
 
21
22
 
22
23
  def apply_config_migrations(cfg: dict[str, Any], *, policy: ConfigMigrationPolicy) -> None:
24
+ anthropic_defaults_to_one_million_context = (
25
+ policy.anthropic_defaults_to_one_million_context
26
+ )
23
27
  DEFAULT_REQUEST_TIMEOUT_MS = policy.default_request_timeout_ms
24
28
  KIMI_K3_MODEL = policy.kimi_k3_model
25
29
  OPENCODE_PROVIDER_NAMES = policy.opencode_provider_names
@@ -34,6 +38,62 @@ def apply_config_migrations(cfg: dict[str, Any], *, policy: ConfigMigrationPolic
34
38
  migrations = {}
35
39
  cfg["migrations"] = migrations
36
40
 
41
+ marker = "meta_muse_spark_13_catalog_20260902"
42
+ if not migrations.get(marker):
43
+ providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
44
+ pcfg = providers.get("meta")
45
+ if isinstance(pcfg, dict):
46
+ custom = pcfg.get("custom_models")
47
+ if not isinstance(custom, list):
48
+ custom = []
49
+ pcfg["custom_models"] = custom
50
+ known = {
51
+ normalize_model_id("meta", str(model))
52
+ for model in custom
53
+ if str(model).strip()
54
+ }
55
+ for model in (
56
+ "muse-spark-1.3",
57
+ "muse-spark-1.3-contributor",
58
+ "muse-spark-1.2",
59
+ "muse-spark-1.2-contributor",
60
+ "muse-spark-1.1",
61
+ ):
62
+ normalized = normalize_model_id("meta", model)
63
+ if normalized not in known:
64
+ custom.append(model)
65
+ known.add(normalized)
66
+ migrations[marker] = True
67
+
68
+ marker = "anthropic_default_1m_model_ids_20260902"
69
+ if not migrations.get(marker):
70
+ providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
71
+ pcfg = providers.get("anthropic")
72
+ if isinstance(pcfg, dict):
73
+ current = str(pcfg.get("current_model") or "").strip()
74
+ if (
75
+ current
76
+ and "[1m]" not in current.lower()
77
+ and anthropic_defaults_to_one_million_context(current)
78
+ ):
79
+ pcfg["current_model"] = f"{current}[1m]"
80
+ custom = pcfg.get("custom_models")
81
+ if not isinstance(custom, list):
82
+ custom = []
83
+ pcfg["custom_models"] = custom
84
+ known = {str(model).strip().casefold() for model in custom}
85
+ for model in (
86
+ "claude-fable-5-1[1m]",
87
+ "claude-fable-5[1m]",
88
+ "claude-opus-5[1m]",
89
+ "claude-opus-4-8[1m]",
90
+ "claude-sonnet-5[1m]",
91
+ "claude-sonnet-4-6[1m]",
92
+ ):
93
+ if model.casefold() not in known:
94
+ custom.append(model)
95
+ migrations[marker] = True
96
+
37
97
  marker = "zcode_wire_version_0163_20260824"
38
98
  if not migrations.get(marker):
39
99
  providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
@@ -191,6 +251,25 @@ def apply_config_migrations(cfg: dict[str, Any], *, policy: ConfigMigrationPolic
191
251
  pcfg["max_model_len"] = 1_000_000
192
252
  migrations[marker] = True
193
253
 
254
+ marker = "alibaba_qwen38_0902_catalog_20260902"
255
+ if not migrations.get(marker):
256
+ providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
257
+ model_studio = providers.get("alims-intl")
258
+ if isinstance(model_studio, dict):
259
+ custom = model_studio.get("custom_models")
260
+ if not isinstance(custom, list):
261
+ custom = []
262
+ model_studio["custom_models"] = custom
263
+ known = {
264
+ normalize_model_id("alims-intl", str(model))
265
+ for model in custom
266
+ if str(model).strip()
267
+ }
268
+ snapshot = "qwen3.8-max-0902"
269
+ if normalize_model_id("alims-intl", snapshot) not in known:
270
+ custom.insert(1 if custom else 0, snapshot)
271
+ migrations[marker] = True
272
+
194
273
  marker = "alibaba_kimi_k3_20260831"
195
274
  if not migrations.get(marker):
196
275
  providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}
@@ -125,6 +125,13 @@ def build_default_config(provider_defaults: dict[str, Any]) -> dict[str, Any]:
125
125
  "max_batch_bytes": 1048576,
126
126
  "start_mode": "tail",
127
127
  },
128
+ "tool_call_events": {
129
+ "enabled": True,
130
+ "poll_interval_ms": 500,
131
+ "max_batch_bytes": 1048576,
132
+ "start_mode": "tail",
133
+ "include_arguments": True,
134
+ },
128
135
  "remote_instructions": {
129
136
  "enabled": False,
130
137
  "claude_url": "",
@@ -161,6 +161,25 @@ class ContextSummaryPolicy:
161
161
  if (start := (index * count) // chunks) < (end := ((index + 1) * count) // chunks)
162
162
  ]
163
163
 
164
+ @staticmethod
165
+ def cache_stable_summary_budget(budget_tokens: int) -> int:
166
+ """Bucket a wire-fit budget before it can shape a cached summary.
167
+
168
+ Provider request fitting recalculates ``budget_tokens`` from the exact
169
+ serialized byte count. Appending one turn can therefore move that
170
+ value by a few tokens even when the omitted history is unchanged. A
171
+ prompt cache requires an exact prefix, so embedding or otherwise using
172
+ that per-request value would regenerate the first compacted item.
173
+
174
+ Keep the real budget in the compactor's final fit checks. The summary
175
+ projection only needs a conservative, stable size class; rounding down
176
+ to the compactor's minimum quantum makes nearby fits byte-identical and
177
+ never grants the summary a larger budget than the request has.
178
+ """
179
+
180
+ normalized = max(8192, int(budget_tokens))
181
+ return max(8192, (normalized // 8192) * 8192)
182
+
164
183
  def guard_chunk_count(
165
184
  self,
166
185
  omitted_messages: list[dict[str, Any]],
@@ -185,13 +204,14 @@ class ContextSummaryPolicy:
185
204
  if count <= 0:
186
205
  return (
187
206
  "[ciel-runtime context guard: older conversation history was compacted because "
188
- f"the provider context budget is {budget_tokens} tokens.]"
207
+ "the provider context budget was exceeded.]"
189
208
  )
190
- max_tokens = max(1024, min(24576, max(1, budget_tokens) // 10))
209
+ summary_budget = self.cache_stable_summary_budget(budget_tokens)
210
+ max_tokens = max(1024, min(24576, summary_budget // 10))
191
211
  max_chars = max_tokens * 4
192
- chunks = self.guard_chunk_count(omitted_messages, budget_tokens)
212
+ chunks = self.guard_chunk_count(omitted_messages, summary_budget)
193
213
  lines = [
194
- f"[ciel-runtime context guard: compacted {count} older messages, approx {tokens} tokens, because the provider context budget is {budget_tokens} tokens.]",
214
+ f"[ciel-runtime context guard: compacted {count} older messages, approx {tokens} tokens, because the provider context budget was exceeded.]",
195
215
  "The recent tail is preserved verbatim. Older history is represented below as deterministic chunk summaries; use file reads or MCP queries if exact old content is needed.",
196
216
  ]
197
217
  for number, (start, end) in enumerate(self.chunk_ranges(count, chunks), start=1):