crprotocol 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. crp/__init__.py +126 -0
  2. crp/__main__.py +8 -0
  3. crp/_typing.py +27 -0
  4. crp/_version.py +5 -0
  5. crp/adapters.py +31 -0
  6. crp/advanced/__init__.py +40 -0
  7. crp/advanced/auto_ingest.py +400 -0
  8. crp/advanced/cqs.py +235 -0
  9. crp/advanced/cross_window.py +477 -0
  10. crp/advanced/curator.py +265 -0
  11. crp/advanced/feedback.py +146 -0
  12. crp/advanced/hierarchical.py +211 -0
  13. crp/advanced/meta_learning.py +401 -0
  14. crp/advanced/parallel.py +98 -0
  15. crp/advanced/review_cycle.py +329 -0
  16. crp/advanced/scale_mode.py +129 -0
  17. crp/advanced/source_grounding.py +207 -0
  18. crp/ckf/__init__.py +35 -0
  19. crp/ckf/community.py +377 -0
  20. crp/ckf/fabric.py +445 -0
  21. crp/ckf/gc.py +175 -0
  22. crp/ckf/graph_walk.py +87 -0
  23. crp/ckf/merge.py +133 -0
  24. crp/ckf/pattern_query.py +122 -0
  25. crp/ckf/pubsub.py +128 -0
  26. crp/ckf/semantic.py +207 -0
  27. crp/cli/__init__.py +7 -0
  28. crp/cli/main.py +329 -0
  29. crp/cli/sidecar.py +929 -0
  30. crp/cli/startup.py +272 -0
  31. crp/continuation/__init__.py +103 -0
  32. crp/continuation/completion.py +348 -0
  33. crp/continuation/degradation.py +157 -0
  34. crp/continuation/document_map.py +160 -0
  35. crp/continuation/flow.py +109 -0
  36. crp/continuation/gap.py +419 -0
  37. crp/continuation/manager.py +484 -0
  38. crp/continuation/quality_monitor.py +179 -0
  39. crp/continuation/stitch.py +419 -0
  40. crp/continuation/trigger.py +142 -0
  41. crp/continuation/voice.py +157 -0
  42. crp/core/__init__.py +69 -0
  43. crp/core/batch.py +77 -0
  44. crp/core/circuit_breaker.py +116 -0
  45. crp/core/config.py +377 -0
  46. crp/core/context_tools.py +540 -0
  47. crp/core/dispatch_router.py +3977 -0
  48. crp/core/errors.py +128 -0
  49. crp/core/extraction_facade.py +384 -0
  50. crp/core/facilitator.py +713 -0
  51. crp/core/idempotency.py +215 -0
  52. crp/core/orchestrator.py +1435 -0
  53. crp/core/relay_strategies.py +613 -0
  54. crp/core/security_manager.py +140 -0
  55. crp/core/session.py +134 -0
  56. crp/core/task_intent.py +36 -0
  57. crp/core/window.py +363 -0
  58. crp/envelope/__init__.py +30 -0
  59. crp/envelope/builder.py +288 -0
  60. crp/envelope/decomposer.py +236 -0
  61. crp/envelope/formatter.py +168 -0
  62. crp/envelope/packer.py +211 -0
  63. crp/envelope/reranker.py +209 -0
  64. crp/envelope/scoring.py +310 -0
  65. crp/extraction/__init__.py +45 -0
  66. crp/extraction/complexity.py +96 -0
  67. crp/extraction/contradiction.py +132 -0
  68. crp/extraction/pipeline.py +360 -0
  69. crp/extraction/quality_gate.py +237 -0
  70. crp/extraction/stage1_regex.py +173 -0
  71. crp/extraction/stage2_statistical.py +244 -0
  72. crp/extraction/stage3_gliner.py +210 -0
  73. crp/extraction/stage4_uie.py +183 -0
  74. crp/extraction/stage5_discourse.py +175 -0
  75. crp/extraction/stage6_llm.py +178 -0
  76. crp/extraction/structured_output.py +219 -0
  77. crp/extraction/types.py +299 -0
  78. crp/license_guard.py +722 -0
  79. crp/observability/__init__.py +30 -0
  80. crp/observability/audit.py +118 -0
  81. crp/observability/events.py +233 -0
  82. crp/observability/metrics.py +264 -0
  83. crp/observability/quality.py +135 -0
  84. crp/observability/structured_logging.py +81 -0
  85. crp/observability/telemetry.py +117 -0
  86. crp/provenance/__init__.py +314 -0
  87. crp/provenance/_embeddings.py +97 -0
  88. crp/provenance/_types.py +378 -0
  89. crp/provenance/attribution_scorer.py +252 -0
  90. crp/provenance/claim_detector.py +229 -0
  91. crp/provenance/contradiction_detector.py +243 -0
  92. crp/provenance/distortion_detector.py +397 -0
  93. crp/provenance/entailment_verifier.py +358 -0
  94. crp/provenance/fabrication_detector.py +203 -0
  95. crp/provenance/hallucination_scorer.py +320 -0
  96. crp/provenance/omission_analyzer.py +106 -0
  97. crp/provenance/provenance_chain.py +205 -0
  98. crp/provenance/report_generator.py +440 -0
  99. crp/providers/__init__.py +43 -0
  100. crp/providers/anthropic.py +270 -0
  101. crp/providers/base.py +135 -0
  102. crp/providers/custom.py +63 -0
  103. crp/providers/diagnostic.py +251 -0
  104. crp/providers/llamacpp.py +224 -0
  105. crp/providers/manager.py +139 -0
  106. crp/providers/ollama.py +243 -0
  107. crp/providers/openai.py +628 -0
  108. crp/providers/tokenizers.py +48 -0
  109. crp/py.typed +0 -0
  110. crp/resources/__init__.py +53 -0
  111. crp/resources/adaptive_allocator.py +525 -0
  112. crp/resources/cost_model.py +388 -0
  113. crp/resources/overhead_manager.py +217 -0
  114. crp/resources/resource_manager.py +262 -0
  115. crp/schemas/__init__.py +20 -0
  116. crp/schemas/cost-estimate.json +33 -0
  117. crp/schemas/crp-error.json +43 -0
  118. crp/schemas/envelope-preview.json +40 -0
  119. crp/schemas/persisted-state-header.json +27 -0
  120. crp/schemas/quality-report.json +94 -0
  121. crp/schemas/session-handle.json +33 -0
  122. crp/schemas/session-status.json +57 -0
  123. crp/schemas/stream-event.json +18 -0
  124. crp/schemas/task-intent.json +42 -0
  125. crp/security/__init__.py +93 -0
  126. crp/security/audit_trail.py +392 -0
  127. crp/security/binding.py +192 -0
  128. crp/security/compliance.py +813 -0
  129. crp/security/consent.py +593 -0
  130. crp/security/embedding_defense.py +161 -0
  131. crp/security/encryption.py +202 -0
  132. crp/security/injection.py +335 -0
  133. crp/security/integrity.py +267 -0
  134. crp/security/privacy.py +662 -0
  135. crp/security/quarantine.py +249 -0
  136. crp/security/rbac.py +221 -0
  137. crp/security/validation.py +164 -0
  138. crp/state/__init__.py +31 -0
  139. crp/state/cold_storage.py +258 -0
  140. crp/state/compaction.py +263 -0
  141. crp/state/critical_state.py +104 -0
  142. crp/state/event_log.py +313 -0
  143. crp/state/fact.py +189 -0
  144. crp/state/serialization.py +189 -0
  145. crp/state/session_cleanup.py +77 -0
  146. crp/state/snapshot.py +290 -0
  147. crp/state/warm_store.py +346 -0
  148. crprotocol-2.0.0.dist-info/METADATA +1295 -0
  149. crprotocol-2.0.0.dist-info/RECORD +153 -0
  150. crprotocol-2.0.0.dist-info/WHEEL +4 -0
  151. crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
  152. crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
  153. crprotocol-2.0.0.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,540 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Tool-mediated context relay — pull-based architecture (§20).
