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,222 @@
|
|
|
1
|
+
"""Memory index selection for the system prompt: recent window, curator when token-heavy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from monkeybot.core.context import TurnContext
|
|
10
|
+
from monkeybot.core.context.curator import (
|
|
11
|
+
CuratedPromptParts,
|
|
12
|
+
_env_int,
|
|
13
|
+
curation_enabled_from_env,
|
|
14
|
+
curator_model_id,
|
|
15
|
+
memory_index_token_estimate,
|
|
16
|
+
run_context_curator,
|
|
17
|
+
)
|
|
18
|
+
from monkeybot.core.llm.provider import Provider
|
|
19
|
+
from monkeybot.core.logging_utils import kv
|
|
20
|
+
from monkeybot.core.memory.index_format import memory_window_slice
|
|
21
|
+
|
|
22
|
+
_log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# Process-local: thread_id -> (cache_key, curated_lines). Evicted on session teardown.
|
|
25
|
+
# cache_key covers index content + user message so query-aware picks are not reused.
|
|
26
|
+
_curation_cache: dict[str, tuple[str, list[str]]] = {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def reset_curation_cache_for_tests() -> None:
|
|
30
|
+
_curation_cache.clear()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def evict_curation_cache(thread_id: str) -> None:
|
|
34
|
+
"""Drop the cached curator selection for a thread (call on session removal)."""
|
|
35
|
+
_curation_cache.pop(thread_id, None)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class MemoryPromptSelection:
|
|
40
|
+
"""Memory lines and structural coverage for volatile system-prompt injection."""
|
|
41
|
+
|
|
42
|
+
lines: list[str]
|
|
43
|
+
total_lines: int
|
|
44
|
+
coverage: float
|
|
45
|
+
confidence: float
|
|
46
|
+
nudge_search: bool
|
|
47
|
+
use_custom_lines: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def memory_window_lines_from_env() -> int:
|
|
51
|
+
return max(1, _env_int("CONTEXT_CURATION_MEMORY_WINDOW_LINES", 12))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def memory_token_threshold_from_env() -> int:
|
|
55
|
+
return max(1, _env_int("CONTEXT_CURATION_MEMORY_TOKEN_THRESHOLD", 2000))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def memory_index_fingerprint(lines: list[str]) -> str:
|
|
59
|
+
"""Stable hash of index lines; used to detect mid-turn INDEX refreshes."""
|
|
60
|
+
payload = "\n".join(lines)
|
|
61
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _curator_cache_key(lines: list[str], user_message: str) -> str:
|
|
65
|
+
"""Fingerprint for curator cache: index + query (curator is query-aware)."""
|
|
66
|
+
payload = "\n".join(lines) + "\0" + user_message
|
|
67
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def memory_coverage(injected_count: int, total_count: int) -> float:
|
|
71
|
+
if total_count <= 0:
|
|
72
|
+
return 1.0
|
|
73
|
+
return min(1.0, injected_count / total_count)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def memory_confidence(*, coverage: float, truncated: bool) -> float:
|
|
77
|
+
"""Structural confidence (not LLM-reported). Full index → 1.0; else equals coverage."""
|
|
78
|
+
if not truncated:
|
|
79
|
+
return 1.0
|
|
80
|
+
return round(coverage, 4)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _selection_from_lines(
|
|
84
|
+
injected: list[str],
|
|
85
|
+
total: list[str],
|
|
86
|
+
*,
|
|
87
|
+
use_custom_lines: bool,
|
|
88
|
+
) -> MemoryPromptSelection:
|
|
89
|
+
total_n = len(total)
|
|
90
|
+
injected_n = len(injected)
|
|
91
|
+
truncated = injected_n < total_n
|
|
92
|
+
coverage = memory_coverage(injected_n, total_n)
|
|
93
|
+
confidence = memory_confidence(coverage=coverage, truncated=truncated)
|
|
94
|
+
return MemoryPromptSelection(
|
|
95
|
+
lines=list(injected),
|
|
96
|
+
total_lines=total_n,
|
|
97
|
+
coverage=coverage,
|
|
98
|
+
confidence=confidence,
|
|
99
|
+
nudge_search=truncated,
|
|
100
|
+
use_custom_lines=use_custom_lines,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _cached_curator_lines(thread_id: str, cache_key: str) -> list[str] | None:
|
|
105
|
+
cached = _curation_cache.get(thread_id)
|
|
106
|
+
if cached is None:
|
|
107
|
+
return None
|
|
108
|
+
cached_key, lines = cached
|
|
109
|
+
if cached_key != cache_key:
|
|
110
|
+
return None
|
|
111
|
+
return list(lines)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _store_curator_cache(thread_id: str, cache_key: str, lines: list[str]) -> None:
|
|
115
|
+
_curation_cache[thread_id] = (cache_key, list(lines))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def _run_curator_cached(
|
|
119
|
+
*,
|
|
120
|
+
thread_id: str,
|
|
121
|
+
cache_key: str,
|
|
122
|
+
ctx: TurnContext,
|
|
123
|
+
provider: Provider,
|
|
124
|
+
curator_provider: Provider | None,
|
|
125
|
+
user_message: str,
|
|
126
|
+
max_memory_lines: int,
|
|
127
|
+
) -> CuratedPromptParts:
|
|
128
|
+
cached = _cached_curator_lines(thread_id, cache_key)
|
|
129
|
+
if cached is not None:
|
|
130
|
+
_log.debug(
|
|
131
|
+
"curation cache hit %s",
|
|
132
|
+
kv(thread_id=thread_id, lines=len(cached)),
|
|
133
|
+
)
|
|
134
|
+
return CuratedPromptParts(cached, success=True)
|
|
135
|
+
|
|
136
|
+
_log.debug(
|
|
137
|
+
"curation cache miss %s",
|
|
138
|
+
kv(thread_id=thread_id, index_lines=len(ctx.memory_index)),
|
|
139
|
+
)
|
|
140
|
+
parts = await run_context_curator(
|
|
141
|
+
ctx=ctx,
|
|
142
|
+
provider=provider,
|
|
143
|
+
curator_model=curator_model_id(ctx),
|
|
144
|
+
user_message=user_message,
|
|
145
|
+
max_memory_lines=max_memory_lines,
|
|
146
|
+
curator_provider=curator_provider,
|
|
147
|
+
)
|
|
148
|
+
if parts.success:
|
|
149
|
+
_store_curator_cache(thread_id, cache_key, parts.memory_lines)
|
|
150
|
+
return parts
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def prepare_memory_for_prompt(
|
|
154
|
+
*,
|
|
155
|
+
ctx: TurnContext,
|
|
156
|
+
user_message: str,
|
|
157
|
+
provider: Provider,
|
|
158
|
+
curator_provider: Provider | None,
|
|
159
|
+
) -> MemoryPromptSelection:
|
|
160
|
+
"""Select memory lines for the system prompt.
|
|
161
|
+
|
|
162
|
+
Default path: recent window. When the full index is token-heavy, optionally
|
|
163
|
+
call the LLM curator; on curator failure, fall back to the window.
|
|
164
|
+
"""
|
|
165
|
+
total = list(ctx.memory_index)
|
|
166
|
+
if not curation_enabled_from_env() or not ctx.context_curation_enabled:
|
|
167
|
+
return _selection_from_lines(total, total, use_custom_lines=False)
|
|
168
|
+
|
|
169
|
+
window_n = memory_window_lines_from_env()
|
|
170
|
+
token_n = memory_token_threshold_from_env()
|
|
171
|
+
tokens = memory_index_token_estimate(total)
|
|
172
|
+
exceeds_window = len(total) > window_n
|
|
173
|
+
token_heavy = tokens > token_n
|
|
174
|
+
|
|
175
|
+
if not exceeds_window and not token_heavy:
|
|
176
|
+
return _selection_from_lines(total, total, use_custom_lines=False)
|
|
177
|
+
|
|
178
|
+
window = memory_window_slice(total, window_n)
|
|
179
|
+
if not token_heavy:
|
|
180
|
+
_log.debug(
|
|
181
|
+
"curation window %s",
|
|
182
|
+
kv(
|
|
183
|
+
thread_id=ctx.thread_id,
|
|
184
|
+
injected=len(window),
|
|
185
|
+
total=len(total),
|
|
186
|
+
tokens=tokens,
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
return _selection_from_lines(window, total, use_custom_lines=len(window) < len(total))
|
|
190
|
+
|
|
191
|
+
parts = await _run_curator_cached(
|
|
192
|
+
thread_id=ctx.thread_id,
|
|
193
|
+
cache_key=_curator_cache_key(total, user_message),
|
|
194
|
+
ctx=ctx,
|
|
195
|
+
provider=provider,
|
|
196
|
+
curator_provider=curator_provider,
|
|
197
|
+
user_message=user_message,
|
|
198
|
+
max_memory_lines=window_n,
|
|
199
|
+
)
|
|
200
|
+
if parts.success:
|
|
201
|
+
injected = list(parts.memory_lines)
|
|
202
|
+
_log.info(
|
|
203
|
+
"curation curator %s",
|
|
204
|
+
kv(
|
|
205
|
+
thread_id=ctx.thread_id,
|
|
206
|
+
injected=len(injected),
|
|
207
|
+
total=len(total),
|
|
208
|
+
tokens=tokens,
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
return _selection_from_lines(injected, total, use_custom_lines=True)
|
|
212
|
+
|
|
213
|
+
_log.warning(
|
|
214
|
+
"curation curator failed; using window %s",
|
|
215
|
+
kv(
|
|
216
|
+
thread_id=ctx.thread_id,
|
|
217
|
+
window=len(window),
|
|
218
|
+
total=len(total),
|
|
219
|
+
tokens=tokens,
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
return _selection_from_lines(window, total, use_custom_lines=len(window) < len(total))
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Per-tool output shaping budgets (command_allowlist.yaml ``tool_output`` section)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from monkeybot.core.tools.inspector import CommandTierConfigError
|
|
16
|
+
|
|
17
|
+
ContentTypeHint = Literal["json", "logs", "code", "prose", "auto"]
|
|
18
|
+
_SUPPORTED_CONTENT_TYPES = frozenset({"json", "logs", "code", "prose", "auto"})
|
|
19
|
+
_MIN_OUTPUT_LINES = 5
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ToolOutputBudget:
|
|
24
|
+
"""Operator-configured caps and shaper hints for one tool name."""
|
|
25
|
+
|
|
26
|
+
content_type: ContentTypeHint | None = None
|
|
27
|
+
max_output_lines: int | None = None
|
|
28
|
+
max_array_items: int | None = None
|
|
29
|
+
keep_patterns: tuple[str, ...] = ()
|
|
30
|
+
collapse_repeated: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Sensible defaults when YAML omits ``tool_output`` entries.
|
|
34
|
+
_MCP_DEFAULT_TOOL_BUDGET = ToolOutputBudget(
|
|
35
|
+
content_type="auto",
|
|
36
|
+
max_output_lines=120,
|
|
37
|
+
max_array_items=40,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_known_mcp_tools: frozenset[str] = frozenset()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_BUILTIN_TOOL_BUDGETS: dict[str, ToolOutputBudget] = {
|
|
44
|
+
"run_command": ToolOutputBudget(
|
|
45
|
+
content_type="logs",
|
|
46
|
+
max_output_lines=400,
|
|
47
|
+
collapse_repeated=True,
|
|
48
|
+
keep_patterns=(
|
|
49
|
+
r"(?i)\berror\b",
|
|
50
|
+
r"(?i)\bfatal\b",
|
|
51
|
+
r"(?i)traceback",
|
|
52
|
+
r"(?i)\bexception\b",
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
"web_search": ToolOutputBudget(
|
|
56
|
+
content_type="json",
|
|
57
|
+
max_array_items=30,
|
|
58
|
+
),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_policy_path(path: Path | None) -> Path | None:
|
|
63
|
+
if path is not None:
|
|
64
|
+
return path if path.is_file() else None
|
|
65
|
+
raw = os.environ.get("COMMAND_ALLOWLIST_CONFIG", "").strip()
|
|
66
|
+
if not raw:
|
|
67
|
+
return None
|
|
68
|
+
p = Path(raw).expanduser()
|
|
69
|
+
return p if p.is_file() else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_tool_output_entry(path: Path, tool_name: str, raw: Any) -> ToolOutputBudget:
|
|
73
|
+
if not isinstance(raw, dict):
|
|
74
|
+
raise CommandTierConfigError(
|
|
75
|
+
path, f"tool_output.{tool_name} must be a mapping"
|
|
76
|
+
)
|
|
77
|
+
content_type: ContentTypeHint | None = None
|
|
78
|
+
ct_raw = raw.get("content_type")
|
|
79
|
+
if ct_raw is not None:
|
|
80
|
+
if not isinstance(ct_raw, str) or ct_raw.strip().lower() not in _SUPPORTED_CONTENT_TYPES:
|
|
81
|
+
raise CommandTierConfigError(
|
|
82
|
+
path,
|
|
83
|
+
f"tool_output.{tool_name}.content_type must be one of "
|
|
84
|
+
f"{sorted(_SUPPORTED_CONTENT_TYPES)}",
|
|
85
|
+
)
|
|
86
|
+
content_type = ct_raw.strip().lower() # type: ignore[assignment]
|
|
87
|
+
|
|
88
|
+
max_output_lines: int | None = None
|
|
89
|
+
if "max_output_lines" in raw:
|
|
90
|
+
val = raw["max_output_lines"]
|
|
91
|
+
if not isinstance(val, int) or val < 1:
|
|
92
|
+
raise CommandTierConfigError(
|
|
93
|
+
path, f"tool_output.{tool_name}.max_output_lines must be a positive integer"
|
|
94
|
+
)
|
|
95
|
+
max_output_lines = val
|
|
96
|
+
|
|
97
|
+
max_array_items: int | None = None
|
|
98
|
+
if "max_array_items" in raw:
|
|
99
|
+
val = raw["max_array_items"]
|
|
100
|
+
if not isinstance(val, int) or val < 1:
|
|
101
|
+
raise CommandTierConfigError(
|
|
102
|
+
path, f"tool_output.{tool_name}.max_array_items must be a positive integer"
|
|
103
|
+
)
|
|
104
|
+
max_array_items = val
|
|
105
|
+
|
|
106
|
+
keep_patterns: tuple[str, ...] = ()
|
|
107
|
+
kp_raw = raw.get("keep_patterns")
|
|
108
|
+
if kp_raw is not None:
|
|
109
|
+
if not isinstance(kp_raw, list):
|
|
110
|
+
raise CommandTierConfigError(
|
|
111
|
+
path, f"tool_output.{tool_name}.keep_patterns must be a list of regex strings"
|
|
112
|
+
)
|
|
113
|
+
compiled: list[str] = []
|
|
114
|
+
for i, item in enumerate(kp_raw):
|
|
115
|
+
if not isinstance(item, str) or not item.strip():
|
|
116
|
+
raise CommandTierConfigError(
|
|
117
|
+
path, f"tool_output.{tool_name}.keep_patterns[{i}] must be a non-empty string"
|
|
118
|
+
)
|
|
119
|
+
try:
|
|
120
|
+
re.compile(item)
|
|
121
|
+
except re.error as exc:
|
|
122
|
+
raise CommandTierConfigError(
|
|
123
|
+
path,
|
|
124
|
+
f"tool_output.{tool_name}.keep_patterns[{i}]: invalid regex: {exc}",
|
|
125
|
+
) from exc
|
|
126
|
+
compiled.append(item)
|
|
127
|
+
keep_patterns = tuple(compiled)
|
|
128
|
+
|
|
129
|
+
collapse_repeated = bool(raw.get("collapse_repeated", False))
|
|
130
|
+
return ToolOutputBudget(
|
|
131
|
+
content_type=content_type,
|
|
132
|
+
max_output_lines=max_output_lines,
|
|
133
|
+
max_array_items=max_array_items,
|
|
134
|
+
keep_patterns=keep_patterns,
|
|
135
|
+
collapse_repeated=collapse_repeated,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def parse_tool_output_section(path: Path, data: dict[str, Any]) -> dict[str, ToolOutputBudget]:
|
|
140
|
+
"""Parse optional ``tool_output`` mapping from command allowlist YAML."""
|
|
141
|
+
raw = data.get("tool_output")
|
|
142
|
+
if raw is None:
|
|
143
|
+
return dict(_BUILTIN_TOOL_BUDGETS)
|
|
144
|
+
if not isinstance(raw, dict):
|
|
145
|
+
raise CommandTierConfigError(path, "'tool_output' must be a mapping of tool names")
|
|
146
|
+
|
|
147
|
+
merged = dict(_BUILTIN_TOOL_BUDGETS)
|
|
148
|
+
for tool_name, entry in raw.items():
|
|
149
|
+
if not isinstance(tool_name, str) or not tool_name.strip():
|
|
150
|
+
raise CommandTierConfigError(path, "tool_output keys must be non-empty tool names")
|
|
151
|
+
parsed = _parse_tool_output_entry(path, tool_name.strip(), entry)
|
|
152
|
+
base = merged.get(tool_name.strip())
|
|
153
|
+
if base is not None:
|
|
154
|
+
merged[tool_name.strip()] = ToolOutputBudget(
|
|
155
|
+
content_type=(
|
|
156
|
+
parsed.content_type if "content_type" in entry else base.content_type
|
|
157
|
+
),
|
|
158
|
+
max_output_lines=(
|
|
159
|
+
parsed.max_output_lines
|
|
160
|
+
if "max_output_lines" in entry
|
|
161
|
+
else base.max_output_lines
|
|
162
|
+
),
|
|
163
|
+
max_array_items=(
|
|
164
|
+
parsed.max_array_items if "max_array_items" in entry else base.max_array_items
|
|
165
|
+
),
|
|
166
|
+
keep_patterns=(
|
|
167
|
+
parsed.keep_patterns if "keep_patterns" in entry else base.keep_patterns
|
|
168
|
+
),
|
|
169
|
+
collapse_repeated=(
|
|
170
|
+
parsed.collapse_repeated
|
|
171
|
+
if "collapse_repeated" in entry
|
|
172
|
+
else base.collapse_repeated
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
merged[tool_name.strip()] = parsed
|
|
177
|
+
return merged
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def load_tool_output_policies(path: Path | None = None) -> dict[str, ToolOutputBudget]:
|
|
181
|
+
"""Load merged built-in + YAML per-tool output budgets."""
|
|
182
|
+
resolved = _resolve_policy_path(path)
|
|
183
|
+
if resolved is None:
|
|
184
|
+
return dict(_BUILTIN_TOOL_BUDGETS)
|
|
185
|
+
try:
|
|
186
|
+
data = yaml.safe_load(resolved.read_bytes())
|
|
187
|
+
except yaml.YAMLError as exc:
|
|
188
|
+
raise CommandTierConfigError(resolved, f"invalid YAML: {exc}") from exc
|
|
189
|
+
if data is None:
|
|
190
|
+
return dict(_BUILTIN_TOOL_BUDGETS)
|
|
191
|
+
if not isinstance(data, dict):
|
|
192
|
+
raise CommandTierConfigError(resolved, "root must be a mapping")
|
|
193
|
+
return parse_tool_output_section(resolved, data)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@lru_cache(maxsize=1)
|
|
197
|
+
def cached_tool_output_policies() -> dict[str, ToolOutputBudget]:
|
|
198
|
+
"""Process-wide cache of tool output policies (invalidated only on restart)."""
|
|
199
|
+
return load_tool_output_policies()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def register_mcp_tool_names(names: Iterable[str]) -> None:
|
|
203
|
+
"""Record prefixed MCP tool names for output-budget resolution (updated on MCP connect)."""
|
|
204
|
+
global _known_mcp_tools
|
|
205
|
+
added = frozenset(n for n in names if n)
|
|
206
|
+
if not added:
|
|
207
|
+
return
|
|
208
|
+
_known_mcp_tools = _known_mcp_tools | added
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def unregister_mcp_server_tools(server_name: str) -> None:
|
|
212
|
+
"""Drop every ``server__*`` tool registered for ``server_name`` (MCP disconnect)."""
|
|
213
|
+
global _known_mcp_tools
|
|
214
|
+
if not server_name:
|
|
215
|
+
return
|
|
216
|
+
prefix = f"{server_name}__"
|
|
217
|
+
_known_mcp_tools = frozenset(n for n in _known_mcp_tools if not n.startswith(prefix))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def reset_mcp_tool_registry_for_tests() -> None:
|
|
221
|
+
"""Clear the MCP tool registry (tests only)."""
|
|
222
|
+
global _known_mcp_tools
|
|
223
|
+
_known_mcp_tools = frozenset()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def resolve_tool_budget(tool_name: str) -> ToolOutputBudget | None:
|
|
227
|
+
"""Return configured budget for ``tool_name``, or MCP default for registered MCP tools."""
|
|
228
|
+
merged = cached_tool_output_policies()
|
|
229
|
+
if tool_name in merged:
|
|
230
|
+
return merged[tool_name]
|
|
231
|
+
if tool_name in _known_mcp_tools:
|
|
232
|
+
return merged.get("*") or _MCP_DEFAULT_TOOL_BUDGET
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def validate_tool_output_budgets(
|
|
237
|
+
policies: dict[str, ToolOutputBudget],
|
|
238
|
+
) -> list[str]:
|
|
239
|
+
"""Return human-readable warnings for implausibly tight operator caps."""
|
|
240
|
+
warnings: list[str] = []
|
|
241
|
+
for name, budget in policies.items():
|
|
242
|
+
if budget.max_output_lines is not None and budget.max_output_lines < _MIN_OUTPUT_LINES:
|
|
243
|
+
warnings.append(
|
|
244
|
+
f"tool_output.{name}.max_output_lines={budget.max_output_lines} is very small "
|
|
245
|
+
f"(<{_MIN_OUTPUT_LINES}); tool output may lose critical signal"
|
|
246
|
+
)
|
|
247
|
+
if budget.max_array_items is not None and budget.max_array_items < 3:
|
|
248
|
+
warnings.append(
|
|
249
|
+
f"tool_output.{name}.max_array_items={budget.max_array_items} is very small; "
|
|
250
|
+
"search/API results may be unusable"
|
|
251
|
+
)
|
|
252
|
+
return warnings
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def reset_tool_output_policy_cache_for_tests() -> None:
|
|
256
|
+
cached_tool_output_policies.cache_clear()
|
|
257
|
+
reset_mcp_tool_registry_for_tests()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
__all__ = [
|
|
261
|
+
"ToolOutputBudget",
|
|
262
|
+
"load_tool_output_policies",
|
|
263
|
+
"parse_tool_output_section",
|
|
264
|
+
"register_mcp_tool_names",
|
|
265
|
+
"resolve_tool_budget",
|
|
266
|
+
"reset_mcp_tool_registry_for_tests",
|
|
267
|
+
"reset_tool_output_policy_cache_for_tests",
|
|
268
|
+
"unregister_mcp_server_tools",
|
|
269
|
+
"validate_tool_output_budgets",
|
|
270
|
+
]
|