monkeybot 2.1.1__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 (178) hide show
  1. monkeybot/__init__.py +3 -0
  2. monkeybot/cli/__init__.py +3 -0
  3. monkeybot/cli/__main__.py +8 -0
  4. monkeybot/cli/audio_io.py +8 -0
  5. monkeybot/cli/gateway_manager.py +17 -0
  6. monkeybot/cli/main.py +22 -0
  7. monkeybot/cli/push_to_talk.py +12 -0
  8. monkeybot/cli/realtime_client.py +13 -0
  9. monkeybot/core/__init__.py +19 -0
  10. monkeybot/core/attachments/__init__.py +22 -0
  11. monkeybot/core/attachments/catalog.py +62 -0
  12. monkeybot/core/attachments/config.py +52 -0
  13. monkeybot/core/attachments/freeze.py +158 -0
  14. monkeybot/core/attachments/resolve.py +70 -0
  15. monkeybot/core/attachments/store.py +180 -0
  16. monkeybot/core/attachments/text.py +72 -0
  17. monkeybot/core/attachments/tools.py +54 -0
  18. monkeybot/core/bootstrap.py +242 -0
  19. monkeybot/core/config/__init__.py +71 -0
  20. monkeybot/core/config/realtime_config.py +150 -0
  21. monkeybot/core/config/runtime_env.py +262 -0
  22. monkeybot/core/config/settings.py +341 -0
  23. monkeybot/core/config/validation.py +249 -0
  24. monkeybot/core/config/yaml_loader.py +45 -0
  25. monkeybot/core/context/__init__.py +781 -0
  26. monkeybot/core/context/campaign_context.py +8 -0
  27. monkeybot/core/context/common.py +14 -0
  28. monkeybot/core/context/curator.py +255 -0
  29. monkeybot/core/context/epoch.py +226 -0
  30. monkeybot/core/context/memory_prompt.py +222 -0
  31. monkeybot/core/context/tool_output_policy.py +270 -0
  32. monkeybot/core/context/tool_result_ingress.py +290 -0
  33. monkeybot/core/context/tool_shapers.py +361 -0
  34. monkeybot/core/hooks/__init__.py +261 -0
  35. monkeybot/core/llm/__init__.py +4 -0
  36. monkeybot/core/llm/provider.py +296 -0
  37. monkeybot/core/llm/realtime_provider.py +203 -0
  38. monkeybot/core/llm/usage.py +57 -0
  39. monkeybot/core/logging_utils.py +24 -0
  40. monkeybot/core/mcp/__init__.py +1 -0
  41. monkeybot/core/mcp/mcp_client.py +1215 -0
  42. monkeybot/core/mcp/ports_mcp.py +109 -0
  43. monkeybot/core/memory/__init__.py +24 -0
  44. monkeybot/core/memory/hook.py +413 -0
  45. monkeybot/core/memory/index_format.py +104 -0
  46. monkeybot/core/memory/integrity.py +180 -0
  47. monkeybot/core/memory/organizer.py +270 -0
  48. monkeybot/core/memory/storage_ops.py +139 -0
  49. monkeybot/core/memory/subsystem.py +91 -0
  50. monkeybot/core/messages/__init__.py +16 -0
  51. monkeybot/core/messages/convert_provider.py +41 -0
  52. monkeybot/core/messages/tool_integrity.py +262 -0
  53. monkeybot/core/messages/transform_context.py +84 -0
  54. monkeybot/core/path_safety.py +11 -0
  55. monkeybot/core/persistence/__init__.py +17 -0
  56. monkeybot/core/persistence/backends.py +236 -0
  57. monkeybot/core/persistence/db.py +28 -0
  58. monkeybot/core/persistence/durable_runs.py +286 -0
  59. monkeybot/core/persistence/firestore.py +658 -0
  60. monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
  61. monkeybot/core/persistence/history.py +156 -0
  62. monkeybot/core/persistence/postgres.py +895 -0
  63. monkeybot/core/persistence/runs.py +76 -0
  64. monkeybot/core/persistence/scheduled_loops.py +435 -0
  65. monkeybot/core/persistence/session_turn_locks.py +94 -0
  66. monkeybot/core/persistence/sqlite.py +218 -0
  67. monkeybot/core/persistence/sqlite_backend.py +74 -0
  68. monkeybot/core/persistence/thread_summary.py +61 -0
  69. monkeybot/core/persistence/transcript.py +194 -0
  70. monkeybot/core/persistence/usage.py +149 -0
  71. monkeybot/core/prompts/__init__.py +1 -0
  72. monkeybot/core/prompts/harness_prompt.py +197 -0
  73. monkeybot/core/prompts/prompt.py +215 -0
  74. monkeybot/core/runtime/__init__.py +1 -0
  75. monkeybot/core/runtime/context_budget.py +267 -0
  76. monkeybot/core/runtime/events.py +819 -0
  77. monkeybot/core/runtime/input_admission.py +154 -0
  78. monkeybot/core/runtime/loop.py +2374 -0
  79. monkeybot/core/runtime/provider_stream_mapper.py +159 -0
  80. monkeybot/core/runtime/realtime_loop.py +654 -0
  81. monkeybot/core/runtime/utterance_buffer.py +179 -0
  82. monkeybot/core/subagents/__init__.py +1 -0
  83. monkeybot/core/subagents/subagent_proto.py +331 -0
  84. monkeybot/core/subagents/subagent_worker.py +441 -0
  85. monkeybot/core/subagents/worker_pool.py +403 -0
  86. monkeybot/core/testing/__init__.py +1 -0
  87. monkeybot/core/testing/mocks_provider.py +86 -0
  88. monkeybot/core/testing/mocks_realtime_provider.py +137 -0
  89. monkeybot/core/tools/__init__.py +1 -0
  90. monkeybot/core/tools/core_tool_executor.py +1548 -0
  91. monkeybot/core/tools/inspector.py +226 -0
  92. monkeybot/core/tools/loop_inspector.py +45 -0
  93. monkeybot/core/tools/patch.py +480 -0
  94. monkeybot/core/tools/permission.py +284 -0
  95. monkeybot/core/tools/sandbox_executor.py +255 -0
  96. monkeybot/core/tools/spill_inventory.py +35 -0
  97. monkeybot/core/tools/terminal.py +381 -0
  98. monkeybot/core/tools/text_normalize.py +25 -0
  99. monkeybot/core/tools/types.py +33 -0
  100. monkeybot/core/tools/workspace_service.py +710 -0
  101. monkeybot/core/tools/workspace_tools.py +116 -0
  102. monkeybot/core/types/__init__.py +1 -0
  103. monkeybot/core/types/content_blocks.py +644 -0
  104. monkeybot/core/types/interfaces.py +156 -0
  105. monkeybot/core/types/types_tools.py +29 -0
  106. monkeybot/core/workspace/__init__.py +8 -0
  107. monkeybot/core/workspace/factory.py +45 -0
  108. monkeybot/core/workspace/gcs.py +130 -0
  109. monkeybot/core/workspace/local.py +162 -0
  110. monkeybot/core/workspace/protocol.py +45 -0
  111. monkeybot/core/workspace/s3.py +151 -0
  112. monkeybot/core/workspace_layout.py +27 -0
  113. monkeybot/gateway/__init__.py +1 -0
  114. monkeybot/gateway/bootstrap.py +18 -0
  115. monkeybot/gateway/main.py +47 -0
  116. monkeybot/gateway/realtime/__init__.py +31 -0
  117. monkeybot/gateway/realtime/app.py +321 -0
  118. monkeybot/gateway/realtime/deps.py +52 -0
  119. monkeybot/gateway/realtime/errors.py +81 -0
  120. monkeybot/gateway/realtime/guardrails.py +88 -0
  121. monkeybot/gateway/realtime/manager.py +77 -0
  122. monkeybot/gateway/realtime/metrics.py +144 -0
  123. monkeybot/gateway/realtime/routes.py +864 -0
  124. monkeybot/gateway/realtime/session.py +232 -0
  125. monkeybot/gateway/realtime/wire.py +412 -0
  126. monkeybot/gateway/realtime_main.py +49 -0
  127. monkeybot/gateway/sse/__init__.py +1 -0
  128. monkeybot/gateway/sse/app.py +733 -0
  129. monkeybot/gateway/sse/loop_port.py +31 -0
  130. monkeybot/gateway/sse/models.py +177 -0
  131. monkeybot/gateway/sse/reply_body.py +91 -0
  132. monkeybot/gateway/sse/routes.py +1101 -0
  133. monkeybot/gateway/sse/scheduler_routes.py +200 -0
  134. monkeybot/gateway/sse/scheduler_wiring.py +96 -0
  135. monkeybot/gateway/sse/session_bus.py +226 -0
  136. monkeybot/gateway/sse/sse.py +46 -0
  137. monkeybot/gateway/sse/workspace_layout.py +7 -0
  138. monkeybot/observability/__init__.py +220 -0
  139. monkeybot/observability/_state.py +10 -0
  140. monkeybot/observability/instrumentation.py +153 -0
  141. monkeybot/observability/propagation.py +65 -0
  142. monkeybot/observability/spans.py +455 -0
  143. monkeybot/providers/__init__.py +19 -0
  144. monkeybot/providers/_openai_compat.py +450 -0
  145. monkeybot/providers/_utils.py +473 -0
  146. monkeybot/providers/bedrock.py +145 -0
  147. monkeybot/providers/claude.py +125 -0
  148. monkeybot/providers/gemini.py +677 -0
  149. monkeybot/providers/gemini_live.py +398 -0
  150. monkeybot/providers/huggingface.py +129 -0
  151. monkeybot/providers/nvidia.py +104 -0
  152. monkeybot/providers/ollama.py +152 -0
  153. monkeybot/providers/openai.py +127 -0
  154. monkeybot/providers/pricing.py +60 -0
  155. monkeybot/providers/sampling.py +44 -0
  156. monkeybot/providers/vertex_claude.py +148 -0
  157. monkeybot/scaffold/__init__.py +33 -0
  158. monkeybot/scheduler/__init__.py +13 -0
  159. monkeybot/scheduler/__main__.py +4 -0
  160. monkeybot/scheduler/engine.py +333 -0
  161. monkeybot/scheduler/http_invoker.py +61 -0
  162. monkeybot/scheduler/interval.py +77 -0
  163. monkeybot/scheduler/tick_result.py +34 -0
  164. monkeybot/scheduler/worker.py +87 -0
  165. monkeybot/subagents/__init__.py +1 -0
  166. monkeybot/subagents/worker/__init__.py +1 -0
  167. monkeybot/subagents/worker/__main__.py +22 -0
  168. monkeybot/web_search/__init__.py +82 -0
  169. monkeybot/web_search/backends/__init__.py +5 -0
  170. monkeybot/web_search/backends/duckduckgo.py +32 -0
  171. monkeybot/web_search/backends/firecrawl.py +43 -0
  172. monkeybot/web_search/backends/tavily.py +45 -0
  173. monkeybot/web_search/protocol.py +25 -0
  174. monkeybot/web_search/tool.py +56 -0
  175. monkeybot-2.1.1.dist-info/METADATA +318 -0
  176. monkeybot-2.1.1.dist-info/RECORD +178 -0
  177. monkeybot-2.1.1.dist-info/WHEEL +4 -0
  178. monkeybot-2.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,8 @@
