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