@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
@@ -0,0 +1,488 @@
1
+ """Stop runaway repetition loops in model output.
2
+
3
+ A model can fall into a degenerate sampling loop and emit the same block of
4
+ text back to back until the request budget runs out. ciel-runtime cannot fix
5
+ that upstream: for the providers where this was reported no anti-repetition
6
+ sampling parameter is even in play. DeepSeek documents sampling overrides as
7
+ ineffective while thinking is enabled, so the adapter deliberately drops them,
8
+ and Ollama's Go sampler has a documented history of accepting
9
+ ``repeat_penalty``/``frequency_penalty``/``presence_penalty`` and then ignoring
10
+ them (ollama/ollama#15783).
11
+
12
+ So this guard does not reason about *why* the model looped. It watches the one
13
+ thing that is directly observable -- the emitted characters -- and reports that
14
+ the tail of the output is literally the same block repeated beyond a budget.
15
+ Detection is exact: no similarity scoring, no semantic judgement, no sampling.
16
+
17
+ Defaults are deliberately conservative. A verdict needs both a minimum number
18
+ of consecutive identical repeats *and* a minimum repeated length, so ordinary
19
+ output that happens to contain duplicated lines never trips it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from typing import Any, Callable
26
+
27
+ DEFAULT_PROBE_CHARS = 32
28
+ DEFAULT_MIN_REPEATS = 10
29
+ DEFAULT_MIN_REPEATED_CHARS = 2000
30
+ DEFAULT_MAX_PERIOD_CHARS = 4096
31
+ DEFAULT_CHECK_INTERVAL_CHARS = 256
32
+ DEFAULT_DENSE_PROBE_CHARS = 64
33
+ DEFAULT_DENSE_PROBE_SAMPLES = 24
34
+ DEFAULT_DENSE_WINDOW_CHARS = 8000
35
+ DEFAULT_MIN_DENSITY_PERCENT = 70
36
+
37
+ _DISABLED_VALUES = {"0", "off", "false", "no", "disable", "disabled"}
38
+
39
+ CONSECUTIVE = "consecutive"
40
+ INTERLEAVED = "interleaved"
41
+
42
+ # What the router was able to do about the loop, which decides the wording.
43
+ STOPPED = "stopped"
44
+ TRIMMED = "trimmed"
45
+
46
+ # Stable prefix shared by both notices. Recovering from a loop means handing the
47
+ # agent another turn, so the router has to be able to see -- from the
48
+ # conversation alone, with no cross-request state -- that it already did that
49
+ # once and must not keep doing it.
50
+ NOTICE_MARKER = "[ciel-runtime] The model repeated itself in a loop"
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class RunawayOutputPolicy:
55
+ """Thresholds a repeated tail must clear before the turn is cut short."""
56
+
57
+ enabled: bool = True
58
+ recover: bool = True
59
+ probe_chars: int = DEFAULT_PROBE_CHARS
60
+ min_repeats: int = DEFAULT_MIN_REPEATS
61
+ min_repeated_chars: int = DEFAULT_MIN_REPEATED_CHARS
62
+ max_period_chars: int = DEFAULT_MAX_PERIOD_CHARS
63
+ check_interval_chars: int = DEFAULT_CHECK_INTERVAL_CHARS
64
+ dense_probe_chars: int = DEFAULT_DENSE_PROBE_CHARS
65
+ dense_probe_samples: int = DEFAULT_DENSE_PROBE_SAMPLES
66
+ dense_window_chars: int = DEFAULT_DENSE_WINDOW_CHARS
67
+ min_density_percent: int = DEFAULT_MIN_DENSITY_PERCENT
68
+
69
+ def tail_budget(self) -> int:
70
+ """Characters worth retaining to still measure ``min_repeats`` repeats."""
71
+
72
+ return self.max_period_chars * (self.min_repeats + 1) + self.probe_chars
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class RunawayVerdict:
77
+ """An exact, reproducible statement about the repeated tail."""
78
+
79
+ period_chars: int
80
+ repeats: int
81
+ repeated_chars: int
82
+ unit_preview: str
83
+ kind: str = CONSECUTIVE
84
+ span_chars: int = 0
85
+
86
+ def spanned_chars(self) -> int:
87
+ return self.span_chars or self.repeated_chars
88
+
89
+ def notice(self, outcome: str = STOPPED) -> str:
90
+ """One short line for the human reading the transcript.
91
+
92
+ The measurements stay out of it on purpose. This text lands in the
93
+ assistant message, so the model reads it back on the next turn; feeding
94
+ it block sizes and repeat counts only invites the agent to start
95
+ theorising about the router instead of doing its work. The numbers go
96
+ to :meth:`log_fields` where an operator can find them.
97
+
98
+ The wording has to match what actually happened. ``STOPPED`` means the
99
+ upstream read was cut short, which is only true where the router is
100
+ streaming. ``TRIMMED`` means the whole response had already been
101
+ generated and the loop was removed afterwards.
102
+ """
103
+
104
+ if outcome == TRIMMED:
105
+ return (
106
+ "[ciel-runtime] The model repeated itself in a loop. "
107
+ "The repeated text was removed from this response."
108
+ )
109
+ return (
110
+ "[ciel-runtime] The model repeated itself in a loop, so the "
111
+ "response was cut short."
112
+ )
113
+
114
+ def log_fields(self) -> str:
115
+ return (
116
+ f"kind={self.kind} period={self.period_chars} repeats={self.repeats} "
117
+ f"repeated_chars={self.repeated_chars} span={self.spanned_chars()} "
118
+ f"unit={self.unit_preview!r}"
119
+ )
120
+
121
+
122
+ def _positive_int(value: str | None, fallback: int) -> int:
123
+ try:
124
+ parsed = int(str(value).strip())
125
+ except (TypeError, ValueError):
126
+ return fallback
127
+ return parsed if parsed > 0 else fallback
128
+
129
+
130
+ def policy_from_env(
131
+ env_get: Callable[[str], str | None],
132
+ base: RunawayOutputPolicy | None = None,
133
+ ) -> RunawayOutputPolicy:
134
+ """Operator overrides for the guard, including a full kill switch."""
135
+
136
+ policy = base or RunawayOutputPolicy()
137
+ raw_enabled = env_get("CIEL_RUNTIME_RUNAWAY_GUARD")
138
+ enabled = policy.enabled
139
+ if raw_enabled is not None and str(raw_enabled).strip():
140
+ enabled = str(raw_enabled).strip().lower() not in _DISABLED_VALUES
141
+ raw_recover = env_get("CIEL_RUNTIME_RUNAWAY_CONTINUE")
142
+ recover = policy.recover
143
+ if raw_recover is not None and str(raw_recover).strip():
144
+ recover = str(raw_recover).strip().lower() not in _DISABLED_VALUES
145
+ return RunawayOutputPolicy(
146
+ enabled=enabled,
147
+ recover=recover,
148
+ probe_chars=policy.probe_chars,
149
+ min_repeats=_positive_int(
150
+ env_get("CIEL_RUNTIME_RUNAWAY_MIN_REPEATS"), policy.min_repeats
151
+ ),
152
+ min_repeated_chars=_positive_int(
153
+ env_get("CIEL_RUNTIME_RUNAWAY_MIN_CHARS"), policy.min_repeated_chars
154
+ ),
155
+ max_period_chars=_positive_int(
156
+ env_get("CIEL_RUNTIME_RUNAWAY_MAX_PERIOD"), policy.max_period_chars
157
+ ),
158
+ check_interval_chars=policy.check_interval_chars,
159
+ dense_probe_chars=policy.dense_probe_chars,
160
+ dense_probe_samples=policy.dense_probe_samples,
161
+ dense_window_chars=policy.dense_window_chars,
162
+ min_density_percent=_positive_int(
163
+ env_get("CIEL_RUNTIME_RUNAWAY_MIN_DENSITY"), policy.min_density_percent
164
+ ),
165
+ )
166
+
167
+
168
+ def _find_consecutive_loop(
169
+ text: str, policy: RunawayOutputPolicy
170
+ ) -> RunawayVerdict | None:
171
+ """The tail is one block repeated back to back with nothing in between.
172
+
173
+ The probe is the final ``probe_chars`` characters. Its most recent earlier
174
+ occurrence gives the candidate period, and the repeat count is then verified
175
+ by exact block comparison, so a match is a fact about the string rather than
176
+ an estimate.
177
+ """
178
+
179
+ probe_chars = policy.probe_chars
180
+ if len(text) <= probe_chars:
181
+ return None
182
+ probe = text[-probe_chars:]
183
+ previous = text.rfind(probe, 0, len(text) - probe_chars)
184
+ if previous < 0:
185
+ return None
186
+ period = len(text) - probe_chars - previous
187
+ if period <= 0 or period > policy.max_period_chars:
188
+ return None
189
+ unit = text[-period:]
190
+ repeats = 1
191
+ end = len(text) - period
192
+ while end >= period and text[end - period : end] == unit:
193
+ repeats += 1
194
+ end -= period
195
+ repeated_chars = repeats * period
196
+ if repeats < policy.min_repeats or repeated_chars < policy.min_repeated_chars:
197
+ return None
198
+ return RunawayVerdict(
199
+ period_chars=period,
200
+ repeats=repeats,
201
+ repeated_chars=repeated_chars,
202
+ unit_preview=unit[:120],
203
+ kind=CONSECUTIVE,
204
+ span_chars=repeated_chars,
205
+ )
206
+
207
+
208
+ def _occurrences(text: str, probe: str) -> list[int]:
209
+ """Non-overlapping start positions of ``probe`` in ``text``."""
210
+
211
+ positions: list[int] = []
212
+ start = text.find(probe)
213
+ while start >= 0:
214
+ positions.append(start)
215
+ start = text.find(probe, start + len(probe))
216
+ return positions
217
+
218
+
219
+ def _grow_common_block(
220
+ text: str, positions: list[int], probe_chars: int
221
+ ) -> tuple[int, int]:
222
+ """Widen the probe to the longest block every occurrence still shares.
223
+
224
+ A fixed-width probe under-measures the repeated block, which would make a
225
+ real loop look less dense than it is. Growing to the actual shared extent
226
+ removes that bias, and the growth is capped so neighbouring occurrences
227
+ cannot be counted twice.
228
+ """
229
+
230
+ limit = min(
231
+ later - earlier for earlier, later in zip(positions, positions[1:])
232
+ )
233
+ head = positions[0]
234
+ left = 0
235
+ while probe_chars + left < limit and head - left - 1 >= 0:
236
+ char = text[head - left - 1]
237
+ if any(text[pos - left - 1] != char for pos in positions):
238
+ break
239
+ left += 1
240
+ right = 0
241
+ while probe_chars + left + right < limit:
242
+ index = head + probe_chars + right
243
+ if index >= len(text):
244
+ break
245
+ char = text[index]
246
+ if any(
247
+ pos + probe_chars + right >= len(text)
248
+ or text[pos + probe_chars + right] != char
249
+ for pos in positions
250
+ ):
251
+ break
252
+ right += 1
253
+ return head - left, probe_chars + left + right
254
+
255
+
256
+ def _find_interleaved_loop(
257
+ text: str, policy: RunawayOutputPolicy
258
+ ) -> RunawayVerdict | None:
259
+ """The same block keeps coming back with other text between the repeats.
260
+
261
+ A loop rarely repeats cleanly. It usually alternates with a little
262
+ variation, which breaks strict periodicity while still being a loop. This
263
+ rule counts exact occurrences of a recurring block and requires them to make
264
+ up most of the text they span.
265
+
266
+ It is deliberately stricter than the back-to-back rule: twice the repeat
267
+ count, and at least ``min_density_percent`` of the spanned characters must
268
+ be the repeated block itself. That is a real threshold, not a proof -- text
269
+ that is genuinely mostly boilerplate (a table whose rows carry little new
270
+ data, a long block of near-identical log lines) can sit close to the same
271
+ density. Operators can move the bar or switch the guard off entirely; the
272
+ back-to-back rule above needs no such judgement call.
273
+ """
274
+
275
+ probe_chars = policy.dense_probe_chars
276
+ # Scan a bounded window rather than the whole tail buffer. The rule needs
277
+ # only enough room for its occurrence and span budgets, and every measure
278
+ # below is relative to the end, so dropping older text is safe.
279
+ text = text[-policy.dense_window_chars :]
280
+ if len(text) <= probe_chars:
281
+ return None
282
+ # The very last characters are often the part that varies between repeats,
283
+ # so probing only the tail would miss the loop. Sample a few probes stepping
284
+ # back from the end; a repeating core lands inside at least one of them.
285
+ # Half-probe strides so successive samples land on different phases of the
286
+ # loop; one of them falls entirely inside the part that does not vary.
287
+ stride = max(8, probe_chars // 2)
288
+ min_occurrences = policy.min_repeats * 2
289
+ best: RunawayVerdict | None = None
290
+ for sample in range(policy.dense_probe_samples):
291
+ end = len(text) - sample * stride
292
+ if end - probe_chars <= 0:
293
+ break
294
+ probe = text[end - probe_chars : end]
295
+ positions = _occurrences(text, probe)
296
+ occurrences = len(positions)
297
+ if occurrences < min_occurrences:
298
+ continue
299
+ span = len(text) - positions[0]
300
+ if span < policy.min_repeated_chars:
301
+ continue
302
+ start, core = _grow_common_block(text, positions, probe_chars)
303
+ repeated_chars = occurrences * core
304
+ if repeated_chars * 100 < span * policy.min_density_percent:
305
+ continue
306
+ if best is not None and repeated_chars <= best.repeated_chars:
307
+ continue
308
+ best = RunawayVerdict(
309
+ period_chars=core,
310
+ repeats=occurrences,
311
+ repeated_chars=repeated_chars,
312
+ unit_preview=text[start : start + 120],
313
+ kind=INTERLEAVED,
314
+ span_chars=len(text) - start,
315
+ )
316
+ return best
317
+
318
+
319
+ def find_runaway_tail(
320
+ text: str, policy: RunawayOutputPolicy | None = None
321
+ ) -> RunawayVerdict | None:
322
+ """Report the tail of ``text`` when it has collapsed into a repetition loop.
323
+
324
+ Two exact rules, checked in order of how confident they are: a block
325
+ repeated back to back, then the same block recurring densely with other
326
+ text mixed in.
327
+ """
328
+
329
+ policy = policy or RunawayOutputPolicy()
330
+ if not policy.enabled or not text or len(text) < policy.min_repeated_chars:
331
+ return None
332
+ return _find_consecutive_loop(text, policy) or _find_interleaved_loop(text, policy)
333
+
334
+
335
+ def trim_runaway_tail(
336
+ text: str, policy: RunawayOutputPolicy | None = None
337
+ ) -> tuple[str, RunawayVerdict | None]:
338
+ """Cut a repeated tail down to its first pass, keeping everything before it."""
339
+
340
+ verdict = find_runaway_tail(text, policy)
341
+ if verdict is None:
342
+ return text, None
343
+ keep = len(text) - verdict.spanned_chars() + verdict.period_chars
344
+ return text[:keep], verdict
345
+
346
+
347
+ def _message_text(message: Any) -> str:
348
+ content = message.get("content") if isinstance(message, dict) else None
349
+ if isinstance(content, str):
350
+ return content
351
+ if not isinstance(content, list):
352
+ return ""
353
+ return "".join(
354
+ str(block.get("text") or "")
355
+ for block in content
356
+ if isinstance(block, dict) and block.get("type") == "text"
357
+ )
358
+
359
+
360
+ def recent_runaway_notices(body: Any, lookback: int = 4) -> int:
361
+ """Count loop notices already sitting in the recent assistant history.
362
+
363
+ Recovery hands the agent another turn. If that turn loops again, recovering
364
+ a second time would build an outer loop out of the inner one, so this is the
365
+ stop condition -- read from the conversation itself rather than from state
366
+ the router would have to keep between requests.
367
+ """
368
+
369
+ if not isinstance(body, dict):
370
+ return 0
371
+ messages = body.get("messages")
372
+ if not isinstance(messages, list):
373
+ return 0
374
+ seen = 0
375
+ count = 0
376
+ for message in reversed(messages):
377
+ if not isinstance(message, dict) or message.get("role") != "assistant":
378
+ continue
379
+ seen += 1
380
+ if seen > max(1, lookback):
381
+ break
382
+ if NOTICE_MARKER in _message_text(message):
383
+ count += 1
384
+ return count
385
+
386
+
387
+ _TRIMMABLE_BLOCK_FIELDS = {"text": "text", "thinking": "thinking"}
388
+
389
+
390
+ def trim_runaway_message_content(
391
+ content: object, policy: RunawayOutputPolicy | None = None
392
+ ) -> tuple[object, RunawayVerdict | None]:
393
+ """Trim runaway tails out of collected Anthropic content blocks.
394
+
395
+ The non-streaming collection path hands the whole message over at once, so
396
+ there is nothing to cut short -- the loop has already been generated. The
397
+ guard still removes it, because relaying thousands of repeated characters
398
+ back into the next request's history is what turns one looping turn into a
399
+ looping session.
400
+ """
401
+
402
+ if not isinstance(content, list):
403
+ return content, None
404
+ verdict: RunawayVerdict | None = None
405
+ blocks: list[Any] = []
406
+ for block in content:
407
+ field = (
408
+ _TRIMMABLE_BLOCK_FIELDS.get(str(block.get("type") or ""))
409
+ if isinstance(block, dict)
410
+ else None
411
+ )
412
+ raw = block.get(field) if field else None
413
+ if not isinstance(raw, str) or not raw:
414
+ blocks.append(block)
415
+ continue
416
+ trimmed, block_verdict = trim_runaway_tail(raw, policy)
417
+ if block_verdict is None:
418
+ blocks.append(block)
419
+ continue
420
+ verdict = verdict or block_verdict
421
+ blocks.append({**block, field: trimmed})
422
+ if verdict is None:
423
+ return content, None
424
+ blocks.append({"type": "text", "text": verdict.notice(TRIMMED)})
425
+ return blocks, verdict
426
+
427
+
428
+ class RunawayOutputDetector:
429
+ """Streaming view of :func:`find_runaway_tail` over a bounded tail buffer."""
430
+
431
+ __slots__ = ("_policy", "_tail", "_since_check", "_total_chars", "_verdict")
432
+
433
+ def __init__(self, policy: RunawayOutputPolicy | None = None) -> None:
434
+ self._policy = policy or RunawayOutputPolicy()
435
+ self._tail = ""
436
+ self._since_check = 0
437
+ self._total_chars = 0
438
+ self._verdict: RunawayVerdict | None = None
439
+
440
+ @property
441
+ def policy(self) -> RunawayOutputPolicy:
442
+ return self._policy
443
+
444
+ @property
445
+ def verdict(self) -> RunawayVerdict | None:
446
+ return self._verdict
447
+
448
+ @property
449
+ def total_chars(self) -> int:
450
+ return self._total_chars
451
+
452
+ def feed(self, text: str) -> RunawayVerdict | None:
453
+ """Append streamed text and return a verdict the first time one holds."""
454
+
455
+ if self._verdict is not None:
456
+ return self._verdict
457
+ if not text or not self._policy.enabled:
458
+ return None
459
+ self._total_chars += len(text)
460
+ self._tail = (self._tail + text)[-self._policy.tail_budget() :]
461
+ self._since_check += len(text)
462
+ if self._since_check < self._policy.check_interval_chars:
463
+ return None
464
+ self._since_check = 0
465
+ self._verdict = find_runaway_tail(self._tail, self._policy)
466
+ return self._verdict
467
+
468
+
469
+ __all__ = [
470
+ "CONSECUTIVE",
471
+ "INTERLEAVED",
472
+ "NOTICE_MARKER",
473
+ "STOPPED",
474
+ "TRIMMED",
475
+ "recent_runaway_notices",
476
+ "DEFAULT_CHECK_INTERVAL_CHARS",
477
+ "DEFAULT_MAX_PERIOD_CHARS",
478
+ "DEFAULT_MIN_REPEATED_CHARS",
479
+ "DEFAULT_MIN_REPEATS",
480
+ "DEFAULT_PROBE_CHARS",
481
+ "RunawayOutputDetector",
482
+ "RunawayOutputPolicy",
483
+ "RunawayVerdict",
484
+ "find_runaway_tail",
485
+ "policy_from_env",
486
+ "trim_runaway_message_content",
487
+ "trim_runaway_tail",
488
+ ]
@@ -0,0 +1,147 @@
1
+ """Compose runtime command assets, paths, and tool-guard policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Callable, Mapping
8
+
9
+ from .command_asset_installer import CommandAsset
10
+ from .runtime_asset_context import (
11
+ RuntimeAssetCompatibilityPorts,
12
+ RuntimeAssetContext,
13
+ RuntimeAssetEffects,
14
+ RuntimeAssetPaths,
15
+ RuntimeCommandAssetCatalog,
16
+ RuntimeExecutablePaths,
17
+ RuntimeToolGuardPolicy,
18
+ )
19
+ from .settings_repository import JsonSettingsRepository, SettingsFileEffects
20
+ from .slash_command_assets import (
21
+ ADVISOR_SLASH_COMMAND,
22
+ API_KEYS_SLASH_COMMAND,
23
+ CHANNEL_CLEAR_SLASH_COMMAND,
24
+ CIEL_RUNTIME_ADVISOR_COMMAND_MARKERS,
25
+ CIEL_RUNTIME_API_KEYS_COMMAND_MARKERS,
26
+ CIEL_RUNTIME_CHANNEL_CLEAR_COMMAND_MARKERS,
27
+ CIEL_RUNTIME_IMPORT_SESSION_COMMAND_MARKERS,
28
+ CIEL_RUNTIME_LLM_OPTIONS_COMMAND_MARKERS,
29
+ CIEL_RUNTIME_ROUTER_DEBUG_COMMAND_MARKERS,
30
+ CIEL_RUNTIME_VERSION_COMMAND_MARKERS,
31
+ IMPORT_SESSION_SLASH_COMMAND,
32
+ LLM_OPTIONS_SLASH_COMMAND,
33
+ LLM_RESTORE_SLASH_COMMAND,
34
+ LLM_SLIDER_SLASH_COMMAND,
35
+ ROUTER_DEBUG_SLASH_COMMAND,
36
+ VERSION_SLASH_COMMAND,
37
+ )
38
+ from .statusline_script import STATUSLINE_SCRIPT
39
+ from .tool_guard_hooks import DEFAULT_TOOL_GUARD_HOOK_POLICY
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class RuntimeAssetPathBindings:
44
+ home: Path
45
+ source_file: Path
46
+ settings_path: Path
47
+ statusline_path: Path
48
+ commands_dir: Path
49
+ codex_prompts_dir_name: str
50
+ platform_path: Callable[..., Path]
51
+ runtime_user_bin_dir: Callable[..., Path]
52
+ agy_user_bin_dir: Callable[..., Path]
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class RuntimeAssetAssemblyPorts:
57
+ paths: RuntimeAssetPathBindings
58
+ python_executable: str
59
+ chmod: Callable[..., Any]
60
+ environ: Mapping[str, str]
61
+ log: Callable[..., Any]
62
+ warning: Callable[[str], Any]
63
+ compatibility: RuntimeAssetCompatibilityPorts
64
+
65
+
66
+ def build_runtime_asset_context(ports: RuntimeAssetAssemblyPorts) -> RuntimeAssetContext:
67
+ def settings_repository() -> JsonSettingsRepository:
68
+ return JsonSettingsRepository(
69
+ path=ports.paths.settings_path,
70
+ effects=SettingsFileEffects(log=ports.log),
71
+ )
72
+
73
+ standard_assets = {
74
+ "router-debug.md": CommandAsset(
75
+ ROUTER_DEBUG_SLASH_COMMAND, CIEL_RUNTIME_ROUTER_DEBUG_COMMAND_MARKERS
76
+ ),
77
+ "ciel-version.md": CommandAsset(
78
+ VERSION_SLASH_COMMAND, CIEL_RUNTIME_VERSION_COMMAND_MARKERS
79
+ ),
80
+ "llm.md": CommandAsset(
81
+ LLM_SLIDER_SLASH_COMMAND, CIEL_RUNTIME_LLM_OPTIONS_COMMAND_MARKERS
82
+ ),
83
+ "llm-options.md": CommandAsset(
84
+ LLM_OPTIONS_SLASH_COMMAND, CIEL_RUNTIME_LLM_OPTIONS_COMMAND_MARKERS
85
+ ),
86
+ "llm-restore.md": CommandAsset(
87
+ LLM_RESTORE_SLASH_COMMAND, CIEL_RUNTIME_LLM_OPTIONS_COMMAND_MARKERS
88
+ ),
89
+ "channel-clear.md": CommandAsset(
90
+ CHANNEL_CLEAR_SLASH_COMMAND, CIEL_RUNTIME_CHANNEL_CLEAR_COMMAND_MARKERS
91
+ ),
92
+ "api-key.md": CommandAsset(
93
+ API_KEYS_SLASH_COMMAND, CIEL_RUNTIME_API_KEYS_COMMAND_MARKERS
94
+ ),
95
+ "api-keys.md": CommandAsset(
96
+ API_KEYS_SLASH_COMMAND, CIEL_RUNTIME_API_KEYS_COMMAND_MARKERS
97
+ ),
98
+ "ImportSession.md": CommandAsset(
99
+ IMPORT_SESSION_SLASH_COMMAND, CIEL_RUNTIME_IMPORT_SESSION_COMMAND_MARKERS
100
+ ),
101
+ }
102
+ return RuntimeAssetContext(
103
+ executable=RuntimeExecutablePaths(
104
+ ports.paths.home,
105
+ ports.paths.source_file,
106
+ ports.paths.platform_path,
107
+ ports.paths.runtime_user_bin_dir,
108
+ ports.paths.agy_user_bin_dir,
109
+ ports.python_executable,
110
+ ),
111
+ paths=RuntimeAssetPaths(
112
+ ports.paths.source_file.resolve().parent,
113
+ ports.paths.statusline_path,
114
+ STATUSLINE_SCRIPT,
115
+ ports.paths.commands_dir,
116
+ ports.paths.codex_prompts_dir_name,
117
+ ),
118
+ effects=RuntimeAssetEffects(
119
+ settings_repository,
120
+ ports.chmod,
121
+ ports.log,
122
+ ports.warning,
123
+ ports.environ,
124
+ ),
125
+ catalog=RuntimeCommandAssetCatalog(
126
+ standard_assets,
127
+ CommandAsset(
128
+ ADVISOR_SLASH_COMMAND, CIEL_RUNTIME_ADVISOR_COMMAND_MARKERS
129
+ ),
130
+ CIEL_RUNTIME_ADVISOR_COMMAND_MARKERS,
131
+ CommandAsset(
132
+ IMPORT_SESSION_SLASH_COMMAND,
133
+ CIEL_RUNTIME_IMPORT_SESSION_COMMAND_MARKERS,
134
+ ),
135
+ CIEL_RUNTIME_IMPORT_SESSION_COMMAND_MARKERS,
136
+ CIEL_RUNTIME_LLM_OPTIONS_COMMAND_MARKERS,
137
+ ),
138
+ tool_guard=RuntimeToolGuardPolicy(DEFAULT_TOOL_GUARD_HOOK_POLICY),
139
+ compatibility=ports.compatibility,
140
+ )
141
+
142
+
143
+ __all__ = [
144
+ "RuntimeAssetAssemblyPorts",
145
+ "RuntimeAssetPathBindings",
146
+ "build_runtime_asset_context",
147
+ ]