ct-toolkit 0.3.7__tar.gz → 0.3.8__tar.gz
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.
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/PKG-INFO +1 -1
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/__init__.py +1 -1
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/core/exceptions.py +16 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/core/wrapper.py +134 -88
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/divergence/l3_icm.py +34 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/endorsement/reflective.py +306 -15
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit.egg-info/PKG-INFO +1 -1
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/pyproject.toml +1 -1
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/LICENSE +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/README.md +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/__main__.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/cli.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/core/__init__.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/core/compatibility.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/core/compression_guard.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/core/integrity.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/core/kernel.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/divergence/__init__.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/divergence/analysis.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/divergence/engine.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/divergence/l2_judge.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/divergence/loss.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/divergence/scheduler.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/endorsement/__init__.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/endorsement/probes/__init__.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/identity/__init__.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/identity/embedding.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/identity/templates/__init__.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/identity/templates/defense.yaml +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/identity/templates/finance.yaml +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/identity/templates/general.yaml +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/identity/templates/medical.yaml +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/kernels/__init__.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/kernels/default.yaml +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/kernels/defense.yaml +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/middleware/__init__.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/middleware/autogen.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/middleware/crewai.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/middleware/deepagents.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/middleware/langchain.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/middleware/litellm.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/provenance/__init__.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/provenance/log.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/provenance/vault/__init__.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/server.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/utils/__init__.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/utils/logger.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit.egg-info/SOURCES.txt +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit.egg-info/dependency_links.txt +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit.egg-info/entry_points.txt +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit.egg-info/requires.txt +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit.egg-info/top_level.txt +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/setup.cfg +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/tests/test_analysis.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/tests/test_cli.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/tests/test_cli_extended.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/tests/test_deep_agents_integration.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/tests/test_loss.py +0 -0
- {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/tests/test_summarization_drift.py +0 -0
|
@@ -85,6 +85,22 @@ class CriticalDivergenceError(DivergenceError):
|
|
|
85
85
|
)
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
class CriticalSandboxDivergenceError(DivergenceError):
|
|
89
|
+
"""
|
|
90
|
+
Raised when a sandbox (staged) agent exhibits critical divergence during cooldown.
|
|
91
|
+
The staged kernel update is rejected and the sandbox is torn down.
|
|
92
|
+
"""
|
|
93
|
+
def __init__(self, endorsement_id: str, l1_score: float | None, reason: str):
|
|
94
|
+
self.endorsement_id = endorsement_id
|
|
95
|
+
self.l1_score = l1_score
|
|
96
|
+
self.reason = reason
|
|
97
|
+
super().__init__(
|
|
98
|
+
f"Sandbox agent for staged endorsement '{endorsement_id[:8]}...' exhibited "
|
|
99
|
+
f"critical divergence during cooldown (score={l1_score}, reason={reason!r}). "
|
|
100
|
+
"Staged update rejected and sandbox torn down."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
88
104
|
# ── Provenance Errors ────────────────────────────────────────────────────────
|
|
89
105
|
|
|
90
106
|
class ProvenanceError(CTToolkitError):
|
|
@@ -16,23 +16,33 @@ In the background:
|
|
|
16
16
|
2. Each request/response is written to the Provenance Log with HMAC signature.
|
|
17
17
|
3. Embedding cosine similarity (L1 ECS) is calculated.
|
|
18
18
|
4. If the threshold is exceeded, L2/L3 is triggered.
|
|
19
|
+
5. Staged Approval (Cooldown): Risky kernel updates are monitored via shadow requests
|
|
20
|
+
before being promoted to production.
|
|
19
21
|
"""
|
|
20
22
|
from __future__ import annotations
|
|
21
23
|
|
|
22
24
|
import time
|
|
23
|
-
import hashlib
|
|
24
25
|
import os
|
|
25
26
|
from dataclasses import dataclass, field
|
|
26
27
|
from pathlib import Path
|
|
27
|
-
from typing import Any,
|
|
28
|
+
from typing import Any, Optional, Callable, Dict, List
|
|
29
|
+
|
|
28
30
|
import litellm
|
|
29
31
|
|
|
30
32
|
from ct_toolkit.core.kernel import ConstitutionalKernel
|
|
31
33
|
from ct_toolkit.core.compatibility import CompatibilityLayer, CompatibilityResult
|
|
32
|
-
from ct_toolkit.core.exceptions import
|
|
34
|
+
from ct_toolkit.core.exceptions import (
|
|
35
|
+
MissingClientError,
|
|
36
|
+
CriticalSandboxDivergenceError
|
|
37
|
+
)
|
|
33
38
|
from ct_toolkit.core.integrity import IntegrityMonitor
|
|
34
39
|
from ct_toolkit.divergence.scheduler import RiskProfile
|
|
35
40
|
from ct_toolkit.core.compression_guard import ContextCompressionGuard
|
|
41
|
+
from ct_toolkit.endorsement.reflective import (
|
|
42
|
+
ReflectiveEndorsement,
|
|
43
|
+
StagedUpdateManager,
|
|
44
|
+
CooldownCalculator
|
|
45
|
+
)
|
|
36
46
|
from ct_toolkit.utils.logger import get_logger
|
|
37
47
|
|
|
38
48
|
logger = get_logger(__name__)
|
|
@@ -74,6 +84,11 @@ class WrapperConfig:
|
|
|
74
84
|
compression_threshold: float = 0.85
|
|
75
85
|
compression_passive_detection: bool = True
|
|
76
86
|
|
|
87
|
+
# -- Staged Approval (Cooldown) --
|
|
88
|
+
endorsement_cooldown_base: int = CooldownCalculator.DEFAULT_BASE_SECONDS
|
|
89
|
+
endorsement_cooldown_max: int = CooldownCalculator.DEFAULT_MAX_SECONDS
|
|
90
|
+
endorsement_no_probe_penalty: int = CooldownCalculator.DEFAULT_NO_PROBE_PENALTY_S
|
|
91
|
+
|
|
77
92
|
|
|
78
93
|
@dataclass
|
|
79
94
|
class CTResponse:
|
|
@@ -85,6 +100,8 @@ class CTResponse:
|
|
|
85
100
|
divergence_tier: str | None = None # "ok" | "l1_warning" | "l2_judge" | "l3_icm"
|
|
86
101
|
provenance_id: str | None = None
|
|
87
102
|
raw_response: Any = field(default=None, repr=False)
|
|
103
|
+
# Sandbox metadata if staged updates are active
|
|
104
|
+
sandbox_divergence: float | None = None
|
|
88
105
|
|
|
89
106
|
def __str__(self) -> str:
|
|
90
107
|
return self.content
|
|
@@ -125,15 +142,6 @@ class TheseusWrapper:
|
|
|
125
142
|
) -> None:
|
|
126
143
|
"""
|
|
127
144
|
Initializes TheseusWrapper.
|
|
128
|
-
|
|
129
|
-
Args:
|
|
130
|
-
client: An existing API client instance (OpenAI, Anthropic, etc.).
|
|
131
|
-
Optional if 'provider' is specified.
|
|
132
|
-
config: Optional WrapperConfig instance.
|
|
133
|
-
provider: Provider name (e.g. "openai", "anthropic", "ollama", "google", "cohere").
|
|
134
|
-
If provided, LiteLLM will be used to initialize the client internally.
|
|
135
|
-
project_root: Path to the user's project root directory. Used to find a custom 'config/' folder.
|
|
136
|
-
Defaults to the current working directory.
|
|
137
145
|
"""
|
|
138
146
|
self._config = config or WrapperConfig(
|
|
139
147
|
kernel_path=kernel_path,
|
|
@@ -175,6 +183,9 @@ class TheseusWrapper:
|
|
|
175
183
|
)
|
|
176
184
|
self._shadow_history: Optional[List[Dict[str, str]]] = None
|
|
177
185
|
|
|
186
|
+
# ── Staged Approval (Cooldown) ──
|
|
187
|
+
self._staged_manager = StagedUpdateManager()
|
|
188
|
+
|
|
178
189
|
# ── Integrity Monitoring ──
|
|
179
190
|
self._integrity_monitor = IntegrityMonitor()
|
|
180
191
|
self._register_monitored_files()
|
|
@@ -183,8 +194,7 @@ class TheseusWrapper:
|
|
|
183
194
|
|
|
184
195
|
def _register_monitored_files(self):
|
|
185
196
|
"""
|
|
186
|
-
Finds and registers all critical configuration files for integrity monitoring
|
|
187
|
-
including both built-in package files and user-provided custom configs.
|
|
197
|
+
Finds and registers all critical configuration files for integrity monitoring.
|
|
188
198
|
"""
|
|
189
199
|
logger.debug("Registering configuration files for integrity monitoring.")
|
|
190
200
|
|
|
@@ -205,10 +215,7 @@ class TheseusWrapper:
|
|
|
205
215
|
self._integrity_monitor.register_file(file_path)
|
|
206
216
|
|
|
207
217
|
except Exception:
|
|
208
|
-
# Fallback for older Python or different environments
|
|
209
218
|
package_root = Path(__file__).parent.parent
|
|
210
|
-
# This is less ideal as it might find non-package files, but it's a
|
|
211
|
-
# reasonable fallback for development environments.
|
|
212
219
|
for pattern in internal_patterns:
|
|
213
220
|
for file_path in package_root.rglob(pattern):
|
|
214
221
|
if file_path.is_file():
|
|
@@ -229,23 +236,16 @@ class TheseusWrapper:
|
|
|
229
236
|
# ── Factory / Init ─────────────────────────────────────────────────────────
|
|
230
237
|
|
|
231
238
|
def _detect_provider(self, client: Any) -> str:
|
|
232
|
-
"""
|
|
233
|
-
Detects the provider from the client instance or string.
|
|
234
|
-
Returns 'unknown' if detection fails.
|
|
235
|
-
"""
|
|
236
239
|
if client is None:
|
|
237
240
|
return "unknown"
|
|
238
241
|
|
|
239
242
|
if isinstance(client, str):
|
|
240
|
-
# client passed as a string (e.g. "openai")
|
|
241
243
|
return client.lower()
|
|
242
244
|
|
|
243
245
|
try:
|
|
244
|
-
# Check for LiteLLM specific attributes if any
|
|
245
246
|
if hasattr(client, "provider") and isinstance(client.provider, str):
|
|
246
247
|
return client.provider
|
|
247
248
|
|
|
248
|
-
# Check module name
|
|
249
249
|
module = type(client).__module__.lower()
|
|
250
250
|
if "openai" in module: return "openai"
|
|
251
251
|
if "anthropic" in module: return "anthropic"
|
|
@@ -258,21 +258,18 @@ class TheseusWrapper:
|
|
|
258
258
|
return "unknown"
|
|
259
259
|
|
|
260
260
|
def _load_kernel(self) -> ConstitutionalKernel:
|
|
261
|
-
# 1. Check for explicit path
|
|
262
261
|
if self._config.kernel_path:
|
|
263
262
|
logger.debug(f"Loading kernel from explicit path: {self._config.kernel_path}")
|
|
264
263
|
return ConstitutionalKernel.from_yaml(self._config.kernel_path)
|
|
265
264
|
|
|
266
265
|
safe_name = os.path.basename(self._config.kernel_name)
|
|
267
266
|
|
|
268
|
-
# 2. Check for user-defined config directory
|
|
269
267
|
if self._project_root:
|
|
270
268
|
user_config_path = self._project_root / "config" / f"{safe_name}.yaml"
|
|
271
269
|
if user_config_path.exists():
|
|
272
270
|
logger.debug(f"Loading kernel from user config: {user_config_path}")
|
|
273
271
|
return ConstitutionalKernel.from_yaml(user_config_path)
|
|
274
272
|
|
|
275
|
-
# 3. Load built-in kernel using importlib.resources
|
|
276
273
|
try:
|
|
277
274
|
from importlib.resources import files
|
|
278
275
|
kernel_resource = files("ct_toolkit.kernels").joinpath(f"{safe_name}.yaml")
|
|
@@ -280,7 +277,6 @@ class TheseusWrapper:
|
|
|
280
277
|
logger.debug(f"Loading built-in kernel: {safe_name}.yaml")
|
|
281
278
|
return ConstitutionalKernel.from_yaml(kernel_resource)
|
|
282
279
|
except (ImportError, FileNotFoundError):
|
|
283
|
-
# Fallback for older python versions
|
|
284
280
|
kernel_path = (
|
|
285
281
|
Path(__file__).parent.parent
|
|
286
282
|
/ "kernels"
|
|
@@ -296,7 +292,6 @@ class TheseusWrapper:
|
|
|
296
292
|
return ConstitutionalKernel.default()
|
|
297
293
|
|
|
298
294
|
def _init_provenance_log(self) -> Any:
|
|
299
|
-
"""Initializes the Provenance Log vault adapter (Will be fully implemented in Step 2)."""
|
|
300
295
|
from ct_toolkit.provenance.log import ProvenanceLog
|
|
301
296
|
return ProvenanceLog(
|
|
302
297
|
vault_type=self._config.vault_type,
|
|
@@ -304,13 +299,10 @@ class TheseusWrapper:
|
|
|
304
299
|
)
|
|
305
300
|
|
|
306
301
|
def _init_identity_layer(self) -> Any:
|
|
307
|
-
"""Initializes the Identity Embedding Layer."""
|
|
308
302
|
from ct_toolkit.identity.embedding import IdentityEmbeddingLayer
|
|
309
303
|
|
|
310
|
-
# Guard against using raw clients that don't support embeddings
|
|
311
304
|
emb_client = self._config.embedding_client
|
|
312
305
|
if emb_client is None:
|
|
313
|
-
# Only default to main client if it's known to be OpenAI (standard for embeddings)
|
|
314
306
|
if self._provider == "openai":
|
|
315
307
|
emb_client = self._client
|
|
316
308
|
else:
|
|
@@ -325,7 +317,6 @@ class TheseusWrapper:
|
|
|
325
317
|
)
|
|
326
318
|
|
|
327
319
|
def _init_divergence_engine(self) -> Any:
|
|
328
|
-
"""Initializes the Divergence Engine (judge_client is required for L2/L3)."""
|
|
329
320
|
from ct_toolkit.divergence.engine import DivergenceEngine
|
|
330
321
|
from ct_toolkit.divergence.scheduler import ElasticityScheduler
|
|
331
322
|
|
|
@@ -383,15 +374,10 @@ class TheseusWrapper:
|
|
|
383
374
|
) -> CTResponse:
|
|
384
375
|
"""
|
|
385
376
|
Sends a single message, returning the response with identity protection.
|
|
386
|
-
|
|
387
|
-
Args:
|
|
388
|
-
message: User message
|
|
389
|
-
model: Model to use (provider default is used)
|
|
390
|
-
system: Additional system prompt (appended to kernel)
|
|
391
|
-
history: Conversation history [{"role": ..., "content": ...}]
|
|
392
377
|
"""
|
|
393
378
|
# --- Pre-flight Checks ---
|
|
394
379
|
self._integrity_monitor.verify_integrity()
|
|
380
|
+
self._process_staged_updates()
|
|
395
381
|
|
|
396
382
|
composed_system = self._compose_system_prompt(system)
|
|
397
383
|
messages = self._build_messages(message, history, composed_system)
|
|
@@ -449,6 +435,11 @@ class TheseusWrapper:
|
|
|
449
435
|
|
|
450
436
|
break
|
|
451
437
|
|
|
438
|
+
# --- Staged Approval Monitoring (Shadow Requests) ---
|
|
439
|
+
sandbox_divergence = None
|
|
440
|
+
if self._staged_manager.has_active_staged():
|
|
441
|
+
sandbox_divergence = self._run_shadow_requests(message, history, model, **kwargs)
|
|
442
|
+
|
|
452
443
|
# Provenance Log record
|
|
453
444
|
provenance_id = None
|
|
454
445
|
if self._config.log_requests:
|
|
@@ -464,6 +455,7 @@ class TheseusWrapper:
|
|
|
464
455
|
"template": self._config.template,
|
|
465
456
|
"kernel": self._kernel.name,
|
|
466
457
|
"drift_alert_enabled": self._config.drift_alert_callback is not None,
|
|
458
|
+
"sandbox_divergence": sandbox_divergence,
|
|
467
459
|
},
|
|
468
460
|
)
|
|
469
461
|
|
|
@@ -475,8 +467,99 @@ class TheseusWrapper:
|
|
|
475
467
|
divergence_tier=div_result.tier.value,
|
|
476
468
|
provenance_id=provenance_id,
|
|
477
469
|
raw_response=raw_response,
|
|
470
|
+
sandbox_divergence=sandbox_divergence,
|
|
478
471
|
)
|
|
479
472
|
|
|
473
|
+
# ── Shadow Request Logic ──────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
def _run_shadow_requests(
|
|
476
|
+
self,
|
|
477
|
+
message: str,
|
|
478
|
+
history: list[dict[str, str]] | None,
|
|
479
|
+
model: str | None,
|
|
480
|
+
**kwargs: Any,
|
|
481
|
+
) -> float | None:
|
|
482
|
+
"""
|
|
483
|
+
Runs the interaction against one or more 'staged' (sandbox) kernels
|
|
484
|
+
to observe if the proposed changes cause identity drift.
|
|
485
|
+
"""
|
|
486
|
+
active_staged = self._staged_manager.get_active()
|
|
487
|
+
if not active_staged:
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
# To keep it simple, we monitor the first active staged update
|
|
491
|
+
staged_record = active_staged[0]
|
|
492
|
+
|
|
493
|
+
# 1. Clone current kernel and apply the staged rule
|
|
494
|
+
sandbox_kernel = ConstitutionalKernel.from_dict(self._kernel.to_dict())
|
|
495
|
+
try:
|
|
496
|
+
sandbox_kernel.update_commitment(
|
|
497
|
+
staged_record.commitment_id,
|
|
498
|
+
staged_record.rule_text
|
|
499
|
+
)
|
|
500
|
+
except Exception as e:
|
|
501
|
+
logger.error(f"Failed to apply staged rule to sandbox kernel: {e}")
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
# 2. Call provider with sandbox kernel
|
|
505
|
+
sandbox_system = sandbox_kernel.get_system_prompt_injection()
|
|
506
|
+
sandbox_messages = self._build_messages(message, history, sandbox_system)
|
|
507
|
+
|
|
508
|
+
try:
|
|
509
|
+
# We use the same model/client as the live request
|
|
510
|
+
sandbox_response = self._call_provider(sandbox_messages, model=model, **kwargs)
|
|
511
|
+
sandbox_content = self._extract_content(sandbox_response)
|
|
512
|
+
|
|
513
|
+
# 3. Analyze sandbox divergence
|
|
514
|
+
sandbox_div_score = self._identity_layer.compute_divergence(sandbox_content)
|
|
515
|
+
|
|
516
|
+
# 4. Critical Divergence Check
|
|
517
|
+
if sandbox_div_score >= self._config.divergence_l3_threshold:
|
|
518
|
+
reason = f"Sandbox divergence L1 score {sandbox_div_score:.4f} exceeded L3 threshold."
|
|
519
|
+
self._staged_manager.reject_staged(staged_record.id, reason=reason)
|
|
520
|
+
raise CriticalSandboxDivergenceError(
|
|
521
|
+
endorsement_id=staged_record.id,
|
|
522
|
+
l1_score=sandbox_div_score,
|
|
523
|
+
reason=reason
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
return sandbox_div_score
|
|
527
|
+
|
|
528
|
+
except CriticalSandboxDivergenceError:
|
|
529
|
+
raise
|
|
530
|
+
except Exception as e:
|
|
531
|
+
logger.warning(f"Shadow request for staged update failed: {e}")
|
|
532
|
+
return None
|
|
533
|
+
|
|
534
|
+
def _process_staged_updates(self) -> None:
|
|
535
|
+
"""
|
|
536
|
+
Checks for staged updates whose cooldown has expired and
|
|
537
|
+
promotes them to the production kernel.
|
|
538
|
+
"""
|
|
539
|
+
promotable = self._staged_manager.get_promotable()
|
|
540
|
+
for record in promotable:
|
|
541
|
+
try:
|
|
542
|
+
logger.info(
|
|
543
|
+
f"Promoting staged endorsement to production: {record.id[:8]}... "
|
|
544
|
+
f"rule='{record.rule_text}'"
|
|
545
|
+
)
|
|
546
|
+
self._kernel.update_commitment(record.commitment_id, record.rule_text)
|
|
547
|
+
|
|
548
|
+
# Write promotion event to log
|
|
549
|
+
if self._config.log_requests:
|
|
550
|
+
self._provenance_log.record(
|
|
551
|
+
request_text=f"[COOLDOWN_EXPIRED] Promoting staged rule",
|
|
552
|
+
response_text=f"Rule applied to production kernel: {record.rule_text}",
|
|
553
|
+
metadata={
|
|
554
|
+
"event_type": "staged_promotion",
|
|
555
|
+
"endorsement_id": record.id,
|
|
556
|
+
"kernel": self._kernel.name,
|
|
557
|
+
"commitment_id": record.commitment_id,
|
|
558
|
+
}
|
|
559
|
+
)
|
|
560
|
+
except Exception as e:
|
|
561
|
+
logger.error(f"Failed to promote staged update {record.id[:8]}: {e}")
|
|
562
|
+
|
|
480
563
|
# ── Provider Dispatch ──────────────────────────────────────────────────────
|
|
481
564
|
|
|
482
565
|
def _has_env_credentials(self) -> bool:
|
|
@@ -487,7 +570,6 @@ class TheseusWrapper:
|
|
|
487
570
|
if self._provider == "anthropic":
|
|
488
571
|
return bool(os.environ.get("ANTHROPIC_API_KEY"))
|
|
489
572
|
if self._provider == "ollama":
|
|
490
|
-
# Ollama typically doesn't need a key locally
|
|
491
573
|
return True
|
|
492
574
|
if self._provider == "google":
|
|
493
575
|
return bool(os.environ.get("GOOGLE_API_KEY"))
|
|
@@ -500,44 +582,32 @@ class TheseusWrapper:
|
|
|
500
582
|
**kwargs: Any,
|
|
501
583
|
) -> Any:
|
|
502
584
|
"""Uses LiteLLM for unified provider calling."""
|
|
503
|
-
# --- Passive Context Compression Detection ---
|
|
504
585
|
if self._config.compression_passive_detection and self._shadow_history:
|
|
505
|
-
# Simple heuristic: message count dropped significantly
|
|
506
|
-
# We use 0.7 as a threshold (30% drop)
|
|
507
586
|
if len(messages) < len(self._shadow_history) * 0.7:
|
|
508
587
|
self._compression_guard.on_passive_detection(
|
|
509
588
|
original=self._shadow_history,
|
|
510
589
|
compressed=messages
|
|
511
590
|
)
|
|
512
591
|
|
|
513
|
-
# Update shadow history for next call
|
|
514
592
|
self._shadow_history = list(messages)
|
|
515
593
|
|
|
516
|
-
# LiteLLM handles different message formats internally
|
|
517
|
-
|
|
518
|
-
# Set default models per provider if not specified
|
|
519
594
|
if not model:
|
|
520
595
|
if self._provider == "openai": model = "gpt-4o-mini"
|
|
521
596
|
elif self._provider == "anthropic": model = "claude-3-5-sonnet-latest"
|
|
522
597
|
elif self._provider == "ollama": model = "llama3"
|
|
523
598
|
else: model = "gpt-4o-mini"
|
|
524
599
|
|
|
525
|
-
# LiteLLM expects "provider/model" format for non-default providers
|
|
526
|
-
# Usually OpenAI models don't need a prefix.
|
|
527
600
|
full_model = model
|
|
528
601
|
if ":" in model:
|
|
529
|
-
# If it has the legacy format "provider:model", convert it
|
|
530
602
|
full_model = model.replace(":", "/", 1)
|
|
531
603
|
elif self._provider not in ("openai", "unknown"):
|
|
532
604
|
full_model = f"{self._provider}/{model}"
|
|
533
605
|
|
|
534
|
-
# Extract connection info from client if possible
|
|
535
606
|
if hasattr(self._client, "base_url") and self._client.base_url:
|
|
536
607
|
kwargs["api_base"] = str(self._client.base_url)
|
|
537
608
|
if hasattr(self._client, "api_key") and self._client.api_key:
|
|
538
609
|
kwargs["api_key"] = self._client.api_key
|
|
539
610
|
|
|
540
|
-
# Transfer common client configurations if available
|
|
541
611
|
if hasattr(self._client, "timeout") and "timeout" not in kwargs:
|
|
542
612
|
kwargs["timeout"] = self._client.timeout
|
|
543
613
|
|
|
@@ -552,7 +622,6 @@ class TheseusWrapper:
|
|
|
552
622
|
def _compose_system_prompt(self, extra: str | None) -> str:
|
|
553
623
|
kernel_injection = self._kernel.get_system_prompt_injection()
|
|
554
624
|
|
|
555
|
-
# If this kernel contains propagated constraints, add a specific header
|
|
556
625
|
if any(a.id.startswith("propagated_") for a in self._kernel.anchors):
|
|
557
626
|
kernel_injection = (
|
|
558
627
|
"# Mother Agent Constraints\n"
|
|
@@ -568,13 +637,11 @@ class TheseusWrapper:
|
|
|
568
637
|
def propagate_headers(self) -> dict[str, str]:
|
|
569
638
|
"""
|
|
570
639
|
Returns a dictionary of headers to be used when calling sub-agents.
|
|
571
|
-
Includes the current kernel serialized for propagation.
|
|
572
640
|
"""
|
|
573
641
|
import json
|
|
574
642
|
import base64
|
|
575
643
|
|
|
576
644
|
kernel_data = self._kernel.to_dict()
|
|
577
|
-
# Mark as read-only for sub-agents
|
|
578
645
|
kernel_data["is_readonly"] = True
|
|
579
646
|
|
|
580
647
|
encoded_kernel = base64.b64encode(json.dumps(kernel_data).encode()).decode()
|
|
@@ -598,31 +665,26 @@ class TheseusWrapper:
|
|
|
598
665
|
return messages
|
|
599
666
|
|
|
600
667
|
def _extract_content(self, raw: Any) -> str:
|
|
601
|
-
# Handle dict responses
|
|
602
668
|
if isinstance(raw, dict):
|
|
603
669
|
if "choices" in raw and isinstance(raw["choices"], list) and raw["choices"]:
|
|
604
670
|
return raw["choices"][0].get("message", {}).get("content", "") or ""
|
|
605
671
|
if "message" in raw:
|
|
606
|
-
# Ollama dictate format
|
|
607
672
|
return raw["message"].get("content", "") or ""
|
|
608
673
|
return str(raw)
|
|
609
674
|
|
|
610
|
-
# LiteLLM ModelResponse (Standardized for all providers)
|
|
611
675
|
try:
|
|
612
676
|
if hasattr(raw, "choices") and raw.choices:
|
|
613
677
|
choice = raw.choices[0]
|
|
614
678
|
if hasattr(choice, "message") and choice.message:
|
|
615
679
|
return choice.message.content or ""
|
|
616
|
-
# Some older/alternative formats
|
|
617
680
|
if hasattr(choice, "text"):
|
|
618
681
|
return choice.text or ""
|
|
619
682
|
except Exception:
|
|
620
683
|
pass
|
|
621
684
|
|
|
622
|
-
|
|
623
|
-
if hasattr(raw, "content") and isinstance(raw.content, list): # Anthropic style
|
|
685
|
+
if hasattr(raw, "content") and isinstance(raw.content, list):
|
|
624
686
|
return raw.content[0].text if raw.content else ""
|
|
625
|
-
if hasattr(raw, "message"):
|
|
687
|
+
if hasattr(raw, "message"):
|
|
626
688
|
return getattr(raw.message, "content", "") or ""
|
|
627
689
|
|
|
628
690
|
return str(raw)
|
|
@@ -635,7 +697,6 @@ class TheseusWrapper:
|
|
|
635
697
|
return fallback or "unknown"
|
|
636
698
|
|
|
637
699
|
def _run_divergence_engine(self, message: str, response: str, interaction_count: int = 0, skip_l3: bool = False) -> Any:
|
|
638
|
-
"""Runs the staged divergence engine (L1 -> L2 -> L3)."""
|
|
639
700
|
try:
|
|
640
701
|
return self._divergence_engine.analyze(message, response, interaction_count, skip_l3=skip_l3)
|
|
641
702
|
except Exception as e:
|
|
@@ -649,10 +710,6 @@ class TheseusWrapper:
|
|
|
649
710
|
# ── Kernel Management ────────────────────────────────────────────────────────
|
|
650
711
|
|
|
651
712
|
def validate_user_rule(self, rule_text: str) -> None:
|
|
652
|
-
"""
|
|
653
|
-
Validates a user-defined rule against the kernel.
|
|
654
|
-
Raises an exception if there is a conflict.
|
|
655
|
-
"""
|
|
656
713
|
self._kernel.validate_user_rule(rule_text)
|
|
657
714
|
|
|
658
715
|
def endorse_rule(
|
|
@@ -664,15 +721,7 @@ class TheseusWrapper:
|
|
|
664
721
|
) -> Any:
|
|
665
722
|
"""
|
|
666
723
|
Validates the rule; initiates the Reflective Endorsement
|
|
667
|
-
flow if there is a plastic conflict.
|
|
668
|
-
|
|
669
|
-
Args:
|
|
670
|
-
rule_text: The rule to be enforced
|
|
671
|
-
operator_id: The identity of the person granting approval
|
|
672
|
-
approval_channel: Custom approval channel (default: CLI)
|
|
673
|
-
commitment_new_value: New value to assign to the commitment
|
|
674
|
-
|
|
675
|
-
Returns: EndorsementRecord
|
|
724
|
+
flow if there is a plastic conflict.
|
|
676
725
|
"""
|
|
677
726
|
from ct_toolkit.endorsement.reflective import ReflectiveEndorsement
|
|
678
727
|
|
|
@@ -680,6 +729,11 @@ class TheseusWrapper:
|
|
|
680
729
|
kernel=self._kernel,
|
|
681
730
|
provenance_log=self._provenance_log,
|
|
682
731
|
approval_channel=approval_channel,
|
|
732
|
+
staged_manager=self._staged_manager,
|
|
733
|
+
cooldown_base_s=self._config.endorsement_cooldown_base,
|
|
734
|
+
cooldown_max_s=self._config.endorsement_cooldown_max,
|
|
735
|
+
no_probe_penalty_s=self._config.endorsement_no_probe_penalty,
|
|
736
|
+
template=self._config.template,
|
|
683
737
|
)
|
|
684
738
|
return re.validate_and_endorse(
|
|
685
739
|
rule_text=rule_text,
|
|
@@ -688,18 +742,6 @@ class TheseusWrapper:
|
|
|
688
742
|
)
|
|
689
743
|
|
|
690
744
|
def export_provenance_log(self) -> list[dict[str, Any]]:
|
|
691
|
-
"""
|
|
692
|
-
Exports the entire verified provenance log.
|
|
693
|
-
|
|
694
|
-
Verifies the integrity of the log before returning the data.
|
|
695
|
-
This is intended for external auditing and analysis.
|
|
696
|
-
|
|
697
|
-
Returns:
|
|
698
|
-
A list of all log entries as dictionaries.
|
|
699
|
-
|
|
700
|
-
Raises:
|
|
701
|
-
ChainIntegrityError: If the log's hash chain is broken.
|
|
702
|
-
"""
|
|
703
745
|
return self._provenance_log.export_log()
|
|
704
746
|
|
|
705
747
|
@property
|
|
@@ -714,6 +756,10 @@ class TheseusWrapper:
|
|
|
714
756
|
def divergence_engine(self) -> Any:
|
|
715
757
|
return self._divergence_engine
|
|
716
758
|
|
|
759
|
+
@property
|
|
760
|
+
def staged_manager(self) -> StagedUpdateManager:
|
|
761
|
+
return self._staged_manager
|
|
762
|
+
|
|
717
763
|
def __repr__(self) -> str:
|
|
718
764
|
return (
|
|
719
765
|
f"TheseusWrapper("
|
|
@@ -230,6 +230,40 @@ class ICMRunner:
|
|
|
230
230
|
"ollama": "llama3",
|
|
231
231
|
}.get(provider, "gpt-4o-mini")
|
|
232
232
|
|
|
233
|
+
# ── Probe Availability Check ────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def has_probes(
|
|
237
|
+
template: str,
|
|
238
|
+
kernel_name: str, # noqa: ARG004 (reserved for future kernel-specific probes)
|
|
239
|
+
project_root: Path | None = None,
|
|
240
|
+
) -> bool:
|
|
241
|
+
"""
|
|
242
|
+
Returns True if any probe files exist for the given template/kernel combination.
|
|
243
|
+
Does NOT run the probes — intended as a fast pre-flight check for cooldown
|
|
244
|
+
duration calculation in the Reflective Endorsement protocol.
|
|
245
|
+
|
|
246
|
+
Probe availability order (mirrors _load_probes):
|
|
247
|
+
1. base_probes.json — always counts as available
|
|
248
|
+
2. user config: <project_root>/config/<template>_probes.json
|
|
249
|
+
3. built-in domain: endorsement/probes/domain_probes/<template>_probes.json
|
|
250
|
+
"""
|
|
251
|
+
probes_dir = ICMRunner.PROBES_DIR
|
|
252
|
+
|
|
253
|
+
# Base probes are always present; if they exist, we consider probes available.
|
|
254
|
+
if (probes_dir / "base_probes.json").exists():
|
|
255
|
+
return True
|
|
256
|
+
|
|
257
|
+
# Check user-supplied domain probes
|
|
258
|
+
if project_root:
|
|
259
|
+
user_probe_path = project_root / "config" / f"{template}_probes.json"
|
|
260
|
+
if user_probe_path.exists():
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
# Check built-in domain probes
|
|
264
|
+
domain_path = probes_dir / "domain_probes" / f"{template}_probes.json"
|
|
265
|
+
return domain_path.exists()
|
|
266
|
+
|
|
233
267
|
# ── Main Runner ────────────────────────────────────────────────────────
|
|
234
268
|
|
|
235
269
|
def run(self) -> ICMReport:
|
|
@@ -7,9 +7,14 @@ Flow:
|
|
|
7
7
|
1. User-defined rule conflicts with kernel → PlasticConflictError is caught
|
|
8
8
|
2. ConflictRecord is created — conflict details are documented
|
|
9
9
|
3. User is directed to approval channel (CLI / callback / API)
|
|
10
|
-
4.
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
4. Decision options:
|
|
11
|
+
a. APPROVED → EndorsementRecord signed, kernel updated immediately
|
|
12
|
+
b. STAGED → EndorsementRecord signed, kernel update deferred to cooldown period.
|
|
13
|
+
A sandbox agent (cloned kernel) observes shadow traffic.
|
|
14
|
+
After cooldown expires without violations → kernel updated.
|
|
15
|
+
If CriticalDivergenceError during cooldown → REJECTED.
|
|
16
|
+
c. REJECTED → System remains unchanged, rejection record written to log
|
|
17
|
+
5. All decisions are written to the Provenance Log.
|
|
13
18
|
|
|
14
19
|
Each override is additionally monitored in subsequent ICM tests (flagged=True).
|
|
15
20
|
"""
|
|
@@ -37,6 +42,7 @@ class EndorsementDecision(str, Enum):
|
|
|
37
42
|
REJECTED = "rejected"
|
|
38
43
|
PENDING = "pending"
|
|
39
44
|
FAILED = "failed"
|
|
45
|
+
STAGED = "staged" # Approved conditionally — pending cooldown observation
|
|
40
46
|
|
|
41
47
|
|
|
42
48
|
@dataclass
|
|
@@ -64,7 +70,7 @@ class ConflictRecord:
|
|
|
64
70
|
|
|
65
71
|
@dataclass
|
|
66
72
|
class EndorsementRecord:
|
|
67
|
-
"""Signed record of user approval/rejection decision."""
|
|
73
|
+
"""Signed record of user approval/rejection/staged decision."""
|
|
68
74
|
id: str
|
|
69
75
|
timestamp: float
|
|
70
76
|
conflict_id: str
|
|
@@ -74,6 +80,10 @@ class EndorsementRecord:
|
|
|
74
80
|
operator_id: str # Who approved / rejected
|
|
75
81
|
rationale: str # Why it was approved / rejected
|
|
76
82
|
kernel_name: str
|
|
83
|
+
# Cooldown fields (only set for STAGED decisions)
|
|
84
|
+
cooldown_duration_s: int | None = None # Total cooldown window in seconds
|
|
85
|
+
cooldown_until: float | None = None # Epoch timestamp when cooldown ends
|
|
86
|
+
probe_available: bool = True # Whether probes were available for this kernel/template
|
|
77
87
|
content_hash: str = field(default="", repr=False)
|
|
78
88
|
|
|
79
89
|
def __post_init__(self) -> None:
|
|
@@ -91,12 +101,27 @@ class EndorsementRecord:
|
|
|
91
101
|
"operator_id": self.operator_id,
|
|
92
102
|
"rationale": self.rationale,
|
|
93
103
|
"kernel_name": self.kernel_name,
|
|
104
|
+
"cooldown_duration_s": self.cooldown_duration_s,
|
|
105
|
+
"cooldown_until": self.cooldown_until,
|
|
106
|
+
"probe_available": self.probe_available,
|
|
94
107
|
}, sort_keys=True)
|
|
95
108
|
return hashlib.sha256(payload.encode()).hexdigest()
|
|
96
109
|
|
|
110
|
+
@property
|
|
111
|
+
def is_staged(self) -> bool:
|
|
112
|
+
return self.decision == EndorsementDecision.STAGED
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def is_cooldown_expired(self) -> bool:
|
|
116
|
+
"""True if this is a STAGED record whose cooldown period has elapsed."""
|
|
117
|
+
if not self.is_staged or self.cooldown_until is None:
|
|
118
|
+
return False
|
|
119
|
+
deadline: float = self.cooldown_until # narrowed: not None
|
|
120
|
+
return time.time() >= deadline
|
|
121
|
+
|
|
97
122
|
def to_provenance_metadata(self) -> dict[str, Any]:
|
|
98
123
|
"""Metadata to be written to Provenance Log."""
|
|
99
|
-
|
|
124
|
+
meta: dict[str, Any] = {
|
|
100
125
|
"event_type": "reflective_endorsement",
|
|
101
126
|
"endorsement_id": self.id,
|
|
102
127
|
"conflict_id": self.conflict_id,
|
|
@@ -106,8 +131,188 @@ class EndorsementRecord:
|
|
|
106
131
|
"commitment_id": self.commitment_id,
|
|
107
132
|
"kernel_name": self.kernel_name,
|
|
108
133
|
"content_hash": self.content_hash,
|
|
109
|
-
"flagged_for_icm": self.decision
|
|
134
|
+
"flagged_for_icm": self.decision in (
|
|
135
|
+
EndorsementDecision.APPROVED, EndorsementDecision.STAGED
|
|
136
|
+
) and self.conflict_id != "none",
|
|
110
137
|
}
|
|
138
|
+
if self.is_staged:
|
|
139
|
+
meta.update({
|
|
140
|
+
"cooldown_duration_s": self.cooldown_duration_s,
|
|
141
|
+
"cooldown_until": self.cooldown_until,
|
|
142
|
+
"probe_available": self.probe_available,
|
|
143
|
+
})
|
|
144
|
+
return meta
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── Cooldown Duration Calculator ─────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
class CooldownCalculator:
|
|
150
|
+
"""
|
|
151
|
+
Calculates the appropriate cooldown duration for a staged kernel update.
|
|
152
|
+
|
|
153
|
+
Duration formula:
|
|
154
|
+
duration = min(base + no_probe_penalty + traffic_adjustment, max_duration)
|
|
155
|
+
|
|
156
|
+
Where:
|
|
157
|
+
- base : Starting cooldown length (default: 5 min)
|
|
158
|
+
- no_probe_penalty: Added when no domain probes exist for this kernel/template
|
|
159
|
+
- traffic_adjustment: Added based on requests-per-minute observed in the
|
|
160
|
+
Provenance Log (busier system → longer observation)
|
|
161
|
+
- max_duration : Hard cap (default: 10 min)
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
DEFAULT_BASE_SECONDS = 300 # 5 minutes
|
|
165
|
+
DEFAULT_MAX_SECONDS = 600 # 10 minutes
|
|
166
|
+
DEFAULT_NO_PROBE_PENALTY_S = 120 # 2 minutes penalty for missing probes
|
|
167
|
+
TRAFFIC_BUCKET_SECONDS = 60 # Window for measuring request rate (1 min)
|
|
168
|
+
TRAFFIC_THRESHOLD_PER_MIN = 10 # Requests/min above which we add time
|
|
169
|
+
TRAFFIC_PENALTY_SECONDS = 60 # Extra second-tier added per threshold breach
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
base_seconds: int = DEFAULT_BASE_SECONDS,
|
|
174
|
+
max_seconds: int = DEFAULT_MAX_SECONDS,
|
|
175
|
+
no_probe_penalty_s: int = DEFAULT_NO_PROBE_PENALTY_S,
|
|
176
|
+
) -> None:
|
|
177
|
+
self._base = base_seconds
|
|
178
|
+
self._max = max_seconds
|
|
179
|
+
self._no_probe_penalty = no_probe_penalty_s
|
|
180
|
+
|
|
181
|
+
def calculate(
|
|
182
|
+
self,
|
|
183
|
+
probe_available: bool,
|
|
184
|
+
provenance_log: Any | None,
|
|
185
|
+
kernel_name: str,
|
|
186
|
+
template: str,
|
|
187
|
+
) -> int:
|
|
188
|
+
"""
|
|
189
|
+
Returns the cooldown duration in seconds.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
probe_available: Whether ICM probes exist for this kernel/template.
|
|
193
|
+
provenance_log: ProvenanceLog instance (for traffic estimation).
|
|
194
|
+
kernel_name: Kernel name (used for traffic lookup).
|
|
195
|
+
template: Template name (used for traffic lookup).
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
int: Cooldown duration in seconds.
|
|
199
|
+
"""
|
|
200
|
+
duration = self._base
|
|
201
|
+
penalty_reasons: list[str] = []
|
|
202
|
+
|
|
203
|
+
# Penalty 1: No domain probes available
|
|
204
|
+
if not probe_available:
|
|
205
|
+
duration += self._no_probe_penalty
|
|
206
|
+
penalty_reasons.append(
|
|
207
|
+
f"No domain probes found (+{self._no_probe_penalty}s)"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Penalty 2: Traffic volume adjustment
|
|
211
|
+
if provenance_log:
|
|
212
|
+
rpm = self._estimate_rpm(provenance_log, kernel_name, template)
|
|
213
|
+
if rpm >= self.TRAFFIC_THRESHOLD_PER_MIN:
|
|
214
|
+
# Each full multiple of the threshold adds one time bucket
|
|
215
|
+
multiplier = int(rpm / self.TRAFFIC_THRESHOLD_PER_MIN)
|
|
216
|
+
traffic_penalty = min(multiplier * self.TRAFFIC_PENALTY_SECONDS, 120)
|
|
217
|
+
duration += traffic_penalty
|
|
218
|
+
penalty_reasons.append(
|
|
219
|
+
f"High traffic ({rpm} req/min, +{traffic_penalty}s)"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
final = min(duration, self._max)
|
|
223
|
+
if penalty_reasons:
|
|
224
|
+
logger.info(
|
|
225
|
+
f"Cooldown duration adjusted: base={self._base}s → {final}s "
|
|
226
|
+
f"[{', '.join(penalty_reasons)}]"
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
logger.info(f"Cooldown duration: {final}s (base, no penalties)")
|
|
230
|
+
|
|
231
|
+
return final
|
|
232
|
+
|
|
233
|
+
def _estimate_rpm(
|
|
234
|
+
self,
|
|
235
|
+
provenance_log: Any,
|
|
236
|
+
kernel_name: str,
|
|
237
|
+
template: str,
|
|
238
|
+
) -> float:
|
|
239
|
+
"""Estimates recent requests-per-minute from the Provenance Log."""
|
|
240
|
+
try:
|
|
241
|
+
entries = provenance_log.get_entries(limit=200)
|
|
242
|
+
cutoff = time.time() - self.TRAFFIC_BUCKET_SECONDS
|
|
243
|
+
recent = [
|
|
244
|
+
e for e in entries
|
|
245
|
+
if e.timestamp >= cutoff
|
|
246
|
+
and e.metadata.get("kernel") == kernel_name
|
|
247
|
+
and e.metadata.get("template") == template
|
|
248
|
+
]
|
|
249
|
+
return len(recent)
|
|
250
|
+
except Exception as e:
|
|
251
|
+
logger.debug(f"Could not estimate RPM from provenance log: {e}")
|
|
252
|
+
return 0.0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ── Staged Update Manager ──────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
class StagedUpdateManager:
|
|
258
|
+
"""
|
|
259
|
+
Tracks pending STAGED endorsements and promotes them to APPROVED
|
|
260
|
+
after the cooldown period elapses without critical violations.
|
|
261
|
+
|
|
262
|
+
Developer Usage:
|
|
263
|
+
# On each TheseusWrapper.chat() cycle, call:
|
|
264
|
+
manager.tick(provenance_log)
|
|
265
|
+
# If the production kernel needs updating:
|
|
266
|
+
promoted = manager.get_promotable()
|
|
267
|
+
for record in promoted:
|
|
268
|
+
kernel.update_commitment(record.commitment_id, record.rule_text)
|
|
269
|
+
|
|
270
|
+
Typically owned by TheseusWrapper and wired into the chat() loop.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
def __init__(self) -> None:
|
|
274
|
+
self._staged: list[EndorsementRecord] = []
|
|
275
|
+
|
|
276
|
+
def register(self, record: EndorsementRecord) -> None:
|
|
277
|
+
"""Registers a STAGED record for tracking."""
|
|
278
|
+
if record.is_staged:
|
|
279
|
+
self._staged.append(record)
|
|
280
|
+
logger.info(
|
|
281
|
+
f"StagedUpdateManager: registered staged endorsement "
|
|
282
|
+
f"id={record.id[:8]}... cooldown_until={record.cooldown_until}"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def get_active(self) -> list[EndorsementRecord]:
|
|
286
|
+
"""Returns all staged endorsements still in their cooldown window."""
|
|
287
|
+
return [r for r in self._staged if not r.is_cooldown_expired]
|
|
288
|
+
|
|
289
|
+
def get_promotable(self) -> list[EndorsementRecord]:
|
|
290
|
+
"""
|
|
291
|
+
Returns staged endorsements whose cooldown has expired without
|
|
292
|
+
being rejected. Removes them from the tracking list.
|
|
293
|
+
"""
|
|
294
|
+
promotable = [r for r in self._staged if r.is_cooldown_expired]
|
|
295
|
+
for r in promotable:
|
|
296
|
+
self._staged.remove(r)
|
|
297
|
+
return promotable
|
|
298
|
+
|
|
299
|
+
def reject_staged(self, record_id: str, reason: str = "") -> bool:
|
|
300
|
+
"""
|
|
301
|
+
Rejects and removes a staged endorsement (e.g. on CriticalDivergenceError).
|
|
302
|
+
Returns True if found and removed.
|
|
303
|
+
"""
|
|
304
|
+
for r in self._staged:
|
|
305
|
+
if r.id == record_id:
|
|
306
|
+
self._staged.remove(r)
|
|
307
|
+
logger.warning(
|
|
308
|
+
f"StagedUpdateManager: staged endorsement REJECTED "
|
|
309
|
+
f"id={record_id[:8]}... reason={reason!r}"
|
|
310
|
+
)
|
|
311
|
+
return True
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
def has_active_staged(self) -> bool:
|
|
315
|
+
return len(self.get_active()) > 0
|
|
111
316
|
|
|
112
317
|
|
|
113
318
|
# ── Approval Channels ─────────────────────────────────────────────────────────────
|
|
@@ -123,7 +328,8 @@ def cli_approval_channel(conflict: ConflictRecord) -> tuple[EndorsementDecision,
|
|
|
123
328
|
"""
|
|
124
329
|
Default CLI approval channel.
|
|
125
330
|
Prompts user for approval in interactive terminal.
|
|
126
|
-
|
|
331
|
+
Includes a 'STAGED' option for supervised cooldown observation.
|
|
332
|
+
|
|
127
333
|
WARNING: This blocks the current thread. In server environments (FastAPI/Flask),
|
|
128
334
|
always use a custom non-blocking callback.
|
|
129
335
|
"""
|
|
@@ -136,24 +342,31 @@ def cli_approval_channel(conflict: ConflictRecord) -> tuple[EndorsementDecision,
|
|
|
136
342
|
return EndorsementDecision.PENDING, "system", "Awaiting external approval (non-TTY)"
|
|
137
343
|
|
|
138
344
|
print(conflict.summary())
|
|
139
|
-
print("\
|
|
140
|
-
print(" [y] Yes — approve and
|
|
345
|
+
print("\nHow do you want to handle this rule conflict?")
|
|
346
|
+
print(" [y] Yes — approve and apply kernel update immediately")
|
|
347
|
+
print(" [s] Stage — approve with cooldown (sandbox observation period)")
|
|
141
348
|
print(" [n] No — cancel, keep existing rule")
|
|
142
349
|
|
|
143
350
|
while True:
|
|
144
351
|
try:
|
|
145
|
-
choice = input("\nYour decision (y/n): ").strip().lower()
|
|
352
|
+
choice = input("\nYour decision (y/s/n): ").strip().lower()
|
|
146
353
|
if choice in ("y", "yes"):
|
|
147
354
|
operator_id = input("Operator ID (name/email): ").strip() or "anonymous"
|
|
148
355
|
rationale = input("Override rationale: ").strip() or "Manual approval"
|
|
149
356
|
return EndorsementDecision.APPROVED, operator_id, rationale
|
|
357
|
+
elif choice in ("s", "stage", "staged"):
|
|
358
|
+
operator_id = input("Operator ID (name/email): ").strip() or "anonymous"
|
|
359
|
+
rationale = input("Staged approval rationale: ").strip() or "Staged manual approval"
|
|
360
|
+
return EndorsementDecision.STAGED, operator_id, rationale
|
|
150
361
|
elif choice in ("n", "no"):
|
|
151
362
|
return EndorsementDecision.REJECTED, "system", "User rejected"
|
|
152
363
|
else:
|
|
153
|
-
print("Invalid input. Type 'y' or 'n'.")
|
|
364
|
+
print("Invalid input. Type 'y', 's', or 'n'.")
|
|
154
365
|
except EOFError:
|
|
155
366
|
# Handle cases where input() is called but stdin is closed
|
|
156
367
|
return EndorsementDecision.PENDING, "system", "Stdin closed while awaiting input"
|
|
368
|
+
# Unreachable — satisfies static analysers that don't track while-True exhaustion
|
|
369
|
+
return EndorsementDecision.PENDING, "system", "Unexpected exit from approval loop"
|
|
157
370
|
|
|
158
371
|
|
|
159
372
|
def auto_approve_channel(
|
|
@@ -173,6 +386,24 @@ def auto_approve_channel(
|
|
|
173
386
|
return _channel
|
|
174
387
|
|
|
175
388
|
|
|
389
|
+
def auto_staged_channel(
|
|
390
|
+
operator_id: str = "auto",
|
|
391
|
+
rationale: str = "Auto staged approval",
|
|
392
|
+
) -> ApprovalCallback:
|
|
393
|
+
"""
|
|
394
|
+
Auto staged approval channel for test and CI environments.
|
|
395
|
+
Returns STAGED decision, which will trigger cooldown logic.
|
|
396
|
+
Do not use in production.
|
|
397
|
+
"""
|
|
398
|
+
def _channel(conflict: ConflictRecord) -> tuple[EndorsementDecision, str, str]:
|
|
399
|
+
logger.warning(
|
|
400
|
+
f"AUTO-STAGED active: Override of '{conflict.rule_text}' staged. "
|
|
401
|
+
"This channel should only be used in test environments."
|
|
402
|
+
)
|
|
403
|
+
return EndorsementDecision.STAGED, operator_id, rationale
|
|
404
|
+
return _channel
|
|
405
|
+
|
|
406
|
+
|
|
176
407
|
def auto_reject_channel() -> ApprovalCallback:
|
|
177
408
|
"""Auto reject channel for test environments."""
|
|
178
409
|
def _channel(conflict: ConflictRecord) -> tuple[EndorsementDecision, str, str]:
|
|
@@ -206,10 +437,22 @@ class ReflectiveEndorsement:
|
|
|
206
437
|
kernel: Any, # ConstitutionalKernel
|
|
207
438
|
provenance_log: Any, # ProvenanceLog
|
|
208
439
|
approval_channel: ApprovalCallback | None = None,
|
|
440
|
+
staged_manager: StagedUpdateManager | None = None,
|
|
441
|
+
cooldown_base_s: int = CooldownCalculator.DEFAULT_BASE_SECONDS,
|
|
442
|
+
cooldown_max_s: int = CooldownCalculator.DEFAULT_MAX_SECONDS,
|
|
443
|
+
no_probe_penalty_s: int = CooldownCalculator.DEFAULT_NO_PROBE_PENALTY_S,
|
|
444
|
+
template: str = "general",
|
|
209
445
|
) -> None:
|
|
210
446
|
self._kernel = kernel
|
|
211
447
|
self._log = provenance_log
|
|
212
448
|
self._approval_channel = approval_channel or cli_approval_channel
|
|
449
|
+
self._staged_manager = staged_manager or StagedUpdateManager()
|
|
450
|
+
self._cooldown_calc = CooldownCalculator(
|
|
451
|
+
base_seconds=cooldown_base_s,
|
|
452
|
+
max_seconds=cooldown_max_s,
|
|
453
|
+
no_probe_penalty_s=no_probe_penalty_s,
|
|
454
|
+
)
|
|
455
|
+
self._template = template
|
|
213
456
|
self._pending_records: list[EndorsementRecord] = []
|
|
214
457
|
self._pre_update_snapshot: dict[str, Any] | None = None
|
|
215
458
|
|
|
@@ -228,7 +471,7 @@ class ReflectiveEndorsement:
|
|
|
228
471
|
- Plastic conflict → RE flow starts, user approval requested
|
|
229
472
|
- No conflict → rule is applied directly, written to log
|
|
230
473
|
|
|
231
|
-
Returns: EndorsementRecord (APPROVED or REJECTED)
|
|
474
|
+
Returns: EndorsementRecord (APPROVED, STAGED, or REJECTED)
|
|
232
475
|
Raises: AxiomaticViolationError — on axiomatic violation
|
|
233
476
|
"""
|
|
234
477
|
try:
|
|
@@ -258,7 +501,7 @@ class ReflectiveEndorsement:
|
|
|
258
501
|
operator_id: str,
|
|
259
502
|
commitment_new_value: Any,
|
|
260
503
|
) -> EndorsementRecord:
|
|
261
|
-
"""
|
|
504
|
+
"""Four stages of RE flow: Conflict record → Approval → Application → Log."""
|
|
262
505
|
|
|
263
506
|
# Stage 1: Create ConflictRecord
|
|
264
507
|
conflict = ConflictRecord(
|
|
@@ -275,6 +518,10 @@ class ReflectiveEndorsement:
|
|
|
275
518
|
decision, actual_operator_id, rationale = self._approval_channel(conflict)
|
|
276
519
|
|
|
277
520
|
# Stage 3: Apply decision
|
|
521
|
+
probe_available = self._check_probe_availability()
|
|
522
|
+
cooldown_duration_s: int | None = None
|
|
523
|
+
cooldown_until: float | None = None
|
|
524
|
+
|
|
278
525
|
if decision == EndorsementDecision.APPROVED:
|
|
279
526
|
applied = self._apply_override(
|
|
280
527
|
commitment_id=conflict.conflicting_commitment_id,
|
|
@@ -295,6 +542,24 @@ class ReflectiveEndorsement:
|
|
|
295
542
|
f"commitment={conflict.conflicting_commitment_id} | "
|
|
296
543
|
f"operator={actual_operator_id or operator_id}"
|
|
297
544
|
)
|
|
545
|
+
|
|
546
|
+
elif decision == EndorsementDecision.STAGED:
|
|
547
|
+
# Calculate cooldown duration — applies penalties as needed
|
|
548
|
+
cooldown_duration_s = self._cooldown_calc.calculate(
|
|
549
|
+
probe_available=probe_available,
|
|
550
|
+
provenance_log=self._log,
|
|
551
|
+
kernel_name=self._kernel.name,
|
|
552
|
+
template=self._template,
|
|
553
|
+
)
|
|
554
|
+
cooldown_until = time.time() + cooldown_duration_s
|
|
555
|
+
logger.info(
|
|
556
|
+
f"Override STAGED | "
|
|
557
|
+
f"commitment={conflict.conflicting_commitment_id} | "
|
|
558
|
+
f"cooldown={cooldown_duration_s}s | "
|
|
559
|
+
f"probe_available={probe_available} | "
|
|
560
|
+
f"operator={actual_operator_id or operator_id}"
|
|
561
|
+
)
|
|
562
|
+
|
|
298
563
|
else:
|
|
299
564
|
logger.info(
|
|
300
565
|
f"Override rejected | "
|
|
@@ -313,12 +578,19 @@ class ReflectiveEndorsement:
|
|
|
313
578
|
operator_id=actual_operator_id or operator_id,
|
|
314
579
|
rationale=rationale,
|
|
315
580
|
kernel_name=self._kernel.name,
|
|
581
|
+
cooldown_duration_s=cooldown_duration_s,
|
|
582
|
+
cooldown_until=cooldown_until,
|
|
583
|
+
probe_available=probe_available,
|
|
316
584
|
)
|
|
317
585
|
|
|
318
586
|
# Write to log in either case
|
|
319
587
|
self._write_to_log(record)
|
|
320
588
|
self._pending_records.append(record)
|
|
321
589
|
|
|
590
|
+
# Register with staged manager if STAGED
|
|
591
|
+
if record.is_staged:
|
|
592
|
+
self._staged_manager.register(record)
|
|
593
|
+
|
|
322
594
|
return record
|
|
323
595
|
|
|
324
596
|
# ── Helper Methods ──────────────────────────────────────────────────────
|
|
@@ -343,6 +615,21 @@ class ReflectiveEndorsement:
|
|
|
343
615
|
)
|
|
344
616
|
return False
|
|
345
617
|
|
|
618
|
+
def _check_probe_availability(self) -> bool:
|
|
619
|
+
"""
|
|
620
|
+
Checks whether ICM probes exist for the current kernel/template combination.
|
|
621
|
+
Returns True if probes are available, False otherwise.
|
|
622
|
+
"""
|
|
623
|
+
try:
|
|
624
|
+
from ct_toolkit.divergence.l3_icm import ICMRunner
|
|
625
|
+
return ICMRunner.has_probes(
|
|
626
|
+
template=self._template,
|
|
627
|
+
kernel_name=self._kernel.name,
|
|
628
|
+
)
|
|
629
|
+
except Exception as e:
|
|
630
|
+
logger.debug(f"Probe availability check failed: {e}. Assuming unavailable.")
|
|
631
|
+
return False
|
|
632
|
+
|
|
346
633
|
def _write_to_log(self, record: EndorsementRecord) -> None:
|
|
347
634
|
"""Writes the endorsement record to the Provenance Log."""
|
|
348
635
|
try:
|
|
@@ -373,12 +660,16 @@ class ReflectiveEndorsement:
|
|
|
373
660
|
kernel_name=self._kernel.name,
|
|
374
661
|
)
|
|
375
662
|
|
|
376
|
-
# ──
|
|
663
|
+
# ── Public Properties / Accessors ──────────────────────────────────────────
|
|
664
|
+
|
|
665
|
+
@property
|
|
666
|
+
def staged_manager(self) -> StagedUpdateManager:
|
|
667
|
+
return self._staged_manager
|
|
377
668
|
|
|
378
669
|
def get_pending_records(self) -> list[EndorsementRecord]:
|
|
379
670
|
"""Overrides approved in this session requiring ICM monitoring."""
|
|
380
671
|
return [r for r in self._pending_records
|
|
381
|
-
if r.decision
|
|
672
|
+
if r.decision in (EndorsementDecision.APPROVED, EndorsementDecision.STAGED)
|
|
382
673
|
and r.conflict_id != "none"]
|
|
383
674
|
|
|
384
675
|
def has_active_overrides(self) -> bool:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ct-toolkit"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.8"
|
|
8
8
|
description = "Computational Theseus Toolkit — Identity Continuity Guardrails for Agentic Systems"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|