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,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
+ ]