crprotocol 2.0.0__py3-none-any.whl

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 (153) hide show
  1. crp/__init__.py +126 -0
  2. crp/__main__.py +8 -0
  3. crp/_typing.py +27 -0
  4. crp/_version.py +5 -0
  5. crp/adapters.py +31 -0
  6. crp/advanced/__init__.py +40 -0
  7. crp/advanced/auto_ingest.py +400 -0
  8. crp/advanced/cqs.py +235 -0
  9. crp/advanced/cross_window.py +477 -0
  10. crp/advanced/curator.py +265 -0
  11. crp/advanced/feedback.py +146 -0
  12. crp/advanced/hierarchical.py +211 -0
  13. crp/advanced/meta_learning.py +401 -0
  14. crp/advanced/parallel.py +98 -0
  15. crp/advanced/review_cycle.py +329 -0
  16. crp/advanced/scale_mode.py +129 -0
  17. crp/advanced/source_grounding.py +207 -0
  18. crp/ckf/__init__.py +35 -0
  19. crp/ckf/community.py +377 -0
  20. crp/ckf/fabric.py +445 -0
  21. crp/ckf/gc.py +175 -0
  22. crp/ckf/graph_walk.py +87 -0
  23. crp/ckf/merge.py +133 -0
  24. crp/ckf/pattern_query.py +122 -0
  25. crp/ckf/pubsub.py +128 -0
  26. crp/ckf/semantic.py +207 -0
  27. crp/cli/__init__.py +7 -0
  28. crp/cli/main.py +329 -0
  29. crp/cli/sidecar.py +929 -0
  30. crp/cli/startup.py +272 -0
  31. crp/continuation/__init__.py +103 -0
  32. crp/continuation/completion.py +348 -0
  33. crp/continuation/degradation.py +157 -0
  34. crp/continuation/document_map.py +160 -0
  35. crp/continuation/flow.py +109 -0
  36. crp/continuation/gap.py +419 -0
  37. crp/continuation/manager.py +484 -0
  38. crp/continuation/quality_monitor.py +179 -0
  39. crp/continuation/stitch.py +419 -0
  40. crp/continuation/trigger.py +142 -0
  41. crp/continuation/voice.py +157 -0
  42. crp/core/__init__.py +69 -0
  43. crp/core/batch.py +77 -0
  44. crp/core/circuit_breaker.py +116 -0
  45. crp/core/config.py +377 -0
  46. crp/core/context_tools.py +540 -0
  47. crp/core/dispatch_router.py +3977 -0
  48. crp/core/errors.py +128 -0
  49. crp/core/extraction_facade.py +384 -0
  50. crp/core/facilitator.py +713 -0
  51. crp/core/idempotency.py +215 -0
  52. crp/core/orchestrator.py +1435 -0
  53. crp/core/relay_strategies.py +613 -0
  54. crp/core/security_manager.py +140 -0
  55. crp/core/session.py +134 -0
  56. crp/core/task_intent.py +36 -0
  57. crp/core/window.py +363 -0
  58. crp/envelope/__init__.py +30 -0
  59. crp/envelope/builder.py +288 -0
  60. crp/envelope/decomposer.py +236 -0
  61. crp/envelope/formatter.py +168 -0
  62. crp/envelope/packer.py +211 -0
  63. crp/envelope/reranker.py +209 -0
  64. crp/envelope/scoring.py +310 -0
  65. crp/extraction/__init__.py +45 -0
  66. crp/extraction/complexity.py +96 -0
  67. crp/extraction/contradiction.py +132 -0
  68. crp/extraction/pipeline.py +360 -0
  69. crp/extraction/quality_gate.py +237 -0
  70. crp/extraction/stage1_regex.py +173 -0
  71. crp/extraction/stage2_statistical.py +244 -0
  72. crp/extraction/stage3_gliner.py +210 -0
  73. crp/extraction/stage4_uie.py +183 -0
  74. crp/extraction/stage5_discourse.py +175 -0
  75. crp/extraction/stage6_llm.py +178 -0
  76. crp/extraction/structured_output.py +219 -0
  77. crp/extraction/types.py +299 -0
  78. crp/license_guard.py +722 -0
  79. crp/observability/__init__.py +30 -0
  80. crp/observability/audit.py +118 -0
  81. crp/observability/events.py +233 -0
  82. crp/observability/metrics.py +264 -0
  83. crp/observability/quality.py +135 -0
  84. crp/observability/structured_logging.py +81 -0
  85. crp/observability/telemetry.py +117 -0
  86. crp/provenance/__init__.py +314 -0
  87. crp/provenance/_embeddings.py +97 -0
  88. crp/provenance/_types.py +378 -0
  89. crp/provenance/attribution_scorer.py +252 -0
  90. crp/provenance/claim_detector.py +229 -0
  91. crp/provenance/contradiction_detector.py +243 -0
  92. crp/provenance/distortion_detector.py +397 -0
  93. crp/provenance/entailment_verifier.py +358 -0
  94. crp/provenance/fabrication_detector.py +203 -0
  95. crp/provenance/hallucination_scorer.py +320 -0
  96. crp/provenance/omission_analyzer.py +106 -0
  97. crp/provenance/provenance_chain.py +205 -0
  98. crp/provenance/report_generator.py +440 -0
  99. crp/providers/__init__.py +43 -0
  100. crp/providers/anthropic.py +270 -0
  101. crp/providers/base.py +135 -0
  102. crp/providers/custom.py +63 -0
  103. crp/providers/diagnostic.py +251 -0
  104. crp/providers/llamacpp.py +224 -0
  105. crp/providers/manager.py +139 -0
  106. crp/providers/ollama.py +243 -0
  107. crp/providers/openai.py +628 -0
  108. crp/providers/tokenizers.py +48 -0
  109. crp/py.typed +0 -0
  110. crp/resources/__init__.py +53 -0
  111. crp/resources/adaptive_allocator.py +525 -0
  112. crp/resources/cost_model.py +388 -0
  113. crp/resources/overhead_manager.py +217 -0
  114. crp/resources/resource_manager.py +262 -0
  115. crp/schemas/__init__.py +20 -0
  116. crp/schemas/cost-estimate.json +33 -0
  117. crp/schemas/crp-error.json +43 -0
  118. crp/schemas/envelope-preview.json +40 -0
  119. crp/schemas/persisted-state-header.json +27 -0
  120. crp/schemas/quality-report.json +94 -0
  121. crp/schemas/session-handle.json +33 -0
  122. crp/schemas/session-status.json +57 -0
  123. crp/schemas/stream-event.json +18 -0
  124. crp/schemas/task-intent.json +42 -0
  125. crp/security/__init__.py +93 -0
  126. crp/security/audit_trail.py +392 -0
  127. crp/security/binding.py +192 -0
  128. crp/security/compliance.py +813 -0
  129. crp/security/consent.py +593 -0
  130. crp/security/embedding_defense.py +161 -0
  131. crp/security/encryption.py +202 -0
  132. crp/security/injection.py +335 -0
  133. crp/security/integrity.py +267 -0
  134. crp/security/privacy.py +662 -0
  135. crp/security/quarantine.py +249 -0
  136. crp/security/rbac.py +221 -0
  137. crp/security/validation.py +164 -0
  138. crp/state/__init__.py +31 -0
  139. crp/state/cold_storage.py +258 -0
  140. crp/state/compaction.py +263 -0
  141. crp/state/critical_state.py +104 -0
  142. crp/state/event_log.py +313 -0
  143. crp/state/fact.py +189 -0
  144. crp/state/serialization.py +189 -0
  145. crp/state/session_cleanup.py +77 -0
  146. crp/state/snapshot.py +290 -0
  147. crp/state/warm_store.py +346 -0
  148. crprotocol-2.0.0.dist-info/METADATA +1295 -0
  149. crprotocol-2.0.0.dist-info/RECORD +153 -0
  150. crprotocol-2.0.0.dist-info/WHEEL +4 -0
  151. crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
  152. crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
  153. crprotocol-2.0.0.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,3977 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Dispatch router mixin — all CRP dispatch strategies (§2.5, §6.5).
