@oneciel-ai/ciel-runtime 0.2.2 → 0.2.3

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 (126) hide show
  1. package/ciel_runtime.py +2553 -9635
  2. package/ciel_runtime_support/advisor_request_builder.py +8 -21
  3. package/ciel_runtime_support/anthropic_tool_turns.py +13 -8
  4. package/ciel_runtime_support/architecture.py +68 -0
  5. package/ciel_runtime_support/architecture_budget.py +1 -1
  6. package/ciel_runtime_support/channel_connection_context.py +233 -0
  7. package/ciel_runtime_support/channel_delivery_context.py +332 -0
  8. package/ciel_runtime_support/channel_mcp_context.py +313 -0
  9. package/ciel_runtime_support/channel_mcp_discovery.py +47 -0
  10. package/ciel_runtime_support/channel_mcp_transport.py +5 -1
  11. package/ciel_runtime_support/channel_message_context.py +212 -0
  12. package/ciel_runtime_support/channel_message_repository.py +14 -3
  13. package/ciel_runtime_support/channel_pending_injection.py +9 -0
  14. package/ciel_runtime_support/channel_probe_launch_context.py +213 -0
  15. package/ciel_runtime_support/channel_replay_policy.py +38 -0
  16. package/ciel_runtime_support/channel_runtime_environment.py +8 -0
  17. package/ciel_runtime_support/channel_session_context.py +236 -0
  18. package/ciel_runtime_support/channel_terminal_context.py +350 -0
  19. package/ciel_runtime_support/channel_wake_context.py +532 -0
  20. package/ciel_runtime_support/claude_environment.py +60 -0
  21. package/ciel_runtime_support/claude_launch_assembly.py +249 -0
  22. package/ciel_runtime_support/claude_router.py +62 -12
  23. package/ciel_runtime_support/cli_application_context.py +132 -0
  24. package/ciel_runtime_support/cli_assembly.py +50 -0
  25. package/ciel_runtime_support/codex_backend_context.py +363 -0
  26. package/ciel_runtime_support/codex_config.py +13 -1
  27. package/ciel_runtime_support/codex_launch_assembly.py +213 -0
  28. package/ciel_runtime_support/codex_launch_configuration.py +30 -1
  29. package/ciel_runtime_support/codex_mcp_integration.py +90 -8
  30. package/ciel_runtime_support/codex_model_catalog.py +4 -1
  31. package/ciel_runtime_support/codex_reasoning_rejects.py +225 -0
  32. package/ciel_runtime_support/codex_router.py +38 -8
  33. package/ciel_runtime_support/codex_turn_recovery.py +154 -0
  34. package/ciel_runtime_support/config_migrations.py +103 -0
  35. package/ciel_runtime_support/configuration_cli.py +38 -0
  36. package/ciel_runtime_support/context_compaction.py +9 -4
  37. package/ciel_runtime_support/credential_management.py +12 -0
  38. package/ciel_runtime_support/credentials.py +12 -0
  39. package/ciel_runtime_support/github_copilot_oauth.py +2 -2
  40. package/ciel_runtime_support/hosted_formula_tools.py +216 -0
  41. package/ciel_runtime_support/kimi_runtime_context.py +208 -0
  42. package/ciel_runtime_support/llm_preset_context.py +338 -0
  43. package/ciel_runtime_support/managed_mcp_config.py +8 -4
  44. package/ciel_runtime_support/mcp_configuration_context.py +291 -0
  45. package/ciel_runtime_support/mcp_http_proxy.py +14 -8
  46. package/ciel_runtime_support/mcp_probe_transport.py +47 -15
  47. package/ciel_runtime_support/mcp_transport.py +14 -1
  48. package/ciel_runtime_support/native_context_recovery.py +72 -0
  49. package/ciel_runtime_support/ollama_catalog_context.py +213 -0
  50. package/ciel_runtime_support/ollama_stream_collection.py +103 -0
  51. package/ciel_runtime_support/ollama_thinking.py +6 -1
  52. package/ciel_runtime_support/ollama_wire_projection.py +157 -0
  53. package/ciel_runtime_support/openai_forwarding.py +32 -10
  54. package/ciel_runtime_support/openai_responses_router.py +12 -0
  55. package/ciel_runtime_support/package_lifecycle.py +39 -0
  56. package/ciel_runtime_support/prelaunch_assembly.py +37 -0
  57. package/ciel_runtime_support/prelaunch_panel_context.py +418 -0
  58. package/ciel_runtime_support/prelaunch_shell_context.py +394 -0
  59. package/ciel_runtime_support/prompt_compaction.py +144 -0
  60. package/ciel_runtime_support/prompt_injection.py +45 -0
  61. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +1 -1
  62. package/ciel_runtime_support/protocols/chat_projection.py +85 -5
  63. package/ciel_runtime_support/protocols/conversation_turn_policy.py +43 -0
  64. package/ciel_runtime_support/protocols/ollama_chat.py +31 -0
  65. package/ciel_runtime_support/protocols/ollama_response.py +57 -5
  66. package/ciel_runtime_support/protocols/openai_reasoning.py +5 -2
  67. package/ciel_runtime_support/protocols/openai_responses.py +61 -15
  68. package/ciel_runtime_support/provider_adapters.py +26 -0
  69. package/ciel_runtime_support/provider_administration_context.py +207 -0
  70. package/ciel_runtime_support/provider_config_mutations.py +3 -0
  71. package/ciel_runtime_support/provider_model_catalog_context.py +137 -0
  72. package/ciel_runtime_support/provider_model_context.py +107 -0
  73. package/ciel_runtime_support/provider_model_metadata_context.py +197 -0
  74. package/ciel_runtime_support/provider_model_selection.py +10 -3
  75. package/ciel_runtime_support/provider_models.py +45 -2
  76. package/ciel_runtime_support/provider_option_cli.py +19 -0
  77. package/ciel_runtime_support/provider_policy.py +1 -1
  78. package/ciel_runtime_support/provider_readiness_context.py +189 -0
  79. package/ciel_runtime_support/provider_request_builder.py +64 -28
  80. package/ciel_runtime_support/provider_responses_passthrough.py +21 -2
  81. package/ciel_runtime_support/provider_timeout_policy.py +54 -0
  82. package/ciel_runtime_support/provider_tool_policy.py +9 -1
  83. package/ciel_runtime_support/providers/__init__.py +6 -0
  84. package/ciel_runtime_support/providers/alibaba.py +634 -0
  85. package/ciel_runtime_support/providers/catalog.py +24 -16
  86. package/ciel_runtime_support/providers/deepseek.py +73 -0
  87. package/ciel_runtime_support/providers/github_copilot_oauth.py +22 -1
  88. package/ciel_runtime_support/providers/kimi.py +69 -9
  89. package/ciel_runtime_support/providers/ollama.py +8 -0
  90. package/ciel_runtime_support/providers/ollama_context.py +21 -2
  91. package/ciel_runtime_support/providers/vllm.py +7 -1
  92. package/ciel_runtime_support/response_collection.py +68 -18
  93. package/ciel_runtime_support/response_collection_context.py +391 -0
  94. package/ciel_runtime_support/response_stream_context.py +555 -0
  95. package/ciel_runtime_support/responses_input_compatibility.py +121 -0
  96. package/ciel_runtime_support/responses_usage_observer.py +83 -0
  97. package/ciel_runtime_support/router_client_lifecycle.py +1 -0
  98. package/ciel_runtime_support/router_http.py +239 -3
  99. package/ciel_runtime_support/router_observability_context.py +251 -0
  100. package/ciel_runtime_support/router_process_context.py +200 -0
  101. package/ciel_runtime_support/router_process_lifecycle.py +2 -0
  102. package/ciel_runtime_support/router_request_assembly.py +399 -0
  103. package/ciel_runtime_support/router_request_context.py +215 -0
  104. package/ciel_runtime_support/router_server_context.py +82 -0
  105. package/ciel_runtime_support/runaway_output_guard.py +488 -0
  106. package/ciel_runtime_support/runtime_asset_assembly.py +147 -0
  107. package/ciel_runtime_support/runtime_asset_context.py +297 -0
  108. package/ciel_runtime_support/runtime_constants.py +16 -1
  109. package/ciel_runtime_support/runtime_launch.py +9 -5
  110. package/ciel_runtime_support/runtime_launch_context.py +130 -0
  111. package/ciel_runtime_support/runtime_maintenance_assembly.py +60 -0
  112. package/ciel_runtime_support/runtime_maintenance_context.py +309 -0
  113. package/ciel_runtime_support/runtime_maintenance_services.py +265 -0
  114. package/ciel_runtime_support/runtime_paths.py +60 -40
  115. package/ciel_runtime_support/runtime_primitives.py +78 -0
  116. package/ciel_runtime_support/sse_stream_collection.py +236 -0
  117. package/ciel_runtime_support/statusline_script.py +57 -8
  118. package/ciel_runtime_support/streaming_anthropic.py +361 -24
  119. package/ciel_runtime_support/tool_schema.py +40 -2
  120. package/ciel_runtime_support/tool_side_effect_dedupe.py +117 -12
  121. package/ciel_runtime_support/upstream_dump.py +68 -0
  122. package/ciel_runtime_support/upstream_retry_context.py +259 -0
  123. package/ciel_runtime_support/workspace_router_selection.py +86 -0
  124. package/docs/Configuration.md +50 -0
  125. package/docs/Test-Suite.md +1 -0
  126. package/package.json +1 -1