1
+ """Per-invocation campaign / memory context for tools that are not path-rewritten by middleware."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextvars import ContextVar
6
+
7
+ # Set by MemoryPathMiddleware from config["configurable"]["memory_context_dir"] (or campaign_dir).
8
+ memory_context_dir_ctx: ContextVar[str | None] = ContextVar("memory_context_dir_ctx", default=None)
@@ -0,0 +1,14 @@
1
+ """Shared context-shaping types and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Literal
7
+
8
+ from monkeybot.core.types.content_blocks import ContentBlock, Text
9
+
10
+ ContextPressureTier = Literal["light", "moderate", "aggressive"]
11
+
12
+
13
+ def text_from_blocks(blocks: Sequence[ContentBlock]) -> str:
14
+ return "".join(block.text for block in blocks if isinstance(block, Text))
@@ -0,0 +1,255 @@
1
+ """Optional secondary LLM pass to pick memory lines for the system prompt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ import time
11
+ from contextlib import aclosing
12
+ from dataclasses import dataclass
13
+ from typing import Any, cast
14
+
15
+ from monkeybot.core.types.content_blocks import Text
16
+ from monkeybot.core.context import TurnContext
17
+ from monkeybot.core.logging_utils import kv
18
+ from monkeybot.core.memory.subsystem import MemorySubsystem
19
+ from monkeybot.core.llm.provider import Done, Message, Provider, TextDelta, ToolCall, UsageEvent
20
+ from monkeybot.core.runtime.context_budget import estimate_tokens
21
+
22
+ _log = logging.getLogger(__name__)
23
+
24
+
25
+ def curation_enabled_from_env() -> bool:
26
+ v = os.getenv("CONTEXT_CURATION_ENABLED", "1").strip().lower()
27
+ return v not in ("0", "false", "no", "off")
28
+
29
+
30
+ def _env_int(name: str, default: int) -> int:
31
+ raw = os.getenv(name, str(default)).strip()
32
+ try:
33
+ return int(raw)
34
+ except ValueError:
35
+ return default
36
+
37
+
38
+ def _env_float(name: str, default: float) -> float:
39
+ raw = os.getenv(name, str(default)).strip()
40
+ try:
41
+ return float(raw)
42
+ except ValueError:
43
+ return default
44
+
45
+
46
+ def memory_index_token_estimate(lines: list[str]) -> int:
47
+ """Cheap local estimate of memory-index prompt size (no provider call)."""
48
+ if not lines:
49
+ return 0
50
+ return estimate_tokens("\n".join(lines))
51
+
52
+
53
+ # Cap on search hits added to the curator MEMORY_POOL (not user-configurable).
54
+ _CURATOR_SEARCH_MAX_HITS = 8
55
+
56
+
57
+ def curator_model_id(ctx: TurnContext) -> str:
58
+ return os.getenv("CONTEXT_CURATOR_MODEL", "").strip() or ctx.model
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class CuratedPromptParts:
63
+ """Subset of memory lines chosen for this user message (frozen for follow-up turns)."""
64
+
65
+ memory_lines: list[str]
66
+ success: bool
67
+ """False on timeout, provider error, invalid JSON, or invalid selections when the model proposed content."""
68
+
69
+
70
+ def _parse_json_object(text: str) -> dict[str, object] | None:
71
+ raw = text.strip()
72
+ if raw.startswith("```"):
73
+ raw = re.sub(r"^```(?:json)?\s*", "", raw, flags=re.IGNORECASE)
74
+ raw = re.sub(r"\s*```\s*$", "", raw).strip()
75
+ start = raw.find("{")
76
+ end = raw.rfind("}")
77
+ if start < 0 or end <= start:
78
+ return None
79
+ try:
80
+ out = json.loads(raw[start : end + 1])
81
+ except json.JSONDecodeError:
82
+ return None
83
+ return out if isinstance(out, dict) else None
84
+
85
+
86
+ async def _gather_search_pool_lines(
87
+ memory: MemorySubsystem | None, user_text: str, *, max_hits: int
88
+ ) -> list[str]:
89
+ if memory is None or not user_text.strip():
90
+ return []
91
+ q = user_text.strip()[:400]
92
+ payload = await memory.search_files(q, max_hits=max_hits, skip_raw=True)
93
+ hits = payload.get("hits") or []
94
+ lines: list[str] = []
95
+ if not isinstance(hits, list):
96
+ return []
97
+ for h in hits:
98
+ if not isinstance(h, dict):
99
+ continue
100
+ path = str(h.get("path", "")).strip()
101
+ snip = str(h.get("snippet", "")).strip()
102
+ if path and snip:
103
+ lines.append(f"{path} — {snip}")
104
+ return lines
105
+
106
+
107
+ def _coerce_index(value: object) -> int | None:
108
+ if isinstance(value, bool):
109
+ return None
110
+ if isinstance(value, int):
111
+ return value
112
+ if isinstance(value, float) and value.is_integer():
113
+ return int(value)
114
+ if isinstance(value, str) and value.strip().isdigit():
115
+ return int(value.strip())
116
+ return None
117
+
118
+
119
+ def _lines_from_indices(indices: list[object], pool: list[str], cap: int) -> list[str]:
120
+ out: list[str] = []
121
+ for item in indices:
122
+ idx = _coerce_index(item)
123
+ if idx is None or idx < 1 or idx > len(pool):
124
+ continue
125
+ line = pool[idx - 1]
126
+ if line not in out:
127
+ out.append(line)
128
+ if len(out) >= cap:
129
+ break
130
+ return out
131
+
132
+
133
+ async def run_context_curator(
134
+ *,
135
+ ctx: TurnContext,
136
+ provider: Provider,
137
+ curator_model: str,
138
+ user_message: str,
139
+ max_memory_lines: int,
140
+ curator_provider: Provider | None = None,
141
+ ) -> CuratedPromptParts:
142
+ """Call a small JSON-only completion to pick numbered memory pool lines.
143
+
144
+ ``max_memory_lines`` is the caller's window size (no env re-read here).
145
+ ``curator_provider`` is an optional dedicated provider instance tuned for this
146
+ auxiliary call (e.g. ``thinking_budget=0, max_output_tokens=1024``). Falls back
147
+ to ``provider`` when not supplied.
148
+ """
149
+ _provider = curator_provider if curator_provider is not None else provider
150
+ max_mem = max(1, max_memory_lines)
151
+ search_hits = _CURATOR_SEARCH_MAX_HITS
152
+ timeout_sec = max(1.0, _env_float("CONTEXT_CURATION_TIMEOUT_SEC", 10.0))
153
+
154
+ index_lines = list(ctx.memory_index)
155
+ search_lines: list[str] = []
156
+ memory_scan_sec = 0.0
157
+ if ctx.memory is not None:
158
+ t_scan = time.monotonic()
159
+ search_lines = await _gather_search_pool_lines(ctx.memory, user_message, max_hits=search_hits)
160
+ memory_scan_sec = time.monotonic() - t_scan
161
+
162
+ pool = index_lines + search_lines
163
+
164
+ catalog_user = []
165
+ catalog_user.append("## MEMORY_POOL (1-based line numbers)")
166
+ for i, ln in enumerate(pool, 1):
167
+ catalog_user.append(f"{i}. {ln}")
168
+ catalog_user.append("\n## USER_MESSAGE")
169
+ catalog_user.append(user_message.strip() or "(empty)")
170
+
171
+ system = Message(
172
+ role="system",
173
+ content=[
174
+ Text(
175
+ text=(
176
+ "You narrow context for another assistant. Reply with ONLY a JSON object, no markdown fences. "
177
+ f'Schema: {{"memory_line_indices": number[]}}. '
178
+ f"At most {max_mem} indices. "
179
+ "Each index must be a 1-based line number from MEMORY_POOL. "
180
+ "If nothing helps, return an empty array."
181
+ )
182
+ )
183
+ ],
184
+ )
185
+ catalog_text = "\n".join(catalog_user)
186
+ catalog_chars = len(catalog_text)
187
+ user = Message(role="user", content=[Text(text=catalog_text)])
188
+
189
+ async def _stream_once() -> str:
190
+ buf: list[str] = []
191
+ async with aclosing(
192
+ cast(Any, _provider.stream([system, user], [], model=curator_model))
193
+ ) as stream:
194
+ async for ev in stream:
195
+ if isinstance(ev, TextDelta):
196
+ buf.append(ev.text)
197
+ elif isinstance(ev, ToolCall):
198
+ _log.warning(
199
+ "curation unexpected tool call %s",
200
+ kv(curator_model=curator_model),
201
+ )
202
+ return ""
203
+ elif isinstance(ev, UsageEvent):
204
+ pass
205
+ elif isinstance(ev, Done):
206
+ break
207
+ return "".join(buf)
208
+
209
+ try:
210
+ raw = await asyncio.wait_for(_stream_once(), timeout=timeout_sec)
211
+ except TimeoutError:
212
+ _log.warning(
213
+ "curation timeout %s",
214
+ kv(
215
+ timeout_sec=timeout_sec,
216
+ curator_model=curator_model,
217
+ index_lines=len(index_lines),
218
+ search_pool_lines=len(search_lines),
219
+ catalog_chars=catalog_chars,
220
+ memory_scan_sec=f"{memory_scan_sec:.2f}",
221
+ ),
222
+ )
223
+ return CuratedPromptParts([], success=False)
224
+ except Exception as exc:
225
+ _log.warning("curation provider error %s", kv(error=exc))
226
+ return CuratedPromptParts([], success=False)
227
+
228
+ parsed = _parse_json_object(raw)
229
+ if parsed is None:
230
+ _log.warning("curation invalid JSON %s", kv(curator_model=curator_model))
231
+ return CuratedPromptParts([], success=False)
232
+
233
+ raw_indices = parsed.get("memory_line_indices", [])
234
+ if not isinstance(raw_indices, list):
235
+ raw_indices = []
236
+
237
+ mem_out = _lines_from_indices(raw_indices, pool, max_mem)
238
+
239
+ proposed = bool(raw_indices)
240
+ if proposed and not mem_out:
241
+ _log.warning(
242
+ "curation indices unmatched %s",
243
+ kv(curator_model=curator_model, proposed=len(raw_indices), pool=len(pool)),
244
+ )
245
+ return CuratedPromptParts([], success=False)
246
+
247
+ _log.info(
248
+ "curation selected %s",
249
+ kv(
250
+ selected=len(mem_out),
251
+ pool=len(pool),
252
+ curator_model=curator_model,
253
+ ),
254
+ )
255
+ return CuratedPromptParts(mem_out, success=True)
@@ -0,0 +1,226 @@
1
+ """Context Epoch — stable baseline + mid-conversation volatile updates.
2
+
3
+ An epoch is the span during which one rendered system-prompt baseline remains the
4
+ immutable provider-cache prefix. Volatile sources (memory, skills, current-request)
5
+ may change within an epoch and produce chronological system-context updates without
6
+ rewriting the baseline. Compaction (or an incompatible stable-source change) starts
7
+ a new epoch.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import dataclasses
13
+ import hashlib
14
+ from collections.abc import Mapping
15
+ from dataclasses import dataclass, field
16
+ from typing import Literal
17
+
18
+
19
+ # Heading for the chronological mid-epoch update text (see
20
+ # ``_format_system_context_update``). Exported so callers that need to locate
21
+ # the stable/volatile boundary in a flattened prompt string (e.g. Anthropic
22
+ # cache-block splitting in ``providers._utils.split_system_prompt_for_cache``)
23
+ # import this instead of re-declaring the literal and risking drift.
24
+ SYSTEM_CONTEXT_UPDATE_HEADING = "\n\n## System context update\n"
25
+
26
+
27
+ def fingerprint_text(*parts: str) -> str:
28
+ """Stable short hash of concatenated text parts (empty parts allowed)."""
29
+ h = hashlib.sha256()
30
+ for part in parts:
31
+ h.update(part.encode("utf-8"))
32
+ h.update(b"\0")
33
+ return h.hexdigest()[:16]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class EpochAdmit:
38
+ """Result of reconciling system context at a safe provider-turn boundary."""
39
+
40
+ kind: Literal["unchanged", "volatile_updated", "new_epoch"]
41
+ epoch_id: int
42
+ """Full system text for the leading system message (baseline within the epoch)."""
43
+ leading_system_text: str
44
+ """Chronological update text when volatile sources changed mid-epoch; else empty."""
45
+ mid_conversation_update: str
46
+ changed_sources: tuple[str, ...]
47
+
48
+
49
+ @dataclass
50
+ class _EpochState:
51
+ epoch_id: int
52
+ stable_baseline: str
53
+ stable_fingerprint: str
54
+ """Volatile text admitted into the leading baseline at epoch start."""
55
+ baseline_volatile: str
56
+ volatile_fingerprint: str
57
+ """Per-source fingerprints as of the last admitted volatile render (for
58
+
59
+ diagnosing which specific source changed on the next reconcile — memory,
60
+ skills, current-request — rather than reporting the opaque catch-all
61
+ ``"volatile"``.
62
+ """
63
+ volatile_part_fingerprints: dict[str, str] = field(default_factory=dict)
64
+
65
+
66
+ class ContextEpochTracker:
67
+ """Track one session turn's context epoch across inner provider calls.
68
+
69
+ Call :meth:`reconcile` at each safe provider-turn boundary. Call
70
+ :meth:`begin_new_epoch` after compaction (or session move) so the next
71
+ reconcile folds current context into a fresh baseline.
72
+ """
73
+
74
+ def __init__(self) -> None:
75
+ self._state: _EpochState | None = None
76
+ self._force_new = True
77
+
78
+ def begin_new_epoch(self) -> None:
79
+ """Mark that the next reconcile must open a new epoch (post-compaction)."""
80
+ self._force_new = True
81
+
82
+ def reconcile(
83
+ self,
84
+ *,
85
+ stable_baseline: str,
86
+ volatile_text: str,
87
+ stable_fingerprint: str,
88
+ volatile_fingerprint: str,
89
+ volatile_part_fingerprints: Mapping[str, str] | None = None,
90
+ ) -> EpochAdmit:
91
+ """Admit current stable/volatile renders at a provider-turn boundary.
92
+
93
+ ``volatile_part_fingerprints`` (e.g. ``{"memory": ..., "skills": ...,
94
+ "current_request": ...}``) is optional; when supplied it makes
95
+ ``EpochAdmit.changed_sources`` name the specific volatile source(s) that
96
+ changed instead of the catch-all ``"volatile"``.
97
+ """
98
+ admit, next_state, force_new = self._compute(
99
+ stable_baseline=stable_baseline,
100
+ volatile_text=volatile_text,
101
+ stable_fingerprint=stable_fingerprint,
102
+ volatile_fingerprint=volatile_fingerprint,
103
+ volatile_part_fingerprints=volatile_part_fingerprints,
104
+ )
105
+ self._state = next_state
106
+ self._force_new = force_new
107
+ return admit
108
+
109
+ def peek(
110
+ self,
111
+ *,
112
+ stable_baseline: str,
113
+ volatile_text: str,
114
+ stable_fingerprint: str,
115
+ volatile_fingerprint: str,
116
+ volatile_part_fingerprints: Mapping[str, str] | None = None,
117
+ ) -> EpochAdmit:
118
+ """Compute the :class:`EpochAdmit` for the given renders without mutating state.
119
+
120
+ Use for out-of-band accounting (e.g. token recounts) that must reflect the
121
+ true wire shape — leading baseline plus any mid-conversation update — without
122
+ advancing the epoch or admitting a volatile update that wasn't actually sent.
123
+ """
124
+ admit, _next_state, _force_new = self._compute(
125
+ stable_baseline=stable_baseline,
126
+ volatile_text=volatile_text,
127
+ stable_fingerprint=stable_fingerprint,
128
+ volatile_fingerprint=volatile_fingerprint,
129
+ volatile_part_fingerprints=volatile_part_fingerprints,
130
+ )
131
+ return admit
132
+
133
+ def _compute(
134
+ self,
135
+ *,
136
+ stable_baseline: str,
137
+ volatile_text: str,
138
+ stable_fingerprint: str,
139
+ volatile_fingerprint: str,
140
+ volatile_part_fingerprints: Mapping[str, str] | None,
141
+ ) -> tuple[EpochAdmit, _EpochState | None, bool]:
142
+ parts = dict(volatile_part_fingerprints) if volatile_part_fingerprints else {}
143
+ if (
144
+ self._force_new
145
+ or self._state is None
146
+ or stable_fingerprint != self._state.stable_fingerprint
147
+ ):
148
+ next_id = 1 if self._state is None else self._state.epoch_id + 1
149
+ next_state = _EpochState(
150
+ epoch_id=next_id,
151
+ stable_baseline=stable_baseline,
152
+ stable_fingerprint=stable_fingerprint,
153
+ baseline_volatile=volatile_text,
154
+ volatile_fingerprint=volatile_fingerprint,
155
+ volatile_part_fingerprints=parts,
156
+ )
157
+ admit = EpochAdmit(
158
+ kind="new_epoch",
159
+ epoch_id=next_id,
160
+ leading_system_text=self._leading(next_state),
161
+ mid_conversation_update="",
162
+ changed_sources=("epoch",),
163
+ )
164
+ return admit, next_state, False
165
+
166
+ state = self._state
167
+ if volatile_fingerprint == state.volatile_fingerprint:
168
+ admit = EpochAdmit(
169
+ kind="unchanged",
170
+ epoch_id=state.epoch_id,
171
+ leading_system_text=self._leading(state),
172
+ mid_conversation_update="",
173
+ changed_sources=(),
174
+ )
175
+ return admit, state, self._force_new
176
+
177
+ # Mid-epoch volatile update: keep leading baseline byte-identical for cache.
178
+ changed = _diff_source_names(state.volatile_part_fingerprints, parts)
179
+ next_state = dataclasses.replace(
180
+ state,
181
+ volatile_fingerprint=volatile_fingerprint,
182
+ volatile_part_fingerprints=parts,
183
+ )
184
+ admit = EpochAdmit(
185
+ kind="volatile_updated",
186
+ epoch_id=next_state.epoch_id,
187
+ leading_system_text=self._leading(next_state),
188
+ mid_conversation_update=_format_system_context_update(volatile_text),
189
+ changed_sources=changed,
190
+ )
191
+ return admit, next_state, self._force_new
192
+
193
+ @staticmethod
194
+ def _leading(state: _EpochState) -> str:
195
+ return f"{state.stable_baseline}{state.baseline_volatile}"
196
+
197
+
198
+ def _diff_source_names(
199
+ prior: Mapping[str, str], current: Mapping[str, str]
200
+ ) -> tuple[str, ...]:
201
+ """Names of volatile sources whose fingerprint changed (added/removed/edited).
202
+
203
+ Falls back to the catch-all ``"volatile"`` when the caller didn't supply
204
+ per-source fingerprints (``current`` and ``prior`` both empty) — the
205
+ volatile text still changed (caller already checked the whole-text
206
+ fingerprint), we just can't attribute it to a named source.
207
+ """
208
+ names = sorted(set(prior) | set(current))
209
+ changed = tuple(name for name in names if prior.get(name) != current.get(name))
210
+ return changed or ("volatile",)
211
+
212
+
213
+ def _format_system_context_update(volatile_text: str) -> str:
214
+ heading = SYSTEM_CONTEXT_UPDATE_HEADING.lstrip("\n")
215
+ body = volatile_text.lstrip("\n")
216
+ if not body.strip():
217
+ return (
218
+ f"{heading}"
219
+ "Volatile context sections (memory, skills, current request) were cleared."
220
+ )
221
+ return (
222
+ f"{heading}"
223
+ "The following replaces prior mid-epoch memory, skills, and current-request "
224
+ "sections for this conversation.\n\n"
225
+ f"{body}"
226
+ )