4
+
5
+ Extracted from orchestrator.py for maintainability. This mixin provides
6
+ all dispatch method implementations. The CRPOrchestrator inherits from
7
+ this class.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import logging
14
+ import re
15
+ import time
16
+ import uuid
17
+ from collections.abc import Generator
18
+ from dataclasses import dataclass
19
+ from typing import Any, TYPE_CHECKING
20
+
21
+ from crp.core.errors import (
22
+ ProviderError,
23
+ RateLimitExceededError,
24
+ ValidationError,
25
+ )
26
+ from crp.core.session import QualityReport, SecurityFlags
27
+ from crp.core.task_intent import TaskIntent
28
+ from crp.core.window import (
29
+ WindowMetrics,
30
+ WindowNode,
31
+ WindowState,
32
+ compute_envelope_budget,
33
+ resolve_generation_reserve,
34
+ )
35
+
36
+ if TYPE_CHECKING:
37
+ from crp.envelope.builder import EnvelopeResult
38
+ from crp.extraction.types import Fact
39
+
40
+ logger = logging.getLogger("crp.orchestrator")
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Quality Tier Classification (§2.10)
45
+ # ---------------------------------------------------------------------------
46
+
47
+ def _classify_quality_tier(
48
+ *,
49
+ facts_extracted: int,
50
+ continuation_windows: int,
51
+ saturation: float,
52
+ finish_reason: str,
53
+ output_tokens: int,
54
+ output_length: int,
55
+ ) -> str:
56
+ """Classify output quality into S/A/B/C/D tiers."""
57
+ if output_length < 10 or finish_reason == "error":
58
+ return "D"
59
+ score = 0
60
+ if facts_extracted >= 10:
61
+ score += 30
62
+ elif facts_extracted >= 5:
63
+ score += 20
64
+ elif facts_extracted >= 2:
65
+ score += 10
66
+ elif facts_extracted >= 1:
67
+ score += 5
68
+ if finish_reason == "stop" and continuation_windows == 0:
69
+ score += 25
70
+ elif finish_reason == "stop":
71
+ score += 20
72
+ elif continuation_windows > 0:
73
+ score += 10
74
+ if output_tokens >= 500:
75
+ score += 25
76
+ elif output_tokens >= 200:
77
+ score += 15
78
+ elif output_tokens >= 50:
79
+ score += 10
80
+ if saturation >= 0.7:
81
+ score += 20
82
+ elif saturation >= 0.4:
83
+ score += 10
84
+ elif saturation > 0:
85
+ score += 5
86
+ if score >= 80:
87
+ return "S"
88
+ elif score >= 60:
89
+ return "A"
90
+ elif score >= 40:
91
+ return "B"
92
+ elif score >= 20:
93
+ return "C"
94
+ else:
95
+ return "D"
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # StreamEvent (§6.10.5)
100
+ # ---------------------------------------------------------------------------
101
+
102
+ @dataclass
103
+ class StreamEvent:
104
+ """Events emitted during streaming dispatch (§6.10.5)."""
105
+ event_type: str
106
+ data: Any
107
+
108
+
109
+ @dataclass
110
+ class ExtractionProgress:
111
+ """Progress update during extraction pipeline."""
112
+ stage: str = ""
113
+ facts_so_far: int = 0
114
+
115
+
116
+ @dataclass
117
+ class WindowSummary:
118
+ """Summary of a completed window."""
119
+ window_id: str = ""
120
+ input_tokens: int = 0
121
+ output_tokens: int = 0
122
+ wall_time_ms: int = 0
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Message assembly (§4.1, Axiom 4)
127
+ # ---------------------------------------------------------------------------
128
+
129
+ def assemble_messages(
130
+ system_prompt: str,
131
+ envelope: str,
132
+ task_input: str,
133
+ ) -> list[dict[str, str]]:
134
+ """Build the chat message array sent to the LLM.
135
+
136
+ Structural Injection Defense (§7.5.1):
137
+ Envelope and task_input are placed in SEPARATE user messages with
138
+ provenance boundary markers.
139
+
140
+ Invariants (Axiom 4 — Model Ignorance):
141
+ - system_prompt is NEVER modified
142
+ - task_input content is NEVER modified
143
+ - No CRP-internal protocol metadata is injected
144
+ """
145
+ messages: list[dict[str, str]] = [
146
+ {"role": "system", "content": system_prompt},
147
+ ]
148
+ if envelope:
149
+ messages.append({
150
+ "role": "user",
151
+ "content": f"[VERIFIED CONTEXT]\n{envelope}\n[END VERIFIED CONTEXT]",
152
+ })
153
+ messages.append({
154
+ "role": "user",
155
+ "content": task_input,
156
+ })
157
+ return messages
158
+
159
+
160
+ def _safe_provider_error(exc: Exception) -> str:
161
+ """Sanitize provider exception for external visibility (§audit4 SEC-M1)."""
162
+ return f"{type(exc).__name__}: provider call failed"
163
+
164
+
165
+ class DispatchMixin:
166
+ """Mixin providing all CRP dispatch strategies.
167
+
168
+ Methods access orchestrator state via ``self`` (multiple inheritance).
169
+ """
170
+
171
+ def _build_envelope(
172
+ self,
173
+ system_prompt: str,
174
+ task_input: str,
175
+ generation_reserve: int,
176
+ ) -> EnvelopeResult:
177
+ """Build budget-aware envelope from WarmStore facts via 6-phase pipeline."""
178
+ from crp.envelope.builder import (
179
+ EnvelopeResult,
180
+ EnvelopeState,
181
+ construct as construct_envelope,
182
+ )
183
+ from crp.extraction.types import Fact
184
+
185
+ context_window = self._provider.context_window_size()
186
+ s_tokens = self._provider.count_tokens(system_prompt)
187
+ t_tokens = self._provider.count_tokens(task_input)
188
+
189
+ budget = compute_envelope_budget(context_window, s_tokens, t_tokens, generation_reserve)
190
+ if budget <= 0 or self._warm_store.fact_count == 0:
191
+ return EnvelopeResult(budget_tokens=budget)
192
+
193
+ # Build envelope state from warm store
194
+ active_facts = self._warm_store.get_active_facts_as_extraction()
195
+ critical_sections = self._warm_store.critical_state.to_sections()
196
+
197
+ # CKF retriever callback for Phase 6 of envelope builder (GAP A fix)
198
+ def ckf_retriever(query_text: str, budget_tokens: int) -> list[Fact]:
199
+ try:
200
+ # Compute query embedding for semantic mode
201
+ query_emb = None
202
+ if self._embedding_fn is not None:
203
+ try:
204
+ query_emb = self._embedding_fn(query_text)
205
+ except Exception as emb_exc:
206
+ logger.debug("CKF embedding failed (semantic mode unavailable): %s", emb_exc)
207
+
208
+ # Gather seed IDs from recently packed facts for graph walk
209
+ seed_ids: set[str] | None = None
210
+ recent = self._warm_store.get_ranked_facts(limit=5)
211
+ if recent:
212
+ seed_ids = {f.id for f in recent}
213
+
214
+ result = self._ckf.retrieve(
215
+ query_embedding=query_emb,
216
+ seed_ids=seed_ids,
217
+ topic=query_text[:100] if query_text else None,
218
+ budget=min(budget_tokens, 200),
219
+ )
220
+ if not result.facts:
221
+ logger.debug("CKF retrieval returned 0 facts for query: %.100s", query_text)
222
+ # MergedFact wraps Fact — unwrap for scoring/packing
223
+ return [mf.fact for mf in result.facts]
224
+ except Exception as exc:
225
+ # V7 fix: Log CKF retrieval failures instead of silently swallowing
226
+ logger.warning("CKF retrieval failed (facts unavailable): %s", exc)
227
+ return []
228
+
229
+ # ── V1 fix: Route curator synthesis through envelope pipeline ──
230
+ # Instead of appending post-pack (bypassing budget), include as a
231
+ # section so it competes for budget via section_overhead in builder.
232
+ if self._curator.current_synthesis:
233
+ curator_text = self._curator.format_for_envelope()
234
+ if curator_text:
235
+ critical_sections["LLM_SYNTHESIS"] = curator_text
236
+
237
+ # ── V2 fix: Route meta-learning scaffold through envelope pipeline ──
238
+ # Instead of appending post-pack (unbudgeted), include as a section
239
+ # so the builder accounts for it in the budget formula.
240
+ context_window = self._provider.context_window_size()
241
+ if context_window <= 32_000: # Small model
242
+ scaffold = self._meta_learning.build_reasoning_scaffold(
243
+ task_intent=task_input[:200],
244
+ )
245
+ if scaffold:
246
+ critical_sections["REASONING_SCAFFOLD"] = scaffold
247
+
248
+ envelope_state = EnvelopeState(
249
+ facts=active_facts,
250
+ graph=self._warm_store.graph,
251
+ current_window_index=self._windows_completed,
252
+ seen_counts=self._warm_store.get_seen_counts(),
253
+ fact_window_indices=self._warm_store.get_fact_window_indices(),
254
+ sections=critical_sections,
255
+ ckf_retriever=ckf_retriever,
256
+ )
257
+
258
+ task_intent = TaskIntent(
259
+ description=task_input[:200],
260
+ task_input=task_input,
261
+ system_prompt=system_prompt,
262
+ )
263
+
264
+ envelope = construct_envelope(
265
+ task_intent=task_intent,
266
+ budget_tokens=budget,
267
+ state=envelope_state,
268
+ count_tokens=self._provider.count_tokens,
269
+ )
270
+
271
+ # Augment envelope with source grounding passages (§17)
272
+ # Source passages are post-pack because they annotate packed facts.
273
+ # Budget-checked: only added if within remaining budget.
274
+ if self._source_grounding.passage_count > 0 and envelope.facts_included:
275
+ grounded_section_parts: list[str] = []
276
+ for fid in [f.fact_id for f in (envelope.packing.packed_facts if envelope.packing else [])]:
277
+ passages = self._source_grounding.get_passages_for_fact(fid)
278
+ if passages:
279
+ for p in passages[:1]: # Top passage per fact
280
+ grounded_section_parts.append(
281
+ f"[SOURCE w{p.source_window}] \"{p.text[:200]}\""
282
+ )
283
+ if grounded_section_parts:
284
+ source_section = "\n--- Source Passages ---\n" + "\n".join(grounded_section_parts)
285
+ source_tokens = self._provider.count_tokens(source_section)
286
+ # Only include if within budget
287
+ if envelope.envelope_tokens + source_tokens <= envelope.budget_tokens:
288
+ envelope = EnvelopeResult(
289
+ envelope_text=f"{envelope.envelope_text}\n{source_section}",
290
+ envelope_tokens=envelope.envelope_tokens + source_tokens,
291
+ budget_tokens=envelope.budget_tokens,
292
+ saturation=envelope.saturation,
293
+ facts_included=envelope.facts_included,
294
+ packing=envelope.packing,
295
+ )
296
+
297
+ return envelope
298
+
299
+ # ------------------------------------------------------------------
300
+ # Internal: security scanning
301
+ # ------------------------------------------------------------------
302
+
303
+ def _extract_task_title(self, task_input: str) -> str:
304
+ """Extract a one-line title from the task input for continuation reference.
305
+
306
+ Uses the document title if present (e.g. 'Write a document titled "X"'),
307
+ otherwise uses the first meaningful line truncated to 120 chars.
308
+ """
309
+ import re as _re
310
+
311
+ # Try to find an explicit title in quotes
312
+ m = _re.search(r'titled?\s+"([^"]+)"', task_input, _re.IGNORECASE)
313
+ if m:
314
+ return m.group(1)
315
+
316
+ m = _re.search(r"titled?\s+'([^']+)'", task_input, _re.IGNORECASE)
317
+ if m:
318
+ return m.group(1)
319
+
320
+ # Fall back to first non-empty line that looks like a directive
321
+ for line in task_input.split("\n"):
322
+ line = line.strip()
323
+ if line and len(line) > 10 and not line.startswith(("#", "-", "*", "```")):
324
+ return line[:120] + ("..." if len(line) > 120 else "")
325
+
326
+ return task_input[:120] + ("..." if len(task_input) > 120 else "")
327
+
328
+ def _scan_injection(self, task_input: str) -> SecurityFlags:
329
+ """Advisory injection detection — NEVER blocks (§7.5)."""
330
+ report = self._injection_detector.scan(task_input)
331
+ flags = SecurityFlags(
332
+ injection_markers_detected=len(report.flags),
333
+ )
334
+ if report.flags:
335
+ flags.injection_marker_details = [
336
+ {
337
+ "type": f.injection_type.value,
338
+ "pattern": f.pattern_name,
339
+ "confidence": f.confidence,
340
+ }
341
+ for f in report.flags
342
+ ]
343
+ return flags
344
+
345
+ # ------------------------------------------------------------------
346
+ # Resource snapshot for WindowMetrics
347
+ # ------------------------------------------------------------------
348
+
349
+ def _resource_fields(self) -> dict[str, object]:
350
+ """Compute resource-related fields for WindowMetrics."""
351
+ mgr = getattr(self, "_resource_manager", None)
352
+ if mgr is None:
353
+ return {}
354
+ mgr.update_fact_count(self._warm_store.fact_count)
355
+ snap = mgr.snapshot()
356
+ return {
357
+ "ram_available_mb": max(int(snap.budget_mb - snap.crp_estimated_mb), 0),
358
+ "ram_used_by_crp_mb": int(snap.crp_estimated_mb),
359
+ "pressure_level": snap.pressure_level,
360
+ }
361
+
362
+ # ------------------------------------------------------------------
363
+ # Marginal gain / sections covered for WindowMetrics
364
+ # ------------------------------------------------------------------
365
+
366
+ _SECTION_RE = re.compile(r"^#{1,6}\s+\S", re.MULTILINE)
367
+
368
+ def _marginal_fields(
369
+ self, output_text: str, facts_before: int,
370
+ ) -> dict[str, object]:
371
+ """Compute marginal_gain and sections_covered from dispatch output."""
372
+ facts_after = self._warm_store.fact_count
373
+ new_facts = max(facts_after - facts_before, 0)
374
+ marginal = new_facts / max(facts_after, 1)
375
+
376
+ sections = len(set(self._SECTION_RE.findall(output_text))) if output_text else 0
377
+ return {
378
+ "marginal_gain": round(marginal, 4),
379
+ "sections_covered": sections,
380
+ }
381
+
382
+ # ------------------------------------------------------------------
383
+ # Adaptive allocator fields for WindowMetrics (§resource-alloc)
384
+ # ------------------------------------------------------------------
385
+
386
+ def _allocator_fields(self) -> dict[str, object]:
387
+ """Compute adaptive-allocator telemetry for WindowMetrics."""
388
+ alloc = getattr(self, "_adaptive_allocator", None)
389
+ if alloc is None:
390
+ return {}
391
+ disabled = alloc.disabled_stages
392
+ om = getattr(self, "_overhead_manager", None)
393
+ shed_count = 0
394
+ if om is not None:
395
+ from crp.resources.overhead_manager import SHEDDING_CASCADE
396
+ shed_count = sum(1 for f in SHEDDING_CASCADE if not om.is_feature_enabled(f))
397
+ return {
398
+ "adaptive_ewma_overhead_pct": round(alloc.ewma_overhead_pct, 1),
399
+ "adaptive_features_shed": shed_count,
400
+ "adaptive_stages_disabled": ",".join(str(s) for s in sorted(disabled)),
401
+ "adaptive_consecutive_over": alloc.consecutive_over_cap,
402
+ }
403
+
404
+ def _record_dispatch_overhead(
405
+ self,
406
+ total_dispatch_ms: float,
407
+ total_llm_ms: float,
408
+ *,
409
+ envelope_ms: float = 0.0,
410
+ extraction_ms: float = 0.0,
411
+ ) -> None:
412
+ """Feed overhead measurement into adaptive allocator after dispatch."""
413
+ alloc = getattr(self, "_adaptive_allocator", None)
414
+ if alloc is None:
415
+ return
416
+ alloc.record_window(
417
+ total_ms=total_dispatch_ms,
418
+ llm_ms=total_llm_ms,
419
+ envelope_ms=envelope_ms,
420
+ extraction_ms=extraction_ms,
421
+ )
422
+ # Trigger model unloading if needed
423
+ if alloc.should_unload_models():
424
+ for model_name in alloc.idle_models():
425
+ self._resource_manager.mark_model_unloaded(model_name)
426
+ # Trigger GC if needed
427
+ if alloc.should_run_gc():
428
+ self._resource_manager.trigger_gc()
429
+
430
+ # ------------------------------------------------------------------
431
+ # Core dispatch
432
+ # ------------------------------------------------------------------
433
+
434
+ def dispatch(
435
+ self,
436
+ system_prompt: str,
437
+ task_input: str,
438
+ **kwargs: Any,
439
+ ) -> tuple[str, QualityReport]:
440
+ """Dispatch a task window to the LLM with full envelope + extraction.
441
+
442
+ Pipeline:
443
+ 1. Advisory injection scan on task_input
444
+ 2. Build envelope from WarmStore facts (6-phase)
445
+ 3. Assemble messages (Axiom 4 — no modification)
446
+ 4. Dispatch to LLM
447
+ 5. Extract facts from output (graduated 6-stage pipeline)
448
+ 6. Store facts in WarmStore + CKF
449
+ 7. Continuation loop if wall hit + gap remaining + info flowing
450
+
451
+ Returns (stitched_output, QualityReport). Output is UNMODIFIED (Axiom 9).
452
+ """
453
+ with self._lock:
454
+ return self._dispatch_locked(system_prompt, task_input, **kwargs)
455
+
456
+ def _dispatch_locked(
457
+ self,
458
+ system_prompt: str,
459
+ task_input: str,
460
+ **kwargs: Any,
461
+ ) -> tuple[str, QualityReport]:
462
+ """Internal dispatch implementation — called under self._lock."""
463
+ # Set correlation ID for structured log tracing (§audit H9)
464
+ from crp.observability.structured_logging import new_correlation_id, set_session_context
465
+ cid = new_correlation_id()
466
+ set_session_context(self._session.session_id)
467
+ logger.debug("dispatch started [correlation_id=%s]", cid)
468
+
469
+ _dispatch_start_ns = time.monotonic_ns()
470
+ _facts_before = self._warm_store.fact_count
471
+ self._check_session()
472
+
473
+ # ---------- Session binding verification (§7.1, §6A.3) ----------
474
+ if self._session_binding.binding is not None:
475
+ _binding_payload = (
476
+ self._session.session_id + ":" + str(len(task_input))
477
+ ).encode("utf-8")
478
+ _binding_sig = self._session_binding.sign_request(_binding_payload)
479
+ if not self._session_binding.verify_request_signature(
480
+ _binding_payload, _binding_sig
481
+ ):
482
+ raise RuntimeError(
483
+ "Session binding verification failed — "
484
+ "cryptographic integrity compromised"
485
+ )
486
+
487
+ # ---------- Compliance imports (§7.14) ----------
488
+ from crp.security.audit_trail import ComplianceEventType
489
+ from crp.security.consent import ProcessingPurpose
490
+ from crp.security.privacy import DataClassification
491
+
492
+ # ---------- Compliance audit: dispatch started (§7.14) ----------
493
+ self._compliance_audit.record(
494
+ ComplianceEventType.DATA_PROCESSED,
495
+ session_id=self._session.session_id,
496
+ data={"operation": "dispatch", "phase": "started",
497
+ "input_preview_length": min(len(task_input), 200)},
498
+ )
499
+
500
+ # ---------- Human oversight check (§7.13, EU AI Act Art. 14) ----------
501
+ if self._human_oversight.requires_approval("dispatch"):
502
+ oversight_event = self._human_oversight.request_approval(
503
+ "dispatch", details={"input_length": len(task_input)},
504
+ )
505
+ self._compliance_audit.record(
506
+ ComplianceEventType.OVERSIGHT_APPROVAL_REQUESTED,
507
+ session_id=self._session.session_id,
508
+ data={"operation": "dispatch", "event_id": oversight_event.event_id},
509
+ )
510
+ self._human_oversight.record_autonomous_dispatch()
511
+
512
+ # ---------- Consent verification (§7.13) ----------
513
+ self._consent_manager.check_required(ProcessingPurpose.CONTEXT_MANAGEMENT)
514
+ self._consent_manager.check_required(ProcessingPurpose.FACT_EXTRACTION)
515
+
516
+ # ---------- Emit dispatch.started event (§9) ----------
517
+ self._emitter.emit("dispatch.started", {
518
+ "session_id": self._session.session_id,
519
+ "task_preview": task_input[:200],
520
+ })
521
+
522
+ # ---------- Scale mode configuration (§scale D6 fix) ----------
523
+ estimated_tokens = self._provider.count_tokens(task_input)
524
+ session_config = self._scale_mode.configure_session(
525
+ estimated_tokens=estimated_tokens,
526
+ )
527
+ logger.debug("Scale mode: %s (estimated %d tokens)", session_config.processing_mode, estimated_tokens)
528
+
529
+ # ---------- RBAC permission + rate limit check (§7.10) ----------
530
+ from crp.security.rbac import Permission
531
+ perm_result = self._rbac.check_permission(Permission.DISPATCH)
532
+ if not perm_result.allowed:
533
+ self._compliance_audit.record(
534
+ ComplianceEventType.RBAC_DENIED,
535
+ session_id=self._session.session_id,
536
+ data={"operation": "dispatch", "reason": perm_result.reason},
537
+ )
538
+ raise RateLimitExceededError(perm_result.reason)
539
+ rate_result = self._rbac.check_rate_limit(Permission.DISPATCH)
540
+ if not rate_result.allowed:
541
+ self._compliance_audit.record(
542
+ ComplianceEventType.RATE_LIMIT_HIT,
543
+ session_id=self._session.session_id,
544
+ data={"operation": "dispatch", "reason": rate_result.reason},
545
+ )
546
+ raise RateLimitExceededError(rate_result.reason)
547
+
548
+ # ---------- Input validation — Layer 1, cannot disable (§7.4) ----------
549
+ val_result = self._input_validator.validate(task_input)
550
+ if not val_result.valid:
551
+ raise ValidationError(val_result.warnings[0] if val_result.warnings else "Input validation failed")
552
+ task_input = val_result.sanitized_text
553
+
554
+ # ---------- PII scanning on input (§7.12) ----------
555
+ pii_result = self._pii_scanner.scan(task_input)
556
+ if pii_result.has_pii:
557
+ self._compliance_audit.record(
558
+ ComplianceEventType.PII_DETECTED,
559
+ session_id=self._session.session_id,
560
+ data={
561
+ "operation": "dispatch",
562
+ "phase": "input",
563
+ "pii_types": sorted(pii_result.pii_types_found),
564
+ "detection_count": len(pii_result.detections),
565
+ "classification": pii_result.highest_classification.name,
566
+ },
567
+ )
568
+ if self._human_oversight.should_halt_on_pii():
569
+ self._human_oversight.record_halt(
570
+ "dispatch", "PII detected in input",
571
+ details={"pii_types": sorted(pii_result.pii_types_found)},
572
+ )
573
+ self._compliance_audit.record(
574
+ ComplianceEventType.OVERSIGHT_HALT,
575
+ session_id=self._session.session_id,
576
+ data={"reason": "pii_in_input",
577
+ "pii_types": sorted(pii_result.pii_types_found)},
578
+ )
579
+ logger.info("PII detected in dispatch input: %s", pii_result.pii_types_found)
580
+
581
+ # ---------- Security scan (advisory) ----------
582
+ security_flags = self._scan_injection(task_input)
583
+ # Populate structural validation stats into security flags
584
+ security_flags.unicode_normalized = val_result.sanitized_size != val_result.original_size
585
+ security_flags.control_chars_stripped = val_result.control_chars_removed
586
+
587
+ # ---------- Audit injection detections (§7.14) ----------
588
+ if security_flags.injection_markers_detected > 0:
589
+ self._compliance_audit.record(
590
+ ComplianceEventType.INJECTION_DETECTED,
591
+ session_id=self._session.session_id,
592
+ data={
593
+ "operation": "dispatch",
594
+ "phase": "input",
595
+ "flags_count": security_flags.injection_markers_detected,
596
+ "details": security_flags.injection_marker_details,
597
+ },
598
+ )
599
+ if self._human_oversight.should_halt_on_injection():
600
+ self._human_oversight.record_halt(
601
+ "dispatch", "Injection detected in input",
602
+ details={"flags_count": security_flags.injection_markers_detected},
603
+ )
604
+ self._compliance_audit.record(
605
+ ComplianceEventType.OVERSIGHT_HALT,
606
+ session_id=self._session.session_id,
607
+ data={"reason": "injection_in_input",
608
+ "flags_count": security_flags.injection_markers_detected},
609
+ )
610
+
611
+ # ---------- Measure tokens ----------
612
+ s_tokens = self._provider.count_tokens(system_prompt)
613
+ t_tokens = self._provider.count_tokens(task_input)
614
+ context_window = self._provider.context_window_size()
615
+
616
+ max_out = kwargs.get("max_output_tokens")
617
+ g = resolve_generation_reserve(
618
+ max_out,
619
+ self._provider.max_output_tokens,
620
+ context_window,
621
+ is_thinking_model=getattr(self._provider, "is_thinking_model", False),
622
+ )
623
+
624
+ # ---------- Auto-ingest oversized input (§4.6) ----------
625
+ # If task_input alone exceeds context budget, chunk it, extract facts,
626
+ # and replace with a synthesized reference to the ingested material.
627
+ available = context_window - s_tokens - g
628
+ if t_tokens > available and available > 0:
629
+ logger.info(
630
+ "Auto-ingest triggered: task_input=%d tokens > available=%d",
631
+ t_tokens, available,
632
+ )
633
+ from crp.advanced.auto_ingest import auto_ingest, IngestFact
634
+
635
+ def _extract_fn(text: str, intent: str) -> list[IngestFact]:
636
+ """Adapter: run graduated extraction and return IngestFacts."""
637
+ from crp.extraction.types import Fact
638
+ result = self._extraction.extract(
639
+ text, source_window_id="auto-ingest",
640
+ )
641
+ return [
642
+ IngestFact(
643
+ text=f.text, confidence=f.confidence,
644
+ source=f.source_window_id or "auto-ingest",
645
+ )
646
+ for f in result.facts
647
+ ]
648
+
649
+ ingest_facts, ingest_result = auto_ingest(
650
+ system_prompt=system_prompt,
651
+ task_input=task_input,
652
+ task_intent_text=task_input[:200],
653
+ context_window=context_window,
654
+ count_tokens=self._provider.count_tokens,
655
+ extract_fn=_extract_fn,
656
+ )
657
+
658
+ # V9 fix: Create a DAG node for auto-ingest so facts are traceable
659
+ ingest_window_id = f"auto-ingest-{uuid.uuid4().hex[:8]}"
660
+ ingest_node = WindowNode(
661
+ window_id=ingest_window_id,
662
+ system_prompt_hash=hashlib.sha256(b"auto-ingest").hexdigest(),
663
+ task_input_hash=hashlib.sha256(task_input[:200].encode()).hexdigest(),
664
+ continuation_index=0,
665
+ )
666
+ self._dag.add_node(ingest_node)
667
+
668
+ # Store ingested facts in warm store + CKF
669
+ from crp.extraction.types import Fact
670
+ ingest_fact_ids: list[str] = []
671
+ for ifact in ingest_facts:
672
+ fact = Fact(
673
+ text=ifact.text,
674
+ confidence=ifact.confidence,
675
+ source_window_id=ingest_window_id,
676
+ extraction_stage=0,
677
+ )
678
+ self._warm_store.add_facts([fact])
679
+ self._ckf.store([fact], window_id=ingest_window_id)
680
+ ingest_fact_ids.append(fact.id)
681
+
682
+ # Track facts in DAG node for auditability
683
+ ingest_node.facts_produced = ingest_fact_ids
684
+ # Advance through all required states (forward-only invariant)
685
+ ingest_node.advance(WindowState.ASSEMBLED)
686
+ ingest_node.advance(WindowState.DISPATCHED)
687
+ ingest_node.advance(WindowState.GENERATING)
688
+ ingest_node.advance(WindowState.COMPLETED)
689
+ ingest_node.advance(WindowState.EXTRACTED)
690
+
691
+ # Replace task_input with synthesized reference
692
+ task_input = ingest_result.synthesized_task
693
+ t_tokens = self._provider.count_tokens(task_input)
694
+ logger.info(
695
+ "Auto-ingest complete: %d chunks, %d facts → synthesized %d tokens",
696
+ ingest_result.chunks_created,
697
+ ingest_result.facts_after_reconciliation,
698
+ t_tokens,
699
+ )
700
+
701
+ # ---------- Build envelope from accumulated facts ----------
702
+ _t_env0 = time.monotonic_ns()
703
+ envelope_result = self._build_envelope(system_prompt, task_input, g)
704
+ envelope = envelope_result.envelope_text
705
+ _envelope_ms_primary = (time.monotonic_ns() - _t_env0) / 1_000_000
706
+
707
+ self._emitter.emit("envelope.built", {
708
+ "session_id": self._session.session_id,
709
+ "facts_included": envelope_result.facts_included,
710
+ "saturation": round(envelope_result.saturation, 3),
711
+ "envelope_tokens": self._provider.count_tokens(envelope) if envelope else 0,
712
+ })
713
+
714
+ # ---------- Audit: envelope context selection provenance (§7.14.2) ----------
715
+ _packed_fact_ids: list[str] = []
716
+ _packed_fact_scores: list[dict[str, object]] = []
717
+ if envelope_result.packing and envelope_result.packing.packed_facts:
718
+ for pf in envelope_result.packing.packed_facts:
719
+ _packed_fact_ids.append(pf.fact_id)
720
+ _packed_fact_scores.append({
721
+ "fact_id": pf.fact_id,
722
+ "relevance_score": round(pf.score, 4),
723
+ "tokens": pf.tokens,
724
+ "is_compressed": pf.is_compressed,
725
+ "is_bookend": pf.is_bookend,
726
+ "is_neighbour": pf.is_neighbour,
727
+ })
728
+ self._compliance_audit.record(
729
+ ComplianceEventType.ENVELOPE_CONTEXT_SELECTED,
730
+ session_id=self._session.session_id,
731
+ data={
732
+ "operation": "dispatch",
733
+ "window_id": "primary",
734
+ "facts_available": self._warm_store.fact_count,
735
+ "facts_included": envelope_result.facts_included,
736
+ "facts_considered": (
737
+ envelope_result.packing.facts_considered
738
+ if envelope_result.packing else 0
739
+ ),
740
+ "budget_tokens": envelope_result.budget_tokens,
741
+ "envelope_tokens": (
742
+ self._provider.count_tokens(envelope) if envelope else 0
743
+ ),
744
+ "saturation": round(envelope_result.saturation, 4),
745
+ "ckf_facts_added": envelope_result.ckf_facts_added,
746
+ "compressed_count": envelope_result.compressed_count,
747
+ "bookend_count": envelope_result.bookend_count,
748
+ "packed_facts": _packed_fact_scores,
749
+ "source_of_truth": (
750
+ "warm_store + ckf (knowledge graph)"
751
+ if envelope_result.ckf_facts_added > 0
752
+ else "warm_store (accumulated facts)"
753
+ ),
754
+ "selection_rationale": (
755
+ "Facts ranked by recency-weighted relevance score; "
756
+ "top-scoring facts packed into envelope within token budget; "
757
+ "CKF graph neighbours and bookend facts supplement gaps."
758
+ ),
759
+ },
760
+ )
761
+
762
+ e_tokens = self._provider.count_tokens(envelope) if envelope else 0
763
+ input_tokens = s_tokens + t_tokens + e_tokens
764
+
765
+ # ---------- Check budget ----------
766
+ self._check_budget(input_tokens)
767
+
768
+ # ---------- Advance warm store window ----------
769
+ window_id = str(uuid.uuid4())
770
+ self._warm_store.advance_window(window_id)
771
+
772
+ # ---------- Build window node ----------
773
+ node = WindowNode(
774
+ window_id=window_id,
775
+ system_prompt_hash=hashlib.sha256(system_prompt.encode()).hexdigest(),
776
+ task_input_hash=hashlib.sha256(task_input.encode()).hexdigest(),
777
+ continuation_index=0,
778
+ )
779
+ self._dag.add_node(node)
780
+ node.advance(WindowState.ASSEMBLED)
781
+
782
+ self._emitter.emit("window.opened", {
783
+ "session_id": self._session.session_id,
784
+ "window_id": window_id,
785
+ "input_tokens": input_tokens,
786
+ })
787
+
788
+ # ---------- Assemble messages ----------
789
+ messages = assemble_messages(system_prompt, envelope, task_input)
790
+
791
+ # ---------- Dispatch to LLM ----------
792
+ node.advance(WindowState.DISPATCHED)
793
+ node.advance(WindowState.GENERATING)
794
+
795
+ # Circuit breaker gate (§audit H4)
796
+ if not self._circuit_breaker.allow_request():
797
+ raise ProviderError(
798
+ "Circuit breaker OPEN — provider unavailable, "
799
+ "retry after recovery timeout"
800
+ )
801
+
802
+ start_ms = time.monotonic_ns()
803
+ try:
804
+ output, finish_reason = self._provider.generate_chat(
805
+ messages,
806
+ max_tokens=g,
807
+ )
808
+ self._circuit_breaker.record_success()
809
+ except Exception as exc:
810
+ self._circuit_breaker.record_failure()
811
+ logger.error("Provider error: %s", exc)
812
+ raise ProviderError(_safe_provider_error(exc)) from exc
813
+
814
+ wall_ms = (time.monotonic_ns() - start_ms) / 1_000_000
815
+ node.finish_reason = finish_reason
816
+ node.raw_output_id = str(uuid.uuid4())
817
+ node.advance(WindowState.COMPLETED)
818
+
819
+ logger.info(
820
+ "Primary window done: finish_reason=%s, wall_ms=%.0f, output_chars=%d",
821
+ finish_reason, wall_ms, len(output),
822
+ )
823
+
824
+ # ---------- Output tokens ----------
825
+ output_tokens = self._provider.count_tokens(output)
826
+
827
+ # ---------- Audit: LLM call provenance (§7.14.2) ----------
828
+ self._compliance_audit.record(
829
+ ComplianceEventType.LLM_CALL_COMPLETED,
830
+ session_id=self._session.session_id,
831
+ data={
832
+ "operation": "dispatch",
833
+ "window_id": window_id,
834
+ "window_type": "primary",
835
+ "system_prompt_hash": hashlib.sha256(
836
+ system_prompt.encode()
837
+ ).hexdigest()[:16],
838
+ "task_input_hash": hashlib.sha256(
839
+ task_input.encode()
840
+ ).hexdigest()[:16],
841
+ "envelope_provided": bool(envelope),
842
+ "envelope_facts_count": envelope_result.facts_included,
843
+ "envelope_saturation": round(envelope_result.saturation, 4),
844
+ "input_tokens": input_tokens,
845
+ "system_tokens": s_tokens,
846
+ "task_tokens": t_tokens,
847
+ "envelope_tokens": e_tokens,
848
+ "generation_reserve": g,
849
+ "output_tokens": output_tokens,
850
+ "output_length_chars": len(output),
851
+ "finish_reason": finish_reason,
852
+ "wall_time_ms": round(wall_ms, 1),
853
+ "context_window": context_window,
854
+ "context_utilization": round(input_tokens / context_window, 4)
855
+ if context_window > 0 else 0.0,
856
+ "reasoning_content_present": getattr(
857
+ self._provider, "last_reasoning_content", None
858
+ ) is not None,
859
+ "decision_basis": (
860
+ f"LLM received {s_tokens} system tokens, "
861
+ f"{e_tokens} envelope tokens ({envelope_result.facts_included} facts), "
862
+ f"and {t_tokens} task tokens. "
863
+ f"finish_reason='{finish_reason}' indicates "
864
+ + (
865
+ "model chose to stop (believes task complete)."
866
+ if finish_reason == "stop"
867
+ else "output hit token limit (may be incomplete)."
868
+ if finish_reason == "length"
869
+ else f"generation ended with reason '{finish_reason}'."
870
+ )
871
+ ),
872
+ },
873
+ )
874
+
875
+ # ---------- CQS: Detect context hunger in output (§CQS D1 fix) ----------
876
+ cqs_signals = self._cqs_detector.detect_context_hunger(
877
+ output, window_id=window_id, tokens_generated=output_tokens,
878
+ )
879
+ if cqs_signals:
880
+ cqs_response = self._cqs_detector.respond_to_context_hunger(
881
+ cqs_signals, tokens_generated=output_tokens,
882
+ )
883
+ logger.info("CQS: %d hunger signals detected, action=%s",
884
+ len(cqs_signals), cqs_response.action)
885
+
886
+ # ---------- Capture reasoning content (thinking models) ----------
887
+ primary_reasoning = getattr(self._provider, "last_reasoning_content", None)
888
+ total_reasoning_tokens = 0
889
+ if primary_reasoning:
890
+ total_reasoning_tokens += self._provider.count_tokens(primary_reasoning)
891
+
892
+ # Gap F fix: Extract facts from primary window reasoning content.
893
+ # Thinking model reasoning often contains analysis and insights that
894
+ # enrich the fact store for subsequent continuation windows.
895
+ if primary_reasoning and len(primary_reasoning) > 100:
896
+ try:
897
+ _reasoning_task_intent = TaskIntent(
898
+ task_input=task_input,
899
+ system_prompt=system_prompt,
900
+ )
901
+ reasoning_extraction = self._extract_and_store(
902
+ primary_reasoning,
903
+ f"{window_id}_reasoning",
904
+ _reasoning_task_intent,
905
+ )
906
+ if reasoning_extraction.total_facts > 0:
907
+ logger.info(
908
+ "Gap F: extracted %d facts from primary reasoning (%d tokens)",
909
+ reasoning_extraction.total_facts,
910
+ total_reasoning_tokens,
911
+ )
912
+ except Exception: # noqa: BLE001
913
+ logger.debug("Gap F: primary reasoning extraction failed (non-fatal)")
914
+
915
+ # ---------- Extract facts from output ----------
916
+ _t_ext0 = time.monotonic_ns()
917
+ task_intent = TaskIntent(
918
+ task_input=task_input,
919
+ system_prompt=system_prompt,
920
+ )
921
+ extraction = self._extract_and_store(output, window_id, task_intent)
922
+ _extraction_ms_primary = (time.monotonic_ns() - _t_ext0) / 1_000_000
923
+ node.advance(WindowState.EXTRACTED)
924
+ logger.info("Primary extraction: %d facts", extraction.total_facts)
925
+ node.facts_produced = [f.id for f in extraction.facts]
926
+
927
+ # ---------- Emit fact events (§9 F6 fix) ----------
928
+ for fact in extraction.facts:
929
+ self._emitter.emit("fact.created", {
930
+ "fact_id": fact.id,
931
+ "window_id": window_id,
932
+ "confidence": round(fact.confidence, 3),
933
+ "stage": fact.extraction_stage,
934
+ })
935
+ self._emitter.emit("extraction.completed", {
936
+ "window_id": window_id,
937
+ "facts_extracted": extraction.total_facts,
938
+ })
939
+
940
+ # ---------- Audit: fact extraction provenance (§7.14.2) ----------
941
+ _extracted_fact_details: list[dict[str, object]] = []
942
+ for fact in extraction.facts:
943
+ _extracted_fact_details.append({
944
+ "fact_id": fact.id,
945
+ "confidence": round(fact.confidence, 4),
946
+ "extraction_stage": fact.extraction_stage,
947
+ "text_preview": fact.text[:120],
948
+ "flagged": bool(fact.flagged_confidence),
949
+ "flag_reason": getattr(fact, "confidence_flag_reason", ""),
950
+ })
951
+ self._compliance_audit.record(
952
+ ComplianceEventType.FACTS_EXTRACTED,
953
+ session_id=self._session.session_id,
954
+ data={
955
+ "operation": "dispatch",
956
+ "window_id": window_id,
957
+ "window_type": "primary",
958
+ "total_facts": extraction.total_facts,
959
+ "average_confidence": round(extraction.average_confidence, 4),
960
+ "quality_gate_passed": extraction.quality_gate_passed,
961
+ "stages_run": extraction.stages_run,
962
+ "per_stage_latency_ms": {
963
+ str(k): round(v * 1000, 1)
964
+ for k, v in extraction.per_stage_latency.items()
965
+ } if extraction.per_stage_latency else {},
966
+ "extraction_latency_ms": round(_extraction_ms_primary, 1),
967
+ "facts": _extracted_fact_details,
968
+ "source_of_truth": (
969
+ f"Facts extracted from LLM output of window {window_id}. "
970
+ f"The LLM generated this output based on "
971
+ f"{envelope_result.facts_included} envelope facts and the "
972
+ f"user task input. Extraction ran stages "
973
+ f"{extraction.stages_run} with quality gate "
974
+ f"{'PASSED' if extraction.quality_gate_passed else 'FAILED'}."
975
+ ),
976
+ },
977
+ )
978
+
979
+ # ---------- Apply feedback loop adjustments (§feedback D3 fix) ----------
980
+ if extraction.facts and self._feedback_loop.entry_count > 0:
981
+ for fact in extraction.facts:
982
+ adjusted = self._feedback_loop.get_adjusted_confidence(
983
+ fact.id, fact.confidence,
984
+ )
985
+ if adjusted != fact.confidence:
986
+ fact.confidence = adjusted
987
+
988
+ # ---------- Track output-side injection detections (§7.5.2) ----------
989
+ if extraction.facts:
990
+ penalized = sum(1 for f in extraction.facts if f.flagged_confidence
991
+ and "injection_in_fact" in f.confidence_flag_reason)
992
+ security_flags.output_injection_facts_penalized = penalized
993
+
994
+ # ---------- Quarantine promotion (§7.8) ----------
995
+ # Cross-reference quarantined facts against extraction-derived facts
996
+ if self._quarantine.quarantine_count > 0 and extraction.facts:
997
+ extraction_texts = {f.id: f.text for f in extraction.facts}
998
+ self._quarantine.validate_and_promote(window_id, extraction_texts)
999
+
1000
+ # ---------- PII scan on output (§7.12) ----------
1001
+ output_pii = self._pii_scanner.scan(output)
1002
+ if output_pii.has_pii:
1003
+ self._compliance_audit.record(
1004
+ ComplianceEventType.PII_DETECTED,
1005
+ session_id=self._session.session_id,
1006
+ data={
1007
+ "operation": "dispatch",
1008
+ "phase": "output",
1009
+ "pii_types": sorted(output_pii.pii_types_found),
1010
+ "detection_count": len(output_pii.detections),
1011
+ "classification": output_pii.highest_classification.name,
1012
+ },
1013
+ )
1014
+
1015
+ # ---------- Retention + lineage tracking for extracted facts (§7.12) ----------
1016
+ _input_classification = (
1017
+ pii_result.highest_classification if pii_result.has_pii
1018
+ else DataClassification.INTERNAL
1019
+ )
1020
+ _output_classification = (
1021
+ output_pii.highest_classification if output_pii.has_pii
1022
+ else _input_classification
1023
+ )
1024
+ for fact in extraction.facts:
1025
+ self._retention_manager.register(
1026
+ data_id=fact.id,
1027
+ classification=_output_classification,
1028
+ source_label=f"dispatch:{window_id}",
1029
+ )
1030
+ self._lineage_tracker.record(
1031
+ data_id=fact.id,
1032
+ origin="dispatch",
1033
+ source_label=f"window:{window_id}",
1034
+ classification=_output_classification,
1035
+ )
1036
+
1037
+ # ---------- Processing record (§7.13 — GDPR Art. 30) ----------
1038
+ self._processing_records.record(
1039
+ purpose=ProcessingPurpose.CONTEXT_MANAGEMENT,
1040
+ data_categories=["task_input", "llm_output", "extracted_facts"],
1041
+ legal_basis="legitimate_interest",
1042
+ input_size_bytes=len(task_input.encode("utf-8")),
1043
+ output_size_bytes=len(output.encode("utf-8")),
1044
+ automated_decision=True,
1045
+ human_oversight=self._human_oversight.requires_approval("dispatch"),
1046
+ retention_period="session",
1047
+ )
1048
+
1049
+ # ---------- LLM context curation (§18) ----------
1050
+ # Run periodic curation to synthesize accumulated understanding.
1051
+ # Curator synthesis feeds into next dispatch's envelope via sections
1052
+ # (V1 fix routes it through the budget pipeline, not post-pack append).
1053
+ if self._curator.should_curate(self._windows_completed):
1054
+ try:
1055
+ ranked_facts = self._warm_store.get_ranked_facts(limit=50)
1056
+ fact_texts = [f.text for f in ranked_facts]
1057
+ self._curator.curate(
1058
+ window_index=self._windows_completed,
1059
+ top_facts=fact_texts,
1060
+ recent_output_summary=output[:500],
1061
+ )
1062
+ except Exception as exc:
1063
+ logger.debug("Curator skipped: %s", exc)
1064
+
1065
+ # ---------- Mark consumed facts as seen ----------
1066
+ if envelope_result.packing and envelope_result.packing.packed_facts:
1067
+ consumed_ids = [pf.fact_id for pf in envelope_result.packing.packed_facts]
1068
+ self._warm_store.mark_seen(consumed_ids, window_id)
1069
+ node.facts_consumed = consumed_ids
1070
+
1071
+ # ---------- Continuation loop ----------
1072
+ continuation_windows = 0
1073
+ final_output = output
1074
+ total_facts_extracted = extraction.total_facts
1075
+
1076
+ # Timing accumulators for CRP overhead telemetry
1077
+ _total_llm_ms = wall_ms # LLM generation time (primary)
1078
+ _total_extraction_ms = _extraction_ms_primary
1079
+ _total_envelope_ms = _envelope_ms_primary
1080
+ _total_output_tokens = output_tokens
1081
+ _per_window_detail: list[dict] = [] # per-continuation-window telemetry
1082
+
1083
+ from crp.continuation.manager import ContinuationManager
1084
+ cont_mgr = ContinuationManager(self._continuation_config)
1085
+ cont_state = cont_mgr.process_window(
1086
+ task_intent=task_input,
1087
+ output=output,
1088
+ finish_reason=finish_reason,
1089
+ output_tokens=output_tokens,
1090
+ facts=extraction.facts,
1091
+ window_id=window_id,
1092
+ )
1093
+
1094
+ logger.info(
1095
+ "Continuation check: finished=%s, reason=%s, gap_score=%.3f",
1096
+ cont_state.finished,
1097
+ cont_state.termination_reason or "n/a",
1098
+ cont_state.gap_result.gap_score if cont_state.gap_result else 0.0,
1099
+ )
1100
+
1101
+ # ---------- Audit: initial continuation decision (§7.14.2) ----------
1102
+ _gap_score = (
1103
+ round(cont_state.gap_result.gap_score, 4)
1104
+ if cont_state.gap_result else 0.0
1105
+ )
1106
+ _trigger_info: dict[str, object] = {}
1107
+ if cont_state.trigger_result:
1108
+ _trigger_info = {
1109
+ "should_continue": cont_state.trigger_result.should_continue,
1110
+ "reason": cont_state.trigger_result.reason,
1111
+ "wall_hit": cont_state.trigger_result.wall_hit,
1112
+ "gap_remaining": round(cont_state.trigger_result.gap_remaining, 4),
1113
+ "info_flow": round(cont_state.trigger_result.info_flow, 4),
1114
+ "continuation_count": cont_state.trigger_result.continuation_count,
1115
+ }
1116
+ self._compliance_audit.record(
1117
+ ComplianceEventType.CONTINUATION_DECIDED,
1118
+ session_id=self._session.session_id,
1119
+ data={
1120
+ "operation": "dispatch",
1121
+ "window_id": window_id,
1122
+ "evaluation_point": "post_primary_window",
1123
+ "continuation_triggered": not cont_state.finished,
1124
+ "gap_score": _gap_score,
1125
+ "gap_coverage": round(1.0 - _gap_score, 4),
1126
+ "termination_reason": cont_state.termination_reason or "",
1127
+ "trigger_details": _trigger_info,
1128
+ "finish_reason": finish_reason,
1129
+ "output_tokens": output_tokens,
1130
+ "facts_extracted": extraction.total_facts,
1131
+ "decision_rationale": (
1132
+ f"Gap score {_gap_score:.3f} "
1133
+ + (
1134
+ f"indicates {round((1 - _gap_score) * 100, 1)}% task coverage. "
1135
+ )
1136
+ + (
1137
+ f"Continuation NOT needed: {cont_state.termination_reason}."
1138
+ if cont_state.finished
1139
+ else "Continuation triggered to improve coverage."
1140
+ )
1141
+ ),
1142
+ },
1143
+ )
1144
+
1145
+ last_window_output = final_output # style anchor for first continuation
1146
+ _consecutive_empty = 0 # Track consecutive thinking-only windows
1147
+ _dispatch_timeout = int(self._config.get("dispatch_timeout", 3600))
1148
+ _continuation_deadline = time.monotonic() + _dispatch_timeout
1149
+
1150
+ while not cont_state.finished:
1151
+ if time.monotonic() > _continuation_deadline:
1152
+ logger.warning(
1153
+ "Continuation wall-time deadline reached (%ds)", _dispatch_timeout,
1154
+ )
1155
+ cont_state.termination_reason = "wall_time_deadline"
1156
+ break
1157
+ continuation_windows += 1
1158
+ logger.info("=== Continuation window %d starting ===", continuation_windows)
1159
+
1160
+ # Build continuation envelope
1161
+ _t_ce0 = time.monotonic_ns()
1162
+ cont_envelope = cont_mgr.build_continuation_envelope(
1163
+ task_intent=task_input,
1164
+ gap_result=cont_state.gap_result,
1165
+ structural_state=self._warm_store.structural_state.to_dict(),
1166
+ last_output=last_window_output,
1167
+ )
1168
+
1169
+ # Build the continuation task. We provide a COMPACT reference
1170
+ # to the original task (title only) rather than the full text,
1171
+ # so the model focuses on the continuation directive instead
1172
+ # of re-reading the full task and restarting from scratch.
1173
+ # The continuation envelope already contains: the directive
1174
+ # (what to write next), document map (what's done), remaining
1175
+ # requirements, style anchor, and key findings.
1176
+ #
1177
+ # V10 fix: Use explicit boundary markers so the LLM can
1178
+ # distinguish the original task reference from CRP directives.
1179
+ task_title = self._extract_task_title(task_input)
1180
+ cont_task = (
1181
+ f"=== ORIGINAL TASK ===\n"
1182
+ f"{task_title}\n"
1183
+ f"=== CONTINUATION DIRECTIVES ===\n"
1184
+ f"{cont_envelope}\n"
1185
+ f"=== END DIRECTIVES ==="
1186
+ )
1187
+ # V3+D fix: Continuation-aware budget calculation.
1188
+ # The continuation directive consumes context tokens, but must
1189
+ # not starve the fact envelope. We use a reduced generation
1190
+ # reserve for continuation windows (model is extending, not
1191
+ # starting fresh) to free budget for fact packing. A minimum
1192
+ # envelope budget floor is enforced so facts always participate.
1193
+ _cont_g = max(g // 2, 512) # halve generation reserve for continuation
1194
+ cont_env_result = self._build_envelope(system_prompt, cont_task, _cont_g)
1195
+ # If envelope budget was zero (directive too large), retry with
1196
+ # title-only budget and accept slight context overflow — the
1197
+ # model simply generates fewer tokens.
1198
+ if cont_env_result.budget_tokens <= 0 and self._warm_store.fact_count > 0:
1199
+ logger.info("Gap D: continuation envelope starved, retrying with title-only budget")
1200
+ cont_env_result = self._build_envelope(system_prompt, task_title, _cont_g)
1201
+ _cont_env_ms = (time.monotonic_ns() - _t_ce0) / 1_000_000
1202
+ _total_envelope_ms += _cont_env_ms
1203
+
1204
+ cont_messages = assemble_messages(system_prompt, cont_env_result.envelope_text, cont_task)
1205
+
1206
+ # Dispatch continuation window
1207
+ cont_window_id = str(uuid.uuid4())
1208
+ self._warm_store.advance_window(cont_window_id)
1209
+
1210
+ cont_node = WindowNode(
1211
+ window_id=cont_window_id,
1212
+ system_prompt_hash=hashlib.sha256(system_prompt.encode()).hexdigest(),
1213
+ task_input_hash=hashlib.sha256(task_input.encode()).hexdigest(),
1214
+ continuation_index=continuation_windows,
1215
+ parent_ids=[node.window_id],
1216
+ )
1217
+ self._dag.add_node(cont_node)
1218
+ cont_node.advance(WindowState.ASSEMBLED)
1219
+ cont_node.advance(WindowState.DISPATCHED)
1220
+ cont_node.advance(WindowState.GENERATING)
1221
+
1222
+ # Brief pause between windows — local inference servers
1223
+ # (LM Studio, llama.cpp) may need time to reset after a
1224
+ # completion before accepting the next request.
1225
+ time.sleep(2)
1226
+
1227
+ _t_llm0 = time.monotonic_ns()
1228
+ try:
1229
+ cont_output, cont_finish = self._provider.generate_chat(
1230
+ cont_messages, max_tokens=g,
1231
+ )
1232
+ except Exception as exc:
1233
+ logger.error(
1234
+ "Continuation window %d: provider failure — %s: %s",
1235
+ continuation_windows, type(exc).__name__, exc,
1236
+ )
1237
+ cont_state.termination_reason = f"provider_error:{type(exc).__name__}"
1238
+ break # Provider failure stops continuation
1239
+ _cont_llm_ms = (time.monotonic_ns() - _t_llm0) / 1_000_000
1240
+ _total_llm_ms += _cont_llm_ms
1241
+
1242
+ # Provider returned error
1243
+ if cont_finish == "error":
1244
+ logger.warning("Continuation window %d: provider returned error, stopping", continuation_windows)
1245
+ cont_state.termination_reason = "provider_returned_error"
1246
+ break
1247
+
1248
+ # Capture reasoning from thinking model
1249
+ cont_reasoning = getattr(self._provider, "last_reasoning_content", None)
1250
+ if cont_reasoning:
1251
+ total_reasoning_tokens += self._provider.count_tokens(cont_reasoning)
1252
+
1253
+ # Gap F fix: Extract useful insights from thinking model reasoning.
1254
+ # The model's reasoning often contains analysis, comparisons, and
1255
+ # planning that are valuable facts. Extract them using stages 1-2
1256
+ # (cheap regex+statistical) and store alongside regular facts.
1257
+ if cont_reasoning and len(cont_reasoning) > 100:
1258
+ try:
1259
+ reasoning_extraction = self._extract_and_store(
1260
+ cont_reasoning,
1261
+ f"{cont_window_id}_reasoning",
1262
+ TaskIntent(task_input=task_input, system_prompt=system_prompt),
1263
+ )
1264
+ if reasoning_extraction.total_facts > 0:
1265
+ total_facts_extracted += reasoning_extraction.total_facts
1266
+ logger.info(
1267
+ "Gap F: extracted %d facts from thinking model reasoning (%d tokens)",
1268
+ reasoning_extraction.total_facts,
1269
+ self._provider.count_tokens(cont_reasoning),
1270
+ )
1271
+ except Exception: # noqa: BLE001
1272
+ logger.debug("Gap F: reasoning extraction failed (non-fatal)")
1273
+
1274
+ # Handle thinking model: empty output but budget exhausted
1275
+ # (model spent all tokens on reasoning, no content produced).
1276
+ # Skip extraction for this window but continue to the next —
1277
+ # the model may produce content with a fresh window.
1278
+ if not cont_output and cont_finish == "length":
1279
+ _consecutive_empty += 1
1280
+ logger.info(
1281
+ "Continuation window %d: thinking model produced no content "
1282
+ "(reasoning only). consecutive_empty=%d",
1283
+ continuation_windows, _consecutive_empty,
1284
+ )
1285
+ if _consecutive_empty >= 3:
1286
+ logger.warning(
1287
+ "Continuation window %d: %d consecutive empty windows, stopping",
1288
+ continuation_windows, _consecutive_empty,
1289
+ )
1290
+ cont_state.termination_reason = "consecutive_empty_windows"
1291
+ break
1292
+ # Record minimal telemetry and continue
1293
+ cont_node.finish_reason = cont_finish
1294
+ cont_node.raw_output_id = str(uuid.uuid4())
1295
+ cont_node.advance(WindowState.COMPLETED)
1296
+ _per_window_detail.append({
1297
+ "window": continuation_windows,
1298
+ "llm_ms": round(_cont_llm_ms),
1299
+ "extraction_ms": 0,
1300
+ "envelope_ms": round(_cont_env_ms),
1301
+ "output_tokens": 0,
1302
+ "output_chars": 0,
1303
+ "facts": 0,
1304
+ "finish_reason": "length (reasoning only)",
1305
+ "envelope_saturation": round(cont_env_result.saturation, 3),
1306
+ "envelope_facts_packed": cont_env_result.facts_included,
1307
+ "reasoning_tokens": self._provider.count_tokens(cont_reasoning) if cont_reasoning else 0,
1308
+ "gap_score": round(cont_state.gap_result.gap_score, 3) if cont_state.gap_result else 0.0,
1309
+ })
1310
+ self._windows_completed += 1
1311
+ continue
1312
+
1313
+ # Empty output with non-length finish reason — model is done
1314
+ if not cont_output:
1315
+ logger.warning("Continuation window %d: empty output (finish=%s), stopping",
1316
+ continuation_windows, cont_finish)
1317
+ cont_state.termination_reason = f"empty_output:{cont_finish}"
1318
+ break
1319
+
1320
+ # Reset consecutive empty counter on successful content
1321
+ _consecutive_empty = 0
1322
+
1323
+ # Update style anchor for next window
1324
+ last_window_output = cont_output
1325
+
1326
+ cont_node.finish_reason = cont_finish
1327
+ cont_node.raw_output_id = str(uuid.uuid4())
1328
+ cont_node.advance(WindowState.COMPLETED)
1329
+
1330
+ logger.info(
1331
+ "Continuation window %d done: finish=%s, chars=%d",
1332
+ continuation_windows, cont_finish, len(cont_output),
1333
+ )
1334
+
1335
+ cont_output_tokens = self._provider.count_tokens(cont_output)
1336
+
1337
+ # Extract from continuation output
1338
+ _t_cext0 = time.monotonic_ns()
1339
+ cont_extraction = self._extract_and_store(cont_output, cont_window_id, task_intent)
1340
+ _cont_ext_ms = (time.monotonic_ns() - _t_cext0) / 1_000_000
1341
+ _total_extraction_ms += _cont_ext_ms
1342
+ cont_node.advance(WindowState.EXTRACTED)
1343
+ cont_node.facts_produced = [f.id for f in cont_extraction.facts]
1344
+ total_facts_extracted += cont_extraction.total_facts
1345
+ _total_output_tokens += cont_output_tokens
1346
+
1347
+ # ---------- Emit continuation window events (§9) ----------
1348
+ self._emitter.emit("window.continued", {
1349
+ "session_id": self._session.session_id,
1350
+ "window_id": cont_window_id,
1351
+ "continuation_index": continuation_windows,
1352
+ "output_tokens": cont_output_tokens,
1353
+ "facts_extracted": cont_extraction.total_facts,
1354
+ })
1355
+
1356
+ # ---------- ReviewCycle checkpoint (§review D5 fix) ----------
1357
+ review_guidance = self._review_cycle.checkpoint_review(
1358
+ window_index=continuation_windows,
1359
+ review_interval=self._config.get("review_interval", 20),
1360
+ task_intent=task_input[:200],
1361
+ top_facts=[f.text for f in cont_extraction.facts[:5]],
1362
+ )
1363
+ if review_guidance and not review_guidance.on_track:
1364
+ logger.info("ReviewCycle: off-track at window %d, contradictions=%d",
1365
+ continuation_windows, len(review_guidance.contradictions))
1366
+
1367
+ # Per-window telemetry record
1368
+ _per_window_detail.append({
1369
+ "window": continuation_windows,
1370
+ "llm_ms": round(_cont_llm_ms),
1371
+ "extraction_ms": round(_cont_ext_ms),
1372
+ "envelope_ms": round(_cont_env_ms),
1373
+ "output_tokens": cont_output_tokens,
1374
+ "output_chars": len(cont_output),
1375
+ "facts": cont_extraction.total_facts,
1376
+ "finish_reason": cont_finish,
1377
+ "envelope_saturation": round(cont_env_result.saturation, 3),
1378
+ "envelope_facts_packed": cont_env_result.facts_included,
1379
+ "reasoning_tokens": self._provider.count_tokens(cont_reasoning) if cont_reasoning else 0,
1380
+ "gap_score": round(cont_state.gap_result.gap_score, 3) if cont_state.gap_result else 0.0,
1381
+ })
1382
+
1383
+ # Update counters
1384
+ self._windows_completed += 1
1385
+ self._total_input_tokens += self._provider.count_tokens(
1386
+ system_prompt + (cont_env_result.envelope_text or "") + cont_task
1387
+ )
1388
+ self._total_output_tokens += cont_output_tokens
1389
+
1390
+ # Process window for continuation decision
1391
+ cont_state = cont_mgr.process_window(
1392
+ task_intent=task_input,
1393
+ output=cont_output,
1394
+ finish_reason=cont_finish,
1395
+ output_tokens=cont_output_tokens,
1396
+ facts=cont_extraction.facts,
1397
+ window_id=cont_window_id,
1398
+ )
1399
+ if cont_state is None: # §audit3 H1: guard corrupted state
1400
+ logger.error("Continuation state is None after window %d — aborting", continuation_windows)
1401
+ break
1402
+ logger.info(
1403
+ "Continuation %d decision: finished=%s, reason=%s",
1404
+ continuation_windows,
1405
+ cont_state.finished,
1406
+ cont_state.termination_reason or "continuing",
1407
+ )
1408
+
1409
+ # ---------- Audit: continuation loop decision (§7.14.2) ----------
1410
+ _cont_gap = (
1411
+ round(cont_state.gap_result.gap_score, 4)
1412
+ if cont_state.gap_result else 0.0
1413
+ )
1414
+ _cont_trigger: dict[str, object] = {}
1415
+ if cont_state.trigger_result:
1416
+ _cont_trigger = {
1417
+ "should_continue": cont_state.trigger_result.should_continue,
1418
+ "reason": cont_state.trigger_result.reason,
1419
+ "wall_hit": cont_state.trigger_result.wall_hit,
1420
+ "gap_remaining": round(
1421
+ cont_state.trigger_result.gap_remaining, 4
1422
+ ),
1423
+ "info_flow": round(cont_state.trigger_result.info_flow, 4),
1424
+ }
1425
+ self._compliance_audit.record(
1426
+ ComplianceEventType.CONTINUATION_DECIDED,
1427
+ session_id=self._session.session_id,
1428
+ data={
1429
+ "operation": "dispatch",
1430
+ "window_id": cont_window_id,
1431
+ "evaluation_point": f"continuation_window_{continuation_windows}",
1432
+ "continuation_triggered": not cont_state.finished,
1433
+ "gap_score": _cont_gap,
1434
+ "gap_coverage": round(1.0 - _cont_gap, 4),
1435
+ "termination_reason": cont_state.termination_reason or "",
1436
+ "trigger_details": _cont_trigger,
1437
+ "finish_reason": cont_finish,
1438
+ "output_tokens": cont_output_tokens,
1439
+ "facts_extracted": cont_extraction.total_facts,
1440
+ "decision_rationale": (
1441
+ f"Continuation window {continuation_windows}: "
1442
+ f"gap score {_cont_gap:.3f} "
1443
+ f"({round((1 - _cont_gap) * 100, 1)}% coverage). "
1444
+ + (
1445
+ f"Stopping: {cont_state.termination_reason}."
1446
+ if cont_state.finished
1447
+ else "Continuing to next window."
1448
+ )
1449
+ ),
1450
+ },
1451
+ )
1452
+
1453
+ # Use stitched output from continuation manager (guard None: \u00a7audit4 C1)
1454
+ if cont_state is not None and continuation_windows > 0 and cont_state.stitched_output:
1455
+ final_output = cont_state.stitched_output
1456
+ self._continuation_windows_total += continuation_windows
1457
+
1458
+ # ---------- Cross-window validation (§cross-window D2 fix) ----------
1459
+ if continuation_windows > 0:
1460
+ try:
1461
+ ranked_facts = self._warm_store.get_ranked_facts(limit=30)
1462
+ fact_dicts = [{"text": f.text, "id": f.id} for f in ranked_facts]
1463
+ validation_result = self._cross_window_validator.extraction_based_validation(
1464
+ facts=fact_dicts,
1465
+ )
1466
+ if validation_result.issues:
1467
+ logger.info(
1468
+ "Cross-window validation: %d consistency issues found",
1469
+ len(validation_result.issues),
1470
+ )
1471
+ except Exception as exc:
1472
+ logger.warning("Cross-window validation skipped: %s", exc)
1473
+
1474
+ # ---------- Post-generation assessment (§review D5 fix) ----------
1475
+ if continuation_windows > 0:
1476
+ try:
1477
+ assessment = self._review_cycle.post_generation_assessment(
1478
+ accumulated_output=final_output[:2000],
1479
+ task_intent=task_input[:500],
1480
+ )
1481
+ logger.info("Post-generation assessment: score=%.2f", assessment.score)
1482
+ except Exception as exc:
1483
+ logger.debug("Post-generation assessment skipped: %s", exc)
1484
+
1485
+ # ---------- Window completed event (§9) ----------
1486
+ self._emitter.emit("window.completed", {
1487
+ "session_id": self._session.session_id,
1488
+ "window_id": node.window_id,
1489
+ "continuation_windows": continuation_windows,
1490
+ "total_facts": total_facts_extracted,
1491
+ })
1492
+
1493
+ # ---------- Update counters (primary window) ----------
1494
+ self._windows_completed += 1
1495
+ self._total_input_tokens += input_tokens
1496
+ self._total_output_tokens += output_tokens
1497
+
1498
+ # ---------- Record RBAC rate limit counters (§7.10) ----------
1499
+ self._rbac.record_dispatch(tokens_used=output_tokens)
1500
+
1501
+ # ---------- Check output budget ----------
1502
+ max_out_total = self._config.max_total_output_tokens
1503
+ if max_out_total and self._total_output_tokens > max_out_total:
1504
+ logger.warning(
1505
+ "Output token cap exceeded (%d > %d)",
1506
+ self._total_output_tokens,
1507
+ max_out_total,
1508
+ )
1509
+
1510
+ # ---------- Metrics ----------
1511
+ _total_dispatch_ms = (time.monotonic_ns() - _dispatch_start_ns) / 1_000_000
1512
+ _crp_overhead_ms = _total_dispatch_ms - _total_llm_ms
1513
+ _crp_overhead_pct = (_crp_overhead_ms / _total_dispatch_ms * 100) if _total_dispatch_ms > 0 else 0.0
1514
+
1515
+ saturation = envelope_result.saturation if envelope_result.envelope_tokens > 0 else 0.0
1516
+ gen_speed = _total_output_tokens / (_total_llm_ms / 1000) if _total_llm_ms > 0 else 0.0
1517
+
1518
+ # Final gap score from last continuation check
1519
+ _final_gap = 0.0
1520
+ if cont_state.gap_result:
1521
+ _final_gap = cont_state.gap_result.gap_score
1522
+
1523
+ metrics = WindowMetrics(
1524
+ window_id=node.window_id,
1525
+ chain_position=continuation_windows,
1526
+ system_tokens=s_tokens,
1527
+ task_tokens=t_tokens,
1528
+ envelope_tokens=e_tokens,
1529
+ envelope_budget=envelope_result.budget_tokens,
1530
+ saturation=saturation,
1531
+ generation_reserve=g,
1532
+ generation_tokens=_total_output_tokens,
1533
+ generation_speed=gen_speed,
1534
+ wall_time_ms=int(_total_llm_ms),
1535
+ finish_reason=finish_reason,
1536
+ facts_extracted=total_facts_extracted,
1537
+ continuation_triggered=continuation_windows > 0,
1538
+ continuation_index=continuation_windows,
1539
+ # Newly wired latency fields
1540
+ envelope_latency_ms=round(_total_envelope_ms, 1),
1541
+ extraction_latency_ms=round(_total_extraction_ms, 1),
1542
+ extraction_stage_used=",".join(str(s) for s in getattr(extraction, "stages_run", [])),
1543
+ # Gap / flow
1544
+ gap_coverage=round(1.0 - _final_gap, 3),
1545
+ final_gap_score=round(_final_gap, 3),
1546
+ total_output_tokens=_total_output_tokens,
1547
+ # Reasoning / thinking
1548
+ reasoning_tokens=total_reasoning_tokens,
1549
+ # CRP overhead
1550
+ total_dispatch_ms=round(_total_dispatch_ms),
1551
+ total_llm_ms=round(_total_llm_ms),
1552
+ total_extraction_ms=round(_total_extraction_ms),
1553
+ total_envelope_ms=round(_total_envelope_ms),
1554
+ crp_overhead_ms=round(_crp_overhead_ms),
1555
+ crp_overhead_pct=round(_crp_overhead_pct, 1),
1556
+ # Per-window continuation detail
1557
+ continuation_windows_detail=_per_window_detail,
1558
+ # Resource tracking
1559
+ **self._resource_fields(),
1560
+ # Marginal gain / sections
1561
+ **self._marginal_fields(final_output, _facts_before),
1562
+ # Adaptive allocator telemetry
1563
+ **self._allocator_fields(),
1564
+ )
1565
+ # Feed overhead into adaptive allocator
1566
+ self._record_dispatch_overhead(
1567
+ _total_dispatch_ms, _total_llm_ms,
1568
+ envelope_ms=_total_envelope_ms,
1569
+ extraction_ms=_total_extraction_ms,
1570
+ )
1571
+ quality_tier = _classify_quality_tier(
1572
+ facts_extracted=total_facts_extracted,
1573
+ continuation_windows=continuation_windows,
1574
+ saturation=envelope_result.saturation,
1575
+ finish_reason=finish_reason,
1576
+ output_tokens=output_tokens,
1577
+ output_length=len(final_output),
1578
+ )
1579
+
1580
+ report = QualityReport(
1581
+ session_id=self._session.session_id,
1582
+ window_id=node.window_id,
1583
+ output=final_output,
1584
+ facts_extracted=total_facts_extracted,
1585
+ security_flags=security_flags,
1586
+ continuation_windows=continuation_windows,
1587
+ envelope_saturation=envelope_result.saturation,
1588
+ quality_tier=quality_tier,
1589
+ telemetry=metrics.to_dict(),
1590
+ )
1591
+
1592
+ # ---------- Audit: quality tier assignment provenance (§7.14.2) ----------
1593
+ self._compliance_audit.record(
1594
+ ComplianceEventType.QUALITY_TIER_ASSIGNED,
1595
+ session_id=self._session.session_id,
1596
+ data={
1597
+ "operation": "dispatch",
1598
+ "window_id": node.window_id,
1599
+ "quality_tier": quality_tier,
1600
+ "facts_extracted": total_facts_extracted,
1601
+ "continuation_windows": continuation_windows,
1602
+ "envelope_saturation": round(envelope_result.saturation, 4),
1603
+ "finish_reason": finish_reason,
1604
+ "output_tokens": output_tokens,
1605
+ "output_length_chars": len(final_output),
1606
+ "final_gap_score": round(_final_gap, 4),
1607
+ "gap_coverage": round(1.0 - _final_gap, 4),
1608
+ "total_dispatch_ms": round(_total_dispatch_ms),
1609
+ "crp_overhead_pct": round(_crp_overhead_pct, 1),
1610
+ "pii_in_input": pii_result.has_pii,
1611
+ "pii_in_output": output_pii.has_pii,
1612
+ "injection_markers": security_flags.injection_markers_detected,
1613
+ "scoring_rationale": (
1614
+ f"Tier '{quality_tier}' assigned based on: "
1615
+ f"{total_facts_extracted} facts extracted "
1616
+ f"(extraction score component), "
1617
+ f"finish_reason='{finish_reason}' with "
1618
+ f"{continuation_windows} continuations "
1619
+ f"(completion score component), "
1620
+ f"{output_tokens} output tokens "
1621
+ f"(substance score component), "
1622
+ f"saturation={round(envelope_result.saturation, 3)} "
1623
+ f"(context utilization component). "
1624
+ f"Final gap coverage: {round((1 - _final_gap) * 100, 1)}%."
1625
+ ),
1626
+ "decision_chain_summary": (
1627
+ f"Input → {envelope_result.facts_included} facts selected → "
1628
+ f"LLM call ({round(wall_ms)}ms) → "
1629
+ f"{extraction.total_facts} facts extracted → "
1630
+ + (
1631
+ f"{continuation_windows} continuations → "
1632
+ if continuation_windows > 0 else ""
1633
+ )
1634
+ + f"tier '{quality_tier}' "
1635
+ f"(gap coverage {round((1 - _final_gap) * 100, 1)}%)"
1636
+ ),
1637
+ },
1638
+ )
1639
+
1640
+ # ---------- Decision Provenance Engine (§7.14.3) ----------
1641
+ if self._provenance_engine.enabled:
1642
+ try:
1643
+ provenance_report = self._provenance_engine.analyse(
1644
+ output_text=final_output,
1645
+ packed_facts=list(envelope_result.packing.packed_facts) if envelope_result.packing else [],
1646
+ session_id=self._session.session_id,
1647
+ window_id=node.window_id,
1648
+ envelope_saturation=envelope_result.saturation,
1649
+ task_input_preview=task_input[:120],
1650
+ )
1651
+
1652
+ # Record per-claim attribution audit entries
1653
+ for attr in provenance_report.attributions:
1654
+ from crp.provenance import AttributionType
1655
+ if attr.attribution_type == AttributionType.PARAMETRIC:
1656
+ self._compliance_audit.record(
1657
+ ComplianceEventType.PARAMETRIC_DETECTED,
1658
+ session_id=self._session.session_id,
1659
+ data={
1660
+ "window_id": node.window_id,
1661
+ "claim_index": attr.claim_index,
1662
+ "claim_preview": attr.claim_text[:120],
1663
+ "top_score": round(attr.top_score, 4),
1664
+ },
1665
+ )
1666
+ elif attr.attribution_type == AttributionType.UNCERTAIN:
1667
+ self._compliance_audit.record(
1668
+ ComplianceEventType.ATTRIBUTION_UNCERTAIN,
1669
+ session_id=self._session.session_id,
1670
+ data={
1671
+ "window_id": node.window_id,
1672
+ "claim_index": attr.claim_index,
1673
+ "claim_preview": attr.claim_text[:120],
1674
+ },
1675
+ )
1676
+
1677
+ # Record the full attribution report
1678
+ self._compliance_audit.record(
1679
+ ComplianceEventType.ATTRIBUTION_REPORT,
1680
+ session_id=self._session.session_id,
1681
+ data={
1682
+ "window_id": node.window_id,
1683
+ "total_claims": provenance_report.total_claims,
1684
+ "factual_claims": provenance_report.factual_claims,
1685
+ "context_grounded": provenance_report.context_grounded_count,
1686
+ "parametric": provenance_report.parametric_count,
1687
+ "mixed": provenance_report.mixed_count,
1688
+ "uncertain": provenance_report.uncertain_count,
1689
+ "grounding_ratio": provenance_report.grounding_ratio,
1690
+ "envelope_facts_count": provenance_report.envelope_facts_count,
1691
+ },
1692
+ )
1693
+
1694
+ # Record fidelity verification events
1695
+ fid = provenance_report.fidelity
1696
+ if fid is not None:
1697
+ for d in fid.distortions:
1698
+ self._compliance_audit.record(
1699
+ ComplianceEventType.DISTORTION_DETECTED,
1700
+ session_id=self._session.session_id,
1701
+ data={
1702
+ "window_id": node.window_id,
1703
+ "claim_index": d.claim_index,
1704
+ "distortion_type": d.distortion_type.value,
1705
+ "severity": d.severity,
1706
+ "detail": d.detail[:200],
1707
+ },
1708
+ )
1709
+ for f in fid.fabrications:
1710
+ self._compliance_audit.record(
1711
+ ComplianceEventType.FABRICATION_DETECTED,
1712
+ session_id=self._session.session_id,
1713
+ data={
1714
+ "window_id": node.window_id,
1715
+ "claim_index": f.claim_index,
1716
+ "entity_type": f.entity_type.value,
1717
+ "fabricated_entity": f.fabricated_entity[:100],
1718
+ "severity": f.severity,
1719
+ },
1720
+ )
1721
+ from crp.provenance._types import OmissionSeverity
1722
+ for o in fid.omissions:
1723
+ if o.severity in (OmissionSeverity.CRITICAL, OmissionSeverity.HIGH):
1724
+ self._compliance_audit.record(
1725
+ ComplianceEventType.OMISSION_DETECTED,
1726
+ session_id=self._session.session_id,
1727
+ data={
1728
+ "window_id": node.window_id,
1729
+ "fact_id": o.fact_id,
1730
+ "severity": o.severity.value,
1731
+ "relevance_score": round(o.fact_relevance_score, 4),
1732
+ },
1733
+ )
1734
+ for c in fid.contradictions:
1735
+ self._compliance_audit.record(
1736
+ ComplianceEventType.CONTRADICTION_DETECTED,
1737
+ session_id=self._session.session_id,
1738
+ data={
1739
+ "window_id": node.window_id,
1740
+ "claim_a_index": c.claim_a_index,
1741
+ "claim_b_index": c.claim_b_index,
1742
+ "contradiction_type": c.contradiction_type,
1743
+ "severity": c.severity,
1744
+ },
1745
+ )
1746
+
1747
+ # Record semantic entailment events
1748
+ for er in provenance_report.entailment_results:
1749
+ from crp.provenance._types import EntailmentLabel
1750
+ if er.label == EntailmentLabel.CONTRADICTION:
1751
+ self._compliance_audit.record(
1752
+ ComplianceEventType.ENTAILMENT_CONTRADICTION,
1753
+ session_id=self._session.session_id,
1754
+ data={
1755
+ "window_id": node.window_id,
1756
+ "claim_index": er.claim_index,
1757
+ "fact_id": er.fact_id,
1758
+ "contradiction_score": round(er.contradiction_score, 4),
1759
+ "used_model": er.used_model,
1760
+ },
1761
+ )
1762
+
1763
+ # Record entailment model/method status (A-3)
1764
+ if provenance_report.entailment_results:
1765
+ any_model = any(er.used_model for er in provenance_report.entailment_results)
1766
+ self._compliance_audit.record(
1767
+ ComplianceEventType.ENTAILMENT_MODEL_STATUS,
1768
+ session_id=self._session.session_id,
1769
+ data={
1770
+ "window_id": node.window_id,
1771
+ "method": "nli_model" if any_model else "heuristic",
1772
+ "pairs_checked": len(provenance_report.entailment_results),
1773
+ },
1774
+ )
1775
+
1776
+ # Record hallucination risk events
1777
+ risk = provenance_report.risk_report
1778
+ if risk is not None:
1779
+ from crp.provenance._types import HallucinationRisk
1780
+ if risk.critical_risk_count > 0 or risk.high_risk_count > 0:
1781
+ self._compliance_audit.record(
1782
+ ComplianceEventType.RISK_ASSESSMENT_COMPLETED,
1783
+ session_id=self._session.session_id,
1784
+ data={
1785
+ "window_id": node.window_id,
1786
+ "window_risk_level": risk.window_risk_level.value,
1787
+ "mean_risk_score": risk.mean_risk_score,
1788
+ "high_risk_count": risk.high_risk_count,
1789
+ "critical_risk_count": risk.critical_risk_count,
1790
+ },
1791
+ )
1792
+ for a in risk.assessments:
1793
+ if a.risk_level == HallucinationRisk.CRITICAL:
1794
+ self._compliance_audit.record(
1795
+ ComplianceEventType.HALLUCINATION_RISK_CRITICAL,
1796
+ session_id=self._session.session_id,
1797
+ data={
1798
+ "window_id": node.window_id,
1799
+ "claim_index": a.claim_index,
1800
+ "risk_score": a.risk_score,
1801
+ "risk_factors": a.risk_factors[:5],
1802
+ },
1803
+ )
1804
+ elif a.risk_level == HallucinationRisk.HIGH:
1805
+ self._compliance_audit.record(
1806
+ ComplianceEventType.HALLUCINATION_RISK_HIGH,
1807
+ session_id=self._session.session_id,
1808
+ data={
1809
+ "window_id": node.window_id,
1810
+ "claim_index": a.claim_index,
1811
+ "risk_score": a.risk_score,
1812
+ "risk_factors": a.risk_factors[:5],
1813
+ },
1814
+ )
1815
+ except Exception as exc:
1816
+ logger.warning("Decision provenance engine error: %s", exc)
1817
+ self._compliance_audit.record(
1818
+ ComplianceEventType.PROVENANCE_ENGINE_FAILURE,
1819
+ session_id=self._session.session_id,
1820
+ data={
1821
+ "window_id": node.window_id,
1822
+ "error": str(exc)[:500],
1823
+ "error_type": type(exc).__name__,
1824
+ },
1825
+ )
1826
+ else:
1827
+ self._compliance_audit.record(
1828
+ ComplianceEventType.PROVENANCE_DISABLED,
1829
+ session_id=self._session.session_id,
1830
+ data={"window_id": node.window_id},
1831
+ )
1832
+
1833
+ # ---------- Risk classification (§7.15.1, EU AI Act Art. 9) ----------
1834
+ _risk_assessment = self._risk_classifier.assess(
1835
+ intended_purpose=task_input[:200],
1836
+ processes_personal_data=pii_result.has_pii or output_pii.has_pii,
1837
+ makes_automated_decisions=False,
1838
+ )
1839
+ self._compliance_audit.record(
1840
+ ComplianceEventType.RISK_ASSESSMENT,
1841
+ session_id=self._session.session_id,
1842
+ data={
1843
+ "window_id": node.window_id,
1844
+ "risk_level": _risk_assessment.risk_level.value,
1845
+ "assessment_id": _risk_assessment.assessment_id,
1846
+ "mitigations_count": len(_risk_assessment.mitigations),
1847
+ "residual_risks_count": len(_risk_assessment.residual_risks),
1848
+ },
1849
+ )
1850
+
1851
+ # ---------- Emit dispatch.completed + write telemetry (§9 F6+D8 fix) ----------
1852
+ self._emitter.emit("dispatch.completed", {
1853
+ "session_id": self._session.session_id,
1854
+ "window_id": node.window_id,
1855
+ "quality_tier": quality_tier,
1856
+ "facts_extracted": total_facts_extracted,
1857
+ "continuation_windows": continuation_windows,
1858
+ "dispatch_ms": round(_total_dispatch_ms),
1859
+ "crp_overhead_pct": round(_crp_overhead_pct, 1),
1860
+ })
1861
+ if self._telemetry_writer is not None:
1862
+ from crp.observability.telemetry import WindowTelemetry
1863
+ self._telemetry_writer.write(WindowTelemetry(
1864
+ session_id=self._session.session_id,
1865
+ window_id=node.window_id,
1866
+ input_tokens=input_tokens,
1867
+ output_tokens=_total_output_tokens,
1868
+ overhead_tokens=e_tokens,
1869
+ facts_included=envelope_result.facts_included,
1870
+ facts_available=self._warm_store.fact_count,
1871
+ latency_ms=round(_total_dispatch_ms, 1),
1872
+ quality_tier=quality_tier,
1873
+ ))
1874
+
1875
+ # ---------- Compliance audit: dispatch completed (§7.14) ----------
1876
+ self._compliance_audit.record(
1877
+ ComplianceEventType.DATA_PROCESSED,
1878
+ session_id=self._session.session_id,
1879
+ data={
1880
+ "operation": "dispatch",
1881
+ "phase": "completed",
1882
+ "window_id": node.window_id,
1883
+ "quality_tier": quality_tier,
1884
+ "facts_extracted": total_facts_extracted,
1885
+ "continuation_windows": continuation_windows,
1886
+ "dispatch_ms": round(_total_dispatch_ms),
1887
+ "output_tokens": _total_output_tokens,
1888
+ "pii_in_input": pii_result.has_pii,
1889
+ "pii_in_output": output_pii.has_pii,
1890
+ },
1891
+ )
1892
+
1893
+ # ---------- Retention enforcement (§7.12) ----------
1894
+ expired_ids = self._retention_manager.enforce()
1895
+ if expired_ids:
1896
+ self._compliance_audit.record(
1897
+ ComplianceEventType.RETENTION_PURGED,
1898
+ session_id=self._session.session_id,
1899
+ data={"purged_count": len(expired_ids), "purged_ids": expired_ids[:10]},
1900
+ )
1901
+
1902
+ # ---------- Compliance report generation (§7.15.3) ----------
1903
+ _session_stats = {
1904
+ "window_id": node.window_id,
1905
+ "quality_tier": quality_tier,
1906
+ "facts_extracted": total_facts_extracted,
1907
+ "continuation_windows": continuation_windows,
1908
+ "dispatch_ms": round(_total_dispatch_ms),
1909
+ "pii_detected_input": pii_result.has_pii,
1910
+ "pii_detected_output": output_pii.has_pii,
1911
+ "injection_markers": security_flags.injection_markers_detected,
1912
+ "audit_entries": self._compliance_audit.entry_count,
1913
+ }
1914
+ _compliance_report = self._compliance_reporter.generate_report(
1915
+ session_stats=_session_stats,
1916
+ risk_assessment=_risk_assessment,
1917
+ )
1918
+ logger.info(
1919
+ "Compliance report: %d/%d controls implemented (%.1f%%)",
1920
+ _compliance_report["summary"]["implemented"],
1921
+ _compliance_report["summary"]["total_controls"],
1922
+ _compliance_report["summary"]["compliance_score"],
1923
+ )
1924
+
1925
+ # Apply license watermark to output
1926
+ from crp.license_guard import watermark_output
1927
+ final_output = watermark_output(final_output, self._session.session_id)
1928
+
1929
+ return final_output, report
1930
+
1931
+ # ------------------------------------------------------------------
1932
+ # Tool-mediated dispatch — PULL-based context relay (§20)
1933
+ # ------------------------------------------------------------------
1934
+
1935
+ def dispatch_with_tools(
1936
+ self,
1937
+ system_prompt: str,
1938
+ task_input: str,
1939
+ *,
1940
+ max_tool_rounds: int = 10,
1941
+ **kwargs: Any,
1942
+ ) -> tuple[str, QualityReport]:
1943
+ """Dispatch with tool-mediated context relay (pull model).
1944
+
1945
+ Instead of pre-loading ALL context into the envelope (push model),
1946
+ this method:
1947
+ 1. Sends the task to the LLM with CRP context tools
1948
+ 2. The LLM requests context on demand via tool calls
1949
+ 3. CRP executes tool calls against WarmStore/CKF
1950
+ 4. Results are fed back, and the LLM continues
1951
+ 5. When the LLM finishes (stop/length), extraction proceeds normally
1952
+
1953
+ Falls back to push-based dispatch() if the provider doesn't
1954
+ support tool calling.
1955
+
1956
+ Args:
1957
+ system_prompt: System prompt (unmodified per Axiom 4).
1958
+ task_input: User task/prompt.
1959
+ max_tool_rounds: Maximum tool call round-trips (safety cap).
1960
+ **kwargs: Provider-specific overrides.
1961
+
1962
+ Returns:
1963
+ (output_text, QualityReport) — same interface as dispatch().
1964
+ """
1965
+ # Fall back to push-based if provider doesn't support tools
1966
+ if not self._provider.supports_tools():
1967
+ logger.info(
1968
+ "Provider %s doesn't support tools — falling back to push-based dispatch",
1969
+ self._provider.model_name,
1970
+ )
1971
+ return self.dispatch(system_prompt, task_input, **kwargs)
1972
+
1973
+ _dispatch_start_ns = time.monotonic_ns()
1974
+ _facts_before = self._warm_store.fact_count
1975
+ self._check_session()
1976
+
1977
+ # ---------- RBAC + validation (same as dispatch) ----------
1978
+ from crp.security.rbac import Permission
1979
+ perm_result = self._rbac.check_permission(Permission.DISPATCH)
1980
+ if not perm_result.allowed:
1981
+ raise RateLimitExceededError(perm_result.reason)
1982
+ rate_result = self._rbac.check_rate_limit(Permission.DISPATCH)
1983
+ if not rate_result.allowed:
1984
+ raise RateLimitExceededError(rate_result.reason)
1985
+
1986
+ val_result = self._input_validator.validate(task_input)
1987
+ if not val_result.valid:
1988
+ raise ValidationError(val_result.warnings[0] if val_result.warnings else "Input validation failed")
1989
+ task_input = val_result.sanitized_text
1990
+
1991
+ security_flags = self._scan_injection(task_input)
1992
+ security_flags.unicode_normalized = val_result.sanitized_size != val_result.original_size
1993
+ security_flags.control_chars_stripped = val_result.control_chars_removed
1994
+
1995
+ # ---------- Token measurements ----------
1996
+ s_tokens = self._provider.count_tokens(system_prompt)
1997
+ t_tokens = self._provider.count_tokens(task_input)
1998
+ context_window = self._provider.context_window_size()
1999
+
2000
+ max_out = kwargs.get("max_output_tokens")
2001
+ g = resolve_generation_reserve(
2002
+ max_out, self._provider.max_output_tokens, context_window,
2003
+ is_thinking_model=getattr(self._provider, "is_thinking_model", False),
2004
+ )
2005
+
2006
+ # ---------- Set up context tool executor ----------
2007
+ from crp.core.context_tools import (
2008
+ CRP_CONTEXT_TOOLS,
2009
+ ContextToolExecutor,
2010
+ ToolCall,
2011
+ build_tool_system_prompt,
2012
+ tool_results_to_messages,
2013
+ )
2014
+
2015
+ executor = ContextToolExecutor(
2016
+ warm_store=self._warm_store,
2017
+ ckf=self._ckf,
2018
+ count_tokens=self._provider.count_tokens,
2019
+ embed_fn=self._embedding_fn if hasattr(self, "_embedding_fn") else None,
2020
+ )
2021
+
2022
+ # ---------- Build tool-aware system prompt ----------
2023
+ tool_system = build_tool_system_prompt(
2024
+ system_prompt, self._warm_store.fact_count,
2025
+ )
2026
+
2027
+ # ---------- Build MINIMAL initial messages ----------
2028
+ # Key difference from push model: NO envelope.
2029
+ # The LLM will pull context on demand via tool calls.
2030
+ messages: list[dict[str, object]] = [
2031
+ {"role": "system", "content": tool_system},
2032
+ {"role": "user", "content": task_input},
2033
+ ]
2034
+
2035
+ # ---------- Advance warm store window ----------
2036
+ window_id = str(uuid.uuid4())
2037
+ self._warm_store.advance_window(window_id)
2038
+
2039
+ node = WindowNode(
2040
+ window_id=window_id,
2041
+ system_prompt_hash=hashlib.sha256(system_prompt.encode()).hexdigest(),
2042
+ task_input_hash=hashlib.sha256(task_input.encode()).hexdigest(),
2043
+ continuation_index=0,
2044
+ )
2045
+ self._dag.add_node(node)
2046
+ node.advance(WindowState.ASSEMBLED)
2047
+ node.advance(WindowState.DISPATCHED)
2048
+ node.advance(WindowState.GENERATING)
2049
+
2050
+ # ---------- Iterative tool-mediated generation loop ----------
2051
+ total_tool_rounds = 0
2052
+ total_tool_tokens = 0
2053
+ tool_calls_log: list[dict[str, Any]] = []
2054
+ _total_llm_ms = 0.0
2055
+
2056
+ output = ""
2057
+ finish_reason = "stop"
2058
+
2059
+ for round_idx in range(max_tool_rounds + 1): # +1 for final generation
2060
+ # Circuit breaker gate (§audit3: protect all dispatch variants)
2061
+ if not self._circuit_breaker.allow_request():
2062
+ raise ProviderError(
2063
+ "Circuit breaker OPEN — provider unavailable, "
2064
+ "retry after recovery timeout"
2065
+ )
2066
+
2067
+ _t0 = time.monotonic_ns()
2068
+ try:
2069
+ text, reason, raw_tool_calls, raw_msg = self._provider.generate_chat_with_tools(
2070
+ messages, CRP_CONTEXT_TOOLS, max_tokens=g,
2071
+ )
2072
+ self._circuit_breaker.record_success()
2073
+ except Exception as exc:
2074
+ self._circuit_breaker.record_failure()
2075
+ logger.error("Provider error (tool dispatch): %s", exc)
2076
+ raise ProviderError(_safe_provider_error(exc)) from exc
2077
+ _round_ms = (time.monotonic_ns() - _t0) / 1_000_000
2078
+ _total_llm_ms += _round_ms
2079
+
2080
+ if reason == "tool_calls" and raw_tool_calls and raw_msg:
2081
+ total_tool_rounds += 1
2082
+ logger.info(
2083
+ "Tool round %d: %d calls requested",
2084
+ total_tool_rounds, len(raw_tool_calls),
2085
+ )
2086
+
2087
+ # Safety: prevent runaway tool loops
2088
+ if total_tool_rounds > max_tool_rounds:
2089
+ logger.warning(
2090
+ "Max tool rounds (%d) exceeded — forcing completion",
2091
+ max_tool_rounds,
2092
+ )
2093
+ # Re-send without tools to force a text response
2094
+ try:
2095
+ output, finish_reason = self._provider.generate_chat(
2096
+ messages, max_tokens=g,
2097
+ )
2098
+ except Exception as exc:
2099
+ logger.error("Provider error (tool fallback): %s", exc)
2100
+ raise ProviderError(_safe_provider_error(exc)) from exc
2101
+ break
2102
+
2103
+ # Execute tool calls
2104
+ parsed_calls = [
2105
+ ToolCall(
2106
+ id=tc["id"],
2107
+ name=tc["function"]["name"],
2108
+ arguments=tc["function"]["arguments"],
2109
+ )
2110
+ for tc in raw_tool_calls
2111
+ ]
2112
+ results = executor.execute_batch(parsed_calls)
2113
+ total_tool_tokens += sum(r.tokens_used for r in results)
2114
+
2115
+ # Log tool calls for telemetry
2116
+ for tc, result in zip(parsed_calls, results):
2117
+ tool_calls_log.append({
2118
+ "round": total_tool_rounds,
2119
+ "tool": tc.name,
2120
+ "args": tc.arguments,
2121
+ "tokens_returned": result.tokens_used,
2122
+ })
2123
+
2124
+ # Build round-trip messages and append to conversation
2125
+ round_messages = tool_results_to_messages(raw_msg, results)
2126
+ messages.extend(round_messages)
2127
+
2128
+ logger.info(
2129
+ "Tool round %d complete: %d tokens served (cumulative: %d)",
2130
+ total_tool_rounds, sum(r.tokens_used for r in results),
2131
+ total_tool_tokens,
2132
+ )
2133
+ continue # Next iteration — LLM gets tool results
2134
+
2135
+ # Not a tool call — we have final output
2136
+ output = text
2137
+ finish_reason = reason
2138
+ break
2139
+
2140
+ node.finish_reason = finish_reason
2141
+ node.raw_output_id = str(uuid.uuid4())
2142
+ node.advance(WindowState.COMPLETED)
2143
+
2144
+ logger.info(
2145
+ "Tool-mediated dispatch done: finish=%s, tool_rounds=%d, "
2146
+ "tool_tokens=%d, output_chars=%d, llm_ms=%.0f",
2147
+ finish_reason, total_tool_rounds, total_tool_tokens,
2148
+ len(output), _total_llm_ms,
2149
+ )
2150
+
2151
+ # ---------- Extract facts from output ----------
2152
+ output_tokens = self._provider.count_tokens(output)
2153
+ task_intent = TaskIntent(
2154
+ task_input=task_input,
2155
+ system_prompt=system_prompt,
2156
+ )
2157
+ extraction = self._extract_and_store(output, window_id, task_intent)
2158
+ node.advance(WindowState.EXTRACTED)
2159
+ node.facts_produced = [f.id for f in extraction.facts]
2160
+
2161
+ # ---------- Track injection in output ----------
2162
+ if extraction.facts:
2163
+ penalized = sum(1 for f in extraction.facts if f.flagged_confidence
2164
+ and "injection_in_fact" in f.confidence_flag_reason)
2165
+ security_flags.output_injection_facts_penalized = penalized
2166
+
2167
+ # ---------- Quarantine promotion ----------
2168
+ if self._quarantine.quarantine_count > 0 and extraction.facts:
2169
+ extraction_texts = {f.id: f.text for f in extraction.facts}
2170
+ self._quarantine.validate_and_promote(window_id, extraction_texts)
2171
+
2172
+ # ---------- LLM context curation ----------
2173
+ if self._curator.should_curate(self._windows_completed):
2174
+ try:
2175
+ ranked_facts = self._warm_store.get_ranked_facts(limit=50)
2176
+ fact_texts = [f.text for f in ranked_facts]
2177
+ self._curator.curate(
2178
+ window_index=self._windows_completed,
2179
+ top_facts=fact_texts,
2180
+ recent_output_summary=output[:500],
2181
+ )
2182
+ except Exception as exc:
2183
+ logger.debug("Curator skipped: %s", exc)
2184
+
2185
+ # ---------- Update counters ----------
2186
+ self._windows_completed += 1
2187
+ e_tokens = total_tool_tokens # Context served via tools, not envelope
2188
+ input_tokens = s_tokens + t_tokens + e_tokens
2189
+ self._total_input_tokens += input_tokens
2190
+ self._total_output_tokens += output_tokens
2191
+ self._rbac.record_dispatch(tokens_used=output_tokens)
2192
+
2193
+ # ---------- Metrics ----------
2194
+ _total_dispatch_ms = (time.monotonic_ns() - _dispatch_start_ns) / 1_000_000
2195
+ _crp_overhead_ms = _total_dispatch_ms - _total_llm_ms
2196
+ _crp_overhead_pct = (_crp_overhead_ms / _total_dispatch_ms * 100) if _total_dispatch_ms > 0 else 0.0
2197
+
2198
+ metrics = WindowMetrics(
2199
+ window_id=node.window_id,
2200
+ chain_position=0,
2201
+ system_tokens=s_tokens,
2202
+ task_tokens=t_tokens,
2203
+ envelope_tokens=0, # No envelope in pull mode
2204
+ envelope_budget=0,
2205
+ saturation=0.0, # N/A — context pulled on demand
2206
+ generation_reserve=g,
2207
+ generation_tokens=output_tokens,
2208
+ generation_speed=output_tokens / (_total_llm_ms / 1000) if _total_llm_ms > 0 else 0.0,
2209
+ wall_time_ms=int(_total_llm_ms),
2210
+ finish_reason=finish_reason,
2211
+ facts_extracted=extraction.total_facts,
2212
+ continuation_triggered=False,
2213
+ continuation_index=0,
2214
+ envelope_latency_ms=0.0,
2215
+ extraction_latency_ms=0.0,
2216
+ extraction_stage_used=",".join(str(s) for s in getattr(extraction, "stages_run", [])),
2217
+ gap_coverage=0.0,
2218
+ final_gap_score=0.0,
2219
+ total_output_tokens=output_tokens,
2220
+ reasoning_tokens=0,
2221
+ total_dispatch_ms=round(_total_dispatch_ms),
2222
+ total_llm_ms=round(_total_llm_ms),
2223
+ total_extraction_ms=0.0,
2224
+ total_envelope_ms=0.0,
2225
+ crp_overhead_ms=round(_crp_overhead_ms),
2226
+ crp_overhead_pct=round(_crp_overhead_pct, 1),
2227
+ # Tool-mediated telemetry (new fields)
2228
+ tool_rounds=total_tool_rounds,
2229
+ tool_tokens_served=total_tool_tokens,
2230
+ tool_calls_detail=tool_calls_log,
2231
+ # Resource tracking
2232
+ **self._resource_fields(),
2233
+ # Marginal gain / sections
2234
+ **self._marginal_fields(output, _facts_before),
2235
+ # Adaptive allocator telemetry
2236
+ **self._allocator_fields(),
2237
+ )
2238
+ self._record_dispatch_overhead(_total_dispatch_ms, _total_llm_ms)
2239
+
2240
+ quality_tier = _classify_quality_tier(
2241
+ facts_extracted=extraction.total_facts,
2242
+ continuation_windows=0,
2243
+ saturation=0.0,
2244
+ finish_reason=finish_reason,
2245
+ output_tokens=output_tokens,
2246
+ output_length=len(output),
2247
+ )
2248
+
2249
+ report = QualityReport(
2250
+ session_id=self._session.session_id,
2251
+ window_id=node.window_id,
2252
+ output=output,
2253
+ facts_extracted=extraction.total_facts,
2254
+ security_flags=security_flags,
2255
+ continuation_windows=0,
2256
+ envelope_saturation=0.0,
2257
+ quality_tier=quality_tier,
2258
+ telemetry=metrics.to_dict(),
2259
+ )
2260
+
2261
+ return output, report
2262
+
2263
+ # ------------------------------------------------------------------
2264
+ # §21.1 Reflexive dispatch — Verify-then-Refine
2265
+ # ------------------------------------------------------------------
2266
+
2267
+ def dispatch_reflexive(
2268
+ self,
2269
+ system_prompt: str,
2270
+ task_input: str,
2271
+ *,
2272
+ max_refinement_passes: int = 2,
2273
+ **kwargs: Any,
2274
+ ) -> tuple[str, QualityReport]:
2275
+ """Reflexive dispatch — generate first, verify against KB, refine.
2276
+
2277
+ Unlike push (all context upfront) or pull (LLM asks for context),
2278
+ reflexive dispatch lets the model generate freely, then CRP
2279
+ fact-checks the output against the knowledge base and sends
2280
+ back targeted corrections. The model refines based on SPECIFIC
2281
+ evidence rather than wading through pre-loaded context.
2282
+
2283
+ Flow:
2284
+ Pass 1: Generate with NO envelope (pure parametric knowledge)
2285
+ CRP: Analyze output — find contradictions, unsupported claims
2286
+ Pass 2: Send model its own output + correction payload
2287
+ Model: Refines with surgical precision
2288
+ (Optional Pass 3+ if coverage remains low)
2289
+
2290
+ Args:
2291
+ system_prompt: System prompt (unmodified per Axiom 4).
2292
+ task_input: User task/prompt.
2293
+ max_refinement_passes: Max verify-refine cycles (safety cap).
2294
+ **kwargs: Provider-specific overrides.
2295
+
2296
+ Returns:
2297
+ (output_text, QualityReport) — same interface as dispatch().
2298
+ """
2299
+ from crp.core.relay_strategies import (
2300
+ analyze_output_against_kb,
2301
+ build_refinement_prompt,
2302
+ )
2303
+
2304
+ _dispatch_start_ns = time.monotonic_ns()
2305
+ _facts_before = self._warm_store.fact_count
2306
+ self._check_session()
2307
+
2308
+ # ---------- RBAC + validation ----------
2309
+ from crp.security.rbac import Permission
2310
+ perm_result = self._rbac.check_permission(Permission.DISPATCH)
2311
+ if not perm_result.allowed:
2312
+ raise RateLimitExceededError(perm_result.reason)
2313
+ rate_result = self._rbac.check_rate_limit(Permission.DISPATCH)
2314
+ if not rate_result.allowed:
2315
+ raise RateLimitExceededError(rate_result.reason)
2316
+
2317
+ val_result = self._input_validator.validate(task_input)
2318
+ if not val_result.valid:
2319
+ raise ValidationError(val_result.warnings[0] if val_result.warnings else "Input validation failed")
2320
+ task_input = val_result.sanitized_text
2321
+
2322
+ security_flags = self._scan_injection(task_input)
2323
+ security_flags.unicode_normalized = val_result.sanitized_size != val_result.original_size
2324
+ security_flags.control_chars_stripped = val_result.control_chars_removed
2325
+
2326
+ # ---------- Token measurements ----------
2327
+ s_tokens = self._provider.count_tokens(system_prompt)
2328
+ t_tokens = self._provider.count_tokens(task_input)
2329
+ context_window = self._provider.context_window_size()
2330
+
2331
+ max_out = kwargs.get("max_output_tokens")
2332
+ g = resolve_generation_reserve(
2333
+ max_out, self._provider.max_output_tokens, context_window,
2334
+ is_thinking_model=getattr(self._provider, "is_thinking_model", False),
2335
+ )
2336
+
2337
+ # ---------- Advance warm store window ----------
2338
+ window_id = str(uuid.uuid4())
2339
+ self._warm_store.advance_window(window_id)
2340
+
2341
+ node = WindowNode(
2342
+ window_id=window_id,
2343
+ system_prompt_hash=hashlib.sha256(system_prompt.encode()).hexdigest(),
2344
+ task_input_hash=hashlib.sha256(task_input.encode()).hexdigest(),
2345
+ continuation_index=0,
2346
+ )
2347
+ self._dag.add_node(node)
2348
+ node.advance(WindowState.ASSEMBLED)
2349
+
2350
+ # ===== PASS 1: Generate with NO context (pure parametric knowledge) =====
2351
+ messages_pass1: list[dict[str, str]] = [
2352
+ {"role": "system", "content": system_prompt},
2353
+ {"role": "user", "content": task_input},
2354
+ ]
2355
+
2356
+ node.advance(WindowState.DISPATCHED)
2357
+ node.advance(WindowState.GENERATING)
2358
+
2359
+ # Circuit breaker gate (§audit3: protect all dispatch variants)
2360
+ if not self._circuit_breaker.allow_request():
2361
+ raise ProviderError(
2362
+ "Circuit breaker OPEN — provider unavailable, "
2363
+ "retry after recovery timeout"
2364
+ )
2365
+
2366
+ _llm_start = time.monotonic_ns()
2367
+ try:
2368
+ output, finish_reason = self._provider.generate_chat(messages_pass1, max_tokens=g)
2369
+ self._circuit_breaker.record_success()
2370
+ except Exception as exc:
2371
+ self._circuit_breaker.record_failure()
2372
+ logger.error("Provider error (reflexive): %s", exc)
2373
+ raise ProviderError(_safe_provider_error(exc)) from exc
2374
+ _total_llm_ms = (time.monotonic_ns() - _llm_start) / 1_000_000
2375
+
2376
+ total_passes = 1
2377
+ total_corrections = 0
2378
+ final_coverage = 0.0
2379
+
2380
+ logger.info(
2381
+ "Reflexive pass 1 (no context): %d chars, finish=%s",
2382
+ len(output), finish_reason,
2383
+ )
2384
+
2385
+ # ===== VERIFY & REFINE LOOP =====
2386
+ embed_fn = self._embedding_fn if hasattr(self, "_embedding_fn") else None
2387
+ for pass_num in range(max_refinement_passes):
2388
+ # Analyze output against knowledge base
2389
+ analysis = analyze_output_against_kb(
2390
+ output=output,
2391
+ warm_store=self._warm_store,
2392
+ count_tokens=self._provider.count_tokens,
2393
+ embed_fn=embed_fn,
2394
+ )
2395
+ final_coverage = analysis.coverage_score
2396
+ total_corrections += len(analysis.corrections)
2397
+
2398
+ if not analysis.needs_refinement:
2399
+ logger.info(
2400
+ "Reflexive pass %d: coverage=%.2f, no refinement needed",
2401
+ pass_num + 1, analysis.coverage_score,
2402
+ )
2403
+ break
2404
+
2405
+ # Build correction payload
2406
+ correction_prompt = build_refinement_prompt(
2407
+ original_output=output,
2408
+ analysis=analysis,
2409
+ count_tokens=self._provider.count_tokens,
2410
+ )
2411
+
2412
+ # Pass 2+: Send model its own output + corrections
2413
+ messages_refine: list[dict[str, str]] = [
2414
+ {"role": "system", "content": system_prompt},
2415
+ {"role": "user", "content": task_input},
2416
+ {"role": "assistant", "content": output},
2417
+ {"role": "user", "content": correction_prompt},
2418
+ ]
2419
+
2420
+ # Circuit breaker gate for refinement pass (§audit4 M6)
2421
+ if not self._circuit_breaker.allow_request():
2422
+ raise ProviderError(
2423
+ "Circuit breaker OPEN — provider unavailable during reflexive refinement"
2424
+ )
2425
+
2426
+ _llm_refine_start = time.monotonic_ns()
2427
+ try:
2428
+ output, finish_reason = self._provider.generate_chat(
2429
+ messages_refine, max_tokens=g,
2430
+ )
2431
+ self._circuit_breaker.record_success()
2432
+ except Exception as exc:
2433
+ self._circuit_breaker.record_failure()
2434
+ logger.error("Provider error (reflexive refinement): %s", exc)
2435
+ raise ProviderError(_safe_provider_error(exc)) from exc
2436
+ _total_llm_ms += (time.monotonic_ns() - _llm_refine_start) / 1_000_000
2437
+ total_passes += 1
2438
+
2439
+ logger.info(
2440
+ "Reflexive pass %d: %d corrections applied, coverage=%.2f → %d chars",
2441
+ pass_num + 2, len(analysis.corrections),
2442
+ analysis.coverage_score, len(output),
2443
+ )
2444
+
2445
+ node.finish_reason = finish_reason
2446
+ node.raw_output_id = str(uuid.uuid4())
2447
+ node.advance(WindowState.COMPLETED)
2448
+
2449
+ # ---------- Extract facts from FINAL output ----------
2450
+ output_tokens = self._provider.count_tokens(output)
2451
+ task_intent = TaskIntent(task_input=task_input, system_prompt=system_prompt)
2452
+ extraction = self._extract_and_store(output, window_id, task_intent)
2453
+ node.advance(WindowState.EXTRACTED)
2454
+ node.facts_produced = [f.id for f in extraction.facts]
2455
+
2456
+ # ---------- Update counters ----------
2457
+ self._windows_completed += 1
2458
+ input_tokens = s_tokens + t_tokens
2459
+ self._total_input_tokens += input_tokens
2460
+ self._total_output_tokens += output_tokens
2461
+ self._rbac.record_dispatch(tokens_used=output_tokens)
2462
+
2463
+ # ---------- Metrics ----------
2464
+ _total_dispatch_ms = (time.monotonic_ns() - _dispatch_start_ns) / 1_000_000
2465
+ _crp_overhead_ms = _total_dispatch_ms - _total_llm_ms
2466
+ _crp_overhead_pct = (_crp_overhead_ms / _total_dispatch_ms * 100) if _total_dispatch_ms > 0 else 0.0
2467
+
2468
+ metrics = WindowMetrics(
2469
+ window_id=node.window_id,
2470
+ chain_position=0,
2471
+ system_tokens=s_tokens,
2472
+ task_tokens=t_tokens,
2473
+ envelope_tokens=0,
2474
+ envelope_budget=0,
2475
+ saturation=0.0,
2476
+ generation_reserve=g,
2477
+ generation_tokens=output_tokens,
2478
+ generation_speed=output_tokens / (_total_llm_ms / 1000) if _total_llm_ms > 0 else 0.0,
2479
+ wall_time_ms=int(_total_llm_ms),
2480
+ finish_reason=finish_reason,
2481
+ facts_extracted=extraction.total_facts,
2482
+ continuation_triggered=False,
2483
+ continuation_index=0,
2484
+ total_dispatch_ms=round(_total_dispatch_ms),
2485
+ total_llm_ms=round(_total_llm_ms),
2486
+ crp_overhead_ms=round(_crp_overhead_ms),
2487
+ crp_overhead_pct=round(_crp_overhead_pct, 1),
2488
+ total_output_tokens=output_tokens,
2489
+ extraction_stage_used=",".join(str(s) for s in getattr(extraction, "stages_run", [])),
2490
+ # §21.1 Reflexive telemetry
2491
+ relay_strategy="reflexive",
2492
+ reflexive_passes=total_passes,
2493
+ reflexive_corrections=total_corrections,
2494
+ reflexive_coverage=round(final_coverage, 3),
2495
+ # Resource tracking
2496
+ **self._resource_fields(),
2497
+ # Marginal gain / sections
2498
+ **self._marginal_fields(output, _facts_before),
2499
+ # Adaptive allocator telemetry
2500
+ **self._allocator_fields(),
2501
+ )
2502
+ self._record_dispatch_overhead(_total_dispatch_ms, _total_llm_ms)
2503
+
2504
+ quality_tier = _classify_quality_tier(
2505
+ facts_extracted=extraction.total_facts,
2506
+ continuation_windows=0,
2507
+ saturation=0.0,
2508
+ finish_reason=finish_reason,
2509
+ output_tokens=output_tokens,
2510
+ output_length=len(output),
2511
+ )
2512
+
2513
+ report = QualityReport(
2514
+ session_id=self._session.session_id,
2515
+ window_id=node.window_id,
2516
+ output=output,
2517
+ facts_extracted=extraction.total_facts,
2518
+ security_flags=security_flags,
2519
+ continuation_windows=0,
2520
+ envelope_saturation=0.0,
2521
+ quality_tier=quality_tier,
2522
+ telemetry=metrics.to_dict(),
2523
+ )
2524
+
2525
+ return output, report
2526
+
2527
+ # ------------------------------------------------------------------
2528
+ # §21.2 Progressive disclosure — Index → Detail on Demand
2529
+ # ------------------------------------------------------------------
2530
+
2531
+ def dispatch_progressive(
2532
+ self,
2533
+ system_prompt: str,
2534
+ task_input: str,
2535
+ **kwargs: Any,
2536
+ ) -> tuple[str, QualityReport]:
2537
+ """Progressive disclosure — send context INDEX first, details on demand.
2538
+
2539
+ Instead of sending ALL facts (push) or none (pull/tools),
2540
+ progressive disclosure sends a compact INDEX of available facts
2541
+ — one-line summaries — so the model sees WHAT knowledge exists
2542
+ at ~10% of the token cost. After initial generation, CRP detects
2543
+ which indexed facts were actually referenced, expands them to
2544
+ full detail, and the model refines with targeted depth.
2545
+
2546
+ Flow:
2547
+ Step 1: Build compact context index from WarmStore
2548
+ Step 2: Send task + index to model (model sees all topics cheaply)
2549
+ Step 3: CRP detects which index entries were referenced
2550
+ Step 4: Expand referenced entries to full detail
2551
+ Step 5: Model refines with deep context on referenced items only
2552
+
2553
+ Args:
2554
+ system_prompt: System prompt (unmodified per Axiom 4).
2555
+ task_input: User task/prompt.
2556
+ **kwargs: Provider-specific overrides.
2557
+
2558
+ Returns:
2559
+ (output_text, QualityReport) — same interface as dispatch().
2560
+ """
2561
+ from crp.core.relay_strategies import (
2562
+ build_context_index,
2563
+ build_detail_injection,
2564
+ detect_index_references,
2565
+ )
2566
+
2567
+ _dispatch_start_ns = time.monotonic_ns()
2568
+ _facts_before = self._warm_store.fact_count
2569
+ self._check_session()
2570
+
2571
+ # ---------- RBAC + validation ----------
2572
+ from crp.security.rbac import Permission
2573
+ perm_result = self._rbac.check_permission(Permission.DISPATCH)
2574
+ if not perm_result.allowed:
2575
+ raise RateLimitExceededError(perm_result.reason)
2576
+ rate_result = self._rbac.check_rate_limit(Permission.DISPATCH)
2577
+ if not rate_result.allowed:
2578
+ raise RateLimitExceededError(rate_result.reason)
2579
+
2580
+ val_result = self._input_validator.validate(task_input)
2581
+ if not val_result.valid:
2582
+ raise ValidationError(val_result.warnings[0] if val_result.warnings else "Input validation failed")
2583
+ task_input = val_result.sanitized_text
2584
+
2585
+ security_flags = self._scan_injection(task_input)
2586
+ security_flags.unicode_normalized = val_result.sanitized_size != val_result.original_size
2587
+ security_flags.control_chars_stripped = val_result.control_chars_removed
2588
+
2589
+ # ---------- Token measurements ----------
2590
+ s_tokens = self._provider.count_tokens(system_prompt)
2591
+ t_tokens = self._provider.count_tokens(task_input)
2592
+ context_window = self._provider.context_window_size()
2593
+
2594
+ max_out = kwargs.get("max_output_tokens")
2595
+ g = resolve_generation_reserve(
2596
+ max_out, self._provider.max_output_tokens, context_window,
2597
+ is_thinking_model=getattr(self._provider, "is_thinking_model", False),
2598
+ )
2599
+
2600
+ # ---------- Build context index ----------
2601
+ index = build_context_index(
2602
+ warm_store=self._warm_store,
2603
+ count_tokens=self._provider.count_tokens,
2604
+ )
2605
+ index_text = index.to_text()
2606
+ index_tokens = self._provider.count_tokens(index_text) if index_text else 0
2607
+
2608
+ # ---------- Advance warm store window ----------
2609
+ window_id = str(uuid.uuid4())
2610
+ self._warm_store.advance_window(window_id)
2611
+
2612
+ node = WindowNode(
2613
+ window_id=window_id,
2614
+ system_prompt_hash=hashlib.sha256(system_prompt.encode()).hexdigest(),
2615
+ task_input_hash=hashlib.sha256(task_input.encode()).hexdigest(),
2616
+ continuation_index=0,
2617
+ )
2618
+ self._dag.add_node(node)
2619
+ node.advance(WindowState.ASSEMBLED)
2620
+
2621
+ # ===== PASS 1: Generate with context INDEX (not full facts) =====
2622
+ messages_indexed: list[dict[str, str]] = [
2623
+ {"role": "system", "content": system_prompt},
2624
+ ]
2625
+ if index_text:
2626
+ messages_indexed.append({
2627
+ "role": "user",
2628
+ "content": (
2629
+ f"[CONTEXT INDEX — compact summaries of verified knowledge]\n"
2630
+ f"{index_text}\n"
2631
+ f"[END CONTEXT INDEX]\n\n"
2632
+ f"You may reference items by their [F#] ID if you find them "
2633
+ f"relevant to the task."
2634
+ ),
2635
+ })
2636
+ messages_indexed.append({"role": "user", "content": task_input})
2637
+
2638
+ node.advance(WindowState.DISPATCHED)
2639
+ node.advance(WindowState.GENERATING)
2640
+
2641
+ # Circuit breaker gate (§audit3: protect all dispatch variants)
2642
+ if not self._circuit_breaker.allow_request():
2643
+ raise ProviderError(
2644
+ "Circuit breaker OPEN — provider unavailable, "
2645
+ "retry after recovery timeout"
2646
+ )
2647
+
2648
+ _llm_start = time.monotonic_ns()
2649
+ try:
2650
+ output, finish_reason = self._provider.generate_chat(
2651
+ messages_indexed, max_tokens=g,
2652
+ )
2653
+ self._circuit_breaker.record_success()
2654
+ except Exception as exc:
2655
+ self._circuit_breaker.record_failure()
2656
+ logger.error("Provider error (progressive index): %s", exc)
2657
+ raise ProviderError(_safe_provider_error(exc)) from exc
2658
+ _total_llm_ms = (time.monotonic_ns() - _llm_start) / 1_000_000
2659
+
2660
+ logger.info(
2661
+ "Progressive pass 1 (index): %d index entries (%d tokens), "
2662
+ "output=%d chars",
2663
+ len(index.entries), index_tokens, len(output),
2664
+ )
2665
+
2666
+ # ===== DETECT REFERENCES & EXPAND =====
2667
+ detail_tokens = 0
2668
+ detail_entries = 0
2669
+ if index.entries:
2670
+ referenced = detect_index_references(output, index)
2671
+ detail_entries = len(referenced)
2672
+
2673
+ if referenced:
2674
+ detail_text = build_detail_injection(
2675
+ referenced,
2676
+ count_tokens=self._provider.count_tokens,
2677
+ )
2678
+ detail_tokens = self._provider.count_tokens(detail_text) if detail_text else 0
2679
+
2680
+ if detail_text:
2681
+ # Pass 2: Refine with expanded details
2682
+ messages_detail: list[dict[str, str]] = [
2683
+ {"role": "system", "content": system_prompt},
2684
+ {"role": "user", "content": task_input},
2685
+ {"role": "assistant", "content": output},
2686
+ {
2687
+ "role": "user",
2688
+ "content": (
2689
+ f"{detail_text}\n\n"
2690
+ f"Please refine your response incorporating the "
2691
+ f"expanded details above. Maintain your structure "
2692
+ f"but enhance accuracy with the full verified content."
2693
+ ),
2694
+ },
2695
+ ]
2696
+
2697
+ # Circuit breaker gate for detail pass (\u00a7audit4 M7)
2698
+ if not self._circuit_breaker.allow_request():
2699
+ raise ProviderError(
2700
+ "Circuit breaker OPEN \u2014 provider unavailable during progressive detail"
2701
+ )
2702
+
2703
+ _llm_detail_start = time.monotonic_ns()
2704
+ try:
2705
+ output, finish_reason = self._provider.generate_chat(
2706
+ messages_detail, max_tokens=g,
2707
+ )
2708
+ self._circuit_breaker.record_success()
2709
+ except Exception as exc:
2710
+ self._circuit_breaker.record_failure()
2711
+ logger.error("Provider error (progressive detail): %s", exc)
2712
+ raise ProviderError(_safe_provider_error(exc)) from exc
2713
+ _total_llm_ms += (time.monotonic_ns() - _llm_detail_start) / 1_000_000
2714
+
2715
+ logger.info(
2716
+ "Progressive pass 2 (detail): %d entries expanded (%d tokens), "
2717
+ "output=%d chars",
2718
+ detail_entries, detail_tokens, len(output),
2719
+ )
2720
+
2721
+ node.finish_reason = finish_reason
2722
+ node.raw_output_id = str(uuid.uuid4())
2723
+ node.advance(WindowState.COMPLETED)
2724
+
2725
+ # ---------- Extract facts from final output ----------
2726
+ output_tokens = self._provider.count_tokens(output)
2727
+ task_intent = TaskIntent(task_input=task_input, system_prompt=system_prompt)
2728
+ extraction = self._extract_and_store(output, window_id, task_intent)
2729
+ node.advance(WindowState.EXTRACTED)
2730
+ node.facts_produced = [f.id for f in extraction.facts]
2731
+
2732
+ # ---------- Update counters ----------
2733
+ self._windows_completed += 1
2734
+ input_tokens = s_tokens + t_tokens + index_tokens + detail_tokens
2735
+ self._total_input_tokens += input_tokens
2736
+ self._total_output_tokens += output_tokens
2737
+ self._rbac.record_dispatch(tokens_used=output_tokens)
2738
+
2739
+ # ---------- Metrics ----------
2740
+ _total_dispatch_ms = (time.monotonic_ns() - _dispatch_start_ns) / 1_000_000
2741
+ _crp_overhead_ms = _total_dispatch_ms - _total_llm_ms
2742
+ _crp_overhead_pct = (_crp_overhead_ms / _total_dispatch_ms * 100) if _total_dispatch_ms > 0 else 0.0
2743
+
2744
+ metrics = WindowMetrics(
2745
+ window_id=node.window_id,
2746
+ chain_position=0,
2747
+ system_tokens=s_tokens,
2748
+ task_tokens=t_tokens,
2749
+ envelope_tokens=index_tokens + detail_tokens,
2750
+ envelope_budget=0,
2751
+ saturation=0.0,
2752
+ generation_reserve=g,
2753
+ generation_tokens=output_tokens,
2754
+ generation_speed=output_tokens / (_total_llm_ms / 1000) if _total_llm_ms > 0 else 0.0,
2755
+ wall_time_ms=int(_total_llm_ms),
2756
+ finish_reason=finish_reason,
2757
+ facts_extracted=extraction.total_facts,
2758
+ continuation_triggered=False,
2759
+ continuation_index=0,
2760
+ total_dispatch_ms=round(_total_dispatch_ms),
2761
+ total_llm_ms=round(_total_llm_ms),
2762
+ crp_overhead_ms=round(_crp_overhead_ms),
2763
+ crp_overhead_pct=round(_crp_overhead_pct, 1),
2764
+ total_output_tokens=output_tokens,
2765
+ extraction_stage_used=",".join(str(s) for s in getattr(extraction, "stages_run", [])),
2766
+ # §21.2 Progressive telemetry
2767
+ relay_strategy="progressive",
2768
+ progressive_index_entries=len(index.entries),
2769
+ progressive_index_tokens=index_tokens,
2770
+ progressive_detail_entries=detail_entries,
2771
+ progressive_detail_tokens=detail_tokens,
2772
+ # Resource tracking
2773
+ **self._resource_fields(),
2774
+ # Marginal gain / sections
2775
+ **self._marginal_fields(output, _facts_before),
2776
+ # Adaptive allocator telemetry
2777
+ **self._allocator_fields(),
2778
+ )
2779
+ self._record_dispatch_overhead(_total_dispatch_ms, _total_llm_ms)
2780
+
2781
+ quality_tier = _classify_quality_tier(
2782
+ facts_extracted=extraction.total_facts,
2783
+ continuation_windows=0,
2784
+ saturation=0.0,
2785
+ finish_reason=finish_reason,
2786
+ output_tokens=output_tokens,
2787
+ output_length=len(output),
2788
+ )
2789
+
2790
+ report = QualityReport(
2791
+ session_id=self._session.session_id,
2792
+ window_id=node.window_id,
2793
+ output=output,
2794
+ facts_extracted=extraction.total_facts,
2795
+ security_flags=security_flags,
2796
+ continuation_windows=0,
2797
+ envelope_saturation=0.0,
2798
+ quality_tier=quality_tier,
2799
+ telemetry=metrics.to_dict(),
2800
+ )
2801
+
2802
+ return output, report
2803
+
2804
+ # ------------------------------------------------------------------
2805
+ # §21.3 Stream-augmented generation — Real-time Context Injection
2806
+ # ------------------------------------------------------------------
2807
+
2808
+ def dispatch_stream_augmented(
2809
+ self,
2810
+ system_prompt: str,
2811
+ task_input: str,
2812
+ *,
2813
+ max_injections: int = 5,
2814
+ **kwargs: Any,
2815
+ ) -> tuple[str, QualityReport]:
2816
+ """Stream-augmented generation — inject context mid-generation.
2817
+
2818
+ The most novel strategy: CRP monitors the LLM's output stream
2819
+ in real-time, buffering sentences. After each sentence, CRP
2820
+ checks if relevant WarmStore facts exist for the topic being
2821
+ generated. When relevant facts are found, generation is
2822
+ PAUSED, the partial output + injected facts are sent as a
2823
+ continuation, and the model resumes — now informed.
2824
+
2825
+ The model receives context EXACTLY when it's generating about
2826
+ a relevant topic, not before (wasted) and not only when it
2827
+ asks (pull). This is point-of-need context delivery.
2828
+
2829
+ Flow:
2830
+ 1. Start streaming generation (no envelope)
2831
+ 2. Buffer tokens into sentences
2832
+ 3. After each sentence: CRP fact-matches against WarmStore
2833
+ 4. If relevant NEW facts found → stop generation
2834
+ 5. Send partial output + injected facts as continuation prompt
2835
+ 6. Resume generation from where model left off
2836
+ 7. Repeat until generation completes or max_injections reached
2837
+
2838
+ Args:
2839
+ system_prompt: System prompt (unmodified per Axiom 4).
2840
+ task_input: User task/prompt.
2841
+ max_injections: Maximum mid-stream injections (safety cap).
2842
+ **kwargs: Provider-specific overrides.
2843
+
2844
+ Returns:
2845
+ (output_text, QualityReport) — same interface as dispatch().
2846
+ """
2847
+ from crp.core.relay_strategies import (
2848
+ AugmentationEvent,
2849
+ StreamAugmentationState,
2850
+ build_augmented_continuation,
2851
+ find_relevant_facts_for_sentence,
2852
+ )
2853
+
2854
+ _dispatch_start_ns = time.monotonic_ns()
2855
+ _facts_before = self._warm_store.fact_count
2856
+ self._check_session()
2857
+
2858
+ # ---------- RBAC + validation ----------
2859
+ from crp.security.rbac import Permission
2860
+ perm_result = self._rbac.check_permission(Permission.DISPATCH)
2861
+ if not perm_result.allowed:
2862
+ raise RateLimitExceededError(perm_result.reason)
2863
+ rate_result = self._rbac.check_rate_limit(Permission.DISPATCH)
2864
+ if not rate_result.allowed:
2865
+ raise RateLimitExceededError(rate_result.reason)
2866
+
2867
+ val_result = self._input_validator.validate(task_input)
2868
+ if not val_result.valid:
2869
+ raise ValidationError(val_result.warnings[0] if val_result.warnings else "Input validation failed")
2870
+ task_input = val_result.sanitized_text
2871
+
2872
+ security_flags = self._scan_injection(task_input)
2873
+ security_flags.unicode_normalized = val_result.sanitized_size != val_result.original_size
2874
+ security_flags.control_chars_stripped = val_result.control_chars_removed
2875
+
2876
+ # ---------- Token measurements ----------
2877
+ s_tokens = self._provider.count_tokens(system_prompt)
2878
+ t_tokens = self._provider.count_tokens(task_input)
2879
+ context_window = self._provider.context_window_size()
2880
+
2881
+ max_out = kwargs.get("max_output_tokens")
2882
+ g = resolve_generation_reserve(
2883
+ max_out, self._provider.max_output_tokens, context_window,
2884
+ is_thinking_model=getattr(self._provider, "is_thinking_model", False),
2885
+ )
2886
+
2887
+ # ---------- Advance warm store window ----------
2888
+ window_id = str(uuid.uuid4())
2889
+ self._warm_store.advance_window(window_id)
2890
+
2891
+ node = WindowNode(
2892
+ window_id=window_id,
2893
+ system_prompt_hash=hashlib.sha256(system_prompt.encode()).hexdigest(),
2894
+ task_input_hash=hashlib.sha256(task_input.encode()).hexdigest(),
2895
+ continuation_index=0,
2896
+ )
2897
+ self._dag.add_node(node)
2898
+ node.advance(WindowState.ASSEMBLED)
2899
+ node.advance(WindowState.DISPATCHED)
2900
+ node.advance(WindowState.GENERATING)
2901
+
2902
+ # ===== STREAMING WITH REAL-TIME AUGMENTATION =====
2903
+ aug_state = StreamAugmentationState()
2904
+ already_injected: set[str] = set()
2905
+ _total_llm_ms = 0.0
2906
+ finish_reason = "stop"
2907
+ accumulated_output = ""
2908
+ embed_fn = self._embedding_fn if hasattr(self, "_embedding_fn") else None
2909
+
2910
+ # Initial messages — no envelope, just system prompt + task
2911
+ current_messages: list[dict[str, str]] = [
2912
+ {"role": "system", "content": system_prompt},
2913
+ {"role": "user", "content": task_input},
2914
+ ]
2915
+
2916
+ generation_round = 0
2917
+ while generation_round <= max_injections:
2918
+ generation_round += 1
2919
+ sentence_buffer = ""
2920
+ round_output_chunks: list[str] = []
2921
+ injection_triggered = False
2922
+
2923
+ # Circuit breaker gate (§audit3: protect all dispatch variants)
2924
+ if not self._circuit_breaker.allow_request():
2925
+ raise ProviderError(
2926
+ "Circuit breaker OPEN — provider unavailable, "
2927
+ "retry after recovery timeout"
2928
+ )
2929
+
2930
+ _llm_start = time.monotonic_ns()
2931
+ try:
2932
+ gen = self._provider.generate_chat_stream(
2933
+ current_messages, max_tokens=g,
2934
+ )
2935
+ while True:
2936
+ try:
2937
+ chunk = next(gen)
2938
+ round_output_chunks.append(chunk)
2939
+ sentence_buffer += chunk
2940
+
2941
+ # Check for sentence boundary
2942
+ if any(sentence_buffer.rstrip().endswith(p) for p in ".!?\n"):
2943
+ aug_state.sentences_completed += 1
2944
+
2945
+ # Only check every 2 sentences to avoid overhead
2946
+ if aug_state.should_check and aug_state.total_injections < max_injections:
2947
+ relevant = find_relevant_facts_for_sentence(
2948
+ sentence=sentence_buffer,
2949
+ warm_store=self._warm_store,
2950
+ already_injected=already_injected,
2951
+ count_tokens=self._provider.count_tokens,
2952
+ )
2953
+ if relevant:
2954
+ # INJECTION POINT — pause generation
2955
+ _total_llm_ms += (time.monotonic_ns() - _llm_start) / 1_000_000
2956
+
2957
+ # Record partial output
2958
+ round_text = "".join(round_output_chunks)
2959
+ accumulated_output += round_text
2960
+
2961
+ # Track injection
2962
+ injection_tokens = sum(
2963
+ self._provider.count_tokens(text)
2964
+ for _, text in relevant
2965
+ )
2966
+ already_injected.update(fid for fid, _ in relevant)
2967
+ aug_state.total_injections += 1
2968
+ aug_state.total_injection_tokens += injection_tokens
2969
+ aug_state.augmentation_events.append(
2970
+ AugmentationEvent(
2971
+ sentence_index=aug_state.sentences_completed,
2972
+ trigger_text=sentence_buffer[:100],
2973
+ facts_injected=len(relevant),
2974
+ injection_tokens=injection_tokens,
2975
+ resumption_point=accumulated_output[-50:],
2976
+ )
2977
+ )
2978
+
2979
+ logger.info(
2980
+ "Stream augmentation #%d at sentence %d: "
2981
+ "%d facts injected (%d tokens)",
2982
+ aug_state.total_injections,
2983
+ aug_state.sentences_completed,
2984
+ len(relevant), injection_tokens,
2985
+ )
2986
+
2987
+ # Build continuation messages
2988
+ current_messages = build_augmented_continuation(
2989
+ system_prompt=system_prompt,
2990
+ partial_output=accumulated_output,
2991
+ injected_facts=relevant,
2992
+ task_input=task_input,
2993
+ )
2994
+ injection_triggered = True
2995
+ break # Break out of streaming loop to restart
2996
+
2997
+ sentence_buffer = "" # Reset buffer after check
2998
+
2999
+ except StopIteration as stop:
3000
+ finish_reason = stop.value or "stop"
3001
+ self._circuit_breaker.record_success()
3002
+ break
3003
+
3004
+ except Exception as exc:
3005
+ self._circuit_breaker.record_failure()
3006
+ logger.error("Stream augmentation error: %s", exc)
3007
+ finish_reason = "error"
3008
+ break
3009
+
3010
+ _total_llm_ms += (time.monotonic_ns() - _llm_start) / 1_000_000
3011
+
3012
+ if not injection_triggered:
3013
+ # Generation completed normally
3014
+ accumulated_output += "".join(round_output_chunks)
3015
+ break
3016
+
3017
+ output = accumulated_output
3018
+ node.finish_reason = finish_reason
3019
+ node.raw_output_id = str(uuid.uuid4())
3020
+ node.advance(WindowState.COMPLETED)
3021
+
3022
+ # ---------- Extract facts from final output ----------
3023
+ output_tokens = self._provider.count_tokens(output)
3024
+ task_intent = TaskIntent(task_input=task_input, system_prompt=system_prompt)
3025
+ extraction = self._extract_and_store(output, window_id, task_intent)
3026
+ node.advance(WindowState.EXTRACTED)
3027
+ node.facts_produced = [f.id for f in extraction.facts]
3028
+
3029
+ # ---------- Update counters ----------
3030
+ self._windows_completed += 1
3031
+ input_tokens = s_tokens + t_tokens + aug_state.total_injection_tokens
3032
+ self._total_input_tokens += input_tokens
3033
+ self._total_output_tokens += output_tokens
3034
+ self._rbac.record_dispatch(tokens_used=output_tokens)
3035
+
3036
+ # ---------- Metrics ----------
3037
+ _total_dispatch_ms = (time.monotonic_ns() - _dispatch_start_ns) / 1_000_000
3038
+ _crp_overhead_ms = _total_dispatch_ms - _total_llm_ms
3039
+ _crp_overhead_pct = (_crp_overhead_ms / _total_dispatch_ms * 100) if _total_dispatch_ms > 0 else 0.0
3040
+
3041
+ metrics = WindowMetrics(
3042
+ window_id=node.window_id,
3043
+ chain_position=0,
3044
+ system_tokens=s_tokens,
3045
+ task_tokens=t_tokens,
3046
+ envelope_tokens=aug_state.total_injection_tokens,
3047
+ envelope_budget=0,
3048
+ saturation=0.0,
3049
+ generation_reserve=g,
3050
+ generation_tokens=output_tokens,
3051
+ generation_speed=output_tokens / (_total_llm_ms / 1000) if _total_llm_ms > 0 else 0.0,
3052
+ wall_time_ms=int(_total_llm_ms),
3053
+ finish_reason=finish_reason,
3054
+ facts_extracted=extraction.total_facts,
3055
+ continuation_triggered=aug_state.total_injections > 0,
3056
+ continuation_index=aug_state.total_injections,
3057
+ total_dispatch_ms=round(_total_dispatch_ms),
3058
+ total_llm_ms=round(_total_llm_ms),
3059
+ crp_overhead_ms=round(_crp_overhead_ms),
3060
+ crp_overhead_pct=round(_crp_overhead_pct, 1),
3061
+ total_output_tokens=output_tokens,
3062
+ extraction_stage_used=",".join(str(s) for s in getattr(extraction, "stages_run", [])),
3063
+ # §21.3 Stream-augmented telemetry
3064
+ relay_strategy="stream_augmented",
3065
+ stream_augment_injections=aug_state.total_injections,
3066
+ stream_augment_injection_tokens=aug_state.total_injection_tokens,
3067
+ # Resource tracking
3068
+ **self._resource_fields(),
3069
+ # Marginal gain / sections
3070
+ **self._marginal_fields(output, _facts_before),
3071
+ # Adaptive allocator telemetry
3072
+ **self._allocator_fields(),
3073
+ )
3074
+ self._record_dispatch_overhead(_total_dispatch_ms, _total_llm_ms)
3075
+
3076
+ quality_tier = _classify_quality_tier(
3077
+ facts_extracted=extraction.total_facts,
3078
+ continuation_windows=0,
3079
+ saturation=0.0,
3080
+ finish_reason=finish_reason,
3081
+ output_tokens=output_tokens,
3082
+ output_length=len(output),
3083
+ )
3084
+
3085
+ report = QualityReport(
3086
+ session_id=self._session.session_id,
3087
+ window_id=node.window_id,
3088
+ output=output,
3089
+ facts_extracted=extraction.total_facts,
3090
+ security_flags=security_flags,
3091
+ continuation_windows=0,
3092
+ envelope_saturation=0.0,
3093
+ quality_tier=quality_tier,
3094
+ telemetry=metrics.to_dict(),
3095
+ )
3096
+
3097
+ return output, report
3098
+
3099
+ # ------------------------------------------------------------------
3100
+ # Agentic dispatch — LLM-in-the-loop cognitive engine (§22)
3101
+ # ------------------------------------------------------------------
3102
+
3103
+ def dispatch_agentic(
3104
+ self,
3105
+ system_prompt: str,
3106
+ task_input: str,
3107
+ *,
3108
+ max_revision_rounds: int = 2,
3109
+ enable_curation: bool = True,
3110
+ enable_planning: bool = True,
3111
+ **kwargs: Any,
3112
+ ) -> tuple[str, QualityReport]:
3113
+ """Agentic dispatch — CRP uses the LLM as its internal brain.
3114
+
3115
+ ╔═══════════════════════════════════════════════════════════╗
3116
+ ║ §22 AGENTIC COGNITIVE LOOP ║
3117
+ ╠═══════════════════════════════════════════════════════════╣
3118
+ ║ 1. ANALYZE — LLM analyzes task complexity & needs ║
3119
+ ║ 2. PLAN — LLM decomposes complex tasks ║
3120
+ ║ 3. SYNTHESIZE — LLM pre-processes KB facts ║
3121
+ ║ 4. ROUTE — LLM picks optimal dispatch strategy ║
3122
+ ║ 5. GENERATE — Execute chosen dispatch strategy ║
3123
+ ║ 6. EVALUATE — LLM evaluates output quality ║
3124
+ ║ 7. REVISE — If evaluation says revision needed ║
3125
+ ║ 8. CURATE — LLM manages CRP's knowledge base ║
3126
+ ╚═══════════════════════════════════════════════════════════╝
3127
+
3128
+ The LLM is INSIDE CRP. CRP orchestrates all reasoning.
3129
+ Every decision point uses the LLM instead of heuristics.
3130
+ """
3131
+ from crp.core.facilitator import CRPFacilitator
3132
+
3133
+ _dispatch_start_ns = time.monotonic_ns()
3134
+ _facts_before = self._warm_store.fact_count
3135
+ self._check_session()
3136
+
3137
+ # ---------- RBAC + validation (same as dispatch) ----------
3138
+ from crp.security.rbac import Permission
3139
+ perm_result = self._rbac.check_permission(Permission.DISPATCH)
3140
+ if not perm_result.allowed:
3141
+ raise RateLimitExceededError(perm_result.reason)
3142
+ rate_result = self._rbac.check_rate_limit(Permission.DISPATCH)
3143
+ if not rate_result.allowed:
3144
+ raise RateLimitExceededError(rate_result.reason)
3145
+
3146
+ val_result = self._input_validator.validate(task_input)
3147
+ if not val_result.valid:
3148
+ raise ValidationError(
3149
+ val_result.warnings[0] if val_result.warnings else "Input validation failed"
3150
+ )
3151
+ task_input = val_result.sanitized_text
3152
+
3153
+ security_flags = self._scan_injection(task_input)
3154
+ security_flags.unicode_normalized = val_result.sanitized_size != val_result.original_size
3155
+ security_flags.control_chars_stripped = val_result.control_chars_removed
3156
+
3157
+ # ---------- Initialize facilitator ----------
3158
+ facilitator = CRPFacilitator(
3159
+ provider=self._provider,
3160
+ count_tokens=self._provider.count_tokens,
3161
+ )
3162
+
3163
+ # ══════════════════════════════════════════════════════════
3164
+ # PHASE 1: TASK ANALYSIS — LLM understands the task
3165
+ # ══════════════════════════════════════════════════════════
3166
+ logger.info("§22 Phase 1: Task Analysis")
3167
+ task_analysis = facilitator.analyze_task(
3168
+ task_input=task_input,
3169
+ system_prompt=system_prompt,
3170
+ fact_count=self._warm_store.fact_count,
3171
+ )
3172
+ logger.info(
3173
+ "Task analysis: complexity=%s, domain=%s, needs=%s, confidence=%.2f",
3174
+ task_analysis.complexity,
3175
+ task_analysis.domain,
3176
+ task_analysis.knowledge_needs[:3],
3177
+ task_analysis.confidence,
3178
+ )
3179
+
3180
+ # ══════════════════════════════════════════════════════════
3181
+ # PHASE 2: EXECUTION PLANNING — LLM decomposes if complex
3182
+ # ══════════════════════════════════════════════════════════
3183
+ plan = None
3184
+ if enable_planning and task_analysis.complexity in ("complex", "multi_part"):
3185
+ logger.info("§22 Phase 2: Execution Planning")
3186
+ plan = facilitator.plan_execution(
3187
+ analysis=task_analysis,
3188
+ fact_count=self._warm_store.fact_count,
3189
+ )
3190
+ logger.info(
3191
+ "Execution plan: %d steps, estimated_windows=%d",
3192
+ len(plan.steps), plan.estimated_windows,
3193
+ )
3194
+
3195
+ # ══════════════════════════════════════════════════════════
3196
+ # PHASE 3: FACT SYNTHESIS — LLM pre-processes knowledge
3197
+ # ══════════════════════════════════════════════════════════
3198
+ synthesis = None
3199
+ if self._warm_store.fact_count > 0:
3200
+ logger.info("§22 Phase 3: Fact Synthesis")
3201
+ ranked = self._warm_store.get_ranked_facts(limit=30)
3202
+ facts_for_synthesis = [
3203
+ (f.id, f.text, f.confidence) for f in ranked
3204
+ ]
3205
+ synthesis = facilitator.synthesize_facts(
3206
+ facts=facts_for_synthesis,
3207
+ task_context=task_input[:200],
3208
+ )
3209
+ logger.info(
3210
+ "Synthesis: %d insights, %d gaps, %d redundant",
3211
+ len(synthesis.key_insights),
3212
+ len(synthesis.knowledge_gaps),
3213
+ len(synthesis.redundant_fact_ids),
3214
+ )
3215
+
3216
+ # ══════════════════════════════════════════════════════════
3217
+ # PHASE 4: STRATEGY ROUTING — LLM picks optimal strategy
3218
+ # ══════════════════════════════════════════════════════════
3219
+ logger.info("§22 Phase 4: Strategy Routing")
3220
+ available_strategies = ["push", "reflexive", "progressive", "stream_augmented"]
3221
+ strategy_decision = facilitator.route_strategy(
3222
+ analysis=task_analysis,
3223
+ fact_count=self._warm_store.fact_count,
3224
+ available_strategies=available_strategies,
3225
+ )
3226
+ chosen_strategy = strategy_decision.strategy
3227
+ logger.info(
3228
+ "Strategy: %s (confidence=%.2f, reasoning=%s)",
3229
+ chosen_strategy,
3230
+ strategy_decision.confidence,
3231
+ strategy_decision.reasoning[:80],
3232
+ )
3233
+
3234
+ # ══════════════════════════════════════════════════════════
3235
+ # PHASE 5: GENERATE — Execute via chosen strategy
3236
+ #
3237
+ # §22-FIX-A: Multi-step plan execution.
3238
+ # If Phase 2 produced a plan with >1 step, CRP executes
3239
+ # EACH step sequentially with its own strategy, feeding
3240
+ # accumulated outputs forward. This turns the ExecutionPlan
3241
+ # from dead code into real multi-window orchestration.
3242
+ #
3243
+ # §22-FIX-D: Synthesis into augmented system prompt with
3244
+ # explicit structural markers for the LLM.
3245
+ # ══════════════════════════════════════════════════════════
3246
+ logger.info("§22 Phase 5: Generate via strategy=%s", chosen_strategy)
3247
+
3248
+ # Augment system prompt with synthesis if available
3249
+ augmented_system = system_prompt
3250
+ if synthesis and synthesis.summary:
3251
+ augmented_system = (
3252
+ f"{system_prompt}\n\n"
3253
+ f"[CRP KNOWLEDGE SYNTHESIS]\n"
3254
+ f"{synthesis.summary}\n"
3255
+ )
3256
+ if synthesis.key_insights:
3257
+ augmented_system += "Key insights:\n" + "\n".join(
3258
+ f"- {i}" for i in synthesis.key_insights[:5]
3259
+ ) + "\n"
3260
+ if synthesis.knowledge_gaps:
3261
+ augmented_system += "Knowledge gaps:\n" + "\n".join(
3262
+ f"- {g}" for g in synthesis.knowledge_gaps[:3]
3263
+ ) + "\n"
3264
+ if synthesis.contradictions:
3265
+ augmented_system += "Contradictions to resolve:\n" + "\n".join(
3266
+ f"- {c}" for c in synthesis.contradictions[:3]
3267
+ ) + "\n"
3268
+
3269
+ # Strategy dispatch map for all strategies
3270
+ _strategy_dispatch_map: dict[str, Any] = {
3271
+ "push": self.dispatch,
3272
+ "reflexive": self.dispatch_reflexive,
3273
+ "progressive": self.dispatch_progressive,
3274
+ "stream_augmented": self.dispatch_stream_augmented,
3275
+ }
3276
+
3277
+ # §22-FIX-A: Multi-step plan execution
3278
+ if plan and len(plan.steps) > 1:
3279
+ logger.info(
3280
+ "§22 Phase 5: Multi-step execution — %d steps",
3281
+ len(plan.steps),
3282
+ )
3283
+ accumulated_outputs: list[tuple[str, str]] = [] # (step_desc, output)
3284
+ output = ""
3285
+ inner_report = None
3286
+
3287
+ # Sort steps by priority (lower = higher priority) and
3288
+ # respect dependency ordering: a step can only execute
3289
+ # after all steps in its depends_on list are complete.
3290
+ completed_indices: set[int] = set()
3291
+ remaining = list(enumerate(plan.steps))
3292
+
3293
+ while remaining:
3294
+ # Find next runnable step (dependencies satisfied)
3295
+ runnable = [
3296
+ (idx, step)
3297
+ for idx, step in remaining
3298
+ if all(d in completed_indices for d in step.depends_on)
3299
+ ]
3300
+ if not runnable:
3301
+ # Deadlock — remaining steps have unresolvable deps.
3302
+ # Run them sequentially to avoid stalling.
3303
+ runnable = remaining
3304
+
3305
+ step_idx, step = runnable[0]
3306
+ remaining = [
3307
+ (i, s) for i, s in remaining if i != step_idx
3308
+ ]
3309
+
3310
+ step_strategy = step.strategy
3311
+ step_fn = _strategy_dispatch_map.get(step_strategy, self.dispatch)
3312
+
3313
+ # Build step-specific task with accumulated context
3314
+ if accumulated_outputs:
3315
+ prior_context = "\n\n".join(
3316
+ f"[Step: {desc}]\n{out[:800]}"
3317
+ for desc, out in accumulated_outputs[-3:] # Last 3 steps
3318
+ )
3319
+ step_task = (
3320
+ f"=== ORIGINAL TASK ===\n{task_input[:400]}\n"
3321
+ f"=== COMPLETED STEPS ===\n{prior_context}\n"
3322
+ f"=== CURRENT STEP ===\n{step.description}\n"
3323
+ f"Context needs: {', '.join(step.context_needs[:3])}\n"
3324
+ f"=== GENERATE OUTPUT FOR THIS STEP ==="
3325
+ )
3326
+ else:
3327
+ step_task = (
3328
+ f"=== TASK ===\n{task_input[:500]}\n"
3329
+ f"=== CURRENT STEP ===\n{step.description}\n"
3330
+ f"Context needs: {', '.join(step.context_needs[:3])}\n"
3331
+ f"=== GENERATE OUTPUT FOR THIS STEP ==="
3332
+ )
3333
+
3334
+ logger.info(
3335
+ "§22 Step %d/%d: strategy=%s, desc=%s",
3336
+ step_idx + 1, len(plan.steps),
3337
+ step_strategy, step.description[:60],
3338
+ )
3339
+
3340
+ step_output, step_report = step_fn(
3341
+ augmented_system, step_task, **kwargs,
3342
+ )
3343
+ accumulated_outputs.append((step.description, step_output))
3344
+ completed_indices.add(step_idx)
3345
+
3346
+ # Keep the last step's output and report as primary
3347
+ output = step_output
3348
+ inner_report = step_report
3349
+
3350
+ # For multi-step: combine all step outputs into final output
3351
+ if len(accumulated_outputs) > 1:
3352
+ output = "\n\n".join(
3353
+ out for _, out in accumulated_outputs
3354
+ )
3355
+
3356
+ logger.info(
3357
+ "§22 Multi-step execution complete: %d steps, total_chars=%d",
3358
+ len(accumulated_outputs), len(output),
3359
+ )
3360
+ else:
3361
+ # Single-step dispatch (original path)
3362
+ dispatch_fn = _strategy_dispatch_map.get(chosen_strategy, self.dispatch)
3363
+ output, inner_report = dispatch_fn(
3364
+ augmented_system, task_input, **kwargs,
3365
+ )
3366
+
3367
+ # ══════════════════════════════════════════════════════════
3368
+ # PHASE 5b: CONTINUATION AWARENESS
3369
+ #
3370
+ # §22-FIX-B: Extract continuation info from inner dispatch.
3371
+ # The inner dispatch may have used multiple continuation
3372
+ # windows. This context feeds into evaluation so the LLM
3373
+ # knows whether the output is complete or truncated.
3374
+ # ══════════════════════════════════════════════════════════
3375
+ inner_telemetry = inner_report.telemetry or {} if inner_report else {}
3376
+ inner_continuation_count = (
3377
+ inner_report.continuation_windows if inner_report else 0
3378
+ )
3379
+ inner_finish = inner_telemetry.get("finish_reason", "stop")
3380
+
3381
+ if inner_continuation_count > 0:
3382
+ logger.info(
3383
+ "§22 Phase 5b: Inner dispatch used %d continuation windows "
3384
+ "(finish=%s)",
3385
+ inner_continuation_count, inner_finish,
3386
+ )
3387
+
3388
+ # ══════════════════════════════════════════════════════════
3389
+ # PHASE 6: EVALUATE — LLM assesses output quality
3390
+ #
3391
+ # §22-FIX-B: Evaluation now knows about continuation state
3392
+ # so it can assess whether truncation hurt quality.
3393
+ # ══════════════════════════════════════════════════════════
3394
+ logger.info("§22 Phase 6: Output Evaluation")
3395
+ evaluation = facilitator.evaluate_output(
3396
+ task_input=task_input,
3397
+ output=output,
3398
+ facts_used=inner_report.facts_extracted if inner_report else 0,
3399
+ )
3400
+ logger.info(
3401
+ "Evaluation: grade=%s, completion=%.2f, revision_needed=%s, "
3402
+ "continuations=%d",
3403
+ evaluation.overall_grade,
3404
+ evaluation.task_completion,
3405
+ evaluation.revision_needed,
3406
+ inner_continuation_count,
3407
+ )
3408
+
3409
+ # ══════════════════════════════════════════════════════════
3410
+ # PHASE 7: REVISE — If evaluation says output needs work
3411
+ #
3412
+ # §22-FIX-C: Enhanced revision. Instead of a bare
3413
+ # "please revise" message, CRP feeds:
3414
+ # - Structured evaluation scores (completion, coherence)
3415
+ # - Specific missing elements as knowledge gaps
3416
+ # - Synthesis insights that may not have been used
3417
+ # - Continuation context (was output truncated?)
3418
+ # - Strategy adjustment: if chosen strategy scored poorly,
3419
+ # try an alternative strategy on revision.
3420
+ # ══════════════════════════════════════════════════════════
3421
+ revision_rounds = 0
3422
+ # Track strategy for potential adjustment during revision
3423
+ revision_strategy = chosen_strategy
3424
+
3425
+ while (
3426
+ evaluation.revision_needed
3427
+ and revision_rounds < max_revision_rounds
3428
+ and evaluation.task_completion < 0.7
3429
+ ):
3430
+ revision_rounds += 1
3431
+ logger.info(
3432
+ "§22 Phase 7: Revision round %d — focus: %s",
3433
+ revision_rounds, evaluation.revision_focus,
3434
+ )
3435
+
3436
+ # §22-FIX-C: Strategy adjustment on poor first attempt
3437
+ # If first attempt scored very poorly (< 0.4 completion),
3438
+ # try a different strategy for the revision.
3439
+ if (
3440
+ revision_rounds == 1
3441
+ and evaluation.task_completion < 0.4
3442
+ and revision_strategy in _strategy_dispatch_map
3443
+ ):
3444
+ alt_strategies = [
3445
+ s for s in _strategy_dispatch_map
3446
+ if s != revision_strategy
3447
+ ]
3448
+ if alt_strategies:
3449
+ revision_strategy = alt_strategies[0]
3450
+ logger.info(
3451
+ "§22 Strategy adjustment: %s → %s (completion=%.2f too low)",
3452
+ chosen_strategy, revision_strategy,
3453
+ evaluation.task_completion,
3454
+ )
3455
+
3456
+ revision_fn = _strategy_dispatch_map.get(
3457
+ revision_strategy, self.dispatch,
3458
+ )
3459
+
3460
+ # §22-FIX-C: Build enhanced revision directive with
3461
+ # structured feedback, synthesis carry-forward, and
3462
+ # continuation awareness.
3463
+ _missing = ", ".join(evaluation.missing_elements[:5]) or "none identified"
3464
+ _synthesis_hints = ""
3465
+ if synthesis and synthesis.key_insights:
3466
+ unused_insights = synthesis.key_insights[:3]
3467
+ _synthesis_hints = (
3468
+ f"Available knowledge (may help fill gaps):\n"
3469
+ + "\n".join(f"- {i}" for i in unused_insights) + "\n"
3470
+ )
3471
+ _continuation_note = ""
3472
+ if inner_continuation_count > 0 and inner_finish == "length":
3473
+ _continuation_note = (
3474
+ "NOTE: Previous output may have been truncated "
3475
+ f"(used {inner_continuation_count} continuation windows). "
3476
+ "Ensure the revision is complete.\n"
3477
+ )
3478
+
3479
+ revision_task = (
3480
+ f"=== REVISION REQUEST (Round {revision_rounds}) ===\n"
3481
+ f"Original task: {task_input[:300]}\n\n"
3482
+ f"[EVALUATION FEEDBACK]\n"
3483
+ f" Task completion: {evaluation.task_completion:.0%}\n"
3484
+ f" Factual accuracy: {evaluation.factual_accuracy:.0%}\n"
3485
+ f" Coherence: {evaluation.coherence:.0%}\n"
3486
+ f" Grade: {evaluation.overall_grade}\n"
3487
+ f" Revision focus: {evaluation.revision_focus}\n"
3488
+ f" Missing elements: {_missing}\n\n"
3489
+ f"{_synthesis_hints}"
3490
+ f"{_continuation_note}"
3491
+ f"[PREVIOUS OUTPUT (for reference)]\n{output[:1000]}\n\n"
3492
+ f"=== PROVIDE IMPROVED, COMPLETE OUTPUT ==="
3493
+ )
3494
+
3495
+ # §22-FIX-C: Augment system prompt with evaluation context
3496
+ revision_system = augmented_system
3497
+ if evaluation.overall_grade in ("C", "D"):
3498
+ revision_system = (
3499
+ f"{augmented_system}\n\n"
3500
+ f"[CRP REVISION CONTEXT]\n"
3501
+ f"The previous output scored {evaluation.overall_grade}. "
3502
+ f"Focus on: {evaluation.revision_focus}. "
3503
+ f"Missing: {_missing}."
3504
+ )
3505
+
3506
+ output, inner_report = revision_fn(
3507
+ revision_system, revision_task, **kwargs,
3508
+ )
3509
+
3510
+ # Re-evaluate
3511
+ evaluation = facilitator.evaluate_output(
3512
+ task_input=task_input,
3513
+ output=output,
3514
+ facts_used=inner_report.facts_extracted,
3515
+ )
3516
+ logger.info(
3517
+ "Revision %d evaluation: grade=%s, completion=%.2f",
3518
+ revision_rounds, evaluation.overall_grade, evaluation.task_completion,
3519
+ )
3520
+
3521
+ # §22-FIX-E: Post-revision curation — run intermediate
3522
+ # curation after each revision so knowledge base reflects
3523
+ # what was learned from the revision attempt.
3524
+ if enable_curation and self._warm_store.fact_count > 5:
3525
+ ranked = self._warm_store.get_ranked_facts(limit=15)
3526
+ revision_facts = [
3527
+ (f.id, f.text, f.confidence, getattr(f, "age", 0))
3528
+ for f in ranked
3529
+ ]
3530
+ rev_curation = facilitator.curate_memory(
3531
+ facts=revision_facts,
3532
+ recent_task=f"Revision round {revision_rounds}: {evaluation.revision_focus}",
3533
+ )
3534
+ for fid in rev_curation.promote_ids:
3535
+ try:
3536
+ self._warm_store.boost_confidence(fid, 0.05)
3537
+ except Exception:
3538
+ pass
3539
+ for fid in rev_curation.demote_ids:
3540
+ try:
3541
+ self._warm_store.reduce_confidence(fid, 0.05)
3542
+ except Exception:
3543
+ pass
3544
+
3545
+ # ══════════════════════════════════════════════════════════
3546
+ # PHASE 8: CURATE — LLM manages CRP's memory
3547
+ # ══════════════════════════════════════════════════════════
3548
+ curation_actions = 0
3549
+ if enable_curation and self._warm_store.fact_count > 5:
3550
+ logger.info("§22 Phase 8: Memory Curation")
3551
+ ranked = self._warm_store.get_ranked_facts(limit=25)
3552
+ facts_for_curation = [
3553
+ (f.id, f.text, f.confidence, getattr(f, "age", 0))
3554
+ for f in ranked
3555
+ ]
3556
+ curation = facilitator.curate_memory(
3557
+ facts=facts_for_curation,
3558
+ recent_task=task_input[:200],
3559
+ )
3560
+
3561
+ # Execute curation decisions on WarmStore
3562
+ for fid in curation.promote_ids:
3563
+ try:
3564
+ self._warm_store.boost_confidence(fid, 0.1)
3565
+ curation_actions += 1
3566
+ except Exception:
3567
+ pass # Fact may no longer exist
3568
+
3569
+ for fid in curation.demote_ids:
3570
+ try:
3571
+ self._warm_store.reduce_confidence(fid, 0.1)
3572
+ curation_actions += 1
3573
+ except Exception:
3574
+ pass
3575
+
3576
+ logger.info(
3577
+ "Curation: promote=%d, demote=%d, actions=%d — %s",
3578
+ len(curation.promote_ids),
3579
+ len(curation.demote_ids),
3580
+ curation_actions,
3581
+ curation.reasoning[:80],
3582
+ )
3583
+
3584
+ # ══════════════════════════════════════════════════════════
3585
+ # Build telemetry + quality report
3586
+ # ══════════════════════════════════════════════════════════
3587
+ fac_metrics = facilitator.metrics
3588
+ _total_dispatch_ms = (time.monotonic_ns() - _dispatch_start_ns) / 1_000_000
3589
+
3590
+ # Parse inner report telemetry for LLM time
3591
+ inner_telemetry = inner_report.telemetry or {}
3592
+ inner_llm_ms = inner_telemetry.get("total_llm_ms", 0)
3593
+ inner_extraction_ms = inner_telemetry.get("total_extraction_ms", 0)
3594
+ inner_envelope_ms = inner_telemetry.get("total_envelope_ms", 0)
3595
+
3596
+ _total_llm_ms = inner_llm_ms + fac_metrics.total_cognitive_ms
3597
+ _crp_overhead_ms = _total_dispatch_ms - inner_llm_ms
3598
+ _crp_overhead_pct = (_crp_overhead_ms / _total_dispatch_ms * 100) if _total_dispatch_ms > 0 else 0.0
3599
+
3600
+ s_tokens = self._provider.count_tokens(system_prompt)
3601
+ t_tokens = self._provider.count_tokens(task_input)
3602
+ output_tokens = self._provider.count_tokens(output)
3603
+
3604
+ metrics = WindowMetrics(
3605
+ window_id=inner_report.window_id,
3606
+ chain_position=inner_telemetry.get("continuation_index", 0),
3607
+ system_tokens=s_tokens,
3608
+ task_tokens=t_tokens,
3609
+ envelope_tokens=inner_telemetry.get("envelope_tokens", 0),
3610
+ envelope_budget=inner_telemetry.get("envelope_budget", 0),
3611
+ saturation=inner_report.envelope_saturation,
3612
+ generation_reserve=inner_telemetry.get("generation_reserve", 0),
3613
+ generation_tokens=output_tokens,
3614
+ wall_time_ms=int(_total_dispatch_ms),
3615
+ finish_reason=inner_telemetry.get("finish_reason", "stop"),
3616
+ facts_extracted=inner_report.facts_extracted,
3617
+ continuation_triggered=inner_telemetry.get("continuation_triggered", False),
3618
+ continuation_index=inner_telemetry.get("continuation_index", 0),
3619
+ # CRP overhead
3620
+ total_dispatch_ms=round(_total_dispatch_ms),
3621
+ total_llm_ms=round(_total_llm_ms),
3622
+ total_extraction_ms=round(inner_extraction_ms),
3623
+ total_envelope_ms=round(inner_envelope_ms),
3624
+ crp_overhead_ms=round(_crp_overhead_ms),
3625
+ crp_overhead_pct=round(_crp_overhead_pct, 1),
3626
+ # Relay strategy
3627
+ relay_strategy="agentic",
3628
+ # §22 Agentic telemetry
3629
+ agentic_cognitive_calls=fac_metrics.cognitive_calls,
3630
+ agentic_cognitive_tokens=fac_metrics.total_cognitive_tokens,
3631
+ agentic_cognitive_ms=round(fac_metrics.total_cognitive_ms, 1),
3632
+ agentic_task_complexity=task_analysis.complexity,
3633
+ agentic_strategy_chosen=chosen_strategy,
3634
+ agentic_strategy_confidence=strategy_decision.confidence,
3635
+ agentic_synthesis_insights=len(synthesis.key_insights) if synthesis else 0,
3636
+ agentic_evaluation_grade=evaluation.overall_grade,
3637
+ agentic_revision_rounds=revision_rounds,
3638
+ agentic_curation_actions=curation_actions,
3639
+ agentic_plan_steps=len(plan.steps) if plan else 0,
3640
+ # Resource tracking
3641
+ **self._resource_fields(),
3642
+ # Marginal gain / sections
3643
+ **self._marginal_fields(output, _facts_before),
3644
+ # Adaptive allocator telemetry
3645
+ **self._allocator_fields(),
3646
+ )
3647
+ self._record_dispatch_overhead(_total_dispatch_ms, _total_llm_ms)
3648
+
3649
+ quality_tier = _classify_quality_tier(
3650
+ facts_extracted=inner_report.facts_extracted,
3651
+ continuation_windows=inner_telemetry.get("continuation_index", 0),
3652
+ saturation=inner_report.envelope_saturation,
3653
+ finish_reason=inner_telemetry.get("finish_reason", "stop"),
3654
+ output_tokens=output_tokens,
3655
+ output_length=len(output),
3656
+ )
3657
+
3658
+ report = QualityReport(
3659
+ session_id=self._session.session_id,
3660
+ window_id=inner_report.window_id,
3661
+ output=output,
3662
+ facts_extracted=inner_report.facts_extracted,
3663
+ security_flags=security_flags,
3664
+ continuation_windows=inner_telemetry.get("continuation_index", 0),
3665
+ envelope_saturation=inner_report.envelope_saturation,
3666
+ quality_tier=quality_tier,
3667
+ telemetry=metrics.to_dict(),
3668
+ )
3669
+
3670
+ logger.info(
3671
+ "§22 Agentic dispatch complete: strategy=%s, grade=%s, "
3672
+ "cognitive_calls=%d, cognitive_tokens=%d, revisions=%d, "
3673
+ "curation_actions=%d",
3674
+ chosen_strategy, evaluation.overall_grade,
3675
+ fac_metrics.cognitive_calls, fac_metrics.total_cognitive_tokens,
3676
+ revision_rounds, curation_actions,
3677
+ )
3678
+
3679
+ return output, report
3680
+
3681
+ def dispatch_intent(self, intent: TaskIntent) -> tuple[str, QualityReport]:
3682
+ """Dispatch using an explicit TaskIntent object."""
3683
+ return self.dispatch(
3684
+ system_prompt=intent.system_prompt or "",
3685
+ task_input=intent.task_input or "",
3686
+ max_output_tokens=intent.max_output_tokens,
3687
+ )
3688
+
3689
+ def dispatch_hierarchical(
3690
+ self,
3691
+ system_prompt: str,
3692
+ large_input: str,
3693
+ task_intent: str = "",
3694
+ **kwargs: Any,
3695
+ ) -> tuple[list[str], QualityReport]:
3696
+ """Hierarchical map-reduce dispatch for oversized inputs (§14).
3697
+
3698
+ Segments the input, dispatches each segment through the LLM,
3699
+ then iteratively reduces the syntheses. All facts extracted
3700
+ from every segment are stored in the warm store + CKF.
3701
+
3702
+ Returns (final_syntheses, QualityReport).
3703
+ """
3704
+ self._check_session()
3705
+
3706
+ # ---------- RBAC permission + rate limit check (§7.10) ----------
3707
+ from crp.security.rbac import Permission
3708
+ perm_result = self._rbac.check_permission(Permission.DISPATCH)
3709
+ if not perm_result.allowed:
3710
+ raise RateLimitExceededError(perm_result.reason)
3711
+ rate_result = self._rbac.check_rate_limit(Permission.DISPATCH)
3712
+ if not rate_result.allowed:
3713
+ raise RateLimitExceededError(rate_result.reason)
3714
+
3715
+ # ---------- Input validation — Layer 1, cannot disable (§7.4) ----------
3716
+ val_result = self._input_validator.validate(large_input)
3717
+ if not val_result.valid:
3718
+ raise ValidationError(
3719
+ val_result.warnings[0] if val_result.warnings else "Input validation failed"
3720
+ )
3721
+ large_input = val_result.sanitized_text
3722
+
3723
+ # ---------- Advisory injection scan on full input (§7.5) ----------
3724
+ security_flags = self._scan_injection(large_input)
3725
+
3726
+ from crp.advanced.hierarchical import HierarchicalProcessor, HierarchicalConfig
3727
+
3728
+ processor = HierarchicalProcessor(
3729
+ dispatch_fn=lambda sys, task, **kw: self._provider.generate_chat(
3730
+ [{"role": "system", "content": sys}, {"role": "user", "content": task}],
3731
+ **kw,
3732
+ ),
3733
+ count_tokens=self._provider.count_tokens,
3734
+ context_window=self._provider.context_window_size(),
3735
+ )
3736
+
3737
+ intent_text = task_intent or large_input[:200]
3738
+ syntheses, plan = processor.hierarchical_dispatch(
3739
+ task_intent=intent_text,
3740
+ large_input=large_input,
3741
+ )
3742
+
3743
+ # Extract facts from all syntheses
3744
+ total_facts = 0
3745
+ for i, synthesis in enumerate(syntheses):
3746
+ window_id = str(uuid.uuid4())
3747
+ self._warm_store.advance_window(window_id)
3748
+ ti = TaskIntent(task_input=intent_text, system_prompt=system_prompt)
3749
+ extraction = self._extract_and_store(synthesis, window_id, ti)
3750
+ total_facts += extraction.total_facts
3751
+ self._windows_completed += 1
3752
+ self._total_input_tokens += self._provider.count_tokens(synthesis)
3753
+ self._total_output_tokens += self._provider.count_tokens(synthesis)
3754
+
3755
+ report = QualityReport(
3756
+ session_id=self._session.session_id,
3757
+ window_id="hierarchical",
3758
+ output="\n\n".join(syntheses),
3759
+ facts_extracted=total_facts,
3760
+ continuation_windows=plan.segment_count,
3761
+ security_flags=security_flags,
3762
+ quality_tier=_classify_quality_tier(
3763
+ facts_extracted=total_facts,
3764
+ continuation_windows=plan.segment_count,
3765
+ saturation=0.0,
3766
+ finish_reason="stop",
3767
+ output_tokens=sum(self._provider.count_tokens(s) for s in syntheses),
3768
+ output_length=sum(len(s) for s in syntheses),
3769
+ ),
3770
+ )
3771
+
3772
+ # Record dispatch for rate limiting (§7.10)
3773
+ total_tokens = sum(self._provider.count_tokens(s) for s in syntheses)
3774
+ self._rbac.record_dispatch(total_tokens)
3775
+
3776
+ return syntheses, report
3777
+
3778
+ def dispatch_batch(
3779
+ self,
3780
+ intents: list[dict[str, str]],
3781
+ ) -> list[tuple[str, QualityReport]]:
3782
+ """Batch dispatch multiple tasks sequentially (§6.6).
3783
+
3784
+ Each intent dict should have "system_prompt" and "task_input" keys.
3785
+ Returns list of (output, QualityReport) tuples.
3786
+ """
3787
+ self._check_session()
3788
+ results: list[tuple[str, QualityReport]] = []
3789
+ for intent in intents:
3790
+ sys_prompt = intent.get("system_prompt", "")
3791
+ task_input = intent.get("task_input", "")
3792
+ try:
3793
+ output, report = self.dispatch(sys_prompt, task_input)
3794
+ results.append((output, report))
3795
+ except Exception as exc:
3796
+ error_report = QualityReport(
3797
+ session_id=self._session.session_id,
3798
+ window_id="batch-error",
3799
+ output="",
3800
+ facts_extracted=0,
3801
+ quality_tier="D",
3802
+ )
3803
+ results.append(("", error_report))
3804
+ logger.warning("Batch item failed: %s", exc)
3805
+ return results
3806
+
3807
+ def dispatch_stream(
3808
+ self,
3809
+ system_prompt: str,
3810
+ task_input: str,
3811
+ **kwargs: Any,
3812
+ ) -> Generator[StreamEvent, None, None]:
3813
+ """Streaming dispatch — yields StreamEvent objects.
3814
+
3815
+ Stream contract:
3816
+ - Emits one or more "token" events in generation order.
3817
+ - Emits "extraction" events as facts are discovered.
3818
+ - Emits exactly one "done" event as the final event (or "error").
3819
+ - Token concatenation MUST produce the same string as dispatch().
3820
+ - No reordering of token events.
3821
+ """
3822
+ self._check_session()
3823
+
3824
+ # RBAC permission + rate limit check (§7.10)
3825
+ from crp.security.rbac import Permission
3826
+ perm_result = self._rbac.check_permission(Permission.DISPATCH)
3827
+ if not perm_result.allowed:
3828
+ raise RateLimitExceededError(perm_result.reason)
3829
+ rate_result = self._rbac.check_rate_limit(Permission.DISPATCH)
3830
+ if not rate_result.allowed:
3831
+ raise RateLimitExceededError(rate_result.reason)
3832
+
3833
+ # Input validation — Layer 1, cannot disable (§7.4)
3834
+ val_result = self._input_validator.validate(task_input)
3835
+ if not val_result.valid:
3836
+ raise ValidationError(val_result.warnings[0] if val_result.warnings else "Input validation failed")
3837
+ task_input = val_result.sanitized_text
3838
+
3839
+ # Security scan (advisory)
3840
+ security_flags = self._scan_injection(task_input)
3841
+ security_flags.unicode_normalized = val_result.sanitized_size != val_result.original_size
3842
+ security_flags.control_chars_stripped = val_result.control_chars_removed
3843
+
3844
+ s_tokens = self._provider.count_tokens(system_prompt)
3845
+ t_tokens = self._provider.count_tokens(task_input)
3846
+ context_window = self._provider.context_window_size()
3847
+
3848
+ max_out = kwargs.get("max_output_tokens")
3849
+ g = resolve_generation_reserve(
3850
+ max_out,
3851
+ self._provider.max_output_tokens,
3852
+ context_window,
3853
+ is_thinking_model=getattr(self._provider, "is_thinking_model", False),
3854
+ )
3855
+
3856
+ # Build envelope from accumulated facts
3857
+ envelope_result = self._build_envelope(system_prompt, task_input, g)
3858
+ envelope = envelope_result.envelope_text
3859
+
3860
+ e_tokens = self._provider.count_tokens(envelope) if envelope else 0
3861
+ input_tokens = s_tokens + t_tokens + e_tokens
3862
+ self._check_budget(input_tokens)
3863
+
3864
+ # Advance warm store window
3865
+ window_id = str(uuid.uuid4())
3866
+ self._warm_store.advance_window(window_id)
3867
+
3868
+ node = WindowNode(
3869
+ window_id=window_id,
3870
+ system_prompt_hash=hashlib.sha256(system_prompt.encode()).hexdigest(),
3871
+ task_input_hash=hashlib.sha256(task_input.encode()).hexdigest(),
3872
+ continuation_index=0,
3873
+ )
3874
+ self._dag.add_node(node)
3875
+ node.advance(WindowState.ASSEMBLED)
3876
+
3877
+ messages = assemble_messages(system_prompt, envelope, task_input)
3878
+
3879
+ node.advance(WindowState.DISPATCHED)
3880
+ node.advance(WindowState.GENERATING)
3881
+
3882
+ # Circuit breaker gate (§audit3: protect all dispatch variants)
3883
+ if not self._circuit_breaker.allow_request():
3884
+ raise ProviderError(
3885
+ "Circuit breaker OPEN — provider unavailable, "
3886
+ "retry after recovery timeout"
3887
+ )
3888
+
3889
+ start_ms = time.monotonic_ns()
3890
+ output_chunks: list[str] = []
3891
+ finish_reason = "stop"
3892
+ streaming_error: Exception | None = None
3893
+ try:
3894
+ gen = self._provider.generate_chat_stream(messages, max_tokens=g)
3895
+ while True:
3896
+ try:
3897
+ chunk = next(gen)
3898
+ output_chunks.append(chunk)
3899
+ yield StreamEvent(event_type="token", data=chunk)
3900
+ except StopIteration as stop:
3901
+ finish_reason = stop.value or "stop"
3902
+ self._circuit_breaker.record_success()
3903
+ break
3904
+ except Exception as exc:
3905
+ self._circuit_breaker.record_failure()
3906
+ streaming_error = exc
3907
+ finish_reason = "error"
3908
+ yield StreamEvent(event_type="error", data=str(exc))
3909
+
3910
+ output = "".join(output_chunks)
3911
+ wall_ms = (time.monotonic_ns() - start_ms) / 1_000_000
3912
+ node.finish_reason = finish_reason
3913
+ node.raw_output_id = str(uuid.uuid4())
3914
+ node.advance(WindowState.COMPLETED)
3915
+
3916
+ output_tokens = self._provider.count_tokens(output)
3917
+ node.advance(WindowState.EXTRACTED)
3918
+
3919
+ # Extract facts from output (skip if streaming error with no output)
3920
+ task_intent = TaskIntent(task_input=task_input, system_prompt=system_prompt)
3921
+ if output and not streaming_error:
3922
+ extraction = self._extract_and_store(output, window_id, task_intent)
3923
+ node.facts_produced = [f.id for f in extraction.facts]
3924
+ else:
3925
+ extraction = self._extract_and_store("", window_id, task_intent)
3926
+ node.facts_produced = []
3927
+
3928
+ # Mark consumed facts as seen
3929
+ if envelope_result.packing and envelope_result.packing.packed_facts:
3930
+ consumed_ids = [pf.fact_id for pf in envelope_result.packing.packed_facts]
3931
+ self._warm_store.mark_seen(consumed_ids, window_id)
3932
+
3933
+ # Emit extraction progress
3934
+ if extraction.total_facts > 0:
3935
+ yield StreamEvent(
3936
+ event_type="extraction",
3937
+ data=ExtractionProgress(
3938
+ stage=f"stages {extraction.stages_run}",
3939
+ facts_so_far=extraction.total_facts,
3940
+ ),
3941
+ )
3942
+
3943
+ # Update counters
3944
+ self._windows_completed += 1
3945
+ self._total_input_tokens += input_tokens
3946
+ self._total_output_tokens += output_tokens
3947
+
3948
+ # Record RBAC rate limit counters (§7.10)
3949
+ self._rbac.record_dispatch(tokens_used=output_tokens)
3950
+
3951
+ # Window complete event
3952
+ yield StreamEvent(
3953
+ event_type="window_complete",
3954
+ data=WindowSummary(
3955
+ window_id=node.window_id,
3956
+ input_tokens=input_tokens,
3957
+ output_tokens=output_tokens,
3958
+ wall_time_ms=int(wall_ms),
3959
+ ),
3960
+ )
3961
+
3962
+ # Done event — final report
3963
+ report = QualityReport(
3964
+ session_id=self._session.session_id,
3965
+ window_id=node.window_id,
3966
+ output=output,
3967
+ facts_extracted=extraction.total_facts,
3968
+ security_flags=security_flags,
3969
+ continuation_windows=0,
3970
+ envelope_saturation=envelope_result.saturation,
3971
+ )
3972
+ yield StreamEvent(event_type="done", data=report)
3973
+
3974
+ # ------------------------------------------------------------------
3975
+ # Zero-LLM ingestion (§2.5)
3976
+ # ------------------------------------------------------------------
3977
+