@@ -10,6 +10,14 @@ from dataclasses import dataclass
10
10
  from http.server import BaseHTTPRequestHandler
11
11
  from typing import Any, Callable, Iterable
12
12
 
13
+ from .runaway_output_guard import (
14
+ STOPPED,
15
+ RunawayOutputDetector,
16
+ RunawayVerdict,
17
+ policy_from_env,
18
+ recent_runaway_notices,
19
+ )
20
+
13
21
 
14
22
  @dataclass(frozen=True, slots=True)
15
23
  class AnthropicStreamIO:
@@ -157,10 +165,19 @@ def rebatch_anthropic_sse_text(
157
165
  and (has_tool(source_body, "Workflow") or body_ultracode_runtime_enabled(source_body))
158
166
  )
159
167
  visible_tool_call_artifact_filters: dict[int, VisibleToolCallArtifactFilter] = {}
168
+ runaway_policy = policy_from_env(os.environ.get)
169
+ text_runaway = RunawayOutputDetector(runaway_policy)
170
+ thinking_runaway = RunawayOutputDetector(runaway_policy)
171
+ runaway_verdict: RunawayVerdict | None = None
160
172
 
161
173
  class ClientStreamDisconnected(Exception):
162
174
  pass
163
175
 
176
+ def note_runaway(detector: RunawayOutputDetector, text: str) -> None:
177
+ nonlocal runaway_verdict
178
+ if runaway_verdict is None:
179
+ runaway_verdict = detector.feed(text)
180
+
164
181
  def downstream_keepalive_interval() -> float:
165
182
  raw = os.environ.get("CIEL_RUNTIME_ANTHROPIC_STREAM_KEEPALIVE_SECONDS")
166
183
  if raw is None:
@@ -430,6 +447,44 @@ def rebatch_anthropic_sse_text(
430
447
  pending_message_stop[1] if pending_message_stop is not None else "{\"type\":\"message_stop\"}",
431
448
  )
432
449
 
450
+ def emit_runaway_stop() -> None:
451
+ """Close a looping turn instead of relaying the rest of the loop."""
452
+
453
+ nonlocal next_content_index
454
+ if runaway_verdict is None:
455
+ return
456
+ router_log(
457
+ "WARN",
458
+ f"anthropic_stream_runaway_repetition provider={provider} model={model} "
459
+ f"text_len={len(text_so_far)} {runaway_verdict.log_fields()}",
460
+ )
461
+ for index in sorted(open_content_blocks):
462
+ finish_visible_tool_call_artifact_filter(index)
463
+ emit_raw(
464
+ "content_block_stop",
465
+ json.dumps({"type": "content_block_stop", "index": index}, ensure_ascii=False),
466
+ )
467
+ open_content_blocks.clear()
468
+ notice_index = next_content_index
469
+ next_content_index += 1
470
+ emit_text_block(notice_index, runaway_verdict.notice(STOPPED))
471
+ stop_reason = "max_tokens"
472
+ if (
473
+ not emitted_tool_use
474
+ and runaway_policy.recover
475
+ and recent_runaway_notices(source_body) == 0
476
+ ):
477
+ # Cutting the loop should not cost the user the turn. A tool call
478
+ # brings the CLI back for a fresh one; the history check above stops
479
+ # the recovery from becoming a loop of its own.
480
+ router_log("WARN", f"anthropic_stream_runaway_recovery provider={provider} model={model}")
481
+ tool_index = next_content_index
482
+ next_content_index += 1
483
+ emit_tasklist_tool(tool_index)
484
+ stop_reason = "tool_use"
485
+ emit_raw("message_delta", patched_message_delta(stop_reason))
486
+ emit_raw("message_stop", "{\"type\":\"message_stop\"}")
487
+
433
488
  def recover_hidden_only_response_if_needed() -> None:
