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,261 @@
1
+ """In-process lifecycle hooks for the agent loop.
2
+
3
+ Hooks are async callbacks registered against well-known points in the agent
4
+ loop (turn start/end, tool call before/after, provider request/response, etc.).
5
+ They are the extensibility point used by the memory subsystem, but the manager
6
+ itself is memory-agnostic.
7
+
8
+ Design rules (mirrored from rohitg00/agentmemory's hook contract):
9
+
10
+ * **Bounded.** Every hook call is wrapped in ``asyncio.wait_for`` (or scheduled
11
+ as fire-and-forget when ``timeout_s == 0``). Hooks cannot stall the loop.
12
+ * **Silent.** Hook errors and timeouts are logged at WARNING and never
13
+ propagate. A broken hook never breaks a turn.
14
+ * **Re-entrancy safe.** Hooks running inside ``fire()`` cannot recursively
15
+ trigger hooks; the inner call returns the payload unchanged.
16
+ * **Mutable payload.** Hooks may set ``inject_text`` / ``inject_memory_lines``
17
+ on the shared :class:`HookPayload`; later hooks for the same event observe
18
+ and may extend those fields. Provider/tool hooks may also replace
19
+ ``provider_messages`` / ``tools``.
20
+
21
+ This module has no dependency on :mod:`monkeybot.core.memory` or the agent
22
+ loop; both consume it.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import contextvars
29
+ import logging
30
+ from collections.abc import Awaitable, Callable
31
+ from dataclasses import dataclass, field
32
+ from enum import StrEnum
33
+ from typing import TYPE_CHECKING, Any
34
+
35
+ if TYPE_CHECKING:
36
+ from monkeybot.core.context import TurnContext
37
+ from monkeybot.core.llm.provider import Message
38
+ from monkeybot.core.types.types_tools import ToolDef
39
+
40
+ _log = logging.getLogger(__name__)
41
+
42
+
43
+ class HookEvent(StrEnum):
44
+ """Lifecycle points at which hooks may fire.
45
+
46
+ Read-side events (``USER_MESSAGE``, ``PRE_TURN``, ``PRE_TOOL``,
47
+ ``TOOL_DEFINITION``, ``BEFORE_PROVIDER_REQUEST``) run synchronously with a
48
+ short timeout and may mutate the payload (injection fields, tools, or
49
+ provider messages). Write-side events (``POST_TOOL``, ``POST_TURN``,
50
+ ``SESSION_END``, ``AFTER_PROVIDER_RESPONSE``) are fire-and-forget; their
51
+ return values are ignored.
52
+ """
53
+
54
+ USER_MESSAGE = "user_message"
55
+ PRE_TURN = "pre_turn"
56
+ PRE_TOOL = "pre_tool"
57
+ POST_TOOL = "post_tool"
58
+ POST_TURN = "post_turn"
59
+ SESSION_END = "session_end"
60
+ TOOL_DEFINITION = "tool.definition"
61
+ BEFORE_PROVIDER_REQUEST = "before_provider_request"
62
+ AFTER_PROVIDER_RESPONSE = "after_provider_response"
63
+
64
+
65
+ @dataclass
66
+ class HookPayload:
67
+ """Per-fire payload passed to every hook for one event.
68
+
69
+ The payload is **mutable**: hooks may assign ``inject_text`` or extend
70
+ ``inject_memory_lines`` to surface context to the loop. The loop reads
71
+ those fields after ``fire()`` returns.
72
+
73
+ Event-specific fields (``user_message``, ``tool_*``, ``provider_messages``,
74
+ ``tools``, response fields) are populated by the caller based on which
75
+ event is firing; hooks for an unrelated event should ignore them.
76
+
77
+ ``TOOL_DEFINITION`` / ``BEFORE_PROVIDER_REQUEST`` may replace ``tools``
78
+ and/or ``provider_messages`` (``Message`` is frozen — replace list entries,
79
+ do not assign to message fields).
80
+ """
81
+
82
+ event: HookEvent
83
+ thread_id: str
84
+ request_id: str
85
+ ctx: "TurnContext"
86
+
87
+ user_message: str | None = None
88
+ tool_name: str | None = None
89
+ tool_args: dict[str, Any] | None = None
90
+ tool_result: str | None = None
91
+ tool_error: str | None = None
92
+
93
+ # TOOL_DEFINITION / BEFORE_PROVIDER_REQUEST (mutable; replace list to filter)
94
+ tools: list["ToolDef"] | None = None
95
+ provider_messages: list["Message"] | None = None
96
+ inner_turn: int | None = None
97
+
98
+ # AFTER_PROVIDER_RESPONSE (observational)
99
+ assistant_text: str | None = None
100
+ thinking_text: str | None = None
101
+ tool_requests: list[dict[str, Any]] | None = None
102
+ usage: dict[str, int] | None = None
103
+ provider_error: str | None = None
104
+
105
+ inject_text: str | None = None
106
+ inject_memory_lines: list[str] = field(default_factory=list)
107
+
108
+
109
+ HookFn = Callable[[HookPayload], Awaitable[None]]
110
+
111
+
112
+ _in_hook: contextvars.ContextVar[bool] = contextvars.ContextVar(
113
+ "monkeybot_in_hook", default=False
114
+ )
115
+
116
+
117
+ _DEFAULT_TIMEOUT_S = 2.0
118
+
119
+
120
+ class HookManager:
121
+ """Register and dispatch hooks across :class:`HookEvent` points.
122
+
123
+ One ``HookManager`` is constructed per agent (gateway-owned), shared by
124
+ the loop and any subscribers (e.g. the memory hook). Subagents receive a
125
+ no-op manager so duplicate writes do not race the parent's hooks.
126
+
127
+ Fire-and-forget handlers (``timeout_s == 0``) are tracked so
128
+ :meth:`drain_settlement` can wait for them before ``TurnComplete`` or the
129
+ next provider call — without requiring SSE client ACKs.
130
+ """
131
+
132
+ def __init__(self) -> None:
133
+ self._handlers: dict[HookEvent, list[HookFn]] = {}
134
+ self._pending: set[asyncio.Task[None]] = set()
135
+
136
+ def register(self, event: HookEvent, fn: HookFn) -> None:
137
+ """Append ``fn`` to the handler list for ``event``.
138
+
139
+ Handlers fire in registration order. Duplicates are allowed; callers
140
+ must not register the same function twice unless duplicate firing is
141
+ intended.
142
+ """
143
+ self._handlers.setdefault(event, []).append(fn)
144
+
145
+ def clear(self, event: HookEvent | None = None) -> None:
146
+ """Remove all handlers for ``event`` (or every event when ``None``)."""
147
+ if event is None:
148
+ self._handlers.clear()
149
+ else:
150
+ self._handlers.pop(event, None)
151
+
152
+ async def fire(
153
+ self,
154
+ payload: HookPayload,
155
+ *,
156
+ timeout_s: float = _DEFAULT_TIMEOUT_S,
157
+ ) -> HookPayload:
158
+ """Run every handler registered for ``payload.event`` with bounded time.
159
+
160
+ ``timeout_s``:
161
+ * ``> 0``: each handler is awaited up to that many seconds; on
162
+ timeout the handler is cancelled and a warning logged.
163
+ * ``== 0``: the handler is scheduled as a background task; this
164
+ method returns immediately without awaiting completion. Use for
165
+ write-side events where the agent should not wait on memory.
166
+ Detached tasks may not finish before the process exits; in
167
+ short-lived handlers (Lambda, Cloud Functions) pass
168
+ ``hook_manager=None`` or use ``timeout_s > 0`` so hooks complete
169
+ before returning. Call :meth:`drain_settlement` before idle
170
+ boundaries when side effects must land.
171
+
172
+ Hooks may not recursively trigger this method; nested calls return
173
+ ``payload`` unchanged (after a single debug log line).
174
+ """
175
+ if _in_hook.get():
176
+ _log.debug("hook re-entrancy blocked for event=%s", payload.event)
177
+ return payload
178
+
179
+ handlers = self._handlers.get(payload.event)
180
+ if not handlers:
181
+ return payload
182
+
183
+ if timeout_s == 0:
184
+ for fn in handlers:
185
+ self._schedule_background(fn, payload)
186
+ return payload
187
+
188
+ token = _in_hook.set(True)
189
+ try:
190
+ for fn in handlers:
191
+ await self._run_one(fn, payload, timeout_s)
192
+ finally:
193
+ _in_hook.reset(token)
194
+ return payload
195
+
196
+ async def drain_settlement(self, *, timeout_s: float = _DEFAULT_TIMEOUT_S) -> None:
197
+ """Await outstanding fire-and-forget hook tasks up to ``timeout_s``.
198
+
199
+ Does not cancel slow hooks (they may still finish after the barrier).
200
+ Never raises for hook failures — only logs a warning on timeout.
201
+ Safe to call when no tasks are pending.
202
+ """
203
+ pending = [t for t in self._pending if not t.done()]
204
+ if not pending:
205
+ return
206
+ _done, still = await asyncio.wait(pending, timeout=max(0.0, timeout_s))
207
+ if still:
208
+ _log.warning(
209
+ "hook settlement timed out pending=%d timeout_s=%.2f",
210
+ len(still),
211
+ timeout_s,
212
+ )
213
+
214
+ @staticmethod
215
+ async def _run_one(fn: HookFn, payload: HookPayload, timeout_s: float) -> None:
216
+ try:
217
+ await asyncio.wait_for(fn(payload), timeout=timeout_s)
218
+ except asyncio.TimeoutError:
219
+ _log.warning(
220
+ "hook timed out event=%s fn=%s timeout_s=%.2f",
221
+ payload.event,
222
+ getattr(fn, "__qualname__", repr(fn)),
223
+ timeout_s,
224
+ )
225
+ except asyncio.CancelledError:
226
+ raise
227
+ except Exception as exc:
228
+ _log.warning(
229
+ "hook error event=%s fn=%s err=%r",
230
+ payload.event,
231
+ getattr(fn, "__qualname__", repr(fn)),
232
+ exc,
233
+ )
234
+
235
+ def _schedule_background(self, fn: HookFn, payload: HookPayload) -> None:
236
+ async def _wrap() -> None:
237
+ token = _in_hook.set(True)
238
+ try:
239
+ await fn(payload)
240
+ except asyncio.CancelledError:
241
+ raise
242
+ except Exception as exc:
243
+ _log.warning(
244
+ "background hook error event=%s fn=%s err=%r",
245
+ payload.event,
246
+ getattr(fn, "__qualname__", repr(fn)),
247
+ exc,
248
+ )
249
+ finally:
250
+ _in_hook.reset(token)
251
+
252
+ task = asyncio.create_task(_wrap())
253
+ self._pending.add(task)
254
+
255
+ def _done(t: asyncio.Task[None]) -> None:
256
+ self._pending.discard(t)
257
+
258
+ task.add_done_callback(_done)
259
+
260
+
261
+ __all__ = ["HookEvent", "HookPayload", "HookFn", "HookManager"]
@@ -0,0 +1,4 @@
1
+ """LLM streaming protocol (:mod:`monkeybot.core.llm.provider`) and turn usage (:mod:`monkeybot.core.llm.usage`).
2
+
3
+ Concrete vendor backends live in :mod:`monkeybot.providers`.
4
+ """
@@ -0,0 +1,296 @@
1
+ """Thin provider streaming contract (LLM adapter boundary)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any, Literal, Protocol, TypeAlias, cast
8
+
9
+ from monkeybot.core.types.content_blocks import ContentBlock, Text, ToolRequest, ToolResponse
10
+ from monkeybot.core.types.types_tools import ToolDef
11
+
12
+ Role: TypeAlias = Literal["user", "assistant", "system"]
13
+
14
+
15
+ @dataclass(frozen=True, kw_only=True)
16
+ class Message:
17
+ """Single turn for tool-calling chat models (typed content blocks)."""
18
+
19
+ role: Role
20
+ content: list[ContentBlock]
21
+
22
+ def __post_init__(self) -> None:
23
+ if self.role not in ("user", "assistant", "system"):
24
+ raise ValueError(f"invalid role: {self.role!r}")
25
+ for i, block in enumerate(self.content):
26
+ if not isinstance(block, ContentBlock):
27
+ raise ValueError(
28
+ f"content[{i}] must be ContentBlock, got {type(block).__name__}"
29
+ )
30
+
31
+ def to_dict(self) -> dict[str, object]:
32
+ return {"role": self.role, "content": [b.to_dict() for b in self.content]}
33
+
34
+ @classmethod
35
+ def from_dict(cls, d: Mapping[str, object]) -> Message:
36
+ role = d.get("role")
37
+ if role not in ("user", "assistant", "system"):
38
+ raise ValueError(f"invalid role: {role!r}")
39
+ raw_blocks = d.get("content", [])
40
+ if raw_blocks is None:
41
+ raw_blocks = []
42
+ if not isinstance(raw_blocks, list):
43
+ raise ValueError("content must be a list")
44
+ blocks: list[ContentBlock] = []
45
+ for item in raw_blocks:
46
+ if not isinstance(item, dict):
47
+ raise ValueError("content list elements must be JSON objects")
48
+ blocks.append(ContentBlock.from_dict(item))
49
+ return cls(role=role, content=blocks)
50
+
51
+ @classmethod
52
+ def text(cls, role: Role, text: str) -> Message:
53
+ """Build a message whose content is a single :class:`Text` block."""
54
+ return cls(role=role, content=[Text(text=text)])
55
+
56
+
57
+ @dataclass(frozen=True, kw_only=True)
58
+ class TextDelta:
59
+ kind: Literal["text_delta"] = "text_delta"
60
+ text: str
61
+
62
+
63
+ @dataclass(frozen=True, kw_only=True)
64
+ class ThinkingDelta:
65
+ """Incremental thinking/reasoning chunk from a streaming provider."""
66
+
67
+ kind: Literal["thinking_delta"] = "thinking_delta"
68
+ text: str
69
+ signature: str | None = None
70
+
71
+
72
+ @dataclass(frozen=True, kw_only=True)
73
+ class ToolCall:
74
+ kind: Literal["tool_call"] = "tool_call"
75
+ call_id: str
76
+ name: str
77
+ args: dict[str, object]
78
+ parse_error: str | None = None
79
+ metadata: dict[str, object] | None = None
80
+
81
+
82
+ @dataclass(frozen=True, kw_only=True)
83
+ class ToolInputDelta:
84
+ """Incremental tool-argument JSON fragment while args are still streaming.
85
+
86
+ ``delta`` is a raw, opaque fragment of a single streaming JSON document keyed
87
+ by ``call_id`` (e.g. Anthropic ``input_json_delta``). It is not valid JSON on
88
+ its own — consumers must concatenate all fragments for a given ``call_id``
89
+ (in arrival order) before attempting to parse the result. The final,
90
+ validated arguments are delivered separately on :class:`ToolCall`.
91
+ """
92
+
93
+ kind: Literal["tool_input_delta"] = "tool_input_delta"
94
+ call_id: str
95
+ name: str
96
+ delta: str
97
+
98
+
99
+ @dataclass(frozen=True, kw_only=True)
100
+ class GroundingEvent:
101
+ """Provider-native web-search grounding metadata (e.g. Gemini ``google_search``).
102
+
103
+ Additive to the harness's pluggable ``web_search`` custom tool — this carries
104
+ citations/search-suggestion data from a provider-hosted search tool invoked
105
+ server-side, not a tool call the harness dispatched itself.
106
+ """
107
+
108
+ kind: Literal["grounding"] = "grounding"
109
+ sources: list[dict[str, str]]
110
+ search_queries: list[str]
111
+
112
+
113
+ @dataclass(frozen=True, kw_only=True)
114
+ class ProviderCallHints:
115
+ """Optional transport metadata for prompt-cache / session affinity.
116
+
117
+ Content strategy (stable/volatile split + epoch) lives in prompts; these
118
+ hints are provider-specific request options layered on top.
119
+ """
120
+
121
+ session_id: str | None = None
122
+ cache_retention: Literal["none", "short", "long"] = "short"
123
+
124
+
125
+ def cache_retention_from_env() -> Literal["none", "short", "long"]:
126
+ """Resolve ``MODEL_CACHE_RETENTION`` (default ``short``)."""
127
+ import os
128
+
129
+ raw = os.environ.get("MODEL_CACHE_RETENTION", "short").strip().lower()
130
+ if raw in ("none", "short", "long"):
131
+ return raw # type: ignore[return-value]
132
+ return "short"
133
+
134
+
135
+ def gemini_extra_kwargs(provider: Provider, *, vertex_google_search: bool) -> dict[str, bool]:
136
+ if vertex_google_search and provider.name == "gemini":
137
+ return {"vertex_google_search": True}
138
+ return {}
139
+
140
+
141
+ def provider_call_hints_kwargs(
142
+ provider: Provider,
143
+ hints: ProviderCallHints | None,
144
+ ) -> dict[str, Any]:
145
+ """Return kwargs accepted by providers that opt into ``ProviderCallHints``."""
146
+ if hints is None:
147
+ return {}
148
+ # Anthropic-family + OpenAI accept hints; others ignore via Protocol default.
149
+ if provider.name in ("claude", "vertex-claude", "bedrock", "openai"):
150
+ return {"hints": hints}
151
+ return {}
152
+
153
+
154
+ async def provider_count_input_tokens(
155
+ provider: Provider,
156
+ messages: Sequence[Message],
157
+ tools: Sequence[ToolDef],
158
+ *,
159
+ model: str,
160
+ thinking_budget: int | None = None,
161
+ vertex_google_search: bool = False,
162
+ hints: ProviderCallHints | None = None,
163
+ ) -> int:
164
+ kwargs: dict[str, Any] = {"model": model, "thinking_budget": thinking_budget}
165
+ kwargs.update(gemini_extra_kwargs(provider, vertex_google_search=vertex_google_search))
166
+ kwargs.update(provider_call_hints_kwargs(provider, hints))
167
+ if kwargs.keys() - {"model", "thinking_budget"}:
168
+ return int(
169
+ await cast(Any, provider).count_input_tokens(messages, tools, **kwargs)
170
+ )
171
+ return await provider.count_input_tokens(messages, tools, **kwargs)
172
+
173
+
174
+ def provider_stream(
175
+ provider: Provider,
176
+ messages: Sequence[Message],
177
+ tools: Sequence[ToolDef],
178
+ *,
179
+ model: str,
180
+ thinking_budget: int | None = None,
181
+ vertex_google_search: bool = False,
182
+ hints: ProviderCallHints | None = None,
183
+ ) -> AsyncIterator[ProviderEvent]:
184
+ kwargs: dict[str, Any] = {"model": model, "thinking_budget": thinking_budget}
185
+ kwargs.update(gemini_extra_kwargs(provider, vertex_google_search=vertex_google_search))
186
+ kwargs.update(provider_call_hints_kwargs(provider, hints))
187
+ if kwargs.keys() - {"model", "thinking_budget"}:
188
+ return cast(
189
+ AsyncIterator[ProviderEvent],
190
+ cast(Any, provider).stream(messages, tools, **kwargs),
191
+ )
192
+ return provider.stream(messages, tools, **kwargs)
193
+
194
+
195
+ @dataclass(frozen=True, kw_only=True)
196
+ class UsageEvent:
197
+ kind: Literal["usage"] = "usage"
198
+ input_tokens: int
199
+ output_tokens: int
200
+ cached_tokens: int = 0
201
+ cache_read_tokens: int = 0
202
+ cache_creation_tokens: int = 0
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class Done:
207
+ """End of a provider stream. ``truncated`` means the vendor hit an output length limit."""
208
+
209
+ kind: Literal["done"] = "done"
210
+ truncated: bool = False
211
+
212
+
213
+ ProviderEvent: TypeAlias = (
214
+ TextDelta
215
+ | ThinkingDelta
216
+ | ToolCall
217
+ | ToolInputDelta
218
+ | GroundingEvent
219
+ | UsageEvent
220
+ | Done
221
+ )
222
+
223
+
224
+ class Provider(Protocol):
225
+ """Streams model output as :class:`ProviderEvent` values.
226
+
227
+ Exactly one consumer should iterate a given ``stream`` at a time; concurrent
228
+ overlapping calls on the same instance are intentionally undefined.
229
+
230
+ ``stream`` is annotated as a synchronous method returning :class:`AsyncIterator`
231
+ so async-generator implementations match under strict mypy (``async def``
232
+ with ``yield`` is *not* typed as returning ``Coroutine[..., AsyncIterator]``).
233
+ """
234
+
235
+ @property
236
+ def name(self) -> str:
237
+ """Stable provider id (e.g. ``\"gemini\"``)."""
238
+
239
+ @property
240
+ def supports_streaming(self) -> bool:
241
+ """Whether partial output is exposed as incremental deltas."""
242
+
243
+ def stream(
244
+ self,
245
+ messages: Sequence[Message],
246
+ tools: Sequence[ToolDef],
247
+ *,
248
+ model: str,
249
+ thinking_budget: int | None = None,
250
+ ) -> AsyncIterator[ProviderEvent]:
251
+ """Yield provider events for one model request.
252
+
253
+ ``thinking_budget`` overrides the configured reasoning budget for this call
254
+ when the provider supports it (Gemini, Claude). ``None`` uses the default.
255
+ """
256
+
257
+ async def count_input_tokens(
258
+ self,
259
+ messages: Sequence[Message],
260
+ tools: Sequence[ToolDef],
261
+ *,
262
+ model: str,
263
+ thinking_budget: int | None = None,
264
+ ) -> int:
265
+ """Return the provider-aligned input (prompt) token count for one outbound request.
266
+
267
+ Must reflect the same payload shape as :meth:`stream` (messages, tools, model),
268
+ typically via the vendor's tokenizer or count API — not post-hoc usage from a
269
+ prior response.
270
+ ``thinking_budget`` mirrors :meth:`stream` for providers whose token count
271
+ changes with reasoning configuration.
272
+ """
273
+
274
+
275
+ __all__ = [
276
+ "Done",
277
+ "GroundingEvent",
278
+ "Message",
279
+ "Provider",
280
+ "ProviderCallHints",
281
+ "ProviderEvent",
282
+ "Role",
283
+ "Text",
284
+ "TextDelta",
285
+ "ThinkingDelta",
286
+ "ToolCall",
287
+ "ToolDef",
288
+ "ToolRequest",
289
+ "ToolResponse",
290
+ "UsageEvent",
291
+ "cache_retention_from_env",
292
+ "gemini_extra_kwargs",
293
+ "provider_call_hints_kwargs",
294
+ "provider_count_input_tokens",
295
+ "provider_stream",
296
+ ]