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.
- monkeybot/__init__.py +3 -0
- monkeybot/cli/__init__.py +3 -0
- monkeybot/cli/__main__.py +8 -0
- monkeybot/cli/audio_io.py +8 -0
- monkeybot/cli/gateway_manager.py +17 -0
- monkeybot/cli/main.py +22 -0
- monkeybot/cli/push_to_talk.py +12 -0
- monkeybot/cli/realtime_client.py +13 -0
- monkeybot/core/__init__.py +19 -0
- monkeybot/core/attachments/__init__.py +22 -0
- monkeybot/core/attachments/catalog.py +62 -0
- monkeybot/core/attachments/config.py +52 -0
- monkeybot/core/attachments/freeze.py +158 -0
- monkeybot/core/attachments/resolve.py +70 -0
- monkeybot/core/attachments/store.py +180 -0
- monkeybot/core/attachments/text.py +72 -0
- monkeybot/core/attachments/tools.py +54 -0
- monkeybot/core/bootstrap.py +242 -0
- monkeybot/core/config/__init__.py +71 -0
- monkeybot/core/config/realtime_config.py +150 -0
- monkeybot/core/config/runtime_env.py +262 -0
- monkeybot/core/config/settings.py +341 -0
- monkeybot/core/config/validation.py +249 -0
- monkeybot/core/config/yaml_loader.py +45 -0
- monkeybot/core/context/__init__.py +781 -0
- monkeybot/core/context/campaign_context.py +8 -0
- monkeybot/core/context/common.py +14 -0
- monkeybot/core/context/curator.py +255 -0
- monkeybot/core/context/epoch.py +226 -0
- monkeybot/core/context/memory_prompt.py +222 -0
- monkeybot/core/context/tool_output_policy.py +270 -0
- monkeybot/core/context/tool_result_ingress.py +290 -0
- monkeybot/core/context/tool_shapers.py +361 -0
- monkeybot/core/hooks/__init__.py +261 -0
- monkeybot/core/llm/__init__.py +4 -0
- monkeybot/core/llm/provider.py +296 -0
- monkeybot/core/llm/realtime_provider.py +203 -0
- monkeybot/core/llm/usage.py +57 -0
- monkeybot/core/logging_utils.py +24 -0
- monkeybot/core/mcp/__init__.py +1 -0
- monkeybot/core/mcp/mcp_client.py +1215 -0
- monkeybot/core/mcp/ports_mcp.py +109 -0
- monkeybot/core/memory/__init__.py +24 -0
- monkeybot/core/memory/hook.py +413 -0
- monkeybot/core/memory/index_format.py +104 -0
- monkeybot/core/memory/integrity.py +180 -0
- monkeybot/core/memory/organizer.py +270 -0
- monkeybot/core/memory/storage_ops.py +139 -0
- monkeybot/core/memory/subsystem.py +91 -0
- monkeybot/core/messages/__init__.py +16 -0
- monkeybot/core/messages/convert_provider.py +41 -0
- monkeybot/core/messages/tool_integrity.py +262 -0
- monkeybot/core/messages/transform_context.py +84 -0
- monkeybot/core/path_safety.py +11 -0
- monkeybot/core/persistence/__init__.py +17 -0
- monkeybot/core/persistence/backends.py +236 -0
- monkeybot/core/persistence/db.py +28 -0
- monkeybot/core/persistence/durable_runs.py +286 -0
- monkeybot/core/persistence/firestore.py +658 -0
- monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
- monkeybot/core/persistence/history.py +156 -0
- monkeybot/core/persistence/postgres.py +895 -0
- monkeybot/core/persistence/runs.py +76 -0
- monkeybot/core/persistence/scheduled_loops.py +435 -0
- monkeybot/core/persistence/session_turn_locks.py +94 -0
- monkeybot/core/persistence/sqlite.py +218 -0
- monkeybot/core/persistence/sqlite_backend.py +74 -0
- monkeybot/core/persistence/thread_summary.py +61 -0
- monkeybot/core/persistence/transcript.py +194 -0
- monkeybot/core/persistence/usage.py +149 -0
- monkeybot/core/prompts/__init__.py +1 -0
- monkeybot/core/prompts/harness_prompt.py +197 -0
- monkeybot/core/prompts/prompt.py +215 -0
- monkeybot/core/runtime/__init__.py +1 -0
- monkeybot/core/runtime/context_budget.py +267 -0
- monkeybot/core/runtime/events.py +819 -0
- monkeybot/core/runtime/input_admission.py +154 -0
- monkeybot/core/runtime/loop.py +2374 -0
- monkeybot/core/runtime/provider_stream_mapper.py +159 -0
- monkeybot/core/runtime/realtime_loop.py +654 -0
- monkeybot/core/runtime/utterance_buffer.py +179 -0
- monkeybot/core/subagents/__init__.py +1 -0
- monkeybot/core/subagents/subagent_proto.py +331 -0
- monkeybot/core/subagents/subagent_worker.py +441 -0
- monkeybot/core/subagents/worker_pool.py +403 -0
- monkeybot/core/testing/__init__.py +1 -0
- monkeybot/core/testing/mocks_provider.py +86 -0
- monkeybot/core/testing/mocks_realtime_provider.py +137 -0
- monkeybot/core/tools/__init__.py +1 -0
- monkeybot/core/tools/core_tool_executor.py +1548 -0
- monkeybot/core/tools/inspector.py +226 -0
- monkeybot/core/tools/loop_inspector.py +45 -0
- monkeybot/core/tools/patch.py +480 -0
- monkeybot/core/tools/permission.py +284 -0
- monkeybot/core/tools/sandbox_executor.py +255 -0
- monkeybot/core/tools/spill_inventory.py +35 -0
- monkeybot/core/tools/terminal.py +381 -0
- monkeybot/core/tools/text_normalize.py +25 -0
- monkeybot/core/tools/types.py +33 -0
- monkeybot/core/tools/workspace_service.py +710 -0
- monkeybot/core/tools/workspace_tools.py +116 -0
- monkeybot/core/types/__init__.py +1 -0
- monkeybot/core/types/content_blocks.py +644 -0
- monkeybot/core/types/interfaces.py +156 -0
- monkeybot/core/types/types_tools.py +29 -0
- monkeybot/core/workspace/__init__.py +8 -0
- monkeybot/core/workspace/factory.py +45 -0
- monkeybot/core/workspace/gcs.py +130 -0
- monkeybot/core/workspace/local.py +162 -0
- monkeybot/core/workspace/protocol.py +45 -0
- monkeybot/core/workspace/s3.py +151 -0
- monkeybot/core/workspace_layout.py +27 -0
- monkeybot/gateway/__init__.py +1 -0
- monkeybot/gateway/bootstrap.py +18 -0
- monkeybot/gateway/main.py +47 -0
- monkeybot/gateway/realtime/__init__.py +31 -0
- monkeybot/gateway/realtime/app.py +321 -0
- monkeybot/gateway/realtime/deps.py +52 -0
- monkeybot/gateway/realtime/errors.py +81 -0
- monkeybot/gateway/realtime/guardrails.py +88 -0
- monkeybot/gateway/realtime/manager.py +77 -0
- monkeybot/gateway/realtime/metrics.py +144 -0
- monkeybot/gateway/realtime/routes.py +864 -0
- monkeybot/gateway/realtime/session.py +232 -0
- monkeybot/gateway/realtime/wire.py +412 -0
- monkeybot/gateway/realtime_main.py +49 -0
- monkeybot/gateway/sse/__init__.py +1 -0
- monkeybot/gateway/sse/app.py +733 -0
- monkeybot/gateway/sse/loop_port.py +31 -0
- monkeybot/gateway/sse/models.py +177 -0
- monkeybot/gateway/sse/reply_body.py +91 -0
- monkeybot/gateway/sse/routes.py +1101 -0
- monkeybot/gateway/sse/scheduler_routes.py +200 -0
- monkeybot/gateway/sse/scheduler_wiring.py +96 -0
- monkeybot/gateway/sse/session_bus.py +226 -0
- monkeybot/gateway/sse/sse.py +46 -0
- monkeybot/gateway/sse/workspace_layout.py +7 -0
- monkeybot/observability/__init__.py +220 -0
- monkeybot/observability/_state.py +10 -0
- monkeybot/observability/instrumentation.py +153 -0
- monkeybot/observability/propagation.py +65 -0
- monkeybot/observability/spans.py +455 -0
- monkeybot/providers/__init__.py +19 -0
- monkeybot/providers/_openai_compat.py +450 -0
- monkeybot/providers/_utils.py +473 -0
- monkeybot/providers/bedrock.py +145 -0
- monkeybot/providers/claude.py +125 -0
- monkeybot/providers/gemini.py +677 -0
- monkeybot/providers/gemini_live.py +398 -0
- monkeybot/providers/huggingface.py +129 -0
- monkeybot/providers/nvidia.py +104 -0
- monkeybot/providers/ollama.py +152 -0
- monkeybot/providers/openai.py +127 -0
- monkeybot/providers/pricing.py +60 -0
- monkeybot/providers/sampling.py +44 -0
- monkeybot/providers/vertex_claude.py +148 -0
- monkeybot/scaffold/__init__.py +33 -0
- monkeybot/scheduler/__init__.py +13 -0
- monkeybot/scheduler/__main__.py +4 -0
- monkeybot/scheduler/engine.py +333 -0
- monkeybot/scheduler/http_invoker.py +61 -0
- monkeybot/scheduler/interval.py +77 -0
- monkeybot/scheduler/tick_result.py +34 -0
- monkeybot/scheduler/worker.py +87 -0
- monkeybot/subagents/__init__.py +1 -0
- monkeybot/subagents/worker/__init__.py +1 -0
- monkeybot/subagents/worker/__main__.py +22 -0
- monkeybot/web_search/__init__.py +82 -0
- monkeybot/web_search/backends/__init__.py +5 -0
- monkeybot/web_search/backends/duckduckgo.py +32 -0
- monkeybot/web_search/backends/firecrawl.py +43 -0
- monkeybot/web_search/backends/tavily.py +45 -0
- monkeybot/web_search/protocol.py +25 -0
- monkeybot/web_search/tool.py +56 -0
- monkeybot-2.1.1.dist-info/METADATA +318 -0
- monkeybot-2.1.1.dist-info/RECORD +178 -0
- monkeybot-2.1.1.dist-info/WHEEL +4 -0
- 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
|
+
)
|