4
+
5
+ Instead of pre-loading ALL context into the prompt (push model), CRP
6
+ exposes its knowledge stores as callable tools. The LLM requests
7
+ context on demand during generation, consuming only what it needs.
8
+
9
+ This is fundamentally different from RAG/prompt injection:
10
+ - **Push (old)**: Pre-compute all context → stuff into prompt → generate
11
+ - **Pull (new)**: Give LLM task + tools → LLM decides what it needs → CRP serves on demand
12
+
13
+ The module provides:
14
+ 1. Tool definitions in OpenAI-compatible format
15
+ 2. A ContextToolExecutor that routes tool calls to CKF/WarmStore
16
+ 3. Integration with the iterative dispatch loop in the orchestrator
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Callable, TYPE_CHECKING
25
+
26
+ if TYPE_CHECKING:
27
+ from crp.ckf.fabric import ContextualKnowledgeFabric
28
+ from crp.state.warm_store import WarmStateStore
29
+
30
+ logger = logging.getLogger("crp.context_tools")
31
+
32
+
33
+ # ── Tool call data structures ──────────────────────────────────────────
34
+
35
+ @dataclass
36
+ class ToolCall:
37
+ """A single tool call extracted from an LLM response."""
38
+ id: str
39
+ name: str
40
+ arguments: dict[str, Any]
41
+
42
+
43
+ @dataclass
44
+ class ToolResult:
45
+ """Result of executing a tool call."""
46
+ tool_call_id: str
47
+ name: str
48
+ content: str
49
+ tokens_used: int = 0
50
+
51
+
52
+ # ── Tool definitions (OpenAI function-calling format) ──────────────────
53
+
54
+ CRP_CONTEXT_TOOLS: list[dict[str, Any]] = [
55
+ {
56
+ "type": "function",
57
+ "function": {
58
+ "name": "crp_retrieve_context",
59
+ "description": (
60
+ "Search for verified context and facts relevant to a query. "
61
+ "Returns ranked facts from the CRP knowledge base. Use this "
62
+ "when you need specific information, data, or evidence to "
63
+ "support your response."
64
+ ),
65
+ "parameters": {
66
+ "type": "object",
67
+ "properties": {
68
+ "query": {
69
+ "type": "string",
70
+ "description": (
71
+ "What context you need. Be specific about the "
72
+ "topic, concept, or claim you want facts about."
73
+ ),
74
+ },
75
+ "max_results": {
76
+ "type": "integer",
77
+ "description": "Maximum number of facts to return.",
78
+ "default": 5,
79
+ },
80
+ },
81
+ "required": ["query"],
82
+ },
83
+ },
84
+ },
85
+ {
86
+ "type": "function",
87
+ "function": {
88
+ "name": "crp_get_document_structure",
89
+ "description": (
90
+ "Get the current document structure and progress: what sections "
91
+ "have been written, what remains, the document outline and map. "
92
+ "Use this to understand the overall state of the document and "
93
+ "plan your continuation."
94
+ ),
95
+ "parameters": {
96
+ "type": "object",
97
+ "properties": {},
98
+ "required": [],
99
+ },
100
+ },
101
+ },
102
+ {
103
+ "type": "function",
104
+ "function": {
105
+ "name": "crp_check_facts",
106
+ "description": (
107
+ "Verify a factual claim against the verified knowledge base. "
108
+ "Returns matching or contradicting facts. Use this to ensure "
109
+ "accuracy before making factual statements."
110
+ ),
111
+ "parameters": {
112
+ "type": "object",
113
+ "properties": {
114
+ "claim": {
115
+ "type": "string",
116
+ "description": "The factual claim to verify.",
117
+ },
118
+ },
119
+ "required": ["claim"],
120
+ },
121
+ },
122
+ },
123
+ {
124
+ "type": "function",
125
+ "function": {
126
+ "name": "crp_get_related_facts",
127
+ "description": (
128
+ "Given a fact ID or topic, retrieve related facts via graph "
129
+ "traversal. Use this to explore connections between concepts "
130
+ "and find supporting evidence."
131
+ ),
132
+ "parameters": {
133
+ "type": "object",
134
+ "properties": {
135
+ "topic": {
136
+ "type": "string",
137
+ "description": "Topic or concept to find related facts for.",
138
+ },
139
+ "max_hops": {
140
+ "type": "integer",
141
+ "description": "How many relationship hops to traverse (1-3).",
142
+ "default": 2,
143
+ },
144
+ },
145
+ "required": ["topic"],
146
+ },
147
+ },
148
+ },
149
+ {
150
+ "type": "function",
151
+ "function": {
152
+ "name": "crp_get_continuation_state",
153
+ "description": (
154
+ "Get the continuation state: what has been completed, what "
155
+ "gaps remain, requirement coverage, and directives for what "
156
+ "to write next. Use this to understand your mission and what "
157
+ "the document still needs."
158
+ ),
159
+ "parameters": {
160
+ "type": "object",
161
+ "properties": {},
162
+ "required": [],
163
+ },
164
+ },
165
+ },
166
+ ]
167
+
168
+
169
+ # ── Tool name set for validation ───────────────────────────────────────
170
+
171
+ CRP_TOOL_NAMES: frozenset[str] = frozenset(
172
+ t["function"]["name"] for t in CRP_CONTEXT_TOOLS
173
+ )
174
+
175
+
176
+ # ── Context Tool Executor ──────────────────────────────────────────────
177
+
178
+ class ContextToolExecutor:
179
+ """Routes tool calls to CRP subsystems and returns results.
180
+
181
+ Wired to:
182
+ - WarmStateStore: ranked facts, structural state, fact lookup
183
+ - CKF: semantic/graph retrieval, community queries
184
+ - Embedding function: for semantic search
185
+ - Continuation state: gap analysis, directives
186
+ """
187
+
188
+ # Safety: cap tokens returned per tool call to prevent context overflow
189
+ MAX_RESULT_TOKENS: int = 2000
190
+
191
+ def __init__(
192
+ self,
193
+ warm_store: WarmStateStore,
194
+ ckf: ContextualKnowledgeFabric,
195
+ count_tokens: Callable[[str], int],
196
+ embed_fn: Callable[[str], list[float]] | None = None,
197
+ continuation_state: dict[str, Any] | None = None,
198
+ ) -> None:
199
+ self._warm_store = warm_store
200
+ self._ckf = ckf
201
+ self._count_tokens = count_tokens
202
+ self._embed_fn = embed_fn
203
+ self._continuation_state = continuation_state or {}
204
+ self._calls_executed: int = 0
205
+ self._total_tokens_served: int = 0
206
+
207
+ @property
208
+ def calls_executed(self) -> int:
209
+ return self._calls_executed
210
+
211
+ @property
212
+ def total_tokens_served(self) -> int:
213
+ return self._total_tokens_served
214
+
215
+ def update_continuation_state(self, state: dict[str, Any]) -> None:
216
+ """Update continuation state (called between windows)."""
217
+ self._continuation_state = state
218
+
219
+ def execute(self, tool_call: ToolCall) -> ToolResult:
220
+ """Execute a single tool call and return the result."""
221
+ self._calls_executed += 1
222
+
223
+ handler = {
224
+ "crp_retrieve_context": self._handle_retrieve_context,
225
+ "crp_get_document_structure": self._handle_get_document_structure,
226
+ "crp_check_facts": self._handle_check_facts,
227
+ "crp_get_related_facts": self._handle_get_related_facts,
228
+ "crp_get_continuation_state": self._handle_get_continuation_state,
229
+ }.get(tool_call.name)
230
+
231
+ if handler is None:
232
+ logger.warning("Unknown tool call: %s", tool_call.name)
233
+ content = json.dumps({"error": f"Unknown tool: {tool_call.name}"})
234
+ return ToolResult(
235
+ tool_call_id=tool_call.id,
236
+ name=tool_call.name,
237
+ content=content,
238
+ )
239
+
240
+ try:
241
+ content = handler(tool_call.arguments)
242
+ except Exception as exc:
243
+ logger.error("Tool %s failed: %s", tool_call.name, exc)
244
+ content = json.dumps({"error": f"Tool execution failed: {exc}"})
245
+
246
+ tokens = self._count_tokens(content)
247
+ self._total_tokens_served += tokens
248
+
249
+ logger.info(
250
+ "Tool %s executed: %d tokens served (total: %d across %d calls)",
251
+ tool_call.name, tokens, self._total_tokens_served, self._calls_executed,
252
+ )
253
+
254
+ return ToolResult(
255
+ tool_call_id=tool_call.id,
256
+ name=tool_call.name,
257
+ content=content,
258
+ tokens_used=tokens,
259
+ )
260
+
261
+ def execute_batch(self, tool_calls: list[ToolCall]) -> list[ToolResult]:
262
+ """Execute multiple tool calls and return results."""
263
+ return [self.execute(tc) for tc in tool_calls]
264
+
265
+ # ── Individual tool handlers ───────────────────────────────────────
266
+
267
+ def _handle_retrieve_context(self, args: dict[str, Any]) -> str:
268
+ """Search WarmStore + CKF for relevant facts."""
269
+ query = args.get("query", "")
270
+ max_results = min(args.get("max_results", 5), 20) # Cap at 20
271
+
272
+ if not query:
273
+ return json.dumps({"facts": [], "note": "Empty query"})
274
+
275
+ facts_out: list[dict[str, str]] = []
276
+ token_budget = self.MAX_RESULT_TOKENS
277
+
278
+ # Layer 1: WarmStore ranked facts filtered by text relevance
279
+ ranked = self._warm_store.get_ranked_facts(limit=max_results * 3)
280
+ query_lower = query.lower()
281
+ query_terms = set(query_lower.split())
282
+
283
+ # Score facts by term overlap with query
284
+ scored: list[tuple[float, Any]] = []
285
+ for f in ranked:
286
+ text_lower = f.text.lower()
287
+ term_hits = sum(1 for t in query_terms if t in text_lower)
288
+ if term_hits > 0 or query_lower in text_lower:
289
+ score = term_hits / max(len(query_terms), 1)
290
+ if query_lower in text_lower:
291
+ score += 0.5
292
+ scored.append((score, f))
293
+
294
+ scored.sort(key=lambda x: -x[0])
295
+
296
+ for _, f in scored[:max_results]:
297
+ fact_text = f.text
298
+ fact_tokens = self._count_tokens(fact_text)
299
+ if fact_tokens > token_budget:
300
+ break
301
+ token_budget -= fact_tokens
302
+ facts_out.append({
303
+ "id": f.id,
304
+ "text": fact_text,
305
+ "confidence": str(round(f.confidence, 2)),
306
+ "source": f.source_window_id or "unknown",
307
+ })
308
+
309
+ # Layer 2: CKF semantic retrieval if embedding function available
310
+ if len(facts_out) < max_results and self._embed_fn and token_budget > 100:
311
+ try:
312
+ query_emb = self._embed_fn(query)
313
+ seed_ids = {f["id"] for f in facts_out}
314
+ ckf_result = self._ckf.retrieve(
315
+ query_embedding=query_emb,
316
+ seed_ids=seed_ids,
317
+ topic=query[:100],
318
+ budget=max_results - len(facts_out),
319
+ )
320
+ seen_ids = {f["id"] for f in facts_out}
321
+ for cf in ckf_result.facts:
322
+ if cf.id in seen_ids:
323
+ continue
324
+ fact_tokens = self._count_tokens(cf.text)
325
+ if fact_tokens > token_budget:
326
+ break
327
+ token_budget -= fact_tokens
328
+ facts_out.append({
329
+ "id": cf.id,
330
+ "text": cf.text,
331
+ "confidence": str(round(cf.confidence, 2)),
332
+ "source": getattr(cf, "source_window_id", "ckf"),
333
+ })
334
+ if len(facts_out) >= max_results:
335
+ break
336
+ except Exception as exc:
337
+ logger.debug("CKF semantic retrieval skipped: %s", exc)
338
+
339
+ return json.dumps({
340
+ "facts": facts_out,
341
+ "total_available": self._warm_store.fact_count,
342
+ })
343
+
344
+ def _handle_get_document_structure(self, args: dict[str, Any]) -> str:
345
+ """Return structural state: document map, outline, progress."""
346
+ ss = self._warm_store.structural_state
347
+ structure: dict[str, Any] = {}
348
+
349
+ if hasattr(ss, "to_dict"):
350
+ ss_dict = ss.to_dict()
351
+ structure["document_map"] = ss_dict.get("document_map", "")
352
+ structure["outline"] = ss_dict.get("outline", "")
353
+ structure["sections_completed"] = ss_dict.get("sections_completed", [])
354
+ structure["current_section"] = ss_dict.get("current_section", "")
355
+ structure["word_count"] = ss_dict.get("word_count", 0)
356
+ else:
357
+ # Minimal fallback
358
+ structure["note"] = "Structural state not yet populated"
359
+
360
+ # Add critical state info
361
+ cs = self._warm_store.critical_state
362
+ if hasattr(cs, "to_dict"):
363
+ cs_dict = cs.to_dict()
364
+ structure["goal"] = cs_dict.get("goal", "")
365
+ structure["phase"] = cs_dict.get("phase", "")
366
+ structure["constraints"] = cs_dict.get("constraints", [])
367
+
368
+ # Truncate to token budget
369
+ result = json.dumps(structure)
370
+ tokens = self._count_tokens(result)
371
+ if tokens > self.MAX_RESULT_TOKENS:
372
+ # Truncate the document_map if it's too large
373
+ if "document_map" in structure and structure["document_map"]:
374
+ structure["document_map"] = structure["document_map"][:500] + "..."
375
+ result = json.dumps(structure)
376
+
377
+ return result
378
+
379
+ def _handle_check_facts(self, args: dict[str, Any]) -> str:
380
+ """Verify a claim against the knowledge base."""
381
+ claim = args.get("claim", "")
382
+ if not claim:
383
+ return json.dumps({"matching": [], "note": "Empty claim"})
384
+
385
+ claim_lower = claim.lower()
386
+ claim_terms = set(claim_lower.split())
387
+ ranked = self._warm_store.get_ranked_facts(limit=50)
388
+
389
+ matching: list[dict[str, str]] = []
390
+ token_budget = self.MAX_RESULT_TOKENS
391
+
392
+ for f in ranked:
393
+ text_lower = f.text.lower()
394
+ # Check for term overlap
395
+ overlap = sum(1 for t in claim_terms if t in text_lower)
396
+ if overlap >= max(1, len(claim_terms) // 3):
397
+ fact_tokens = self._count_tokens(f.text)
398
+ if fact_tokens > token_budget:
399
+ break
400
+ token_budget -= fact_tokens
401
+ matching.append({
402
+ "id": f.id,
403
+ "text": f.text,
404
+ "confidence": str(round(f.confidence, 2)),
405
+ "relevance": "high" if overlap > len(claim_terms) // 2 else "partial",
406
+ })
407
+ if len(matching) >= 5:
408
+ break
409
+
410
+ return json.dumps({
411
+ "matching_facts": matching,
412
+ "facts_searched": min(len(ranked), 50),
413
+ })
414
+
415
+ def _handle_get_related_facts(self, args: dict[str, Any]) -> str:
416
+ """Graph traversal to find related facts."""
417
+ topic = args.get("topic", "")
418
+ max_hops = min(args.get("max_hops", 2), 3)
419
+
420
+ if not topic:
421
+ return json.dumps({"related": [], "note": "Empty topic"})
422
+
423
+ # Find seed facts matching the topic
424
+ ranked = self._warm_store.get_ranked_facts(limit=30)
425
+ topic_lower = topic.lower()
426
+ seed_ids: set[str] = set()
427
+
428
+ for f in ranked:
429
+ if topic_lower in f.text.lower():
430
+ seed_ids.add(f.id)
431
+ if len(seed_ids) >= 3:
432
+ break
433
+
434
+ if not seed_ids:
435
+ return json.dumps({
436
+ "related": [],
437
+ "note": f"No seed facts found for topic: {topic}",
438
+ })
439
+
440
+ # Graph walk from seeds
441
+ try:
442
+ walk_result = self._ckf.graph_walk(
443
+ seed_ids=seed_ids,
444
+ max_hops=max_hops,
445
+ max_results=10,
446
+ )
447
+ related: list[dict[str, str]] = []
448
+ token_budget = self.MAX_RESULT_TOKENS
449
+
450
+ for fact in walk_result.facts:
451
+ fact_tokens = self._count_tokens(fact.text)
452
+ if fact_tokens > token_budget:
453
+ break
454
+ token_budget -= fact_tokens
455
+ related.append({
456
+ "id": fact.id,
457
+ "text": fact.text,
458
+ "confidence": str(round(fact.confidence, 2)),
459
+ })
460
+
461
+ return json.dumps({
462
+ "related": related,
463
+ "seed_count": len(seed_ids),
464
+ "hops": max_hops,
465
+ })
466
+ except Exception as exc:
467
+ logger.debug("Graph walk failed: %s", exc)
468
+ return json.dumps({
469
+ "related": [],
470
+ "note": f"Graph traversal unavailable: {exc}",
471
+ })
472
+
473
+ def _handle_get_continuation_state(self, args: dict[str, Any]) -> str:
474
+ """Return continuation state: gaps, directives, progress."""
475
+ if not self._continuation_state:
476
+ return json.dumps({
477
+ "status": "initial",
478
+ "note": "No continuation state yet — this is the first window.",
479
+ })
480
+
481
+ result = json.dumps(self._continuation_state)
482
+ tokens = self._count_tokens(result)
483
+ if tokens > self.MAX_RESULT_TOKENS:
484
+ # Truncate large fields
485
+ state = dict(self._continuation_state)
486
+ for key in ("last_output_summary", "document_map", "full_directive"):
487
+ if key in state and isinstance(state[key], str):
488
+ state[key] = state[key][:300] + "..."
489
+ result = json.dumps(state)
490
+
491
+ return result
492
+
493
+
494
+ # ── Helper: build tool-aware system prompt ─────────────────────────────
495
+
496
+ def build_tool_system_prompt(original_system: str, fact_count: int) -> str:
497
+ """Augment the system prompt with context-tool usage guidance.
498
+
499
+ Tells the LLM that it has access to CRP context tools and should
500
+ use them to retrieve verified information instead of relying on
501
+ parametric knowledge alone.
502
+ """
503
+ tool_guidance = (
504
+ "\n\n--- Context Access Protocol ---\n"
505
+ "You have access to a verified knowledge base managed by the "
506
+ "Context Relay Protocol (CRP). Instead of guessing or relying "
507
+ "solely on your training data, use the provided tools to:\n"
508
+ "- Retrieve verified facts relevant to your response\n"
509
+ "- Check claims against the knowledge base before stating them\n"
510
+ "- Get the document structure and continuation state\n"
511
+ "- Explore related concepts via knowledge graph traversal\n"
512
+ f"\nThe knowledge base currently contains {fact_count} verified facts.\n"
513
+ "Call tools as needed during your response. Each tool call "
514
+ "returns targeted context — more efficient than searching "
515
+ "everything at once.\n"
516
+ "--- End Context Access Protocol ---"
517
+ )
518
+ return original_system + tool_guidance
519
+
520
+
521
+ # ── Helper: convert tool results to messages ───────────────────────────
522
+
523
+ def tool_results_to_messages(
524
+ assistant_message: dict[str, Any],
525
+ results: list[ToolResult],
526
+ ) -> list[dict[str, Any]]:
527
+ """Build the message sequence for a tool call round-trip.
528
+
529
+ Returns [assistant_msg_with_tool_calls, tool_result_1, tool_result_2, ...].
530
+ This is the OpenAI-compatible format for continuing generation
531
+ after tool calls.
532
+ """
533
+ messages: list[dict[str, Any]] = [assistant_message]
534
+ for r in results:
535
+ messages.append({
536
+ "role": "tool",
537
+ "tool_call_id": r.tool_call_id,
538
+ "content": r.content,
539
+ })
540
+ return messages