434
489
  nonlocal next_content_index, saw_tool_use, emitted_tool_use, text_so_far, pending_message_delta
435
490
  recovery_reason = ""
@@ -517,6 +572,7 @@ def rebatch_anthropic_sse_text(
517
572
  matched_name = resolve_emitted_tool_name(raw_name, source_body)
518
573
  if not matched_name:
519
574
  matched_name = infer_tool_name_from_args(parsed_args)
575
+ emitted_name = matched_name
520
576
  fixed_input = _validate_and_fix_tool_input(matched_name, parsed_args, source_body)
521
577
  if isinstance(source_body, dict):
522
578
  mapped_name, mapped_input = plan_mode_tool_name_for_emit(source_body, matched_name, fixed_input)
@@ -528,9 +584,12 @@ def rebatch_anthropic_sse_text(
528
584
  return
529
585
  matched_name, fixed_input = mapped_name, mapped_input
530
586
  fixed_input = cap_mcp_notification_wait_tool_input(matched_name, fixed_input)
531
- if should_drop_emitted_tool_call(matched_name, fixed_input, raw_name, source_body):
587
+ # Plan mode can retarget the call at a different tool, and then the
588
+ # original arguments no longer describe the schema being checked.
589
+ supplied_input = parsed_args if matched_name == emitted_name else None
590
+ if should_drop_emitted_tool_call(matched_name, fixed_input, raw_name, source_body, supplied_input):
532
591
  return
533
- if should_drop_duplicate_side_effect_tool_call(matched_name, fixed_input, raw_name):
592
+ if should_drop_duplicate_side_effect_tool_call(matched_name, fixed_input, raw_name, source_body):
534
593
  return
535
594
  tool_id = str(tool_state.get("id") or f"toolu_anthropic_{int(time.time() * 1000)}_{index}")
536
595
  _remember_channel_injected_tool_use(source_body, tool_id, matched_name, fixed_input)
@@ -820,11 +879,14 @@ def rebatch_anthropic_sse_text(
820
879
  patched["index"] = mapped_index
821
880
  event = patched
822
881
  data_str = json.dumps(event, ensure_ascii=False)
882
+ if delta.get("type") == "thinking_delta":
883
+ note_runaway(thinking_runaway, str(delta.get("thinking") or ""))
823
884
  if isinstance(mapped_index, int) and delta.get("type") == "text_delta":
824
885
  text = delta.get("text") or ""
825
886
  if not text:
826
887
  return
827
888
  text_so_far += text
889
+ note_runaway(text_runaway, text)
828
890
  if provider != "anthropic" and mapped_index in held_pseudo_tool_text:
829
891
  held_pseudo_tool_text[mapped_index] += text
830
892
  return
@@ -882,6 +944,10 @@ def rebatch_anthropic_sse_text(
882
944
  process_event(pending_event_type, data_str)
883
945
  pending_event_type = None
884
946
  pending_event_lines = []
947
+ if runaway_verdict is not None:
948
+ # Stop reading upstream; the finally block closes the
949
+ # response so the loop stops being generated and billed.
950
+ break
885
951
  continue
886
952
  if stripped.startswith("event:"):
887
953
  pending_event_type = stripped[len("event:"):].strip() or None
@@ -896,6 +962,10 @@ def rebatch_anthropic_sse_text(
896
962
  flush_buffer(index, force=True)
897
963
  for index in list(suppressed_thinking_blocks.keys()):
898
964
  finish_suppressed_thinking_block(index)
965
+ if runaway_verdict is not None:
966
+ emit_runaway_stop()
967
+ stream_success = True
968
+ return
899
969
  recover_hidden_only_response_if_needed()
900
970
  flush_suppressed_thinking_passback()
901
971
  if pending_message_delta is not None or pending_message_stop is not None:
@@ -1007,6 +1077,7 @@ class OllamaToolProjection:
1007
1077
  @dataclass(frozen=True, slots=True)
1008
1078
  class OllamaContinuationPolicy:
1009
1079
  empty_end_turn_notice_for_body: Callable[..., Any]
1080
+ reasoning_only_notice: Callable[..., Any]
1010
1081
  should_auto_continue_choice_question_with_tasklist: Callable[..., Any]
1011
1082
  should_auto_enter_plan_mode: Callable[..., Any]
1012
1083
  should_keep_work_alive_with_tasklist: Callable[..., Any]
@@ -1057,6 +1128,7 @@ def ollama_stream_to_anthropic_sse(
1057
1128
  should_drop_duplicate_side_effect_tool_call = services.tool_projection.should_drop_duplicate_side_effect_tool_call
1058
1129
  should_drop_emitted_tool_call = services.tool_projection.should_drop_emitted_tool_call
1059
1130
  empty_end_turn_notice_for_body = services.continuation.empty_end_turn_notice_for_body
1131
+ reasoning_only_notice = services.continuation.reasoning_only_notice
1060
1132
  should_auto_continue_choice_question_with_tasklist = services.continuation.should_auto_continue_choice_question_with_tasklist
1061
1133
  should_auto_enter_plan_mode = services.continuation.should_auto_enter_plan_mode
1062
1134
  should_keep_work_alive_with_tasklist = services.continuation.should_keep_work_alive_with_tasklist
@@ -1068,11 +1140,16 @@ def ollama_stream_to_anthropic_sse(
1068
1140
  handler.end_headers()
1069
1141
  msg_id = f"msg_ollama_{int(time.time() * 1000)}"
1070
1142
  started = False
1143
+ thinking_started = False
1071
1144
  text_started = False
1072
1145
  text_suppressed_for_plan = False
1073
1146
  next_content_index = 0
1074
1147
  text_index: int | None = None
1075
1148
  text_block_open = False
1149
+ thinking_index: int | None = None
1150
+ thinking_block_open = False
1151
+ thinking_block_text = ""
1152
+ thinking_so_far = ""
1076
1153
  text_so_far = ""
1077
1154
  text_buffer = ""
1078
1155
  tool_calls: list[dict[str, Any]] = []
@@ -1085,6 +1162,11 @@ def ollama_stream_to_anthropic_sse(
1085
1162
  last_activity_update = 0.0
1086
1163
  thinking_markup_filter = VisibleThinkingMarkupFilter()
1087
1164
  thinking_markup_suppressed = False
1165
+ repeated_completed_tool_dropped = False
1166
+ runaway_policy = policy_from_env(os.environ.get)
1167
+ text_runaway = RunawayOutputDetector(runaway_policy)
1168
+ thinking_runaway = RunawayOutputDetector(runaway_policy)
1169
+ runaway_verdict: RunawayVerdict | None = None
1088
1170
  sse_trace = make_outgoing_sse_trace(provider, model, "ollama_stream", source_body)
1089
1171
  sse_trace_outcome = "started"
1090
1172
  sse_trace_error: str | None = None
@@ -1187,13 +1269,56 @@ def ollama_stream_to_anthropic_sse(
1187
1269
  emit("content_block_stop", {"type": "content_block_stop", "index": text_index})
1188
1270
  text_block_open = False
1189
1271
 
1272
+ def open_thinking_block() -> int:
1273
+ nonlocal next_content_index, thinking_block_open, thinking_block_text
1274
+ nonlocal thinking_index, thinking_started
1275
+ if thinking_block_open and thinking_index is not None:
1276
+ return thinking_index
1277
+ thinking_index = next_content_index
1278
+ next_content_index += 1
1279
+ thinking_started = True
1280
+ thinking_block_open = True
1281
+ thinking_block_text = ""
1282
+ emit(
1283
+ "content_block_start",
1284
+ {
1285
+ "type": "content_block_start",
1286
+ "index": thinking_index,
1287
+ "content_block": {"type": "thinking", "thinking": ""},
1288
+ },
1289
+ )
1290
+ return thinking_index
1291
+
1292
+ def close_thinking_block() -> None:
1293
+ nonlocal thinking_block_open
1294
+ if not thinking_block_open or thinking_index is None:
1295
+ return
1296
+ digest = hashlib.sha256(
1297
+ thinking_block_text.encode("utf-8", errors="replace")
1298
+ ).hexdigest()[:24]
1299
+ emit(
1300
+ "content_block_delta",
1301
+ {
1302
+ "type": "content_block_delta",
1303
+ "index": thinking_index,
1304
+ "delta": {
1305
+ "type": "signature_delta",
1306
+ "signature": f"ciel-runtime-ollama-thinking-{digest}",
1307
+ },
1308
+ },
1309
+ )
1310
+ emit("content_block_stop", {"type": "content_block_stop", "index": thinking_index})
1311
+ thinking_block_open = False
1312
+
1190
1313
  def update_stream_activity(force: bool = False) -> None:
1191
1314
  nonlocal last_activity_update
1192
1315
  now = time.time()
1193
1316
  if not force and now - last_activity_update < 0.5:
1194
1317
  return
1195
1318
  last_activity_update = now
1196
- estimated_output = output_tokens or max(0, len(text_so_far) // 4)
1319
+ estimated_output = output_tokens or max(
1320
+ 0, (len(thinking_so_far) + len(text_so_far)) // 4
1321
+ )
1197
1322
  write_router_activity(
1198
1323
  "request",
1199
1324
  provider,
@@ -1205,9 +1330,12 @@ def ollama_stream_to_anthropic_sse(
1205
1330
  )
1206
1331
 
1207
1332
  def handle_text_chunk(text_chunk: str) -> None:
1208
- nonlocal text_buffer, text_so_far, text_suppressed_for_plan
1333
+ nonlocal text_buffer, text_so_far, text_suppressed_for_plan, runaway_verdict
1209
1334
  if not text_chunk:
1210
1335
  return
1336
+ if runaway_verdict is None:
1337
+ runaway_verdict = text_runaway.feed(text_chunk)
1338
+ close_thinking_block()
1211
1339
  if source_body is not None and not text_started and not tool_calls and should_auto_enter_plan_mode(source_body, text_so_far + text_chunk, []):
1212
1340
  text_so_far += text_chunk
1213
1341
  text_suppressed_for_plan = True
@@ -1257,6 +1385,26 @@ def ollama_stream_to_anthropic_sse(
1257
1385
  emit("content_block_delta", event)
1258
1386
  update_stream_activity()
1259
1387
 
1388
+ def handle_thinking_chunk(thinking_chunk: str) -> None:
1389
+ nonlocal thinking_block_text, thinking_so_far, runaway_verdict
1390
+ if not thinking_chunk:
1391
+ return
1392
+ if runaway_verdict is None:
1393
+ runaway_verdict = thinking_runaway.feed(thinking_chunk)
1394
+ close_text_block()
1395
+ active_thinking_index = open_thinking_block()
1396
+ thinking_block_text += thinking_chunk
1397
+ thinking_so_far += thinking_chunk
1398
+ emit(
1399
+ "content_block_delta",
1400
+ {
1401
+ "type": "content_block_delta",
1402
+ "index": active_thinking_index,
1403
+ "delta": {"type": "thinking_delta", "thinking": thinking_chunk},
1404
+ },
1405
+ )
1406
+ update_stream_activity()
1407
+
1260
1408
  try:
1261
1409
  for line in iter_upstream_lines_until_client_disconnect(handler, resp, idle_timeout):
1262
1410
  chunks_seen += 1
@@ -1274,6 +1422,8 @@ def ollama_stream_to_anthropic_sse(
1274
1422
  output_tokens = max(output_tokens, int(chunk.get("eval_count") or 0))
1275
1423
  if not started:
1276
1424
  ensure_message_started()
1425
+ # Native Ollama thinking is a distinct field, not visible answer text.
1426
+ handle_thinking_chunk(str(message.get("thinking") or ""))
1277
1427
  # Handle text content
1278
1428
  raw_text_chunk = str(message.get("content") or "")
1279
1429
  text_chunk = thinking_markup_filter.feed(raw_text_chunk)
@@ -1290,16 +1440,20 @@ def ollama_stream_to_anthropic_sse(
1290
1440
  matched_name = resolve_emitted_tool_name(raw_name, source_body)
1291
1441
  raw_args = fn.get("arguments")
1292
1442
  normalized_args = normalize_tool_arguments(matched_name, raw_args)
1443
+ emitted_name = matched_name
1293
1444
  fixed_input = _validate_and_fix_tool_input(matched_name, normalized_args)
1294
1445
  if source_body is not None:
1295
1446
  matched_name, fixed_input = plan_mode_tool_name_for_emit(source_body, matched_name, fixed_input)
1296
1447
  if matched_name is None:
1297
1448
  continue
1298
1449
  fixed_input = cap_mcp_notification_wait_tool_input(matched_name, fixed_input)
1299
- if should_drop_emitted_tool_call(matched_name, fixed_input, raw_name, source_body):
1450
+ supplied_input = normalized_args if matched_name == emitted_name else None
1451
+ if should_drop_emitted_tool_call(matched_name, fixed_input, raw_name, source_body, supplied_input):
1300
1452
  continue
1301
- if should_drop_duplicate_side_effect_tool_call(matched_name, fixed_input, raw_name):
1453
+ if should_drop_duplicate_side_effect_tool_call(matched_name, fixed_input, raw_name, source_body):
1454
+ repeated_completed_tool_dropped = True
1302
1455
  continue
1456
+ close_thinking_block()
1303
1457
  close_text_block()
1304
1458
  tool_calls.append({"function": {"name": matched_name, "arguments": fixed_input}})
1305
1459
  tool_id = f"toolu_ollama_{int(time.time() * 1000)}_{len(tool_calls) - 1}"
@@ -1322,14 +1476,37 @@ def ollama_stream_to_anthropic_sse(
1322
1476
  emit_tool_block(tool_index, tool_id, matched_name, fixed_input)
1323
1477
  update_stream_activity()
1324
1478
  update_stream_activity()
1479
+ if runaway_verdict is not None:
1480
+ # Stop reading upstream. The finally block closes the response,
1481
+ # which ends generation instead of paying for the whole loop.
1482
+ router_log(
1483
+ "WARN",
1484
+ f"ollama_stream_runaway_repetition provider={provider} model={model} "
1485
+ f"chunks={chunks_seen} {runaway_verdict.log_fields()}",
1486
+ )
1487
+ write_router_activity(
1488
+ "error", provider, model, error="runaway_repetition", stream=True
1489
+ )
1490
+ break
1491
+ runaway_stopped = runaway_verdict is not None
1325
1492
  trailing_text = thinking_markup_filter.finish()
1326
- if trailing_text:
1493
+ if trailing_text and not runaway_stopped:
1327
1494
  handle_text_chunk(trailing_text)
1328
1495
  if thinking_markup_suppressed:
1329
1496
  router_log("WARN", f"suppressed visible Ollama thinking markup from stream model={model}")
1330
1497
  update_stream_activity(force=True)
1498
+ close_thinking_block()
1499
+ if not runaway_stopped and repeated_completed_tool_dropped and not text_so_far.strip() and not tool_calls:
1500
+ handle_text_chunk(
1501
+ "[ciel-runtime] Stopped an identical completed tool call from repeating. "
1502
+ "The previous result is already in context; choose a different action or finish the turn."
1503
+ )
1504
+ reasoning_only = thinking_started and not text_so_far.strip() and not tool_calls and not runaway_stopped
1505
+ # A looping turn must not be continued for the model. Every synthesis
1506
+ # below exists to keep work moving, which is the opposite of what a
1507
+ # runaway needs, so they are all skipped once the guard has fired.
1331
1508
  # Flush any remaining buffered text when word-chunking is active
1332
- if source_body is not None and should_auto_enter_plan_mode(source_body, text_so_far, tool_calls):
1509
+ if not runaway_stopped and not reasoning_only and source_body is not None and should_auto_enter_plan_mode(source_body, text_so_far, tool_calls):
1333
1510
  ensure_message_started()
1334
1511
  close_text_block()
1335
1512
  router_log("WARN", "auto-synthesized EnterPlanMode from short/empty upstream stream")
@@ -1339,7 +1516,7 @@ def ollama_stream_to_anthropic_sse(
1339
1516
  next_content_index += 1
1340
1517
  tool_indices.append(tool_index)
1341
1518
  emit_tool_block(tool_index, tool_id, "EnterPlanMode", {})
1342
- elif source_body is not None and should_recover_empty_end_turn_with_tasklist(source_body, text_so_far, tool_calls):
1519
+ elif not runaway_stopped and not reasoning_only and source_body is not None and should_recover_empty_end_turn_with_tasklist(source_body, text_so_far, tool_calls):
1343
1520
  ensure_message_started()
1344
1521
  close_text_block()
1345
1522
  router_log("WARN", "auto-synthesized TaskList from empty upstream end_turn stream")
@@ -1357,7 +1534,7 @@ def ollama_stream_to_anthropic_sse(
1357
1534
  "delta": {"type": "text_delta", "text": text_so_far},
1358
1535
  }
1359
1536
  emit("content_block_delta", event)
1360
- if source_body is not None and should_keep_work_alive_with_tasklist(source_body, text_so_far, tool_calls):
1537
+ if not runaway_stopped and not reasoning_only and source_body is not None and should_keep_work_alive_with_tasklist(source_body, text_so_far, tool_calls):
1361
1538
  ensure_message_started()
1362
1539
  close_text_block()
1363
1540
  router_log("WARN", "auto-synthesized TaskList to keep work moving after tool result stream")
@@ -1367,7 +1544,7 @@ def ollama_stream_to_anthropic_sse(
1367
1544
  next_content_index += 1
1368
1545
  tool_indices.append(tool_index)
1369
1546
  emit_tool_block(tool_index, tool_id, "TaskList", {})
1370
- if source_body is not None and should_auto_continue_choice_question_with_tasklist(source_body, text_so_far, tool_calls):
1547
+ if not runaway_stopped and not reasoning_only and source_body is not None and should_auto_continue_choice_question_with_tasklist(source_body, text_so_far, tool_calls):
1371
1548
  ensure_message_started()
1372
1549
  close_text_block()
1373
1550
  router_log("WARN", "auto-synthesized TaskList after clarification question stream")
@@ -1388,7 +1565,41 @@ def ollama_stream_to_anthropic_sse(
1388
1565
  stopped_tool_indices.add(tool_index)
1389
1566
  if not started:
1390
1567
  ensure_message_started()
1391
- if not text_started and not tool_indices:
1568
+ if runaway_stopped and runaway_verdict is not None:
1569
+ notice = runaway_verdict.notice(STOPPED)
1570
+ text_so_far += notice
1571
+ notice_index = next_content_index
1572
+ next_content_index += 1
1573
+ emit_text_block(notice_index, notice)
1574
+ # Cutting the loop should not cost the user the turn. Hand the agent
1575
+ # a tool call so the CLI comes back for a fresh one -- unless a
1576
+ # recent turn already carries this notice, which would mean the
1577
+ # recovery itself is looping.
1578
+ if (
1579
+ not tool_calls
1580
+ and runaway_policy.recover
1581
+ and recent_runaway_notices(source_body) == 0
1582
+ ):
1583
+ router_log("WARN", f"ollama_stream_runaway_recovery provider={provider} model={model}")
1584
+ tool_calls.append({"function": {"name": "TaskList", "arguments": {}}})
1585
+ tool_id = f"toolu_ollama_runaway_{int(time.time() * 1000)}"
1586
+ tool_index = next_content_index
1587
+ next_content_index += 1
1588
+ tool_indices.append(tool_index)
1589
+ emit_tool_block(tool_index, tool_id, "TaskList", {})
1590
+ elif reasoning_only:
1591
+ router_log(
1592
+ "WARN",
1593
+ f"ollama_reasoning_only_stream provider={provider} model={model} "
1594
+ f"chunks={chunks_seen} thinking_chars={len(thinking_so_far)} "
1595
+ f"done_reason={str(chunk.get('done_reason') or '-')}",
1596
+ )
1597
+ notice = reasoning_only_notice(str(chunk.get("done_reason") or ""))
1598
+ text_so_far = notice
1599
+ notice_index = next_content_index
1600
+ next_content_index += 1
1601
+ emit_text_block(notice_index, notice)
1602
+ elif not text_started and not tool_indices:
1392
1603
  router_log("WARN", f"ollama_empty_stream provider={provider} model={model} chunks={chunks_seen}")
1393
1604
  write_router_activity("error", provider, model, error="empty_stream", stream=True)
1394
1605
  empty_index = next_content_index
@@ -1401,6 +1612,10 @@ def ollama_stream_to_anthropic_sse(
1401
1612
  stop_reason = "tool_use" if tool_calls else "end_turn"
1402
1613
  if chunk.get("done_reason") == "length":
1403
1614
  stop_reason = "max_tokens"
1615
+ if runaway_stopped and not tool_calls:
1616
+ # No continuation was synthesized, so the turn really does end here
1617
+ # with output the router truncated.
1618
+ stop_reason = "max_tokens"
1404
1619
  # Send message_delta with final stop_reason
1405
1620
  event = {
1406
1621
  "type": "message_delta",
@@ -1411,13 +1626,14 @@ def ollama_stream_to_anthropic_sse(
1411
1626
  # Send message_stop
1412
1627
  emit("message_stop", {"type": "message_stop"})
1413
1628
  sse_trace_outcome = "success"
1414
- if text_started or tool_indices:
1629
+ if thinking_started or text_started or tool_indices:
1415
1630
  write_router_activity(
1416
1631
  "success",
1417
1632
  provider,
1418
1633
  model,
1419
1634
  tokens=input_tokens,
1420
- output_tokens=output_tokens or max(1, len(text_so_far) // 4),
1635
+ output_tokens=output_tokens
1636
+ or max(1, (len(thinking_so_far) + len(text_so_far)) // 4),
1421
1637
  chunks=chunks_seen,
1422
1638
  stream=True,
1423
1639
  )
@@ -1429,7 +1645,8 @@ def ollama_stream_to_anthropic_sse(
1429
1645
  router_log(
1430
1646
  "WARN",
1431
1647
  f"ollama_stream_client_disconnected provider={provider} model={model} "
1432
- f"chunks={chunks_seen} text_len={len(text_so_far)} error={exc}",
1648
+ f"chunks={chunks_seen} thinking_len={len(thinking_so_far)} "
1649
+ f"text_len={len(text_so_far)} error={exc}",
1433
1650
  )
1434
1651
  write_router_activity(
1435
1652
  "cancel",
@@ -1437,7 +1654,8 @@ def ollama_stream_to_anthropic_sse(
1437
1654
  model,
1438
1655
  error=type(exc).__name__,
1439
1656
  tokens=input_tokens,
1440
- output_tokens=output_tokens or max(0, len(text_so_far) // 4),
1657
+ output_tokens=output_tokens
1658
+ or max(0, (len(thinking_so_far) + len(text_so_far)) // 4),
1441
1659
  chunks=chunks_seen,
1442
1660
  stream=True,
1443
1661
  )
@@ -1449,6 +1667,8 @@ def ollama_stream_to_anthropic_sse(
1449
1667
  write_router_activity("error", provider, model, error=type(exc).__name__, stream=True)
1450
1668
  try:
1451
1669
  ensure_message_started()
1670
+ if thinking_block_open:
1671
+ close_thinking_block()
1452
1672
  if text_block_open:
1453
1673
  close_text_block()
1454
1674
  if not text_started and not tool_indices:
@@ -1594,9 +1814,17 @@ def forward_openai_chat_to_anthropic_sse(
1594
1814
  reasoning_so_far = ""
1595
1815
  tool_fragments: dict[int, dict[str, Any]] = {}
1596
1816
  output_tokens = 0
1817
+ reported_input_tokens = max(0, int(input_tokens or 0))
1818
+ cache_read_tokens = 0
1819
+ cache_creation_tokens = 0
1597
1820
  finish_reason = "stop"
1598
1821
  chunks_seen = 0
1599
1822
  last_activity_update = 0.0
1823
+ runaway_policy = policy_from_env(os.environ.get)
1824
+ text_runaway = RunawayOutputDetector(runaway_policy)
1825
+ reasoning_runaway = RunawayOutputDetector(runaway_policy)
1826
+ runaway_verdict: RunawayVerdict | None = None
1827
+ runaway_stopped = False
1600
1828
 
1601
1829
  def emit(event_name: str, payload: dict[str, Any]) -> None:
1602
1830
  handler.wfile.write(f"event: {event_name}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n".encode())
@@ -1708,19 +1936,70 @@ def forward_openai_chat_to_anthropic_sse(
1708
1936
  usage = event.get("usage")
1709
1937
  if isinstance(usage, dict):
1710
1938
  output_tokens = max(output_tokens, positive_int(usage.get("completion_tokens")) or 0)
1939
+ prompt_tokens = positive_int(usage.get("prompt_tokens")) or 0
1940
+ details = usage.get("prompt_tokens_details")
1941
+ details = details if isinstance(details, dict) else {}
1942
+ cache_read_tokens = max(
1943
+ cache_read_tokens,
1944
+ positive_int(usage.get("prompt_cache_hit_tokens"))
1945
+ or positive_int(usage.get("cache_read_input_tokens"))
1946
+ or positive_int(details.get("cached_tokens"))
1947
+ or 0,
1948
+ )
1949
+ cache_creation_tokens = max(
1950
+ cache_creation_tokens,
1951
+ positive_int(usage.get("cache_creation_input_tokens"))
1952
+ or positive_int(details.get("cache_write_tokens"))
1953
+ or 0,
1954
+ )
1955
+ if prompt_tokens:
1956
+ reported_input_tokens = max(
1957
+ 0,
1958
+ prompt_tokens - cache_read_tokens - cache_creation_tokens,
1959
+ )
1711
1960
  choices = event.get("choices")
1712
1961
  if not isinstance(choices, list) or not choices:
1713
1962
  continue
1714
1963
  choice = choices[0] if isinstance(choices[0], dict) else {}
1964
+ choice_usage = choice.get("usage")
1965
+ if isinstance(choice_usage, dict):
1966
+ output_tokens = max(
1967
+ output_tokens,
1968
+ positive_int(choice_usage.get("completion_tokens")) or 0,
1969
+ )
1970
+ prompt_tokens = positive_int(choice_usage.get("prompt_tokens")) or 0
1971
+ details = choice_usage.get("prompt_tokens_details")
1972
+ details = details if isinstance(details, dict) else {}
1973
+ cache_read_tokens = max(
1974
+ cache_read_tokens,
1975
+ positive_int(choice_usage.get("prompt_cache_hit_tokens"))
1976
+ or positive_int(choice_usage.get("cache_read_input_tokens"))
1977
+ or positive_int(details.get("cached_tokens"))
1978
+ or 0,
1979
+ )
1980
+ if prompt_tokens:
1981
+ reported_input_tokens = max(0, prompt_tokens - cache_read_tokens)
1715
1982
  if choice.get("finish_reason"):
1716
1983
  finish_reason = str(choice.get("finish_reason"))
1717
1984
  delta = choice.get("delta") if isinstance(choice.get("delta"), dict) else {}
1718
1985
  reasoning_chunk = delta.get("reasoning_content") or ""
1719
1986
  if reasoning_chunk:
1720
1987
  reasoning_so_far += str(reasoning_chunk)
1988
+ runaway_verdict = runaway_verdict or reasoning_runaway.feed(str(reasoning_chunk))
1721
1989
  emit_reasoning_delta(str(reasoning_chunk))
1722
1990
  update_stream_activity()
1723
1991
  text_chunk = delta.get("content") or ""
1992
+ if text_chunk:
1993
+ runaway_verdict = runaway_verdict or text_runaway.feed(str(text_chunk))
1994
+ if runaway_verdict is not None:
1995
+ # Stop reading upstream; the finally block closes the response.
1996
+ runaway_stopped = True
1997
+ router_log(
1998
+ "WARN",
1999
+ f"openai_stream_runaway_repetition provider={provider} model={model} "
2000
+ f"chunks={chunks_seen} {runaway_verdict.log_fields()}",
2001
+ )
2002
+ break
1724
2003
  if text_chunk:
1725
2004
  close_reasoning_block()
1726
2005
  if pseudo_mode or PSEUDO_TOOL_START in text_chunk:
@@ -1800,15 +2079,17 @@ def forward_openai_chat_to_anthropic_sse(
1800
2079
  continue
1801
2080
  matched_name = resolve_emitted_tool_name(raw_name, source_body)
1802
2081
  normalized_args = normalize_tool_arguments(matched_name, fragment.get("arguments") or {})
2082
+ emitted_name = matched_name
1803
2083
  fixed_input = _validate_and_fix_tool_input(matched_name, normalized_args)
1804
2084
  if source_body is not None:
1805
2085
  matched_name, fixed_input = plan_mode_tool_name_for_emit(source_body, matched_name, fixed_input)
1806
2086
  if matched_name is None:
1807
2087
  continue
1808
2088
  fixed_input = cap_mcp_notification_wait_tool_input(matched_name, fixed_input)
1809
- if should_drop_emitted_tool_call(matched_name, fixed_input, raw_name, source_body):
2089
+ supplied_input = normalized_args if matched_name == emitted_name else None
2090
+ if should_drop_emitted_tool_call(matched_name, fixed_input, raw_name, source_body, supplied_input):
1810
2091
  continue
1811
- if should_drop_duplicate_side_effect_tool_call(matched_name, fixed_input, raw_name):
2092
+ if should_drop_duplicate_side_effect_tool_call(matched_name, fixed_input, raw_name, source_body):
1812
2093
  continue
1813
2094
  tool_calls.append({"function": {"name": matched_name, "arguments": fixed_input}})
1814
2095
  tool_index = next_content_index
@@ -1844,7 +2125,7 @@ def forward_openai_chat_to_anthropic_sse(
1844
2125
  )
1845
2126
  emit("content_block_stop", {"type": "content_block_stop", "index": tool_index})
1846
2127
 
1847
- if source_body is not None and should_auto_enter_plan_mode(source_body, text_so_far, tool_calls):
2128
+ if not runaway_stopped and source_body is not None and should_auto_enter_plan_mode(source_body, text_so_far, tool_calls):
1848
2129
  router_log("WARN", "auto-synthesized EnterPlanMode from short/empty upstream OpenAI stream")
1849
2130
  tool_index = next_content_index
1850
2131
  next_content_index += 1
@@ -1859,7 +2140,7 @@ def forward_openai_chat_to_anthropic_sse(
1859
2140
  )
1860
2141
  emit("content_block_delta", {"type": "content_block_delta", "index": tool_index, "delta": {"type": "input_json_delta", "partial_json": "{}"}})
1861
2142
  emit("content_block_stop", {"type": "content_block_stop", "index": tool_index})
1862
- elif source_body is not None and should_recover_empty_end_turn_with_tasklist(source_body, text_so_far, tool_calls):
2143
+ elif not runaway_stopped and source_body is not None and should_recover_empty_end_turn_with_tasklist(source_body, text_so_far, tool_calls):
1863
2144
  router_log("WARN", "auto-synthesized TaskList from empty upstream end_turn OpenAI stream")
1864
2145
  tool_index = next_content_index
1865
2146
  next_content_index += 1
@@ -1877,7 +2158,7 @@ def forward_openai_chat_to_anthropic_sse(
1877
2158
  elif text_suppressed_for_plan and not text_started and text_so_far:
1878
2159
  emit_text_delta(text_so_far)
1879
2160
 
1880
- if source_body is not None and should_keep_work_alive_with_tasklist(source_body, text_so_far, tool_calls):
2161
+ if not runaway_stopped and source_body is not None and should_keep_work_alive_with_tasklist(source_body, text_so_far, tool_calls):
1881
2162
  router_log("WARN", "auto-synthesized TaskList to keep work moving after OpenAI stream")
1882
2163
  tool_index = next_content_index
1883
2164
  next_content_index += 1
@@ -1893,7 +2174,7 @@ def forward_openai_chat_to_anthropic_sse(
1893
2174
  emit("content_block_delta", {"type": "content_block_delta", "index": tool_index, "delta": {"type": "input_json_delta", "partial_json": "{}"}})
1894
2175
  emit("content_block_stop", {"type": "content_block_stop", "index": tool_index})
1895
2176
 
1896
- if source_body is not None and should_auto_continue_choice_question_with_tasklist(source_body, text_so_far, tool_calls):
2177
+ if not runaway_stopped and source_body is not None and should_auto_continue_choice_question_with_tasklist(source_body, text_so_far, tool_calls):
1897
2178
  router_log("WARN", "auto-synthesized TaskList after clarification question OpenAI stream")
1898
2179
  tool_index = next_content_index
1899
2180
  next_content_index += 1
@@ -1909,10 +2190,40 @@ def forward_openai_chat_to_anthropic_sse(
1909
2190
  emit("content_block_delta", {"type": "content_block_delta", "index": tool_index, "delta": {"type": "input_json_delta", "partial_json": "{}"}})
1910
2191
  emit("content_block_stop", {"type": "content_block_stop", "index": tool_index})
1911
2192
 
2193
+ if runaway_stopped and runaway_verdict is not None:
2194
+ notice = ("\n\n" if text_started else "") + runaway_verdict.notice(STOPPED)
2195
+ text_so_far += notice
2196
+ emit_text_delta(notice)
1912
2197
  if text_started and text_index is not None:
1913
2198
  emit("content_block_stop", {"type": "content_block_stop", "index": text_index})
1914
2199
  text_stopped = True
1915
- if not text_started and not tool_calls:
2200
+ if (
2201
+ runaway_stopped
2202
+ and not tool_calls
2203
+ and runaway_policy.recover
2204
+ and recent_runaway_notices(source_body) == 0
2205
+ ):
2206
+ # Give the agent another turn instead of ending on a cut-off answer.
2207
+ router_log("WARN", f"openai_stream_runaway_recovery provider={provider} model={model}")
2208
+ tool_index = next_content_index
2209
+ next_content_index += 1
2210
+ tool_calls.append({"function": {"name": "TaskList", "arguments": {}}})
2211
+ emit(
2212
+ "content_block_start",
2213
+ {
2214
+ "type": "content_block_start",
2215
+ "index": tool_index,
2216
+ "content_block": {
2217
+ "type": "tool_use",
2218
+ "id": f"toolu_openai_runaway_{int(time.time() * 1000)}",
2219
+ "name": "TaskList",
2220
+ "input": {},
2221
+ },
2222
+ },
2223
+ )
2224
+ emit("content_block_delta", {"type": "content_block_delta", "index": tool_index, "delta": {"type": "input_json_delta", "partial_json": "{}"}})
2225
+ emit("content_block_stop", {"type": "content_block_stop", "index": tool_index})
2226
+ if not runaway_stopped and not text_started and not tool_calls:
1916
2227
  text_so_far = empty_end_turn_notice_for_body(source_body) if source_body is not None else ""
1917
2228
  if source_body is not None:
1918
2229
  router_log(
@@ -1925,7 +2236,33 @@ def forward_openai_chat_to_anthropic_sse(
1925
2236
  emit("content_block_stop", {"type": "content_block_stop", "index": text_index})
1926
2237
  text_stopped = True
1927
2238
  stop_reason = "tool_use" if tool_calls else ("max_tokens" if finish_reason == "length" else "end_turn")
1928
- write_anthropic_open_stream_stop(handler, {"stop_reason": stop_reason, "usage": {"output_tokens": output_tokens or max(1, len(text_so_far) // 4)}})
2239
+ if runaway_stopped and not tool_calls:
2240
+ # No continuation was synthesized, so the turn ends on truncated output.
2241
+ stop_reason = "max_tokens"
2242
+ final_output_tokens = output_tokens or max(1, len(text_so_far) // 4)
2243
+ final_usage = {
2244
+ "input_tokens": reported_input_tokens,
2245
+ "output_tokens": final_output_tokens,
2246
+ }
2247
+ if cache_read_tokens:
2248
+ final_usage["cache_read_input_tokens"] = cache_read_tokens
2249
+ if cache_creation_tokens:
2250
+ final_usage["cache_creation_input_tokens"] = cache_creation_tokens
2251
+ write_anthropic_open_stream_stop(
2252
+ handler,
2253
+ {"stop_reason": stop_reason, "usage": final_usage},
2254
+ )
2255
+ write_router_activity(
2256
+ "success",
2257
+ provider,
2258
+ model,
2259
+ input_tokens=reported_input_tokens,
2260
+ output_tokens=final_output_tokens,
2261
+ cache_read_tokens=cache_read_tokens,
2262
+ cache_creation_tokens=cache_creation_tokens,
2263
+ chunks=chunks_seen,
2264
+ stream=True,
2265
+ )
1929
2266
  return True
1930
2267
  except Exception as exc:
1931
2268
  router_log("ERROR", f"openai_stream_error provider={provider} model={model} error={type(exc).__name__}: {exc}")