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,654 @@
|
|
|
1
|
+
"""Post-utterance processing for the realtime harness.
|
|
2
|
+
|
|
3
|
+
This module mirrors :func:`~monkeybot.core.runtime.loop.run` for realtime turns: it
|
|
4
|
+
commits finalized user/assistant utterances to ``HistoryStore``, runs hooks, and
|
|
5
|
+
dispatches tools. It does **not** call a model provider; the realtime vendor session
|
|
6
|
+
is managed by the gateway.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import uuid
|
|
15
|
+
from collections.abc import AsyncIterator, Sequence
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from monkeybot.core.attachments.catalog import SessionAttachmentCatalog
|
|
19
|
+
from monkeybot.core.attachments.freeze import freeze_attachments_in_history
|
|
20
|
+
from monkeybot.core.attachments.store import AttachmentStore
|
|
21
|
+
from monkeybot.core.context import (
|
|
22
|
+
PendingResponseBusPort,
|
|
23
|
+
TurnContext,
|
|
24
|
+
MCP_REGISTRY_MUTATING_TOOLS,
|
|
25
|
+
refresh_memory_index,
|
|
26
|
+
refresh_tools_after_mcp_change,
|
|
27
|
+
)
|
|
28
|
+
from monkeybot.core.hooks import HookEvent, HookManager, HookPayload
|
|
29
|
+
from monkeybot.core.llm.provider import Message, ToolCall
|
|
30
|
+
from monkeybot.core.llm.realtime_provider import RealtimeToolCall
|
|
31
|
+
from monkeybot.core.logging_utils import kv
|
|
32
|
+
from monkeybot.core.persistence.backends import HistoryStore
|
|
33
|
+
from monkeybot.core.persistence.transcript import TranscriptWriter
|
|
34
|
+
from monkeybot.core.runtime.events import (
|
|
35
|
+
ActionRequiredEvent,
|
|
36
|
+
AgentEvent,
|
|
37
|
+
AssistantDelta,
|
|
38
|
+
AttachmentDescriptorEvent,
|
|
39
|
+
Error,
|
|
40
|
+
Thinking,
|
|
41
|
+
ToolCallResult,
|
|
42
|
+
ToolCallStarted,
|
|
43
|
+
ToolConfirmationRequestEvent,
|
|
44
|
+
TurnComplete,
|
|
45
|
+
UsageTotals,
|
|
46
|
+
)
|
|
47
|
+
from monkeybot.core.tools.inspector import InspectorToolCall
|
|
48
|
+
from monkeybot.core.tools.permission import remember_always_approval, resource_for_call
|
|
49
|
+
from monkeybot.core.tools.types import ToolExecutionResult
|
|
50
|
+
from monkeybot.core.types.content_blocks import (
|
|
51
|
+
ActionRequired,
|
|
52
|
+
ContentBlock,
|
|
53
|
+
ElicitationAction,
|
|
54
|
+
Text,
|
|
55
|
+
ToolRequest,
|
|
56
|
+
ToolResponse,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
from .loop import (
|
|
60
|
+
ToolExecutorPort,
|
|
61
|
+
_await_user_response_any,
|
|
62
|
+
_rejected_tool_batch_error,
|
|
63
|
+
_should_reject_tool_batch,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
logger = logging.getLogger("monkeybot.core.runtime.realtime_loop")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_HOOK_PRE_TOOL_TIMEOUT_S = 1.5
|
|
70
|
+
|
|
71
|
+
_REALTIME_MCP_NEW_SESSION_NOTE = (
|
|
72
|
+
"MCP registry updated on the harness. Newly enabled MCP tool schemas are not "
|
|
73
|
+
"pushed into the live voice session; start a new realtime session to advertise "
|
|
74
|
+
"them to the model (v1 has no reconnect/resume)."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _user_text_from_content(blocks: Sequence[ContentBlock]) -> str:
|
|
79
|
+
return " ".join(
|
|
80
|
+
b.text.strip() for b in blocks if isinstance(b, Text) and b.text.strip()
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _elicitation_user_data_text(user_data: object) -> str:
|
|
85
|
+
if user_data is None:
|
|
86
|
+
return ""
|
|
87
|
+
if isinstance(user_data, str):
|
|
88
|
+
return user_data
|
|
89
|
+
try:
|
|
90
|
+
return json.dumps(user_data, sort_keys=True)
|
|
91
|
+
except (TypeError, ValueError):
|
|
92
|
+
return str(user_data)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def _resolve_elicitation_blocks(
|
|
96
|
+
blocks: Sequence[ContentBlock],
|
|
97
|
+
*,
|
|
98
|
+
ctx: TurnContext,
|
|
99
|
+
pending_bus: PendingResponseBusPort | None,
|
|
100
|
+
) -> AsyncIterator[AgentEvent | list[ContentBlock]]:
|
|
101
|
+
"""Yield ``ActionRequiredEvent``s for elicitation blocks, then the resolved block list.
|
|
102
|
+
|
|
103
|
+
Tools may return :class:`ActionRequired` / :class:`ElicitationAction` blocks to pause
|
|
104
|
+
the realtime turn until the client answers via ``elicitation_response``.
|
|
105
|
+
"""
|
|
106
|
+
out: list[ContentBlock] = []
|
|
107
|
+
for block in blocks:
|
|
108
|
+
if not (
|
|
109
|
+
isinstance(block, ActionRequired) and isinstance(block.data, ElicitationAction)
|
|
110
|
+
):
|
|
111
|
+
out.append(block)
|
|
112
|
+
continue
|
|
113
|
+
action = block.data
|
|
114
|
+
elicitation_id = action.id.strip() or str(uuid.uuid4())
|
|
115
|
+
if pending_bus is None:
|
|
116
|
+
logger.warning(
|
|
117
|
+
"realtime elicitation unavailable %s",
|
|
118
|
+
kv(
|
|
119
|
+
request_id=ctx.request_id,
|
|
120
|
+
thread_id=ctx.thread_id,
|
|
121
|
+
elicitation_id=elicitation_id,
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
out.append(
|
|
125
|
+
Text(text=f"{action.message} (elicitation unavailable)")
|
|
126
|
+
)
|
|
127
|
+
continue
|
|
128
|
+
fut = pending_bus.register_pending(elicitation_id)
|
|
129
|
+
yield ActionRequiredEvent(
|
|
130
|
+
request_id=ctx.request_id,
|
|
131
|
+
action_type="elicitation",
|
|
132
|
+
id=elicitation_id,
|
|
133
|
+
payload={
|
|
134
|
+
"message": action.message,
|
|
135
|
+
"requestedSchema": dict(action.requested_schema or {}),
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
try:
|
|
139
|
+
payload = await _await_user_response_any(
|
|
140
|
+
pending_bus, fut, elicitation_id, timeout_sec=None
|
|
141
|
+
)
|
|
142
|
+
except asyncio.CancelledError:
|
|
143
|
+
raise
|
|
144
|
+
if payload.get("_timeout"):
|
|
145
|
+
out.append(Text(text="User did not respond to elicitation in time"))
|
|
146
|
+
continue
|
|
147
|
+
if payload.get("cancelled"):
|
|
148
|
+
out.append(Text(text="User cancelled elicitation"))
|
|
149
|
+
continue
|
|
150
|
+
out.append(Text(text=_elicitation_user_data_text(payload.get("user_data"))))
|
|
151
|
+
yield out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def _fire_hook(
|
|
155
|
+
hook_manager: HookManager | None,
|
|
156
|
+
*,
|
|
157
|
+
event: HookEvent,
|
|
158
|
+
ctx: TurnContext,
|
|
159
|
+
timeout_s: float,
|
|
160
|
+
user_message: str | None = None,
|
|
161
|
+
tool_name: str | None = None,
|
|
162
|
+
tool_args: dict[str, Any] | None = None,
|
|
163
|
+
tool_result: str | None = None,
|
|
164
|
+
tool_error: str | None = None,
|
|
165
|
+
) -> HookPayload | None:
|
|
166
|
+
if hook_manager is None:
|
|
167
|
+
return None
|
|
168
|
+
payload = HookPayload(
|
|
169
|
+
event=event,
|
|
170
|
+
thread_id=ctx.thread_id,
|
|
171
|
+
request_id=ctx.request_id,
|
|
172
|
+
ctx=ctx,
|
|
173
|
+
user_message=user_message,
|
|
174
|
+
tool_name=tool_name,
|
|
175
|
+
tool_args=tool_args,
|
|
176
|
+
tool_result=tool_result,
|
|
177
|
+
tool_error=tool_error,
|
|
178
|
+
)
|
|
179
|
+
return await hook_manager.fire(payload, timeout_s=timeout_s)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _blocks_to_sse_summary(blocks: Sequence[ContentBlock]) -> str:
|
|
183
|
+
parts: list[str] = []
|
|
184
|
+
for block in blocks:
|
|
185
|
+
if isinstance(block, Text):
|
|
186
|
+
parts.append(block.text)
|
|
187
|
+
else:
|
|
188
|
+
parts.append(f"[{type(block).__name__}]")
|
|
189
|
+
return "\n".join(parts)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _tool_outcome(
|
|
193
|
+
call: ToolCall,
|
|
194
|
+
request_id: str,
|
|
195
|
+
result: ToolExecutionResult,
|
|
196
|
+
) -> tuple[ToolCallResult, ToolResponse]:
|
|
197
|
+
is_error = result.error is not None
|
|
198
|
+
body = "" if is_error else _blocks_to_sse_summary(result.blocks)
|
|
199
|
+
text = result.error if is_error else body
|
|
200
|
+
response_blocks: list[ContentBlock] = (
|
|
201
|
+
list(result.blocks) if not is_error else [Text(text=text or "")]
|
|
202
|
+
)
|
|
203
|
+
event = ToolCallResult(
|
|
204
|
+
request_id=request_id,
|
|
205
|
+
tool=call.name,
|
|
206
|
+
result=body,
|
|
207
|
+
error=result.error,
|
|
208
|
+
call_id=call.call_id,
|
|
209
|
+
)
|
|
210
|
+
response = ToolResponse(
|
|
211
|
+
id=call.call_id,
|
|
212
|
+
tool_name=call.name,
|
|
213
|
+
result=response_blocks,
|
|
214
|
+
is_error=is_error,
|
|
215
|
+
)
|
|
216
|
+
return event, response
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def _execute_tool(
|
|
220
|
+
call: ToolCall,
|
|
221
|
+
ctx: TurnContext,
|
|
222
|
+
*,
|
|
223
|
+
tool_executor: ToolExecutorPort,
|
|
224
|
+
hook_manager: HookManager | None,
|
|
225
|
+
request_id: str,
|
|
226
|
+
) -> tuple[ToolExecutionResult, str | None]:
|
|
227
|
+
"""Execute one tool; return (result, optional inject_text from pre-tool hooks)."""
|
|
228
|
+
logger.debug(
|
|
229
|
+
"realtime tool execute %s",
|
|
230
|
+
kv(
|
|
231
|
+
request_id=request_id,
|
|
232
|
+
thread_id=ctx.thread_id,
|
|
233
|
+
tool=call.name,
|
|
234
|
+
call_id=call.call_id,
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
pre_tool_payload = await _fire_hook(
|
|
238
|
+
hook_manager,
|
|
239
|
+
event=HookEvent.PRE_TOOL,
|
|
240
|
+
ctx=ctx,
|
|
241
|
+
timeout_s=_HOOK_PRE_TOOL_TIMEOUT_S,
|
|
242
|
+
tool_name=call.name,
|
|
243
|
+
tool_args=dict(call.args),
|
|
244
|
+
)
|
|
245
|
+
inject_text: str | None = None
|
|
246
|
+
if pre_tool_payload is not None and pre_tool_payload.inject_text:
|
|
247
|
+
inject_text = pre_tool_payload.inject_text
|
|
248
|
+
try:
|
|
249
|
+
result = await tool_executor.execute(call=call, ctx=ctx)
|
|
250
|
+
except asyncio.CancelledError:
|
|
251
|
+
raise
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
logger.warning(
|
|
254
|
+
"realtime tool execution failed %s",
|
|
255
|
+
kv(
|
|
256
|
+
request_id=request_id,
|
|
257
|
+
thread_id=ctx.thread_id,
|
|
258
|
+
tool=call.name,
|
|
259
|
+
call_id=call.call_id,
|
|
260
|
+
),
|
|
261
|
+
exc_info=True,
|
|
262
|
+
)
|
|
263
|
+
result = ToolExecutionResult.err(str(exc))
|
|
264
|
+
result_summary = (
|
|
265
|
+
_blocks_to_sse_summary(result.blocks) if result.error is None else None
|
|
266
|
+
)
|
|
267
|
+
await _fire_hook(
|
|
268
|
+
hook_manager,
|
|
269
|
+
event=HookEvent.POST_TOOL,
|
|
270
|
+
ctx=ctx,
|
|
271
|
+
timeout_s=0,
|
|
272
|
+
tool_name=call.name,
|
|
273
|
+
tool_args=dict(call.args),
|
|
274
|
+
tool_result=result_summary,
|
|
275
|
+
tool_error=result.error,
|
|
276
|
+
)
|
|
277
|
+
return result, inject_text
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _realtime_tool_call_to_tool_call(call: RealtimeToolCall) -> ToolCall:
|
|
281
|
+
return ToolCall(
|
|
282
|
+
call_id=call.call_id,
|
|
283
|
+
name=call.name,
|
|
284
|
+
args=dict(call.args),
|
|
285
|
+
parse_error=call.parse_error,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def run_realtime_turn(
|
|
290
|
+
user_content: str | list[ContentBlock],
|
|
291
|
+
assistant_text: str,
|
|
292
|
+
assistant_tool_calls: Sequence[RealtimeToolCall],
|
|
293
|
+
ctx: TurnContext,
|
|
294
|
+
*,
|
|
295
|
+
history: HistoryStore,
|
|
296
|
+
tool_executor: ToolExecutorPort,
|
|
297
|
+
inspectors: Sequence[Any] | None = None,
|
|
298
|
+
hook_manager: HookManager | None = None,
|
|
299
|
+
attachment_store: AttachmentStore | None = None,
|
|
300
|
+
attachment_catalog: SessionAttachmentCatalog | None = None,
|
|
301
|
+
transcript_writer: TranscriptWriter | None = None,
|
|
302
|
+
tool_results_out: list[ContentBlock] | None = None,
|
|
303
|
+
inject_texts_out: list[str] | None = None,
|
|
304
|
+
pending_bus: PendingResponseBusPort | None = None,
|
|
305
|
+
) -> AsyncIterator[AgentEvent]:
|
|
306
|
+
"""Process a finalized realtime user utterance + assistant response.
|
|
307
|
+
|
|
308
|
+
Commits the user message, runs memory hooks, commits the assistant message (with
|
|
309
|
+
any tool requests), and dispatches the tools sequentially. Tool results are
|
|
310
|
+
yielded as ``ToolCallResult`` events and also appended to ``tool_results_out``
|
|
311
|
+
when the generator completes. Pre-tool hook ``inject_text`` values are appended to
|
|
312
|
+
``inject_texts_out`` for the gateway to inject into the live session.
|
|
313
|
+
|
|
314
|
+
The caller is responsible for injecting the collected tool results back into the
|
|
315
|
+
live realtime session via ``RealtimeSession.send_tool_results()`` /
|
|
316
|
+
``send_context()`` when the session is idle.
|
|
317
|
+
|
|
318
|
+
Truncation (``Done.truncated``) is text-loop only — Gemini Live does not expose an
|
|
319
|
+
output length-limit signal. Realtime still rejects all-``parse_error`` batches.
|
|
320
|
+
"""
|
|
321
|
+
usage = UsageTotals()
|
|
322
|
+
# attachment_store is accepted for symmetry with loop.run and future resolve paths;
|
|
323
|
+
# freeze_attachments_in_history uses the catalog rebuilt from history.
|
|
324
|
+
_ = attachment_store
|
|
325
|
+
blocks = [Text(text=user_content)] if isinstance(user_content, str) else list(user_content)
|
|
326
|
+
user_text = _user_text_from_content(blocks)
|
|
327
|
+
|
|
328
|
+
logger.debug(
|
|
329
|
+
"realtime turn start %s",
|
|
330
|
+
kv(
|
|
331
|
+
request_id=ctx.request_id,
|
|
332
|
+
thread_id=ctx.thread_id,
|
|
333
|
+
model=ctx.model,
|
|
334
|
+
user_text_len=len(user_text),
|
|
335
|
+
n_tool_calls=len(assistant_tool_calls),
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
# 1. Commit user message to history (skip empty audio-only placeholders).
|
|
341
|
+
if user_text or any(not isinstance(b, Text) for b in blocks):
|
|
342
|
+
await history.append(ctx.thread_id, Message(role="user", content=list(blocks)))
|
|
343
|
+
await _fire_hook(
|
|
344
|
+
hook_manager,
|
|
345
|
+
event=HookEvent.USER_MESSAGE,
|
|
346
|
+
ctx=ctx,
|
|
347
|
+
timeout_s=0,
|
|
348
|
+
user_message=user_text,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if transcript_writer is not None:
|
|
352
|
+
await transcript_writer.write_user_message(
|
|
353
|
+
request_id=ctx.request_id,
|
|
354
|
+
content=user_text,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# 2. Refresh memory index for the next system prompt.
|
|
358
|
+
ctx = await refresh_memory_index(ctx)
|
|
359
|
+
|
|
360
|
+
yield Thinking(request_id=ctx.request_id)
|
|
361
|
+
|
|
362
|
+
# 3. Build assistant message with text + tool requests.
|
|
363
|
+
assistant_blocks: list[ContentBlock] = []
|
|
364
|
+
if assistant_text.strip():
|
|
365
|
+
assistant_blocks.append(Text(text=assistant_text.strip()))
|
|
366
|
+
yield AssistantDelta(request_id=ctx.request_id, delta=assistant_text.strip())
|
|
367
|
+
|
|
368
|
+
tool_results: list[ContentBlock] = []
|
|
369
|
+
for rtc in assistant_tool_calls:
|
|
370
|
+
assistant_blocks.append(
|
|
371
|
+
ToolRequest(
|
|
372
|
+
id=rtc.call_id,
|
|
373
|
+
name=rtc.name,
|
|
374
|
+
args=dict(rtc.args),
|
|
375
|
+
parse_error=rtc.parse_error,
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
if assistant_blocks:
|
|
380
|
+
# Await the assistant write so tool-response rows are always ordered after it.
|
|
381
|
+
await history.append(
|
|
382
|
+
ctx.thread_id,
|
|
383
|
+
Message(role="assistant", content=assistant_blocks),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# 4. Dispatch tools sequentially (v1: no parallel subagent dispatch here).
|
|
387
|
+
mcp_registry_mutated = False
|
|
388
|
+
pending_calls = [_realtime_tool_call_to_tool_call(rtc) for rtc in assistant_tool_calls]
|
|
389
|
+
# Realtime has no vendor length-limit signal; reject only all-parse_error batches.
|
|
390
|
+
reject_batch = _should_reject_tool_batch(pending_calls, truncated=False)
|
|
391
|
+
if reject_batch:
|
|
392
|
+
logger.warning(
|
|
393
|
+
"rejecting incomplete realtime tool batch %s",
|
|
394
|
+
kv(
|
|
395
|
+
request_id=ctx.request_id,
|
|
396
|
+
thread_id=ctx.thread_id,
|
|
397
|
+
n_calls=len(pending_calls),
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
for call in pending_calls:
|
|
401
|
+
if reject_batch:
|
|
402
|
+
err = _rejected_tool_batch_error(call, truncated=False)
|
|
403
|
+
yield ToolCallStarted(
|
|
404
|
+
request_id=ctx.request_id,
|
|
405
|
+
tool=call.name,
|
|
406
|
+
label=call.name,
|
|
407
|
+
args=dict(call.args),
|
|
408
|
+
parse_error=call.parse_error,
|
|
409
|
+
)
|
|
410
|
+
event, response = _tool_outcome(
|
|
411
|
+
call, ctx.request_id, ToolExecutionResult.err(err)
|
|
412
|
+
)
|
|
413
|
+
yield event
|
|
414
|
+
tool_results.append(response)
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
# Provider couldn't parse streamed tool JSON: surface as a tool error
|
|
418
|
+
# so the model can self-correct instead of executing with empty args.
|
|
419
|
+
if call.parse_error:
|
|
420
|
+
yield ToolCallStarted(
|
|
421
|
+
request_id=ctx.request_id,
|
|
422
|
+
tool=call.name,
|
|
423
|
+
label=call.name,
|
|
424
|
+
args=dict(call.args),
|
|
425
|
+
parse_error=call.parse_error,
|
|
426
|
+
call_id=call.call_id,
|
|
427
|
+
)
|
|
428
|
+
event, response = _tool_outcome(
|
|
429
|
+
call, ctx.request_id, ToolExecutionResult.err(call.parse_error)
|
|
430
|
+
)
|
|
431
|
+
yield event
|
|
432
|
+
tool_results.append(response)
|
|
433
|
+
continue
|
|
434
|
+
|
|
435
|
+
# Inspectors (tool confirmation / deny) are applied if provided.
|
|
436
|
+
allowed = True
|
|
437
|
+
denial_message: str | None = None
|
|
438
|
+
inspector_call = InspectorToolCall(
|
|
439
|
+
call_id=call.call_id, name=call.name, args=dict(call.args)
|
|
440
|
+
)
|
|
441
|
+
for insp in inspectors or []:
|
|
442
|
+
decision = await insp.check(inspector_call, ctx)
|
|
443
|
+
if decision.kind == "deny":
|
|
444
|
+
allowed = False
|
|
445
|
+
denial_message = decision.message
|
|
446
|
+
break
|
|
447
|
+
if decision.kind == "confirm":
|
|
448
|
+
if pending_bus is None:
|
|
449
|
+
allowed = False
|
|
450
|
+
denial_message = (
|
|
451
|
+
decision.message
|
|
452
|
+
or "Confirmation required but realtime HITL is unavailable"
|
|
453
|
+
)
|
|
454
|
+
logger.warning(
|
|
455
|
+
"realtime HITL unavailable %s",
|
|
456
|
+
kv(
|
|
457
|
+
request_id=ctx.request_id,
|
|
458
|
+
thread_id=ctx.thread_id,
|
|
459
|
+
call_id=call.call_id,
|
|
460
|
+
tool=call.name,
|
|
461
|
+
),
|
|
462
|
+
)
|
|
463
|
+
break
|
|
464
|
+
fut = pending_bus.register_pending(call.call_id)
|
|
465
|
+
yield ToolConfirmationRequestEvent(
|
|
466
|
+
request_id=ctx.request_id,
|
|
467
|
+
tool_call_id=call.call_id,
|
|
468
|
+
tool_name=call.name,
|
|
469
|
+
arguments=dict(call.args),
|
|
470
|
+
prompt=decision.message,
|
|
471
|
+
)
|
|
472
|
+
try:
|
|
473
|
+
payload = await _await_user_response_any(
|
|
474
|
+
pending_bus, fut, call.call_id, timeout_sec=None
|
|
475
|
+
)
|
|
476
|
+
except asyncio.CancelledError:
|
|
477
|
+
# Do not swallow cancellation into a normal deny path —
|
|
478
|
+
# re-raise so the outer handler tears down the turn and
|
|
479
|
+
# the generator stops (client disconnect / abort).
|
|
480
|
+
raise
|
|
481
|
+
if payload.get("_timeout"):
|
|
482
|
+
allowed = False
|
|
483
|
+
denial_message = "user did not respond in time"
|
|
484
|
+
logger.info(
|
|
485
|
+
"realtime HITL timeout %s",
|
|
486
|
+
kv(
|
|
487
|
+
request_id=ctx.request_id,
|
|
488
|
+
thread_id=ctx.thread_id,
|
|
489
|
+
call_id=call.call_id,
|
|
490
|
+
approved=False,
|
|
491
|
+
reason="timeout",
|
|
492
|
+
),
|
|
493
|
+
)
|
|
494
|
+
break
|
|
495
|
+
if payload.get("approved"):
|
|
496
|
+
allowed = True
|
|
497
|
+
if payload.get("always"):
|
|
498
|
+
remember_always_approval(
|
|
499
|
+
pending_bus, call.name, resource_for_call(inspector_call)
|
|
500
|
+
)
|
|
501
|
+
logger.debug(
|
|
502
|
+
"realtime HITL always %s",
|
|
503
|
+
kv(
|
|
504
|
+
request_id=ctx.request_id,
|
|
505
|
+
thread_id=ctx.thread_id,
|
|
506
|
+
call_id=call.call_id,
|
|
507
|
+
tool=call.name,
|
|
508
|
+
decision="confirm_always",
|
|
509
|
+
),
|
|
510
|
+
)
|
|
511
|
+
else:
|
|
512
|
+
allowed = False
|
|
513
|
+
reason_raw = payload.get("reason")
|
|
514
|
+
denial_message = (
|
|
515
|
+
(reason_raw if isinstance(reason_raw, str) else None)
|
|
516
|
+
or decision.message
|
|
517
|
+
or "denied by user"
|
|
518
|
+
)
|
|
519
|
+
logger.info(
|
|
520
|
+
"realtime HITL denied %s",
|
|
521
|
+
kv(
|
|
522
|
+
request_id=ctx.request_id,
|
|
523
|
+
thread_id=ctx.thread_id,
|
|
524
|
+
call_id=call.call_id,
|
|
525
|
+
approved=False,
|
|
526
|
+
reason=denial_message,
|
|
527
|
+
),
|
|
528
|
+
)
|
|
529
|
+
break
|
|
530
|
+
|
|
531
|
+
if not allowed:
|
|
532
|
+
result = ToolExecutionResult.err(denial_message or "tool call denied")
|
|
533
|
+
inject_text = None
|
|
534
|
+
else:
|
|
535
|
+
yield ToolCallStarted(
|
|
536
|
+
request_id=ctx.request_id,
|
|
537
|
+
tool=call.name,
|
|
538
|
+
label=call.name,
|
|
539
|
+
args=dict(call.args),
|
|
540
|
+
call_id=call.call_id,
|
|
541
|
+
)
|
|
542
|
+
result, inject_text = await _execute_tool(
|
|
543
|
+
call,
|
|
544
|
+
ctx,
|
|
545
|
+
tool_executor=tool_executor,
|
|
546
|
+
hook_manager=hook_manager,
|
|
547
|
+
request_id=ctx.request_id,
|
|
548
|
+
)
|
|
549
|
+
if result.error is None and result.blocks:
|
|
550
|
+
resolved_blocks: list[ContentBlock] | None = None
|
|
551
|
+
async for item in _resolve_elicitation_blocks(
|
|
552
|
+
result.blocks,
|
|
553
|
+
ctx=ctx,
|
|
554
|
+
pending_bus=pending_bus,
|
|
555
|
+
):
|
|
556
|
+
if isinstance(item, ActionRequiredEvent):
|
|
557
|
+
yield item
|
|
558
|
+
elif isinstance(item, list):
|
|
559
|
+
resolved_blocks = item
|
|
560
|
+
if resolved_blocks is not None:
|
|
561
|
+
result = ToolExecutionResult.ok_blocks(resolved_blocks)
|
|
562
|
+
if (
|
|
563
|
+
result.error is None
|
|
564
|
+
and call.name in MCP_REGISTRY_MUTATING_TOOLS
|
|
565
|
+
):
|
|
566
|
+
mcp_registry_mutated = True
|
|
567
|
+
|
|
568
|
+
if inject_text and inject_texts_out is not None:
|
|
569
|
+
inject_texts_out.append(inject_text)
|
|
570
|
+
|
|
571
|
+
event, response = _tool_outcome(call, ctx.request_id, result)
|
|
572
|
+
yield event
|
|
573
|
+
tool_results.append(response)
|
|
574
|
+
|
|
575
|
+
if mcp_registry_mutated:
|
|
576
|
+
mcp_client = getattr(tool_executor, "mcp", None)
|
|
577
|
+
if mcp_client is None:
|
|
578
|
+
logger.error(
|
|
579
|
+
"MCP registry mutated but tool executor has no mcp client %s",
|
|
580
|
+
kv(request_id=ctx.request_id, thread_id=ctx.thread_id),
|
|
581
|
+
)
|
|
582
|
+
raise RuntimeError(
|
|
583
|
+
"tool executor missing mcp client after MCP registry mutation"
|
|
584
|
+
)
|
|
585
|
+
ctx = refresh_tools_after_mcp_change(ctx, mcp_client)
|
|
586
|
+
logger.info(
|
|
587
|
+
"refreshed ctx.tools after MCP registry change (realtime) %s",
|
|
588
|
+
kv(
|
|
589
|
+
request_id=ctx.request_id,
|
|
590
|
+
thread_id=ctx.thread_id,
|
|
591
|
+
tool_count=len(ctx.tools),
|
|
592
|
+
),
|
|
593
|
+
)
|
|
594
|
+
# Live vendor sessions fix tools at connect time; schemas are not
|
|
595
|
+
# updated mid-session. Tell the model to start a new session if needed.
|
|
596
|
+
if inject_texts_out is not None:
|
|
597
|
+
inject_texts_out.append(_REALTIME_MCP_NEW_SESSION_NOTE)
|
|
598
|
+
|
|
599
|
+
# 5. Append all tool responses as one user message (reuses loop.py semantics).
|
|
600
|
+
if tool_results:
|
|
601
|
+
await history.append(
|
|
602
|
+
ctx.thread_id,
|
|
603
|
+
Message(role="user", content=tool_results),
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
# 6. Freeze attachments and emit descriptors.
|
|
607
|
+
descriptor_events = await freeze_attachments_in_history(
|
|
608
|
+
thread_id=ctx.thread_id,
|
|
609
|
+
history=history,
|
|
610
|
+
catalog=attachment_catalog,
|
|
611
|
+
last_assistant_text=assistant_text.strip(),
|
|
612
|
+
)
|
|
613
|
+
for desc in descriptor_events:
|
|
614
|
+
yield AttachmentDescriptorEvent(
|
|
615
|
+
request_id=ctx.request_id,
|
|
616
|
+
attachment_id=desc.attachment_id,
|
|
617
|
+
mime_type=desc.mime_type,
|
|
618
|
+
filename=desc.filename,
|
|
619
|
+
description=desc.description,
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
await _fire_hook(
|
|
623
|
+
hook_manager,
|
|
624
|
+
event=HookEvent.POST_TURN,
|
|
625
|
+
ctx=ctx,
|
|
626
|
+
timeout_s=0,
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
logger.debug(
|
|
630
|
+
"realtime turn end %s",
|
|
631
|
+
kv(
|
|
632
|
+
request_id=ctx.request_id,
|
|
633
|
+
thread_id=ctx.thread_id,
|
|
634
|
+
input_tokens=usage.input_tokens,
|
|
635
|
+
output_tokens=usage.output_tokens,
|
|
636
|
+
tool_results=len(tool_results),
|
|
637
|
+
),
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# Make tool results available to the caller for later injection into the live session.
|
|
641
|
+
if tool_results_out is not None:
|
|
642
|
+
tool_results_out.extend(tool_results)
|
|
643
|
+
except asyncio.CancelledError:
|
|
644
|
+
yield Error(request_id=ctx.request_id, error="Realtime turn cancelled")
|
|
645
|
+
raise
|
|
646
|
+
except Exception as exc:
|
|
647
|
+
logger.exception(
|
|
648
|
+
"realtime turn failed %s",
|
|
649
|
+
kv(request_id=ctx.request_id, thread_id=ctx.thread_id),
|
|
650
|
+
)
|
|
651
|
+
yield Error(request_id=ctx.request_id, error=str(exc))
|
|
652
|
+
raise
|
|
653
|
+
finally:
|
|
654
|
+
yield TurnComplete(request_id=ctx.request_id, usage=usage)
|