@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.
- package/ciel_runtime.py +2553 -9635
- package/ciel_runtime_support/advisor_request_builder.py +8 -21
- package/ciel_runtime_support/anthropic_tool_turns.py +13 -8
- package/ciel_runtime_support/architecture.py +68 -0
- package/ciel_runtime_support/architecture_budget.py +1 -1
- package/ciel_runtime_support/channel_connection_context.py +233 -0
- package/ciel_runtime_support/channel_delivery_context.py +332 -0
- package/ciel_runtime_support/channel_mcp_context.py +313 -0
- package/ciel_runtime_support/channel_mcp_discovery.py +47 -0
- package/ciel_runtime_support/channel_mcp_transport.py +5 -1
- package/ciel_runtime_support/channel_message_context.py +212 -0
- package/ciel_runtime_support/channel_message_repository.py +14 -3
- package/ciel_runtime_support/channel_pending_injection.py +9 -0
- package/ciel_runtime_support/channel_probe_launch_context.py +213 -0
- package/ciel_runtime_support/channel_replay_policy.py +38 -0
- package/ciel_runtime_support/channel_runtime_environment.py +8 -0
- package/ciel_runtime_support/channel_session_context.py +236 -0
- package/ciel_runtime_support/channel_terminal_context.py +350 -0
- package/ciel_runtime_support/channel_wake_context.py +532 -0
- package/ciel_runtime_support/claude_environment.py +60 -0
- package/ciel_runtime_support/claude_launch_assembly.py +249 -0
- package/ciel_runtime_support/claude_router.py +62 -12
- package/ciel_runtime_support/cli_application_context.py +132 -0
- package/ciel_runtime_support/cli_assembly.py +50 -0
- package/ciel_runtime_support/codex_backend_context.py +363 -0
- package/ciel_runtime_support/codex_config.py +13 -1
- package/ciel_runtime_support/codex_launch_assembly.py +213 -0
- package/ciel_runtime_support/codex_launch_configuration.py +30 -1
- package/ciel_runtime_support/codex_mcp_integration.py +90 -8
- package/ciel_runtime_support/codex_model_catalog.py +4 -1
- package/ciel_runtime_support/codex_reasoning_rejects.py +225 -0
- package/ciel_runtime_support/codex_router.py +38 -8
- package/ciel_runtime_support/codex_turn_recovery.py +154 -0
- package/ciel_runtime_support/config_migrations.py +103 -0
- package/ciel_runtime_support/configuration_cli.py +38 -0
- package/ciel_runtime_support/context_compaction.py +9 -4
- package/ciel_runtime_support/credential_management.py +12 -0
- package/ciel_runtime_support/credentials.py +12 -0
- package/ciel_runtime_support/github_copilot_oauth.py +2 -2
- package/ciel_runtime_support/hosted_formula_tools.py +216 -0
- package/ciel_runtime_support/kimi_runtime_context.py +208 -0
- package/ciel_runtime_support/llm_preset_context.py +338 -0
- package/ciel_runtime_support/managed_mcp_config.py +8 -4
- package/ciel_runtime_support/mcp_configuration_context.py +291 -0
- package/ciel_runtime_support/mcp_http_proxy.py +14 -8
- package/ciel_runtime_support/mcp_probe_transport.py +47 -15
- package/ciel_runtime_support/mcp_transport.py +14 -1
- package/ciel_runtime_support/native_context_recovery.py +72 -0
- package/ciel_runtime_support/ollama_catalog_context.py +213 -0
- package/ciel_runtime_support/ollama_stream_collection.py +103 -0
- package/ciel_runtime_support/ollama_thinking.py +6 -1
- package/ciel_runtime_support/ollama_wire_projection.py +157 -0
- package/ciel_runtime_support/openai_forwarding.py +32 -10
- package/ciel_runtime_support/openai_responses_router.py +12 -0
- package/ciel_runtime_support/package_lifecycle.py +39 -0
- package/ciel_runtime_support/prelaunch_assembly.py +37 -0
- package/ciel_runtime_support/prelaunch_panel_context.py +418 -0
- package/ciel_runtime_support/prelaunch_shell_context.py +394 -0
- package/ciel_runtime_support/prompt_compaction.py +144 -0
- package/ciel_runtime_support/prompt_injection.py +45 -0
- package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +1 -1
- package/ciel_runtime_support/protocols/chat_projection.py +85 -5
- package/ciel_runtime_support/protocols/conversation_turn_policy.py +43 -0
- package/ciel_runtime_support/protocols/ollama_chat.py +31 -0
- package/ciel_runtime_support/protocols/ollama_response.py +57 -5
- package/ciel_runtime_support/protocols/openai_reasoning.py +5 -2
- package/ciel_runtime_support/protocols/openai_responses.py +61 -15
- package/ciel_runtime_support/provider_adapters.py +26 -0
- package/ciel_runtime_support/provider_administration_context.py +207 -0
- package/ciel_runtime_support/provider_config_mutations.py +3 -0
- package/ciel_runtime_support/provider_model_catalog_context.py +137 -0
- package/ciel_runtime_support/provider_model_context.py +107 -0
- package/ciel_runtime_support/provider_model_metadata_context.py +197 -0
- package/ciel_runtime_support/provider_model_selection.py +10 -3
- package/ciel_runtime_support/provider_models.py +45 -2
- package/ciel_runtime_support/provider_option_cli.py +19 -0
- package/ciel_runtime_support/provider_policy.py +1 -1
- package/ciel_runtime_support/provider_readiness_context.py +189 -0
- package/ciel_runtime_support/provider_request_builder.py +64 -28
- package/ciel_runtime_support/provider_responses_passthrough.py +21 -2
- package/ciel_runtime_support/provider_timeout_policy.py +54 -0
- package/ciel_runtime_support/provider_tool_policy.py +9 -1
- package/ciel_runtime_support/providers/__init__.py +6 -0
- package/ciel_runtime_support/providers/alibaba.py +634 -0
- package/ciel_runtime_support/providers/catalog.py +24 -16
- package/ciel_runtime_support/providers/deepseek.py +73 -0
- package/ciel_runtime_support/providers/github_copilot_oauth.py +22 -1
- package/ciel_runtime_support/providers/kimi.py +69 -9
- package/ciel_runtime_support/providers/ollama.py +8 -0
- package/ciel_runtime_support/providers/ollama_context.py +21 -2
- package/ciel_runtime_support/providers/vllm.py +7 -1
- package/ciel_runtime_support/response_collection.py +68 -18
- package/ciel_runtime_support/response_collection_context.py +391 -0
- package/ciel_runtime_support/response_stream_context.py +555 -0
- package/ciel_runtime_support/responses_input_compatibility.py +121 -0
- package/ciel_runtime_support/responses_usage_observer.py +83 -0
- package/ciel_runtime_support/router_client_lifecycle.py +1 -0
- package/ciel_runtime_support/router_http.py +239 -3
- package/ciel_runtime_support/router_observability_context.py +251 -0
- package/ciel_runtime_support/router_process_context.py +200 -0
- package/ciel_runtime_support/router_process_lifecycle.py +2 -0
- package/ciel_runtime_support/router_request_assembly.py +399 -0
- package/ciel_runtime_support/router_request_context.py +215 -0
- package/ciel_runtime_support/router_server_context.py +82 -0
- package/ciel_runtime_support/runaway_output_guard.py +488 -0
- package/ciel_runtime_support/runtime_asset_assembly.py +147 -0
- package/ciel_runtime_support/runtime_asset_context.py +297 -0
- package/ciel_runtime_support/runtime_constants.py +16 -1
- package/ciel_runtime_support/runtime_launch.py +9 -5
- package/ciel_runtime_support/runtime_launch_context.py +130 -0
- package/ciel_runtime_support/runtime_maintenance_assembly.py +60 -0
- package/ciel_runtime_support/runtime_maintenance_context.py +309 -0
- package/ciel_runtime_support/runtime_maintenance_services.py +265 -0
- package/ciel_runtime_support/runtime_paths.py +60 -40
- package/ciel_runtime_support/runtime_primitives.py +78 -0
- package/ciel_runtime_support/sse_stream_collection.py +236 -0
- package/ciel_runtime_support/statusline_script.py +57 -8
- package/ciel_runtime_support/streaming_anthropic.py +361 -24
- package/ciel_runtime_support/tool_schema.py +40 -2
- package/ciel_runtime_support/tool_side_effect_dedupe.py +117 -12
- package/ciel_runtime_support/upstream_dump.py +68 -0
- package/ciel_runtime_support/upstream_retry_context.py +259 -0
- package/ciel_runtime_support/workspace_router_selection.py +86 -0
- package/docs/Configuration.md +50 -0
- package/docs/Test-Suite.md +1 -0
- 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
|
+
]
|