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.
- crp/__init__.py +126 -0
- crp/__main__.py +8 -0
- crp/_typing.py +27 -0
- crp/_version.py +5 -0
- crp/adapters.py +31 -0
- crp/advanced/__init__.py +40 -0
- crp/advanced/auto_ingest.py +400 -0
- crp/advanced/cqs.py +235 -0
- crp/advanced/cross_window.py +477 -0
- crp/advanced/curator.py +265 -0
- crp/advanced/feedback.py +146 -0
- crp/advanced/hierarchical.py +211 -0
- crp/advanced/meta_learning.py +401 -0
- crp/advanced/parallel.py +98 -0
- crp/advanced/review_cycle.py +329 -0
- crp/advanced/scale_mode.py +129 -0
- crp/advanced/source_grounding.py +207 -0
- crp/ckf/__init__.py +35 -0
- crp/ckf/community.py +377 -0
- crp/ckf/fabric.py +445 -0
- crp/ckf/gc.py +175 -0
- crp/ckf/graph_walk.py +87 -0
- crp/ckf/merge.py +133 -0
- crp/ckf/pattern_query.py +122 -0
- crp/ckf/pubsub.py +128 -0
- crp/ckf/semantic.py +207 -0
- crp/cli/__init__.py +7 -0
- crp/cli/main.py +329 -0
- crp/cli/sidecar.py +929 -0
- crp/cli/startup.py +272 -0
- crp/continuation/__init__.py +103 -0
- crp/continuation/completion.py +348 -0
- crp/continuation/degradation.py +157 -0
- crp/continuation/document_map.py +160 -0
- crp/continuation/flow.py +109 -0
- crp/continuation/gap.py +419 -0
- crp/continuation/manager.py +484 -0
- crp/continuation/quality_monitor.py +179 -0
- crp/continuation/stitch.py +419 -0
- crp/continuation/trigger.py +142 -0
- crp/continuation/voice.py +157 -0
- crp/core/__init__.py +69 -0
- crp/core/batch.py +77 -0
- crp/core/circuit_breaker.py +116 -0
- crp/core/config.py +377 -0
- crp/core/context_tools.py +540 -0
- crp/core/dispatch_router.py +3977 -0
- crp/core/errors.py +128 -0
- crp/core/extraction_facade.py +384 -0
- crp/core/facilitator.py +713 -0
- crp/core/idempotency.py +215 -0
- crp/core/orchestrator.py +1435 -0
- crp/core/relay_strategies.py +613 -0
- crp/core/security_manager.py +140 -0
- crp/core/session.py +134 -0
- crp/core/task_intent.py +36 -0
- crp/core/window.py +363 -0
- crp/envelope/__init__.py +30 -0
- crp/envelope/builder.py +288 -0
- crp/envelope/decomposer.py +236 -0
- crp/envelope/formatter.py +168 -0
- crp/envelope/packer.py +211 -0
- crp/envelope/reranker.py +209 -0
- crp/envelope/scoring.py +310 -0
- crp/extraction/__init__.py +45 -0
- crp/extraction/complexity.py +96 -0
- crp/extraction/contradiction.py +132 -0
- crp/extraction/pipeline.py +360 -0
- crp/extraction/quality_gate.py +237 -0
- crp/extraction/stage1_regex.py +173 -0
- crp/extraction/stage2_statistical.py +244 -0
- crp/extraction/stage3_gliner.py +210 -0
- crp/extraction/stage4_uie.py +183 -0
- crp/extraction/stage5_discourse.py +175 -0
- crp/extraction/stage6_llm.py +178 -0
- crp/extraction/structured_output.py +219 -0
- crp/extraction/types.py +299 -0
- crp/license_guard.py +722 -0
- crp/observability/__init__.py +30 -0
- crp/observability/audit.py +118 -0
- crp/observability/events.py +233 -0
- crp/observability/metrics.py +264 -0
- crp/observability/quality.py +135 -0
- crp/observability/structured_logging.py +81 -0
- crp/observability/telemetry.py +117 -0
- crp/provenance/__init__.py +314 -0
- crp/provenance/_embeddings.py +97 -0
- crp/provenance/_types.py +378 -0
- crp/provenance/attribution_scorer.py +252 -0
- crp/provenance/claim_detector.py +229 -0
- crp/provenance/contradiction_detector.py +243 -0
- crp/provenance/distortion_detector.py +397 -0
- crp/provenance/entailment_verifier.py +358 -0
- crp/provenance/fabrication_detector.py +203 -0
- crp/provenance/hallucination_scorer.py +320 -0
- crp/provenance/omission_analyzer.py +106 -0
- crp/provenance/provenance_chain.py +205 -0
- crp/provenance/report_generator.py +440 -0
- crp/providers/__init__.py +43 -0
- crp/providers/anthropic.py +270 -0
- crp/providers/base.py +135 -0
- crp/providers/custom.py +63 -0
- crp/providers/diagnostic.py +251 -0
- crp/providers/llamacpp.py +224 -0
- crp/providers/manager.py +139 -0
- crp/providers/ollama.py +243 -0
- crp/providers/openai.py +628 -0
- crp/providers/tokenizers.py +48 -0
- crp/py.typed +0 -0
- crp/resources/__init__.py +53 -0
- crp/resources/adaptive_allocator.py +525 -0
- crp/resources/cost_model.py +388 -0
- crp/resources/overhead_manager.py +217 -0
- crp/resources/resource_manager.py +262 -0
- crp/schemas/__init__.py +20 -0
- crp/schemas/cost-estimate.json +33 -0
- crp/schemas/crp-error.json +43 -0
- crp/schemas/envelope-preview.json +40 -0
- crp/schemas/persisted-state-header.json +27 -0
- crp/schemas/quality-report.json +94 -0
- crp/schemas/session-handle.json +33 -0
- crp/schemas/session-status.json +57 -0
- crp/schemas/stream-event.json +18 -0
- crp/schemas/task-intent.json +42 -0
- crp/security/__init__.py +93 -0
- crp/security/audit_trail.py +392 -0
- crp/security/binding.py +192 -0
- crp/security/compliance.py +813 -0
- crp/security/consent.py +593 -0
- crp/security/embedding_defense.py +161 -0
- crp/security/encryption.py +202 -0
- crp/security/injection.py +335 -0
- crp/security/integrity.py +267 -0
- crp/security/privacy.py +662 -0
- crp/security/quarantine.py +249 -0
- crp/security/rbac.py +221 -0
- crp/security/validation.py +164 -0
- crp/state/__init__.py +31 -0
- crp/state/cold_storage.py +258 -0
- crp/state/compaction.py +263 -0
- crp/state/critical_state.py +104 -0
- crp/state/event_log.py +313 -0
- crp/state/fact.py +189 -0
- crp/state/serialization.py +189 -0
- crp/state/session_cleanup.py +77 -0
- crp/state/snapshot.py +290 -0
- crp/state/warm_store.py +346 -0
- crprotocol-2.0.0.dist-info/METADATA +1295 -0
- crprotocol-2.0.0.dist-info/RECORD +153 -0
- crprotocol-2.0.0.dist-info/WHEEL +4 -0
- crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
- crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
- 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
|
+
|