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,2374 @@
|
|
|
1
|
+
"""Owned agent loop: provider streaming, inspectors, tool dispatch, event emission."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import dataclasses
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
from collections.abc import AsyncIterator, Sequence
|
|
12
|
+
from contextlib import aclosing
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Protocol, cast, runtime_checkable
|
|
15
|
+
|
|
16
|
+
from monkeybot.core.attachments.catalog import SessionAttachmentCatalog
|
|
17
|
+
from monkeybot.core.attachments.freeze import freeze_attachments_in_history
|
|
18
|
+
from monkeybot.core.attachments.store import AttachmentStore
|
|
19
|
+
from monkeybot.core.context import (
|
|
20
|
+
MCP_REGISTRY_MUTATING_TOOLS,
|
|
21
|
+
TurnContext,
|
|
22
|
+
refresh_memory_index,
|
|
23
|
+
refresh_tools_after_mcp_change,
|
|
24
|
+
)
|
|
25
|
+
from monkeybot.core.context.epoch import ContextEpochTracker, EpochAdmit, fingerprint_text
|
|
26
|
+
from monkeybot.core.context.memory_prompt import (
|
|
27
|
+
MemoryPromptSelection,
|
|
28
|
+
memory_index_fingerprint,
|
|
29
|
+
prepare_memory_for_prompt,
|
|
30
|
+
)
|
|
31
|
+
from monkeybot.core.context.tool_output_policy import resolve_tool_budget
|
|
32
|
+
from monkeybot.core.context.tool_result_ingress import summarize_tool_result_text
|
|
33
|
+
from monkeybot.core.context.tool_shapers import (
|
|
34
|
+
exceeds_tool_output_budget,
|
|
35
|
+
shape_tool_text,
|
|
36
|
+
)
|
|
37
|
+
from monkeybot.core.hooks import HookEvent, HookManager, HookPayload
|
|
38
|
+
from monkeybot.core.llm.provider import (
|
|
39
|
+
Done,
|
|
40
|
+
Message,
|
|
41
|
+
Provider,
|
|
42
|
+
ProviderCallHints,
|
|
43
|
+
TextDelta,
|
|
44
|
+
ToolCall,
|
|
45
|
+
UsageEvent,
|
|
46
|
+
cache_retention_from_env,
|
|
47
|
+
provider_count_input_tokens,
|
|
48
|
+
provider_stream,
|
|
49
|
+
)
|
|
50
|
+
from monkeybot.core.llm.usage import Usage
|
|
51
|
+
from monkeybot.core.logging_utils import kv
|
|
52
|
+
from monkeybot.core.messages import convert_to_provider, transform_context
|
|
53
|
+
from monkeybot.core.persistence.backends import HistoryStore
|
|
54
|
+
from monkeybot.core.persistence.transcript import TranscriptWriter
|
|
55
|
+
from monkeybot.core.prompts.prompt import (
|
|
56
|
+
RUNTIME_NOTES_HEADING,
|
|
57
|
+
compose_stable_baseline,
|
|
58
|
+
compose_volatile_tail,
|
|
59
|
+
compose_volatile_tail_parts,
|
|
60
|
+
latest_user_message_text,
|
|
61
|
+
)
|
|
62
|
+
from monkeybot.core.runtime.context_budget import (
|
|
63
|
+
ContextBudgeter,
|
|
64
|
+
compute_context_pressure_tier,
|
|
65
|
+
summarization_trigger_ratio_from_env,
|
|
66
|
+
)
|
|
67
|
+
from monkeybot.core.runtime.provider_stream_mapper import ProviderStreamMapper
|
|
68
|
+
from monkeybot.core.tools.inspector import InspectorToolCall, ToolInspector
|
|
69
|
+
from monkeybot.core.tools.permission import remember_always_approval, resource_for_call
|
|
70
|
+
from monkeybot.core.tools.types import ToolExecutionResult
|
|
71
|
+
from monkeybot.core.types.content_blocks import (
|
|
72
|
+
ContentBlock,
|
|
73
|
+
File,
|
|
74
|
+
Image,
|
|
75
|
+
Text,
|
|
76
|
+
ToolRequest,
|
|
77
|
+
ToolResponse,
|
|
78
|
+
)
|
|
79
|
+
from monkeybot.core.types.content_blocks import Thinking as ThinkingBlock
|
|
80
|
+
from monkeybot.core.types.types_tools import ToolDef
|
|
81
|
+
from monkeybot.providers.pricing import estimate_cost
|
|
82
|
+
|
|
83
|
+
from .events import (
|
|
84
|
+
AgentEvent,
|
|
85
|
+
AttachmentDescriptorEvent,
|
|
86
|
+
ContextEpochStarted,
|
|
87
|
+
ContextSummarized,
|
|
88
|
+
ContextSummarizing,
|
|
89
|
+
Error,
|
|
90
|
+
ImageBlock,
|
|
91
|
+
SystemContextUpdated,
|
|
92
|
+
SystemPromptSnapshot,
|
|
93
|
+
Thinking,
|
|
94
|
+
ToolCallResult,
|
|
95
|
+
ToolCallStarted,
|
|
96
|
+
ToolConfirmationRequestEvent,
|
|
97
|
+
TurnComplete,
|
|
98
|
+
UsageTotals,
|
|
99
|
+
UserSteered,
|
|
100
|
+
)
|
|
101
|
+
from .input_admission import InputAdmission, preview_text
|
|
102
|
+
|
|
103
|
+
logger = logging.getLogger("monkeybot.core.runtime.loop")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def _await_history_write(task: asyncio.Task[None] | None) -> None:
|
|
107
|
+
"""Await a backgrounded history write, logging (never raising) on failure.
|
|
108
|
+
|
|
109
|
+
The final assistant message is persisted off the token-streaming path, but it
|
|
110
|
+
MUST land before any later ``history.load``/``reset`` (e.g. attachment freeze)
|
|
111
|
+
so the row is not lost. Callers await this at the turn tail.
|
|
112
|
+
"""
|
|
113
|
+
if task is None:
|
|
114
|
+
return
|
|
115
|
+
try:
|
|
116
|
+
await task
|
|
117
|
+
except Exception:
|
|
118
|
+
logger.exception("background assistant history write failed")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _effective_max_turns(max_turns: int | None) -> int:
|
|
122
|
+
if max_turns is not None:
|
|
123
|
+
return max_turns
|
|
124
|
+
return int(os.getenv("MAX_TURNS", "50"))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _effective_doom_loop_threshold() -> int:
|
|
128
|
+
"""Consecutive identical tool calls before doom-loop recovery.
|
|
129
|
+
|
|
130
|
+
Default 3. Set ``DOOM_LOOP_THRESHOLD=0`` to disable. Applies whether the
|
|
131
|
+
calls succeed or fail — identical name+args with no progress is the signal.
|
|
132
|
+
"""
|
|
133
|
+
raw = os.getenv("DOOM_LOOP_THRESHOLD")
|
|
134
|
+
if raw is None or raw.strip() == "":
|
|
135
|
+
return 3
|
|
136
|
+
try:
|
|
137
|
+
return max(0, int(raw))
|
|
138
|
+
except ValueError:
|
|
139
|
+
logger.warning(
|
|
140
|
+
"invalid DOOM_LOOP_THRESHOLD %s; using default 3",
|
|
141
|
+
kv(value=raw),
|
|
142
|
+
)
|
|
143
|
+
return 3
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _tool_call_fingerprint(name: str, args: dict[str, Any]) -> str:
|
|
147
|
+
return f"{name}:{json.dumps(args, sort_keys=True, default=str)}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _doom_loop_texts(tool_name: str, threshold: int) -> tuple[str, str]:
|
|
151
|
+
"""Return ``(error_event_text, recovery_system_note)``."""
|
|
152
|
+
error = (
|
|
153
|
+
f"Doom loop detected: tool {tool_name!r} called identically "
|
|
154
|
+
f"{threshold} times"
|
|
155
|
+
)
|
|
156
|
+
note = (
|
|
157
|
+
f"[Harness] {error}. Do not repeat the same call. Explain the situation "
|
|
158
|
+
"to the user and take a different approach (different tool or args)."
|
|
159
|
+
)
|
|
160
|
+
return error, note
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _should_reject_tool_batch(
|
|
164
|
+
calls: Sequence[ToolCall],
|
|
165
|
+
*,
|
|
166
|
+
truncated: bool,
|
|
167
|
+
) -> bool:
|
|
168
|
+
"""True when no tool in this provider turn is safe to execute.
|
|
169
|
+
|
|
170
|
+
A length/max-tokens stop means every tool call may carry silently
|
|
171
|
+
incomplete args. Also reject when every call already has ``parse_error``
|
|
172
|
+
(incomplete JSON).
|
|
173
|
+
"""
|
|
174
|
+
if not calls:
|
|
175
|
+
return False
|
|
176
|
+
if truncated:
|
|
177
|
+
return True
|
|
178
|
+
return all(c.parse_error for c in calls)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _rejected_tool_batch_error(
|
|
182
|
+
call: ToolCall,
|
|
183
|
+
*,
|
|
184
|
+
truncated: bool,
|
|
185
|
+
) -> str:
|
|
186
|
+
if truncated:
|
|
187
|
+
return (
|
|
188
|
+
f'Tool call "{call.name}" was not executed: the response hit the '
|
|
189
|
+
"output token limit, so its arguments may be truncated. Re-issue "
|
|
190
|
+
"the tool call with complete arguments."
|
|
191
|
+
)
|
|
192
|
+
return call.parse_error or (
|
|
193
|
+
f'Tool call "{call.name}" was not executed: incomplete tool arguments '
|
|
194
|
+
"in this batch. Re-issue with complete arguments."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _doom_loop_exempt_names(tools: Sequence[ToolDef]) -> frozenset[str]:
|
|
199
|
+
"""Tool names marked ``doom_loop_exempt`` (identical-args polling is expected)."""
|
|
200
|
+
return frozenset(t.name for t in tools if t.doom_loop_exempt)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclasses.dataclass
|
|
204
|
+
class _DoomLoopTracker:
|
|
205
|
+
"""Tracks consecutive identical tool calls within one user message.
|
|
206
|
+
|
|
207
|
+
Fingerprint is tool name + args. Outcome (ok vs error) does not reset the
|
|
208
|
+
streak — successful no-progress loops (e.g. repeated screenshots) must trip
|
|
209
|
+
the same guard as repeated failures.
|
|
210
|
+
|
|
211
|
+
Tools in ``exempt_names`` (from ``ToolDef.doom_loop_exempt``) are ignored so
|
|
212
|
+
legitimate polling (e.g. ``loop_status``) does not force a recovery turn.
|
|
213
|
+
|
|
214
|
+
After a recovery turn is consumed, the tracker re-arms so a later streak in
|
|
215
|
+
the same user message can trigger again. While ``triggered`` is set (between
|
|
216
|
+
detection and ``consume_recovery``), further ``record`` calls are ignored so
|
|
217
|
+
the same streak cannot re-fire mid-batch.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
threshold: int
|
|
221
|
+
exempt_names: frozenset[str] = dataclasses.field(default_factory=frozenset)
|
|
222
|
+
streak_fp: str | None = None
|
|
223
|
+
streak_count: int = 0
|
|
224
|
+
triggered: bool = False
|
|
225
|
+
force_no_tools: bool = False
|
|
226
|
+
recovery_note: str | None = None
|
|
227
|
+
triggered_tool: str | None = None
|
|
228
|
+
_pending_error: str | None = None
|
|
229
|
+
|
|
230
|
+
def record(self, name: str, args: dict[str, Any]) -> None:
|
|
231
|
+
if self.threshold <= 0 or self.triggered or name in self.exempt_names:
|
|
232
|
+
return
|
|
233
|
+
fp = _tool_call_fingerprint(name, args)
|
|
234
|
+
if fp == self.streak_fp:
|
|
235
|
+
self.streak_count += 1
|
|
236
|
+
else:
|
|
237
|
+
self.streak_fp = fp
|
|
238
|
+
self.streak_count = 1
|
|
239
|
+
if self.streak_count < self.threshold:
|
|
240
|
+
return
|
|
241
|
+
self.triggered = True
|
|
242
|
+
self.triggered_tool = name
|
|
243
|
+
error, note = _doom_loop_texts(name, self.threshold)
|
|
244
|
+
self._pending_error = error
|
|
245
|
+
self.force_no_tools = True
|
|
246
|
+
self.recovery_note = note
|
|
247
|
+
|
|
248
|
+
def take_error(self) -> str | None:
|
|
249
|
+
msg = self._pending_error
|
|
250
|
+
self._pending_error = None
|
|
251
|
+
return msg
|
|
252
|
+
|
|
253
|
+
def consume_recovery(self) -> tuple[bool, str | None]:
|
|
254
|
+
force = self.force_no_tools
|
|
255
|
+
note = self.recovery_note
|
|
256
|
+
self.force_no_tools = False
|
|
257
|
+
self.recovery_note = None
|
|
258
|
+
if force:
|
|
259
|
+
# Re-arm so a second identical streak later in this user message
|
|
260
|
+
# can trigger another recovery turn.
|
|
261
|
+
self.triggered = False
|
|
262
|
+
self.streak_fp = None
|
|
263
|
+
self.streak_count = 0
|
|
264
|
+
self.triggered_tool = None
|
|
265
|
+
return force, note
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _usage_to_totals(u: Usage) -> UsageTotals:
|
|
269
|
+
return UsageTotals(
|
|
270
|
+
input_tokens=u.input_tokens,
|
|
271
|
+
output_tokens=u.output_tokens,
|
|
272
|
+
cached_tokens=u.cached_tokens,
|
|
273
|
+
cache_read_tokens=u.cache_read_tokens,
|
|
274
|
+
cache_creation_tokens=u.cache_creation_tokens,
|
|
275
|
+
cost_usd=u.cost_usd,
|
|
276
|
+
duration_ms=u.duration_ms,
|
|
277
|
+
estimated_prompt_tokens=u.estimated_prompt_tokens,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _merge_usage_event(usage: Usage, ev: UsageEvent) -> None:
|
|
282
|
+
usage.input_tokens += ev.input_tokens
|
|
283
|
+
usage.output_tokens += ev.output_tokens
|
|
284
|
+
usage.cached_tokens += ev.cached_tokens
|
|
285
|
+
usage.cache_read_tokens += ev.cache_read_tokens
|
|
286
|
+
usage.cache_creation_tokens += ev.cache_creation_tokens
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _normalize_user_content(user_content: str | list[ContentBlock]) -> list[ContentBlock]:
|
|
290
|
+
if isinstance(user_content, str):
|
|
291
|
+
return [Text(text=user_content)]
|
|
292
|
+
return list(user_content)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _user_text_from_content(blocks: Sequence[ContentBlock]) -> str:
|
|
296
|
+
return " ".join(
|
|
297
|
+
b.text.strip() for b in blocks if isinstance(b, Text) and b.text.strip()
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _blocks_to_sse_summary(blocks: Sequence[ContentBlock]) -> str:
|
|
302
|
+
parts: list[str] = []
|
|
303
|
+
for block in blocks:
|
|
304
|
+
if isinstance(block, Text):
|
|
305
|
+
parts.append(block.text)
|
|
306
|
+
elif isinstance(block, Image):
|
|
307
|
+
meta = block.metadata or {}
|
|
308
|
+
att_id = meta.get("attachment_id", "")
|
|
309
|
+
parts.append(f"[loaded image {att_id}]" if att_id else "[loaded image]")
|
|
310
|
+
elif isinstance(block, File):
|
|
311
|
+
parts.append("[loaded pdf]")
|
|
312
|
+
return "\n".join(parts)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _system_message_from_text(body: str) -> Message:
|
|
316
|
+
return Message(role="system", content=[Text(text=body)])
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _admit_system_context(
|
|
320
|
+
epoch: ContextEpochTracker,
|
|
321
|
+
ctx: TurnContext,
|
|
322
|
+
chat_messages: Sequence[Message],
|
|
323
|
+
*,
|
|
324
|
+
memory_selection: MemoryPromptSelection | None = None,
|
|
325
|
+
attachment_catalog: SessionAttachmentCatalog | None = None,
|
|
326
|
+
) -> EpochAdmit:
|
|
327
|
+
"""Compose stable/volatile tails and reconcile against the current context epoch."""
|
|
328
|
+
catalog = (
|
|
329
|
+
attachment_catalog.list_records() if attachment_catalog is not None else None
|
|
330
|
+
)
|
|
331
|
+
stable = compose_stable_baseline(ctx, attachment_catalog=catalog)
|
|
332
|
+
volatile_parts = compose_volatile_tail_parts(
|
|
333
|
+
ctx, chat_messages=chat_messages, memory_selection=memory_selection
|
|
334
|
+
)
|
|
335
|
+
volatile = "".join(volatile_parts.values())
|
|
336
|
+
return epoch.reconcile(
|
|
337
|
+
stable_baseline=stable,
|
|
338
|
+
volatile_text=volatile,
|
|
339
|
+
stable_fingerprint=fingerprint_text(stable),
|
|
340
|
+
volatile_fingerprint=fingerprint_text(volatile),
|
|
341
|
+
volatile_part_fingerprints={
|
|
342
|
+
name: fingerprint_text(text) for name, text in volatile_parts.items()
|
|
343
|
+
},
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _messages_for_provider(
|
|
348
|
+
system: Message,
|
|
349
|
+
history: Sequence[Message],
|
|
350
|
+
*,
|
|
351
|
+
mid_conversation_update: str = "",
|
|
352
|
+
) -> list[Message]:
|
|
353
|
+
"""Leading system + history + optional chronological system-context update.
|
|
354
|
+
|
|
355
|
+
The update is user-role so all providers accept mid-conversation updates
|
|
356
|
+
(system is leading-only for Anthropic/Gemini/OpenAI adapters). When history
|
|
357
|
+
already ends in a ``user`` row (e.g. a tool-response turn), the update is
|
|
358
|
+
folded into that same message instead of appended as a new one — Anthropic
|
|
359
|
+
and Gemini both reject (or, for Anthropic, only sometimes silently coalesce)
|
|
360
|
+
two consecutive same-role messages.
|
|
361
|
+
"""
|
|
362
|
+
out: list[Message] = [system, *list(history)]
|
|
363
|
+
update = mid_conversation_update.strip()
|
|
364
|
+
if not update:
|
|
365
|
+
return out
|
|
366
|
+
update_block = Text(text=mid_conversation_update)
|
|
367
|
+
if out[-1].role == "user":
|
|
368
|
+
out[-1] = Message(role="user", content=[*out[-1].content, update_block])
|
|
369
|
+
else:
|
|
370
|
+
out.append(Message(role="user", content=[update_block]))
|
|
371
|
+
return out
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
async def _load_agent_chat_history(history: HistoryStore, thread_id: str) -> list[Message]:
|
|
375
|
+
"""Load transcript rows and apply agent-facing transforms (integrity + strip UI)."""
|
|
376
|
+
return transform_context(await history.load(thread_id))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _epoch_events(
|
|
380
|
+
admit: EpochAdmit,
|
|
381
|
+
*,
|
|
382
|
+
request_id: str,
|
|
383
|
+
thread_id: str,
|
|
384
|
+
) -> list[AgentEvent]:
|
|
385
|
+
if admit.kind == "unchanged":
|
|
386
|
+
return []
|
|
387
|
+
logger.debug(
|
|
388
|
+
"context epoch %s",
|
|
389
|
+
kv(
|
|
390
|
+
request_id=request_id,
|
|
391
|
+
thread_id=thread_id,
|
|
392
|
+
kind=admit.kind,
|
|
393
|
+
epoch_id=admit.epoch_id,
|
|
394
|
+
changed_sources=",".join(admit.changed_sources),
|
|
395
|
+
),
|
|
396
|
+
)
|
|
397
|
+
if admit.kind == "new_epoch":
|
|
398
|
+
return [
|
|
399
|
+
ContextEpochStarted(
|
|
400
|
+
request_id=request_id,
|
|
401
|
+
epoch_id=admit.epoch_id,
|
|
402
|
+
changed_sources=list(admit.changed_sources),
|
|
403
|
+
)
|
|
404
|
+
]
|
|
405
|
+
return [
|
|
406
|
+
SystemContextUpdated(
|
|
407
|
+
request_id=request_id,
|
|
408
|
+
epoch_id=admit.epoch_id,
|
|
409
|
+
changed_sources=list(admit.changed_sources),
|
|
410
|
+
)
|
|
411
|
+
]
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _provider_messages_prompt_summary(messages: Sequence[Message]) -> str:
|
|
415
|
+
"""Compact prompt text for observability (Langfuse ``gen_ai.prompt`` / observation input)."""
|
|
416
|
+
lines: list[str] = []
|
|
417
|
+
for msg in messages:
|
|
418
|
+
parts: list[str] = []
|
|
419
|
+
for block in msg.content:
|
|
420
|
+
if isinstance(block, Text):
|
|
421
|
+
parts.append(block.text)
|
|
422
|
+
elif isinstance(block, ToolRequest):
|
|
423
|
+
parts.append(f"[tool_call {block.name}]")
|
|
424
|
+
elif isinstance(block, ToolResponse):
|
|
425
|
+
parts.append(f"[tool_result {block.tool_name}]")
|
|
426
|
+
text = " ".join(parts).strip()
|
|
427
|
+
if text:
|
|
428
|
+
lines.append(f"{msg.role}: {text}")
|
|
429
|
+
return "\n".join(lines)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
_HOOK_READ_TIMEOUT_S = 2.0
|
|
433
|
+
_HOOK_PRE_TOOL_TIMEOUT_S = 1.5
|
|
434
|
+
_HOOK_SETTLEMENT_TIMEOUT_S = 2.0
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _settlement_timeout_s() -> float:
|
|
438
|
+
raw = os.environ.get("MONKEYBOT_HOOK_SETTLEMENT_TIMEOUT_S")
|
|
439
|
+
if raw is None or raw.strip() == "":
|
|
440
|
+
return _HOOK_SETTLEMENT_TIMEOUT_S
|
|
441
|
+
try:
|
|
442
|
+
return max(0.0, float(raw))
|
|
443
|
+
except ValueError:
|
|
444
|
+
return _HOOK_SETTLEMENT_TIMEOUT_S
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
async def _drain_hook_settlement(hook_manager: HookManager | None) -> None:
|
|
448
|
+
"""Wait for fire-and-forget hooks before the next provider call / TurnComplete."""
|
|
449
|
+
if hook_manager is None:
|
|
450
|
+
return
|
|
451
|
+
await hook_manager.drain_settlement(timeout_s=_settlement_timeout_s())
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _record_tool_hook_span_event(phase: str, tool_name: str) -> None:
|
|
455
|
+
try:
|
|
456
|
+
from monkeybot.observability.instrumentation import add_tool_hook_span_event
|
|
457
|
+
|
|
458
|
+
if phase == "pre_tool":
|
|
459
|
+
add_tool_hook_span_event(phase="pre_tool", tool_name=tool_name)
|
|
460
|
+
else:
|
|
461
|
+
add_tool_hook_span_event(phase="post_tool", tool_name=tool_name)
|
|
462
|
+
except ImportError:
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
async def _fire_hook(
|
|
467
|
+
hook_manager: HookManager | None,
|
|
468
|
+
*,
|
|
469
|
+
event: HookEvent,
|
|
470
|
+
ctx: TurnContext,
|
|
471
|
+
timeout_s: float,
|
|
472
|
+
user_message: str | None = None,
|
|
473
|
+
tool_name: str | None = None,
|
|
474
|
+
tool_args: dict[str, Any] | None = None,
|
|
475
|
+
tool_result: str | None = None,
|
|
476
|
+
tool_error: str | None = None,
|
|
477
|
+
tools: list[ToolDef] | None = None,
|
|
478
|
+
provider_messages: list[Message] | None = None,
|
|
479
|
+
inner_turn: int | None = None,
|
|
480
|
+
assistant_text: str | None = None,
|
|
481
|
+
thinking_text: str | None = None,
|
|
482
|
+
tool_requests: list[dict[str, Any]] | None = None,
|
|
483
|
+
usage: dict[str, int] | None = None,
|
|
484
|
+
provider_error: str | None = None,
|
|
485
|
+
) -> HookPayload | None:
|
|
486
|
+
"""Construct + fire a payload when ``hook_manager`` is configured; else return ``None``."""
|
|
487
|
+
if hook_manager is None:
|
|
488
|
+
return None
|
|
489
|
+
payload = HookPayload(
|
|
490
|
+
event=event,
|
|
491
|
+
thread_id=ctx.thread_id,
|
|
492
|
+
request_id=ctx.request_id,
|
|
493
|
+
ctx=ctx,
|
|
494
|
+
user_message=user_message,
|
|
495
|
+
tool_name=tool_name,
|
|
496
|
+
tool_args=tool_args,
|
|
497
|
+
tool_result=tool_result,
|
|
498
|
+
tool_error=tool_error,
|
|
499
|
+
tools=tools,
|
|
500
|
+
provider_messages=provider_messages,
|
|
501
|
+
inner_turn=inner_turn,
|
|
502
|
+
assistant_text=assistant_text,
|
|
503
|
+
thinking_text=thinking_text,
|
|
504
|
+
tool_requests=tool_requests,
|
|
505
|
+
usage=usage,
|
|
506
|
+
provider_error=provider_error,
|
|
507
|
+
)
|
|
508
|
+
return await hook_manager.fire(payload, timeout_s=timeout_s)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _apply_before_provider_hook(
|
|
512
|
+
payload: HookPayload | None,
|
|
513
|
+
provider_messages: list[Message],
|
|
514
|
+
turn_tools: Sequence[ToolDef],
|
|
515
|
+
) -> tuple[list[Message], Sequence[ToolDef], bool]:
|
|
516
|
+
"""Return (messages, tools, tools_replaced) after ``BEFORE_PROVIDER_REQUEST``."""
|
|
517
|
+
if payload is None:
|
|
518
|
+
return provider_messages, turn_tools, False
|
|
519
|
+
messages = (
|
|
520
|
+
list(payload.provider_messages)
|
|
521
|
+
if payload.provider_messages is not None
|
|
522
|
+
else provider_messages
|
|
523
|
+
)
|
|
524
|
+
if payload.tools is None:
|
|
525
|
+
return messages, turn_tools, False
|
|
526
|
+
return messages, list(payload.tools), True
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
async def _fire_after_provider_response(
|
|
530
|
+
hook_manager: HookManager | None,
|
|
531
|
+
*,
|
|
532
|
+
ctx: TurnContext,
|
|
533
|
+
inner_turn: int,
|
|
534
|
+
assistant_text: str | None = None,
|
|
535
|
+
thinking_text: str | None = None,
|
|
536
|
+
tool_requests: list[dict[str, Any]] | None = None,
|
|
537
|
+
usage: dict[str, int] | None = None,
|
|
538
|
+
provider_error: str | None = None,
|
|
539
|
+
) -> None:
|
|
540
|
+
await _fire_hook(
|
|
541
|
+
hook_manager,
|
|
542
|
+
event=HookEvent.AFTER_PROVIDER_RESPONSE,
|
|
543
|
+
ctx=ctx,
|
|
544
|
+
timeout_s=0,
|
|
545
|
+
inner_turn=inner_turn,
|
|
546
|
+
assistant_text=assistant_text,
|
|
547
|
+
thinking_text=thinking_text,
|
|
548
|
+
tool_requests=tool_requests,
|
|
549
|
+
usage=usage,
|
|
550
|
+
provider_error=provider_error,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _append_extra_system_text(system: Message, extra: str | None) -> Message:
|
|
555
|
+
"""Return a new system Message with ``extra`` under a ``## Runtime notes`` section.
|
|
556
|
+
|
|
557
|
+
Uses the same markdown heading style as the rest of the composed system prompt
|
|
558
|
+
(``## Memory index``, harness sections). When ``extra`` is empty/None the original
|
|
559
|
+
message is returned unchanged.
|
|
560
|
+
"""
|
|
561
|
+
if not extra:
|
|
562
|
+
return system
|
|
563
|
+
base = "".join(b.text for b in system.content if isinstance(b, Text))
|
|
564
|
+
wrapped = f"{base}{RUNTIME_NOTES_HEADING}\n{extra.strip()}\n"
|
|
565
|
+
return Message(role="system", content=[Text(text=wrapped)])
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _combine_extras(*parts: str | None) -> str | None:
|
|
569
|
+
"""Join non-empty hook-injected fragments with blank lines; ``None`` if all empty."""
|
|
570
|
+
kept = [p.strip() for p in parts if p and p.strip()]
|
|
571
|
+
if not kept:
|
|
572
|
+
return None
|
|
573
|
+
return "\n\n".join(kept)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
# Max concurrent ``task`` (subagent) subprocesses per single model tool batch.
|
|
577
|
+
_MAX_CONCURRENT_SUBAGENTS = 10
|
|
578
|
+
_MAX_CONCURRENT_PARALLEL_TOOLS = 10
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _parallel_tool_concurrency() -> int:
|
|
582
|
+
raw = os.environ.get("MONKEYBOT_PARALLEL_TOOL_CONCURRENCY")
|
|
583
|
+
if raw is None or raw.strip() == "":
|
|
584
|
+
return _MAX_CONCURRENT_PARALLEL_TOOLS
|
|
585
|
+
try:
|
|
586
|
+
return max(1, int(raw))
|
|
587
|
+
except ValueError:
|
|
588
|
+
return _MAX_CONCURRENT_PARALLEL_TOOLS
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _parallel_safe_names(tools: Sequence[ToolDef]) -> frozenset[str]:
|
|
592
|
+
"""Names marked ``parallel_safe`` (read-only / concurrent-safe tools)."""
|
|
593
|
+
return frozenset(t.name for t in tools if t.parallel_safe)
|
|
594
|
+
|
|
595
|
+
_SPILL_REL = Path(".monkeybot") / "spill"
|
|
596
|
+
SUMMARY_TRIGGER_RATIO = summarization_trigger_ratio_from_env()
|
|
597
|
+
"""Same ratio as pre-stream summarization check (``preflight_prompt_tokens >= cap``)."""
|
|
598
|
+
_SUMMARY_TRIGGER_RATIO = SUMMARY_TRIGGER_RATIO
|
|
599
|
+
_SUMMARY_KEEP_HEAD = 1
|
|
600
|
+
_SUMMARY_KEEP_TAIL = 6
|
|
601
|
+
|
|
602
|
+
# Fixed Markdown template for middle-history compression.
|
|
603
|
+
_COMPACTION_SUMMARY_SYSTEM = """\
|
|
604
|
+
Output exactly the Markdown structure shown inside <template> and keep the \
|
|
605
|
+
section order unchanged. Do not include the <template> tags in your response.
|
|
606
|
+
<template>
|
|
607
|
+
## Objective
|
|
608
|
+
- [one or two brief sentences describing what the user is trying to accomplish]
|
|
609
|
+
|
|
610
|
+
## Important Details
|
|
611
|
+
- [constraints/preferences, decisions and why, important facts/assumptions, \
|
|
612
|
+
exact context needed to continue, or "(none)"]
|
|
613
|
+
|
|
614
|
+
## Work State
|
|
615
|
+
### Completed
|
|
616
|
+
- [finished work, verified facts, or changes made; otherwise "(none)"]
|
|
617
|
+
|
|
618
|
+
### Active
|
|
619
|
+
- [current work, partial changes, or investigation state; otherwise "(none)"]
|
|
620
|
+
|
|
621
|
+
### Blocked
|
|
622
|
+
- [blockers, failing commands, or unknowns; otherwise "(none)"]
|
|
623
|
+
|
|
624
|
+
## Next Move
|
|
625
|
+
1. [immediate concrete action, or "(none)"]
|
|
626
|
+
2. [next action if known, or "(none)"]
|
|
627
|
+
|
|
628
|
+
## Relevant Files
|
|
629
|
+
- [file or directory path: why it matters, or "(none)"]
|
|
630
|
+
</template>
|
|
631
|
+
|
|
632
|
+
Rules:
|
|
633
|
+
- Keep every section, even when empty.
|
|
634
|
+
- Use terse bullets, not prose paragraphs.
|
|
635
|
+
- Preserve exact file paths, symbols, commands, error strings, URLs, and \
|
|
636
|
+
identifiers when known.
|
|
637
|
+
- Do not mention the summary process or that context was compacted.\
|
|
638
|
+
"""
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
async def _await_user_response_any(
|
|
642
|
+
bus: object,
|
|
643
|
+
fut: asyncio.Future[Any],
|
|
644
|
+
pending_key: str,
|
|
645
|
+
*,
|
|
646
|
+
timeout_sec: float | None = None,
|
|
647
|
+
) -> dict[str, Any]:
|
|
648
|
+
"""Wait for POST/resolve on ``fut`` with optional bus timeout bookkeeping.
|
|
649
|
+
|
|
650
|
+
Used by the gateway :func:`_await_user_response` and the inspector confirm path.
|
|
651
|
+
"""
|
|
652
|
+
t = timeout_sec if timeout_sec is not None else float(os.environ.get("PENDING_RESPONSE_TIMEOUT_SEC", "300"))
|
|
653
|
+
try:
|
|
654
|
+
return await asyncio.wait_for(asyncio.shield(fut), timeout=t)
|
|
655
|
+
except TimeoutError:
|
|
656
|
+
ap = getattr(bus, "abandon_pending_timeout", None)
|
|
657
|
+
if callable(ap):
|
|
658
|
+
ap(pending_key)
|
|
659
|
+
return {"_timeout": True}
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _flatten_tool_result_for_summary(resp: ToolResponse) -> str:
|
|
663
|
+
parts: list[str] = []
|
|
664
|
+
for b in resp.result:
|
|
665
|
+
if isinstance(b, Text):
|
|
666
|
+
parts.append(summarize_tool_result_text(b.text))
|
|
667
|
+
else:
|
|
668
|
+
parts.append(summarize_tool_result_text(json.dumps(b.to_dict(), sort_keys=True)))
|
|
669
|
+
return "".join(parts) or "(empty)"
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _summary_line_for_message(m: Message) -> str:
|
|
673
|
+
pieces: list[str] = []
|
|
674
|
+
for b in m.content:
|
|
675
|
+
if isinstance(b, Text):
|
|
676
|
+
pieces.append(b.text)
|
|
677
|
+
elif isinstance(b, ToolRequest):
|
|
678
|
+
pieces.append(f"[tool_call: {b.name}({json.dumps(b.args, sort_keys=True)})]")
|
|
679
|
+
elif isinstance(b, ToolResponse):
|
|
680
|
+
body = _flatten_tool_result_for_summary(b)
|
|
681
|
+
tag = "tool_error" if b.is_error else "tool_result"
|
|
682
|
+
pieces.append(f"[{tag} {b.tool_name}: {body}]")
|
|
683
|
+
else:
|
|
684
|
+
pieces.append(f"[{type(b).__name__}]")
|
|
685
|
+
joined = " ".join(pieces) if pieces else "(empty)"
|
|
686
|
+
return f"{m.role}: {joined}"
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _system_prompt_snapshot_text(
|
|
690
|
+
system: Message, mid_conversation_update: str = ""
|
|
691
|
+
) -> str:
|
|
692
|
+
"""Plain string for :class:`SystemPromptSnapshot` (composed prompt + mid-epoch update)."""
|
|
693
|
+
body = "".join(b.text for b in system.content if isinstance(b, Text))
|
|
694
|
+
update = mid_conversation_update.strip()
|
|
695
|
+
if not update:
|
|
696
|
+
return body
|
|
697
|
+
return f"{body}\n\n{update}"
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _is_resume_turn(resolved_messages: Sequence[Message]) -> bool:
|
|
701
|
+
"""True when the model continues after tool results, not a new user question."""
|
|
702
|
+
if not resolved_messages:
|
|
703
|
+
return False
|
|
704
|
+
last = resolved_messages[-1]
|
|
705
|
+
if last.role != "user":
|
|
706
|
+
return False
|
|
707
|
+
has_tool_response = any(isinstance(b, ToolResponse) for b in last.content)
|
|
708
|
+
has_user_text = any(isinstance(b, Text) and b.text.strip() for b in last.content)
|
|
709
|
+
return has_tool_response and not has_user_text
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _is_routine_resume_turn(resolved_messages: Sequence[Message]) -> bool:
|
|
713
|
+
"""Resume turn with only successful tool results (safe to reduce thinking budget)."""
|
|
714
|
+
if not _is_resume_turn(resolved_messages):
|
|
715
|
+
return False
|
|
716
|
+
last = resolved_messages[-1]
|
|
717
|
+
tool_responses = [b for b in last.content if isinstance(b, ToolResponse)]
|
|
718
|
+
return bool(tool_responses) and all(not b.is_error for b in tool_responses)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _stream_thinking_budget(
|
|
722
|
+
provider: Provider,
|
|
723
|
+
resolved_messages: Sequence[Message],
|
|
724
|
+
) -> int | None:
|
|
725
|
+
"""Per-call thinking budget override; None keeps the provider default."""
|
|
726
|
+
if provider.name not in ("gemini", "claude", "ollama"):
|
|
727
|
+
return None
|
|
728
|
+
raw = os.environ.get("MONKEYBOT_RESUME_THINKING_BUDGET", "").strip()
|
|
729
|
+
if not raw:
|
|
730
|
+
return None
|
|
731
|
+
try:
|
|
732
|
+
resume_budget = int(raw)
|
|
733
|
+
except ValueError:
|
|
734
|
+
return None
|
|
735
|
+
if _is_routine_resume_turn(resolved_messages):
|
|
736
|
+
return resume_budget
|
|
737
|
+
return None
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
async def _provider_prompt_input_tokens(
|
|
741
|
+
provider: Provider,
|
|
742
|
+
messages: Sequence[Message],
|
|
743
|
+
tools: Sequence[ToolDef],
|
|
744
|
+
*,
|
|
745
|
+
model: str,
|
|
746
|
+
thinking_budget: int | None = None,
|
|
747
|
+
vertex_google_search: bool = False,
|
|
748
|
+
hints: ProviderCallHints | None = None,
|
|
749
|
+
) -> int:
|
|
750
|
+
return await provider_count_input_tokens(
|
|
751
|
+
provider,
|
|
752
|
+
messages,
|
|
753
|
+
tools,
|
|
754
|
+
model=model,
|
|
755
|
+
thinking_budget=thinking_budget,
|
|
756
|
+
vertex_google_search=vertex_google_search,
|
|
757
|
+
hints=hints,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _provider_call_hints(ctx: TurnContext) -> ProviderCallHints:
|
|
762
|
+
return ProviderCallHints(
|
|
763
|
+
session_id=ctx.thread_id,
|
|
764
|
+
cache_retention=cache_retention_from_env(),
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
async def _prompt_input_tokens_for_history(
|
|
769
|
+
*,
|
|
770
|
+
ctx: TurnContext,
|
|
771
|
+
chat_messages: Sequence[Message],
|
|
772
|
+
provider: Provider,
|
|
773
|
+
attachment_store: AttachmentStore | None,
|
|
774
|
+
attachment_catalog: SessionAttachmentCatalog | None,
|
|
775
|
+
memory_selection: MemoryPromptSelection | None = None,
|
|
776
|
+
extra_system_text: str | None = None,
|
|
777
|
+
vertex_google_search: bool = False,
|
|
778
|
+
epoch: ContextEpochTracker | None = None,
|
|
779
|
+
) -> int:
|
|
780
|
+
"""Provider-accurate prompt size for history rows already persisted (e.g. post-assistant).
|
|
781
|
+
|
|
782
|
+
When ``epoch`` is supplied, the count reflects the true wire shape for the
|
|
783
|
+
*current* epoch state — leading (cached) baseline plus any pending
|
|
784
|
+
mid-conversation update — via a non-mutating :meth:`ContextEpochTracker.peek`.
|
|
785
|
+
Falls back to a flat stable+volatile concatenation when no tracker is given
|
|
786
|
+
(e.g. summarizer/curator calls that have no epoch of their own). Either way,
|
|
787
|
+
the live tracker is never mutated by a budget recount.
|
|
788
|
+
"""
|
|
789
|
+
catalog = (
|
|
790
|
+
attachment_catalog.list_records() if attachment_catalog is not None else None
|
|
791
|
+
)
|
|
792
|
+
stable = compose_stable_baseline(ctx, attachment_catalog=catalog)
|
|
793
|
+
volatile = compose_volatile_tail(
|
|
794
|
+
ctx, chat_messages=chat_messages, memory_selection=memory_selection
|
|
795
|
+
)
|
|
796
|
+
mid_conversation_update = ""
|
|
797
|
+
if epoch is not None:
|
|
798
|
+
admit = epoch.peek(
|
|
799
|
+
stable_baseline=stable,
|
|
800
|
+
volatile_text=volatile,
|
|
801
|
+
stable_fingerprint=fingerprint_text(stable),
|
|
802
|
+
volatile_fingerprint=fingerprint_text(volatile),
|
|
803
|
+
)
|
|
804
|
+
body = admit.leading_system_text
|
|
805
|
+
mid_conversation_update = admit.mid_conversation_update
|
|
806
|
+
else:
|
|
807
|
+
body = stable + volatile
|
|
808
|
+
system = _append_extra_system_text(_system_message_from_text(body), extra_system_text)
|
|
809
|
+
resolved_messages = convert_to_provider(
|
|
810
|
+
chat_messages,
|
|
811
|
+
attachment_store=attachment_store,
|
|
812
|
+
session_id=ctx.thread_id,
|
|
813
|
+
)
|
|
814
|
+
provider_messages = _messages_for_provider(
|
|
815
|
+
system, resolved_messages, mid_conversation_update=mid_conversation_update
|
|
816
|
+
)
|
|
817
|
+
return await _provider_prompt_input_tokens(
|
|
818
|
+
provider, provider_messages, ctx.tools, model=ctx.model,
|
|
819
|
+
vertex_google_search=vertex_google_search,
|
|
820
|
+
hints=_provider_call_hints(ctx),
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _cleanup_spill_files(workspace_root: Path, thread_id: str) -> None:
|
|
825
|
+
spill_path = Path(workspace_root).resolve() / _SPILL_REL / thread_id
|
|
826
|
+
if spill_path.exists():
|
|
827
|
+
shutil.rmtree(spill_path, ignore_errors=True)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _summarization_viable(messages: Sequence[Message]) -> bool:
|
|
831
|
+
return len(messages) > _SUMMARY_KEEP_HEAD + _SUMMARY_KEEP_TAIL
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _summarization_model_id(ctx: TurnContext) -> str:
|
|
835
|
+
"""Model id for history compression; env overrides ``ctx.summarization_model``, then ``ctx.model``."""
|
|
836
|
+
from_env = os.getenv("CONTEXT_SUMMARIZATION_MODEL", "").strip()
|
|
837
|
+
if from_env:
|
|
838
|
+
return from_env
|
|
839
|
+
ctx_sm = (ctx.summarization_model or "").strip()
|
|
840
|
+
if ctx_sm:
|
|
841
|
+
return ctx_sm
|
|
842
|
+
return ctx.model
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
async def _compact_history_if_needed(
|
|
846
|
+
*,
|
|
847
|
+
thread_id: str,
|
|
848
|
+
history: HistoryStore,
|
|
849
|
+
provider: Provider,
|
|
850
|
+
model: str,
|
|
851
|
+
) -> int:
|
|
852
|
+
"""Summarize middle history when tool results exhausted headroom."""
|
|
853
|
+
# Compaction persists via history.reset; use unrepaired rows so synthetic
|
|
854
|
+
# in-memory repairs are never written to the store.
|
|
855
|
+
chat_messages = await history.load(thread_id)
|
|
856
|
+
if not _summarization_viable(chat_messages):
|
|
857
|
+
return 0
|
|
858
|
+
return await _summarize_history(thread_id, chat_messages, history, provider, model)
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
async def _append_budgeted_tool_responses(
|
|
862
|
+
*,
|
|
863
|
+
chunk_responses: list[ContentBlock],
|
|
864
|
+
ctx: TurnContext,
|
|
865
|
+
history: HistoryStore,
|
|
866
|
+
usage: Usage,
|
|
867
|
+
budgeter: ContextBudgeter,
|
|
868
|
+
provider: Provider,
|
|
869
|
+
vertex_google_search: bool = False,
|
|
870
|
+
epoch: ContextEpochTracker | None = None,
|
|
871
|
+
) -> AsyncIterator[AgentEvent]:
|
|
872
|
+
"""Budget tool results against remaining context, append, compact if needed."""
|
|
873
|
+
trimmed, needs_compaction = budgeter.fit_content_blocks(chunk_responses)
|
|
874
|
+
await history.append(
|
|
875
|
+
ctx.thread_id,
|
|
876
|
+
Message(role="user", content=trimmed),
|
|
877
|
+
)
|
|
878
|
+
usage.estimated_prompt_tokens = max(usage.estimated_prompt_tokens, budgeter.used_tokens)
|
|
879
|
+
if not needs_compaction:
|
|
880
|
+
return
|
|
881
|
+
yield ContextSummarizing(
|
|
882
|
+
request_id=ctx.request_id,
|
|
883
|
+
estimated_tokens=usage.estimated_prompt_tokens,
|
|
884
|
+
context_window_tokens=ctx.context_window_tokens,
|
|
885
|
+
)
|
|
886
|
+
try:
|
|
887
|
+
turns_summarized = await _compact_history_if_needed(
|
|
888
|
+
thread_id=ctx.thread_id,
|
|
889
|
+
history=history,
|
|
890
|
+
provider=provider,
|
|
891
|
+
model=_summarization_model_id(ctx),
|
|
892
|
+
)
|
|
893
|
+
except Exception:
|
|
894
|
+
logger.warning(
|
|
895
|
+
"post-tool summarization failed %s; continuing",
|
|
896
|
+
kv(request_id=ctx.request_id, thread_id=ctx.thread_id),
|
|
897
|
+
exc_info=True,
|
|
898
|
+
)
|
|
899
|
+
turns_summarized = 0
|
|
900
|
+
yield ContextSummarized(
|
|
901
|
+
request_id=ctx.request_id,
|
|
902
|
+
turns_summarized=turns_summarized,
|
|
903
|
+
)
|
|
904
|
+
if turns_summarized > 0:
|
|
905
|
+
if epoch is not None:
|
|
906
|
+
epoch.begin_new_epoch()
|
|
907
|
+
chat_messages = await _load_agent_chat_history(history, ctx.thread_id)
|
|
908
|
+
post = await _prompt_input_tokens_for_history(
|
|
909
|
+
ctx=ctx,
|
|
910
|
+
chat_messages=chat_messages,
|
|
911
|
+
provider=provider,
|
|
912
|
+
attachment_store=None,
|
|
913
|
+
attachment_catalog=None,
|
|
914
|
+
vertex_google_search=vertex_google_search,
|
|
915
|
+
epoch=epoch,
|
|
916
|
+
)
|
|
917
|
+
usage.estimated_prompt_tokens = max(usage.estimated_prompt_tokens, post)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
async def _summarize_history(
|
|
921
|
+
thread_id: str,
|
|
922
|
+
messages: list[Message],
|
|
923
|
+
history: HistoryStore,
|
|
924
|
+
provider: Provider,
|
|
925
|
+
model: str,
|
|
926
|
+
) -> int:
|
|
927
|
+
"""Compress middle history into one assistant summary row. Returns middle row count."""
|
|
928
|
+
if not _summarization_viable(messages):
|
|
929
|
+
return 0
|
|
930
|
+
head = messages[:_SUMMARY_KEEP_HEAD]
|
|
931
|
+
tail = messages[-_SUMMARY_KEEP_TAIL :]
|
|
932
|
+
middle = messages[_SUMMARY_KEEP_HEAD : -_SUMMARY_KEEP_TAIL]
|
|
933
|
+
if not middle:
|
|
934
|
+
return 0
|
|
935
|
+
lines = [_summary_line_for_message(m) for m in middle]
|
|
936
|
+
blob = "\n\n---\n\n".join(lines)
|
|
937
|
+
summarize_messages = [
|
|
938
|
+
Message(
|
|
939
|
+
role="system",
|
|
940
|
+
content=[Text(text=_COMPACTION_SUMMARY_SYSTEM)],
|
|
941
|
+
),
|
|
942
|
+
Message(
|
|
943
|
+
role="user",
|
|
944
|
+
content=[
|
|
945
|
+
Text(
|
|
946
|
+
text=(
|
|
947
|
+
"Create a structured summary from the conversation "
|
|
948
|
+
"segment below.\n\n" + blob
|
|
949
|
+
)
|
|
950
|
+
)
|
|
951
|
+
],
|
|
952
|
+
),
|
|
953
|
+
]
|
|
954
|
+
summary_text = ""
|
|
955
|
+
async with aclosing(
|
|
956
|
+
cast(Any, provider.stream(summarize_messages, [], model=model))
|
|
957
|
+
) as stream:
|
|
958
|
+
async for ev in stream:
|
|
959
|
+
if isinstance(ev, TextDelta):
|
|
960
|
+
summary_text += ev.text
|
|
961
|
+
elif isinstance(ev, Done):
|
|
962
|
+
break
|
|
963
|
+
summary_text = summary_text.strip() or "(empty summary)"
|
|
964
|
+
merged = [
|
|
965
|
+
*head,
|
|
966
|
+
Message(
|
|
967
|
+
role="assistant",
|
|
968
|
+
content=[Text(text=f"[Context Summary]:\n{summary_text}")],
|
|
969
|
+
),
|
|
970
|
+
*tail,
|
|
971
|
+
]
|
|
972
|
+
await history.reset(thread_id, merged)
|
|
973
|
+
return len(middle)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _tool_outcome(
|
|
977
|
+
call: ToolCall,
|
|
978
|
+
request_id: str,
|
|
979
|
+
result: ToolExecutionResult,
|
|
980
|
+
) -> tuple[ToolCallResult, ToolResponse]:
|
|
981
|
+
"""Build the (event, history block) pair for a finished tool call.
|
|
982
|
+
|
|
983
|
+
Used by both the sequential and parallel ``task`` dispatch paths so that
|
|
984
|
+
result formatting stays in one place.
|
|
985
|
+
"""
|
|
986
|
+
is_error = result.error is not None
|
|
987
|
+
body = "" if is_error else _blocks_to_sse_summary(result.blocks)
|
|
988
|
+
text = result.error if is_error else body
|
|
989
|
+
response_blocks: list[ContentBlock] = (
|
|
990
|
+
list(result.blocks) if not is_error else [Text(text=text or "")]
|
|
991
|
+
)
|
|
992
|
+
if not is_error:
|
|
993
|
+
budget = resolve_tool_budget(call.name)
|
|
994
|
+
if budget is not None:
|
|
995
|
+
flat = _blocks_to_sse_summary(response_blocks)
|
|
996
|
+
if exceeds_tool_output_budget(flat, tool_name=call.name, budget=budget):
|
|
997
|
+
shaped_blocks: list[ContentBlock] = []
|
|
998
|
+
for block in response_blocks:
|
|
999
|
+
if isinstance(block, Text):
|
|
1000
|
+
shaped = shape_tool_text(
|
|
1001
|
+
block.text,
|
|
1002
|
+
tool_name=call.name,
|
|
1003
|
+
budget=budget,
|
|
1004
|
+
pressure_tier=None,
|
|
1005
|
+
)
|
|
1006
|
+
shaped_blocks.append(
|
|
1007
|
+
Text(text=shaped) if shaped != block.text else block
|
|
1008
|
+
)
|
|
1009
|
+
else:
|
|
1010
|
+
shaped_blocks.append(block)
|
|
1011
|
+
response_blocks = shaped_blocks
|
|
1012
|
+
body = _blocks_to_sse_summary(response_blocks)
|
|
1013
|
+
event = ToolCallResult(
|
|
1014
|
+
request_id=request_id,
|
|
1015
|
+
tool=call.name,
|
|
1016
|
+
result=body,
|
|
1017
|
+
error=result.error,
|
|
1018
|
+
call_id=call.call_id,
|
|
1019
|
+
)
|
|
1020
|
+
response = ToolResponse(
|
|
1021
|
+
id=call.call_id,
|
|
1022
|
+
tool_name=call.name,
|
|
1023
|
+
result=response_blocks,
|
|
1024
|
+
is_error=is_error,
|
|
1025
|
+
)
|
|
1026
|
+
return event, response
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _image_events(
|
|
1030
|
+
request_id: str,
|
|
1031
|
+
call_id: str,
|
|
1032
|
+
result: ToolExecutionResult,
|
|
1033
|
+
) -> list[ImageBlock]:
|
|
1034
|
+
"""SSE image payloads for tool results that include ``Image`` content blocks."""
|
|
1035
|
+
if result.error is not None:
|
|
1036
|
+
return []
|
|
1037
|
+
events: list[ImageBlock] = []
|
|
1038
|
+
for idx, b in enumerate(result.blocks):
|
|
1039
|
+
if not isinstance(b, Image) or not b.data:
|
|
1040
|
+
continue
|
|
1041
|
+
image_id = f"{call_id}:{idx}" if call_id else f"{request_id}:{idx}"
|
|
1042
|
+
events.append(
|
|
1043
|
+
ImageBlock(
|
|
1044
|
+
request_id=request_id,
|
|
1045
|
+
image_id=image_id,
|
|
1046
|
+
mime_type=b.mime_type,
|
|
1047
|
+
data=b.data,
|
|
1048
|
+
)
|
|
1049
|
+
)
|
|
1050
|
+
return events
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def _chunk_tool_calls(
|
|
1054
|
+
ordered: Sequence[ToolCall],
|
|
1055
|
+
*,
|
|
1056
|
+
parallel_safe: frozenset[str] | None = None,
|
|
1057
|
+
) -> list[list[ToolCall]]:
|
|
1058
|
+
"""Split into maximal runs of consecutive parallelizable tools vs serial singles.
|
|
1059
|
+
|
|
1060
|
+
* Consecutive ``task`` calls may run concurrently (bounded by
|
|
1061
|
+
:data:`_MAX_CONCURRENT_SUBAGENTS`).
|
|
1062
|
+
* Consecutive tools in ``parallel_safe`` (read-only) may run concurrently
|
|
1063
|
+
(bounded by :func:`_parallel_tool_concurrency`).
|
|
1064
|
+
* ``task`` never mixes with other parallel-safe tools in one chunk.
|
|
1065
|
+
* Mutating / unmarked tools stay sequential chunk-by-chunk.
|
|
1066
|
+
"""
|
|
1067
|
+
safe = parallel_safe or frozenset()
|
|
1068
|
+
seq = list(ordered)
|
|
1069
|
+
chunks: list[list[ToolCall]] = []
|
|
1070
|
+
i = 0
|
|
1071
|
+
n = len(seq)
|
|
1072
|
+
while i < n:
|
|
1073
|
+
name = seq[i].name
|
|
1074
|
+
if name == "task":
|
|
1075
|
+
j = i + 1
|
|
1076
|
+
while j < n and seq[j].name == "task":
|
|
1077
|
+
j += 1
|
|
1078
|
+
chunks.append(seq[i:j])
|
|
1079
|
+
i = j
|
|
1080
|
+
elif name in safe:
|
|
1081
|
+
j = i + 1
|
|
1082
|
+
while j < n and seq[j].name in safe and seq[j].name != "task":
|
|
1083
|
+
j += 1
|
|
1084
|
+
chunks.append(seq[i:j])
|
|
1085
|
+
i = j
|
|
1086
|
+
else:
|
|
1087
|
+
chunks.append([seq[i]])
|
|
1088
|
+
i += 1
|
|
1089
|
+
return chunks
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
@runtime_checkable
|
|
1093
|
+
class ToolExecutorPort(Protocol):
|
|
1094
|
+
"""Fakeable tool execution boundary (Story 6 does not invoke real shell)."""
|
|
1095
|
+
|
|
1096
|
+
async def execute(self, *, call: ToolCall, ctx: TurnContext) -> ToolExecutionResult:
|
|
1097
|
+
"""Return content blocks for history; ``error`` set on failure."""
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
async def run(
|
|
1101
|
+
user_content: str | list[ContentBlock],
|
|
1102
|
+
ctx: TurnContext,
|
|
1103
|
+
*,
|
|
1104
|
+
provider: Provider,
|
|
1105
|
+
history: HistoryStore,
|
|
1106
|
+
inspectors: list[ToolInspector],
|
|
1107
|
+
tool_executor: ToolExecutorPort,
|
|
1108
|
+
cancelled: asyncio.Event | None = None,
|
|
1109
|
+
max_turns: int | None = None,
|
|
1110
|
+
hook_manager: HookManager | None = None,
|
|
1111
|
+
curator_provider: Provider | None = None,
|
|
1112
|
+
attachment_store: AttachmentStore | None = None,
|
|
1113
|
+
attachment_catalog: SessionAttachmentCatalog | None = None,
|
|
1114
|
+
transcript_writer: TranscriptWriter | None = None,
|
|
1115
|
+
vertex_google_search: bool = False,
|
|
1116
|
+
input_admission: InputAdmission | None = None,
|
|
1117
|
+
) -> AsyncIterator[AgentEvent]:
|
|
1118
|
+
"""Stream agent events for one user message; ends with ``TurnComplete`` (never raises).
|
|
1119
|
+
|
|
1120
|
+
Provider chunks are handled in Gemini-style batches: tool calls accumulate until ``Done``,
|
|
1121
|
+
then execute in lexicographic ``call_id`` order for deterministic replay.
|
|
1122
|
+
|
|
1123
|
+
Consecutive ``task`` tool calls and consecutive ``parallel_safe`` tools in one batch run
|
|
1124
|
+
concurrently (capped); mutating tools run one chunk at a time in order.
|
|
1125
|
+
|
|
1126
|
+
``input_admission`` (optional) supplies mid-turn steer injections drained at safe
|
|
1127
|
+
boundaries (after tool batches / before the next provider call).
|
|
1128
|
+
|
|
1129
|
+
``curator_provider`` is an optional dedicated provider for context curation (e.g. with
|
|
1130
|
+
``thinking_budget=0``). Falls back to ``provider`` when not supplied.
|
|
1131
|
+
|
|
1132
|
+
``vertex_google_search`` opts into Gemini native ``google_search`` grounding for agent
|
|
1133
|
+
turns in this run (main agent or subagent). Only passed through to ``GeminiProvider``;
|
|
1134
|
+
internal harness calls (history summarization, memory organizer) never set this flag.
|
|
1135
|
+
"""
|
|
1136
|
+
usage = Usage()
|
|
1137
|
+
trace_id_capture: list[str | None] = [None]
|
|
1138
|
+
blocks = _normalize_user_content(user_content)
|
|
1139
|
+
effective_max = _effective_max_turns(max_turns)
|
|
1140
|
+
logger.debug(
|
|
1141
|
+
"harness run start %s",
|
|
1142
|
+
kv(
|
|
1143
|
+
request_id=ctx.request_id,
|
|
1144
|
+
thread_id=ctx.thread_id,
|
|
1145
|
+
model=ctx.model,
|
|
1146
|
+
max_turns=effective_max,
|
|
1147
|
+
),
|
|
1148
|
+
)
|
|
1149
|
+
try:
|
|
1150
|
+
async for evt in _run_inner(
|
|
1151
|
+
blocks,
|
|
1152
|
+
ctx,
|
|
1153
|
+
provider=provider,
|
|
1154
|
+
history=history,
|
|
1155
|
+
inspectors=inspectors,
|
|
1156
|
+
tool_executor=tool_executor,
|
|
1157
|
+
cancelled=cancelled,
|
|
1158
|
+
max_turns=max_turns,
|
|
1159
|
+
usage=usage,
|
|
1160
|
+
hook_manager=hook_manager,
|
|
1161
|
+
curator_provider=curator_provider,
|
|
1162
|
+
trace_id_out=trace_id_capture,
|
|
1163
|
+
attachment_store=attachment_store,
|
|
1164
|
+
attachment_catalog=attachment_catalog,
|
|
1165
|
+
transcript_writer=transcript_writer,
|
|
1166
|
+
vertex_google_search=vertex_google_search,
|
|
1167
|
+
input_admission=input_admission,
|
|
1168
|
+
):
|
|
1169
|
+
yield evt
|
|
1170
|
+
except asyncio.CancelledError:
|
|
1171
|
+
try:
|
|
1172
|
+
cur = asyncio.current_task()
|
|
1173
|
+
if cur is not None and getattr(cur, "uncancel", None):
|
|
1174
|
+
cur.uncancel()
|
|
1175
|
+
except Exception:
|
|
1176
|
+
logger.warning(
|
|
1177
|
+
"uncancel cleanup failed %s",
|
|
1178
|
+
kv(request_id=ctx.request_id, thread_id=ctx.thread_id),
|
|
1179
|
+
exc_info=True,
|
|
1180
|
+
)
|
|
1181
|
+
yield Error(request_id=ctx.request_id, error="Request cancelled")
|
|
1182
|
+
except Exception as exc:
|
|
1183
|
+
logger.exception(
|
|
1184
|
+
"harness turn failed %s",
|
|
1185
|
+
kv(request_id=ctx.request_id, thread_id=ctx.thread_id),
|
|
1186
|
+
)
|
|
1187
|
+
yield Error(request_id=ctx.request_id, error=str(exc))
|
|
1188
|
+
finally:
|
|
1189
|
+
await _drain_hook_settlement(hook_manager)
|
|
1190
|
+
logger.debug(
|
|
1191
|
+
"harness run end %s",
|
|
1192
|
+
kv(
|
|
1193
|
+
request_id=ctx.request_id,
|
|
1194
|
+
thread_id=ctx.thread_id,
|
|
1195
|
+
input_tokens=usage.input_tokens,
|
|
1196
|
+
output_tokens=usage.output_tokens,
|
|
1197
|
+
),
|
|
1198
|
+
)
|
|
1199
|
+
yield TurnComplete(
|
|
1200
|
+
request_id=ctx.request_id,
|
|
1201
|
+
usage=_usage_to_totals(usage),
|
|
1202
|
+
trace_id=trace_id_capture[0],
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
async def _run_inner(
|
|
1207
|
+
user_content: list[ContentBlock],
|
|
1208
|
+
ctx: TurnContext,
|
|
1209
|
+
*,
|
|
1210
|
+
provider: Provider,
|
|
1211
|
+
history: HistoryStore,
|
|
1212
|
+
inspectors: list[ToolInspector],
|
|
1213
|
+
tool_executor: ToolExecutorPort,
|
|
1214
|
+
cancelled: asyncio.Event | None,
|
|
1215
|
+
max_turns: int | None,
|
|
1216
|
+
usage: Usage,
|
|
1217
|
+
hook_manager: HookManager | None = None,
|
|
1218
|
+
curator_provider: Provider | None = None,
|
|
1219
|
+
trace_id_out: list[str | None] | None = None,
|
|
1220
|
+
attachment_store: AttachmentStore | None = None,
|
|
1221
|
+
attachment_catalog: SessionAttachmentCatalog | None = None,
|
|
1222
|
+
transcript_writer: TranscriptWriter | None = None,
|
|
1223
|
+
vertex_google_search: bool = False,
|
|
1224
|
+
input_admission: InputAdmission | None = None,
|
|
1225
|
+
) -> AsyncIterator[AgentEvent]:
|
|
1226
|
+
from monkeybot.observability.spans import set_run_output, span_run
|
|
1227
|
+
|
|
1228
|
+
last_assistant: list[str] = [""]
|
|
1229
|
+
user_text = _user_text_from_content(user_content)
|
|
1230
|
+
async with span_run(ctx, user_message=user_text):
|
|
1231
|
+
async for evt in _run_inner_core(
|
|
1232
|
+
user_content,
|
|
1233
|
+
ctx,
|
|
1234
|
+
provider=provider,
|
|
1235
|
+
history=history,
|
|
1236
|
+
inspectors=inspectors,
|
|
1237
|
+
tool_executor=tool_executor,
|
|
1238
|
+
cancelled=cancelled,
|
|
1239
|
+
max_turns=max_turns,
|
|
1240
|
+
usage=usage,
|
|
1241
|
+
hook_manager=hook_manager,
|
|
1242
|
+
curator_provider=curator_provider,
|
|
1243
|
+
last_assistant=last_assistant,
|
|
1244
|
+
attachment_store=attachment_store,
|
|
1245
|
+
attachment_catalog=attachment_catalog,
|
|
1246
|
+
transcript_writer=transcript_writer,
|
|
1247
|
+
vertex_google_search=vertex_google_search,
|
|
1248
|
+
input_admission=input_admission,
|
|
1249
|
+
):
|
|
1250
|
+
yield evt
|
|
1251
|
+
set_run_output(last_assistant[0])
|
|
1252
|
+
if trace_id_out is not None:
|
|
1253
|
+
try:
|
|
1254
|
+
from monkeybot.observability import is_observability_enabled
|
|
1255
|
+
from monkeybot.observability.instrumentation import (
|
|
1256
|
+
get_current_trace_id_hex_optional,
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
if is_observability_enabled():
|
|
1260
|
+
trace_id_out[0] = get_current_trace_id_hex_optional()
|
|
1261
|
+
except ImportError:
|
|
1262
|
+
pass
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
async def _run_inner_core(
|
|
1266
|
+
user_content: list[ContentBlock],
|
|
1267
|
+
ctx: TurnContext,
|
|
1268
|
+
*,
|
|
1269
|
+
provider: Provider,
|
|
1270
|
+
history: HistoryStore,
|
|
1271
|
+
inspectors: list[ToolInspector],
|
|
1272
|
+
tool_executor: ToolExecutorPort,
|
|
1273
|
+
cancelled: asyncio.Event | None,
|
|
1274
|
+
max_turns: int | None,
|
|
1275
|
+
usage: Usage,
|
|
1276
|
+
hook_manager: HookManager | None = None,
|
|
1277
|
+
curator_provider: Provider | None = None,
|
|
1278
|
+
last_assistant: list[str],
|
|
1279
|
+
attachment_store: AttachmentStore | None = None,
|
|
1280
|
+
attachment_catalog: SessionAttachmentCatalog | None = None,
|
|
1281
|
+
transcript_writer: TranscriptWriter | None = None,
|
|
1282
|
+
vertex_google_search: bool = False,
|
|
1283
|
+
input_admission: InputAdmission | None = None,
|
|
1284
|
+
) -> AsyncIterator[AgentEvent]:
|
|
1285
|
+
from monkeybot.observability.spans import (
|
|
1286
|
+
begin_turn_span,
|
|
1287
|
+
end_turn_span,
|
|
1288
|
+
record_tool_outcome,
|
|
1289
|
+
set_llm_io,
|
|
1290
|
+
set_llm_usage,
|
|
1291
|
+
set_summarize_turns,
|
|
1292
|
+
set_turn_io,
|
|
1293
|
+
set_turn_prompt_tokens,
|
|
1294
|
+
span_llm,
|
|
1295
|
+
span_summarize,
|
|
1296
|
+
span_tool,
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
effective_max = _effective_max_turns(max_turns)
|
|
1300
|
+
user_text = _user_text_from_content(user_content)
|
|
1301
|
+
_ = await history.load(ctx.thread_id)
|
|
1302
|
+
if ctx.workspace_root is not None:
|
|
1303
|
+
_cleanup_spill_files(ctx.workspace_root, ctx.thread_id)
|
|
1304
|
+
await history.append(ctx.thread_id, Message(role="user", content=list(user_content)))
|
|
1305
|
+
|
|
1306
|
+
await _fire_hook(
|
|
1307
|
+
hook_manager,
|
|
1308
|
+
event=HookEvent.USER_MESSAGE,
|
|
1309
|
+
ctx=ctx,
|
|
1310
|
+
timeout_s=0,
|
|
1311
|
+
user_message=user_text,
|
|
1312
|
+
)
|
|
1313
|
+
|
|
1314
|
+
turn_index = 0
|
|
1315
|
+
needs_followup_after_tools = False
|
|
1316
|
+
provider_messages_written = 0
|
|
1317
|
+
tools_dirty = False
|
|
1318
|
+
# Final assistant write is backgrounded off the streaming path; awaited at the
|
|
1319
|
+
# turn tail before any history load/reset so the row is never lost.
|
|
1320
|
+
assistant_write_task: asyncio.Task[None] | None = None
|
|
1321
|
+
memory_selection: MemoryPromptSelection | None = None
|
|
1322
|
+
memory_selection_fingerprint: str | None = None
|
|
1323
|
+
pre_turn_extra: str | None = None
|
|
1324
|
+
pre_tool_extra_next: str | None = None
|
|
1325
|
+
doom_tracker = _DoomLoopTracker(
|
|
1326
|
+
threshold=_effective_doom_loop_threshold(),
|
|
1327
|
+
exempt_names=_doom_loop_exempt_names(ctx.tools),
|
|
1328
|
+
)
|
|
1329
|
+
epoch_tracker = ContextEpochTracker()
|
|
1330
|
+
|
|
1331
|
+
def _finish_tool(
|
|
1332
|
+
call: ToolCall,
|
|
1333
|
+
result: ToolExecutionResult,
|
|
1334
|
+
) -> tuple[ToolCallResult, ToolResponse]:
|
|
1335
|
+
event, response = _tool_outcome(call, ctx.request_id, result)
|
|
1336
|
+
doom_tracker.record(call.name, dict(call.args))
|
|
1337
|
+
return event, response
|
|
1338
|
+
|
|
1339
|
+
async def _drain_steers() -> AsyncIterator[AgentEvent]:
|
|
1340
|
+
"""Inject queued steer messages at a safe boundary (before next provider call)."""
|
|
1341
|
+
if input_admission is None:
|
|
1342
|
+
return
|
|
1343
|
+
while True:
|
|
1344
|
+
steered = input_admission.pop_steer()
|
|
1345
|
+
if steered is None:
|
|
1346
|
+
break
|
|
1347
|
+
await history.append(
|
|
1348
|
+
ctx.thread_id, Message(role="user", content=list(steered))
|
|
1349
|
+
)
|
|
1350
|
+
preview = preview_text(steered)
|
|
1351
|
+
logger.info(
|
|
1352
|
+
"steer injected %s",
|
|
1353
|
+
kv(
|
|
1354
|
+
request_id=ctx.request_id,
|
|
1355
|
+
thread_id=ctx.thread_id,
|
|
1356
|
+
preview=preview[:80],
|
|
1357
|
+
),
|
|
1358
|
+
)
|
|
1359
|
+
yield UserSteered(request_id=ctx.request_id, text=preview)
|
|
1360
|
+
|
|
1361
|
+
while turn_index < effective_max:
|
|
1362
|
+
if cancelled is not None and cancelled.is_set():
|
|
1363
|
+
yield Error(request_id=ctx.request_id, error="Request cancelled")
|
|
1364
|
+
needs_followup_after_tools = False
|
|
1365
|
+
break
|
|
1366
|
+
|
|
1367
|
+
async for steer_evt in _drain_steers():
|
|
1368
|
+
yield steer_evt
|
|
1369
|
+
|
|
1370
|
+
# Settlement barrier: fire-and-forget hooks from the prior tool batch
|
|
1371
|
+
# must finish (or time out) before the next provider call.
|
|
1372
|
+
await _drain_hook_settlement(hook_manager)
|
|
1373
|
+
|
|
1374
|
+
turn_index += 1
|
|
1375
|
+
turn_input_text = user_text
|
|
1376
|
+
turn_output_text = ""
|
|
1377
|
+
logger.debug(
|
|
1378
|
+
"inner turn start %s",
|
|
1379
|
+
kv(
|
|
1380
|
+
request_id=ctx.request_id,
|
|
1381
|
+
thread_id=ctx.thread_id,
|
|
1382
|
+
turn=turn_index,
|
|
1383
|
+
estimated_prompt_tokens=usage.estimated_prompt_tokens,
|
|
1384
|
+
),
|
|
1385
|
+
)
|
|
1386
|
+
turn_span = begin_turn_span(
|
|
1387
|
+
turn_index=turn_index,
|
|
1388
|
+
thread_id=ctx.thread_id,
|
|
1389
|
+
request_id=ctx.request_id,
|
|
1390
|
+
)
|
|
1391
|
+
try:
|
|
1392
|
+
yield Thinking(request_id=ctx.request_id)
|
|
1393
|
+
|
|
1394
|
+
if cancelled is not None and cancelled.is_set():
|
|
1395
|
+
yield Error(request_id=ctx.request_id, error="Request cancelled")
|
|
1396
|
+
needs_followup_after_tools = False
|
|
1397
|
+
break
|
|
1398
|
+
|
|
1399
|
+
chat_messages = await _load_agent_chat_history(history, ctx.thread_id)
|
|
1400
|
+
ctx = await refresh_memory_index(ctx)
|
|
1401
|
+
turn_input_text = latest_user_message_text(chat_messages) or user_text
|
|
1402
|
+
set_turn_io(input_value=turn_input_text)
|
|
1403
|
+
|
|
1404
|
+
if turn_index == 1:
|
|
1405
|
+
pre_turn_payload = await _fire_hook(
|
|
1406
|
+
hook_manager,
|
|
1407
|
+
event=HookEvent.PRE_TURN,
|
|
1408
|
+
ctx=ctx,
|
|
1409
|
+
timeout_s=_HOOK_READ_TIMEOUT_S,
|
|
1410
|
+
user_message=user_text,
|
|
1411
|
+
)
|
|
1412
|
+
if pre_turn_payload is not None:
|
|
1413
|
+
pre_turn_extra = pre_turn_payload.inject_text
|
|
1414
|
+
if pre_turn_payload.inject_memory_lines:
|
|
1415
|
+
ctx = dataclasses.replace(
|
|
1416
|
+
ctx,
|
|
1417
|
+
memory_index=[
|
|
1418
|
+
*ctx.memory_index,
|
|
1419
|
+
*pre_turn_payload.inject_memory_lines,
|
|
1420
|
+
],
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
index_fp = memory_index_fingerprint(ctx.memory_index)
|
|
1424
|
+
if memory_selection is None or memory_selection_fingerprint != index_fp:
|
|
1425
|
+
u = latest_user_message_text(chat_messages) or user_text
|
|
1426
|
+
memory_selection = await prepare_memory_for_prompt(
|
|
1427
|
+
ctx=ctx,
|
|
1428
|
+
user_message=u,
|
|
1429
|
+
provider=provider,
|
|
1430
|
+
curator_provider=curator_provider,
|
|
1431
|
+
)
|
|
1432
|
+
memory_selection_fingerprint = index_fp
|
|
1433
|
+
logger.debug(
|
|
1434
|
+
"memory prompt selection %s",
|
|
1435
|
+
kv(
|
|
1436
|
+
request_id=ctx.request_id,
|
|
1437
|
+
thread_id=ctx.thread_id,
|
|
1438
|
+
turn=turn_index,
|
|
1439
|
+
memory_lines=len(memory_selection.lines),
|
|
1440
|
+
total_lines=memory_selection.total_lines,
|
|
1441
|
+
coverage=memory_selection.coverage,
|
|
1442
|
+
confidence=memory_selection.confidence,
|
|
1443
|
+
nudge_search=memory_selection.nudge_search,
|
|
1444
|
+
),
|
|
1445
|
+
)
|
|
1446
|
+
|
|
1447
|
+
admit = _admit_system_context(
|
|
1448
|
+
epoch_tracker,
|
|
1449
|
+
ctx,
|
|
1450
|
+
chat_messages,
|
|
1451
|
+
memory_selection=memory_selection,
|
|
1452
|
+
attachment_catalog=attachment_catalog,
|
|
1453
|
+
)
|
|
1454
|
+
for epoch_evt in _epoch_events(
|
|
1455
|
+
admit, request_id=ctx.request_id, thread_id=ctx.thread_id
|
|
1456
|
+
):
|
|
1457
|
+
yield epoch_evt
|
|
1458
|
+
system = _system_message_from_text(admit.leading_system_text)
|
|
1459
|
+
combined_extra = _combine_extras(pre_turn_extra, pre_tool_extra_next)
|
|
1460
|
+
force_no_tools, doom_loop_note = doom_tracker.consume_recovery()
|
|
1461
|
+
combined_extra = _combine_extras(combined_extra, doom_loop_note)
|
|
1462
|
+
system = _append_extra_system_text(system, combined_extra)
|
|
1463
|
+
pre_tool_extra_next = None
|
|
1464
|
+
turn_tools: Sequence[ToolDef] = () if force_no_tools else ctx.tools
|
|
1465
|
+
tool_def_payload = await _fire_hook(
|
|
1466
|
+
hook_manager,
|
|
1467
|
+
event=HookEvent.TOOL_DEFINITION,
|
|
1468
|
+
ctx=ctx,
|
|
1469
|
+
timeout_s=_HOOK_READ_TIMEOUT_S,
|
|
1470
|
+
tools=list(turn_tools),
|
|
1471
|
+
inner_turn=turn_index,
|
|
1472
|
+
)
|
|
1473
|
+
if tool_def_payload is not None and tool_def_payload.tools is not None:
|
|
1474
|
+
turn_tools = list(tool_def_payload.tools)
|
|
1475
|
+
tools_dirty = True
|
|
1476
|
+
logger.debug(
|
|
1477
|
+
"tool.definition hook replaced tools %s",
|
|
1478
|
+
kv(
|
|
1479
|
+
request_id=ctx.request_id,
|
|
1480
|
+
thread_id=ctx.thread_id,
|
|
1481
|
+
turn=turn_index,
|
|
1482
|
+
tool_count=len(turn_tools),
|
|
1483
|
+
),
|
|
1484
|
+
)
|
|
1485
|
+
# Preflight uses unshaped history; pressure shaping applied after token count.
|
|
1486
|
+
resolved_messages = convert_to_provider(
|
|
1487
|
+
chat_messages,
|
|
1488
|
+
attachment_store=attachment_store,
|
|
1489
|
+
session_id=ctx.thread_id,
|
|
1490
|
+
)
|
|
1491
|
+
provider_messages = _messages_for_provider(
|
|
1492
|
+
system,
|
|
1493
|
+
resolved_messages,
|
|
1494
|
+
mid_conversation_update=admit.mid_conversation_update,
|
|
1495
|
+
)
|
|
1496
|
+
|
|
1497
|
+
stream_thinking = _stream_thinking_budget(provider, resolved_messages)
|
|
1498
|
+
preflight = await _provider_prompt_input_tokens(
|
|
1499
|
+
provider,
|
|
1500
|
+
provider_messages,
|
|
1501
|
+
turn_tools,
|
|
1502
|
+
model=ctx.model,
|
|
1503
|
+
thinking_budget=stream_thinking,
|
|
1504
|
+
vertex_google_search=vertex_google_search,
|
|
1505
|
+
hints=_provider_call_hints(ctx),
|
|
1506
|
+
)
|
|
1507
|
+
usage.estimated_prompt_tokens = max(usage.estimated_prompt_tokens, preflight)
|
|
1508
|
+
cap = max(1, int(ctx.context_window_tokens * _SUMMARY_TRIGGER_RATIO))
|
|
1509
|
+
if preflight >= cap and _summarization_viable(chat_messages):
|
|
1510
|
+
logger.debug(
|
|
1511
|
+
"summarization triggered %s",
|
|
1512
|
+
kv(
|
|
1513
|
+
request_id=ctx.request_id,
|
|
1514
|
+
thread_id=ctx.thread_id,
|
|
1515
|
+
turn=turn_index,
|
|
1516
|
+
preflight=preflight,
|
|
1517
|
+
cap=cap,
|
|
1518
|
+
),
|
|
1519
|
+
)
|
|
1520
|
+
yield ContextSummarizing(
|
|
1521
|
+
request_id=ctx.request_id,
|
|
1522
|
+
estimated_tokens=preflight,
|
|
1523
|
+
context_window_tokens=ctx.context_window_tokens,
|
|
1524
|
+
)
|
|
1525
|
+
async with span_summarize(
|
|
1526
|
+
thread_id=ctx.thread_id,
|
|
1527
|
+
request_id=ctx.request_id,
|
|
1528
|
+
):
|
|
1529
|
+
try:
|
|
1530
|
+
turns_summarized = await _summarize_history(
|
|
1531
|
+
ctx.thread_id,
|
|
1532
|
+
chat_messages,
|
|
1533
|
+
history,
|
|
1534
|
+
provider,
|
|
1535
|
+
_summarization_model_id(ctx),
|
|
1536
|
+
)
|
|
1537
|
+
except Exception:
|
|
1538
|
+
logger.warning(
|
|
1539
|
+
"summarization failed %s; continuing",
|
|
1540
|
+
kv(request_id=ctx.request_id, thread_id=ctx.thread_id),
|
|
1541
|
+
exc_info=True,
|
|
1542
|
+
)
|
|
1543
|
+
turns_summarized = 0
|
|
1544
|
+
set_summarize_turns(turns_summarized)
|
|
1545
|
+
yield ContextSummarized(
|
|
1546
|
+
request_id=ctx.request_id,
|
|
1547
|
+
turns_summarized=turns_summarized,
|
|
1548
|
+
)
|
|
1549
|
+
logger.debug(
|
|
1550
|
+
"summarization done %s",
|
|
1551
|
+
kv(
|
|
1552
|
+
request_id=ctx.request_id,
|
|
1553
|
+
thread_id=ctx.thread_id,
|
|
1554
|
+
turn=turn_index,
|
|
1555
|
+
turns_summarized=turns_summarized,
|
|
1556
|
+
),
|
|
1557
|
+
)
|
|
1558
|
+
if turns_summarized > 0:
|
|
1559
|
+
epoch_tracker.begin_new_epoch()
|
|
1560
|
+
chat_messages = await _load_agent_chat_history(history, ctx.thread_id)
|
|
1561
|
+
ctx = await refresh_memory_index(ctx)
|
|
1562
|
+
admit = _admit_system_context(
|
|
1563
|
+
epoch_tracker,
|
|
1564
|
+
ctx,
|
|
1565
|
+
chat_messages,
|
|
1566
|
+
memory_selection=memory_selection,
|
|
1567
|
+
attachment_catalog=attachment_catalog,
|
|
1568
|
+
)
|
|
1569
|
+
for epoch_evt in _epoch_events(
|
|
1570
|
+
admit, request_id=ctx.request_id, thread_id=ctx.thread_id
|
|
1571
|
+
):
|
|
1572
|
+
yield epoch_evt
|
|
1573
|
+
system = _append_extra_system_text(
|
|
1574
|
+
_system_message_from_text(admit.leading_system_text),
|
|
1575
|
+
pre_turn_extra,
|
|
1576
|
+
)
|
|
1577
|
+
resolved_messages = convert_to_provider(
|
|
1578
|
+
chat_messages,
|
|
1579
|
+
attachment_store=attachment_store,
|
|
1580
|
+
session_id=ctx.thread_id,
|
|
1581
|
+
)
|
|
1582
|
+
provider_messages = _messages_for_provider(
|
|
1583
|
+
system,
|
|
1584
|
+
resolved_messages,
|
|
1585
|
+
mid_conversation_update=admit.mid_conversation_update,
|
|
1586
|
+
)
|
|
1587
|
+
post = await _provider_prompt_input_tokens(
|
|
1588
|
+
provider,
|
|
1589
|
+
provider_messages,
|
|
1590
|
+
turn_tools,
|
|
1591
|
+
model=ctx.model,
|
|
1592
|
+
thinking_budget=_stream_thinking_budget(provider, resolved_messages),
|
|
1593
|
+
vertex_google_search=vertex_google_search,
|
|
1594
|
+
hints=_provider_call_hints(ctx),
|
|
1595
|
+
)
|
|
1596
|
+
usage.estimated_prompt_tokens = max(usage.estimated_prompt_tokens, post)
|
|
1597
|
+
|
|
1598
|
+
pressure_tier = compute_context_pressure_tier(
|
|
1599
|
+
usage.estimated_prompt_tokens,
|
|
1600
|
+
ctx.context_window_tokens,
|
|
1601
|
+
)
|
|
1602
|
+
if pressure_tier in ("moderate", "aggressive"):
|
|
1603
|
+
resolved_messages = convert_to_provider(
|
|
1604
|
+
chat_messages,
|
|
1605
|
+
attachment_store=attachment_store,
|
|
1606
|
+
session_id=ctx.thread_id,
|
|
1607
|
+
pressure_tier=pressure_tier,
|
|
1608
|
+
protect_recent=_SUMMARY_KEEP_TAIL,
|
|
1609
|
+
)
|
|
1610
|
+
provider_messages = _messages_for_provider(
|
|
1611
|
+
system,
|
|
1612
|
+
resolved_messages,
|
|
1613
|
+
mid_conversation_update=admit.mid_conversation_update,
|
|
1614
|
+
)
|
|
1615
|
+
|
|
1616
|
+
before_payload = await _fire_hook(
|
|
1617
|
+
hook_manager,
|
|
1618
|
+
event=HookEvent.BEFORE_PROVIDER_REQUEST,
|
|
1619
|
+
ctx=ctx,
|
|
1620
|
+
timeout_s=_HOOK_READ_TIMEOUT_S,
|
|
1621
|
+
tools=list(turn_tools),
|
|
1622
|
+
provider_messages=list(provider_messages),
|
|
1623
|
+
inner_turn=turn_index,
|
|
1624
|
+
)
|
|
1625
|
+
prev_msg_count = len(provider_messages)
|
|
1626
|
+
provider_messages, turn_tools, tools_replaced = _apply_before_provider_hook(
|
|
1627
|
+
before_payload, provider_messages, turn_tools
|
|
1628
|
+
)
|
|
1629
|
+
if tools_replaced:
|
|
1630
|
+
tools_dirty = True
|
|
1631
|
+
if before_payload is not None and (
|
|
1632
|
+
tools_replaced
|
|
1633
|
+
or before_payload.provider_messages is not None
|
|
1634
|
+
):
|
|
1635
|
+
logger.debug(
|
|
1636
|
+
"before_provider_request hook rewrote request %s",
|
|
1637
|
+
kv(
|
|
1638
|
+
request_id=ctx.request_id,
|
|
1639
|
+
thread_id=ctx.thread_id,
|
|
1640
|
+
turn=turn_index,
|
|
1641
|
+
tools_replaced=tools_replaced,
|
|
1642
|
+
message_count=len(provider_messages),
|
|
1643
|
+
prev_message_count=prev_msg_count,
|
|
1644
|
+
),
|
|
1645
|
+
)
|
|
1646
|
+
|
|
1647
|
+
yield SystemPromptSnapshot(
|
|
1648
|
+
request_id=ctx.request_id,
|
|
1649
|
+
inner_turn=turn_index,
|
|
1650
|
+
text=_system_prompt_snapshot_text(system, admit.mid_conversation_update),
|
|
1651
|
+
)
|
|
1652
|
+
|
|
1653
|
+
llm_input = 0
|
|
1654
|
+
llm_output = 0
|
|
1655
|
+
llm_cached = 0
|
|
1656
|
+
llm_cache_read = 0
|
|
1657
|
+
llm_cache_creation = 0
|
|
1658
|
+
if transcript_writer is not None:
|
|
1659
|
+
if provider_messages_written >= len(provider_messages):
|
|
1660
|
+
delta_messages = provider_messages
|
|
1661
|
+
message_offset = 0
|
|
1662
|
+
messages_reset = provider_messages_written > 0
|
|
1663
|
+
else:
|
|
1664
|
+
delta_messages = provider_messages[provider_messages_written:]
|
|
1665
|
+
message_offset = provider_messages_written
|
|
1666
|
+
messages_reset = False
|
|
1667
|
+
include_tools = turn_index == 1 or messages_reset or tools_dirty
|
|
1668
|
+
await transcript_writer.write_provider_request(
|
|
1669
|
+
request_id=ctx.request_id,
|
|
1670
|
+
inner_turn=turn_index,
|
|
1671
|
+
model=ctx.model,
|
|
1672
|
+
messages=[m.to_dict() for m in delta_messages],
|
|
1673
|
+
message_offset=message_offset,
|
|
1674
|
+
messages_reset=messages_reset,
|
|
1675
|
+
tools=[dataclasses.asdict(t) for t in turn_tools] if include_tools else None,
|
|
1676
|
+
thinking_budget=stream_thinking,
|
|
1677
|
+
)
|
|
1678
|
+
provider_messages_written = len(provider_messages)
|
|
1679
|
+
tools_dirty = False
|
|
1680
|
+
stream_mapper = ProviderStreamMapper(ctx.request_id)
|
|
1681
|
+
try:
|
|
1682
|
+
async with span_llm(ctx=ctx, vertex_google_search=vertex_google_search):
|
|
1683
|
+
async with aclosing(
|
|
1684
|
+
cast(
|
|
1685
|
+
Any,
|
|
1686
|
+
provider_stream(
|
|
1687
|
+
provider,
|
|
1688
|
+
provider_messages,
|
|
1689
|
+
turn_tools,
|
|
1690
|
+
model=ctx.model,
|
|
1691
|
+
thinking_budget=stream_thinking,
|
|
1692
|
+
vertex_google_search=vertex_google_search,
|
|
1693
|
+
hints=_provider_call_hints(ctx),
|
|
1694
|
+
),
|
|
1695
|
+
)
|
|
1696
|
+
) as stream:
|
|
1697
|
+
async for ev in stream:
|
|
1698
|
+
if isinstance(ev, UsageEvent):
|
|
1699
|
+
_merge_usage_event(usage, ev)
|
|
1700
|
+
usage.cost_usd += estimate_cost(
|
|
1701
|
+
ctx.model,
|
|
1702
|
+
ev.input_tokens,
|
|
1703
|
+
ev.output_tokens,
|
|
1704
|
+
cache_read_tokens=ev.cache_read_tokens,
|
|
1705
|
+
cache_creation_tokens=ev.cache_creation_tokens,
|
|
1706
|
+
)
|
|
1707
|
+
llm_input += ev.input_tokens
|
|
1708
|
+
llm_output += ev.output_tokens
|
|
1709
|
+
llm_cached += ev.cached_tokens
|
|
1710
|
+
llm_cache_read += ev.cache_read_tokens
|
|
1711
|
+
llm_cache_creation += ev.cache_creation_tokens
|
|
1712
|
+
continue
|
|
1713
|
+
if isinstance(ev, Done):
|
|
1714
|
+
for aev in stream_mapper.map(ev):
|
|
1715
|
+
yield aev
|
|
1716
|
+
break
|
|
1717
|
+
for aev in stream_mapper.map(ev):
|
|
1718
|
+
yield aev
|
|
1719
|
+
for aev in stream_mapper.finish():
|
|
1720
|
+
yield aev
|
|
1721
|
+
pending = stream_mapper.pending
|
|
1722
|
+
assistant_text = stream_mapper.assistant_text
|
|
1723
|
+
thinking_text = stream_mapper.thinking_text
|
|
1724
|
+
thinking_signature = stream_mapper.thinking_signature
|
|
1725
|
+
stream_truncated = stream_mapper.stream_truncated
|
|
1726
|
+
set_llm_usage(
|
|
1727
|
+
input_tokens=llm_input,
|
|
1728
|
+
output_tokens=llm_output,
|
|
1729
|
+
cached_tokens=llm_cached,
|
|
1730
|
+
cache_read_tokens=llm_cache_read,
|
|
1731
|
+
cache_creation_tokens=llm_cache_creation,
|
|
1732
|
+
)
|
|
1733
|
+
set_llm_io(
|
|
1734
|
+
prompt=_provider_messages_prompt_summary(provider_messages),
|
|
1735
|
+
completion=assistant_text or "",
|
|
1736
|
+
)
|
|
1737
|
+
if transcript_writer is not None:
|
|
1738
|
+
await transcript_writer.write_provider_response(
|
|
1739
|
+
request_id=ctx.request_id,
|
|
1740
|
+
inner_turn=turn_index,
|
|
1741
|
+
model=ctx.model,
|
|
1742
|
+
text=assistant_text,
|
|
1743
|
+
thinking=thinking_text,
|
|
1744
|
+
tool_requests=[
|
|
1745
|
+
{"call_id": tc.call_id, "name": tc.name, "args": tc.args}
|
|
1746
|
+
for tc in pending.values()
|
|
1747
|
+
],
|
|
1748
|
+
usage={
|
|
1749
|
+
"input_tokens": llm_input,
|
|
1750
|
+
"output_tokens": llm_output,
|
|
1751
|
+
"cached_tokens": llm_cached,
|
|
1752
|
+
"cache_read_tokens": llm_cache_read,
|
|
1753
|
+
"cache_creation_tokens": llm_cache_creation,
|
|
1754
|
+
},
|
|
1755
|
+
)
|
|
1756
|
+
logger.debug(
|
|
1757
|
+
"provider stream done %s",
|
|
1758
|
+
kv(
|
|
1759
|
+
request_id=ctx.request_id,
|
|
1760
|
+
thread_id=ctx.thread_id,
|
|
1761
|
+
turn=turn_index,
|
|
1762
|
+
llm_input=llm_input,
|
|
1763
|
+
llm_output=llm_output,
|
|
1764
|
+
llm_cached=llm_cached,
|
|
1765
|
+
tool_calls=len(pending),
|
|
1766
|
+
),
|
|
1767
|
+
)
|
|
1768
|
+
await _fire_after_provider_response(
|
|
1769
|
+
hook_manager,
|
|
1770
|
+
ctx=ctx,
|
|
1771
|
+
inner_turn=turn_index,
|
|
1772
|
+
assistant_text=assistant_text,
|
|
1773
|
+
thinking_text=thinking_text,
|
|
1774
|
+
tool_requests=[
|
|
1775
|
+
{"call_id": tc.call_id, "name": tc.name, "args": dict(tc.args)}
|
|
1776
|
+
for tc in pending.values()
|
|
1777
|
+
],
|
|
1778
|
+
usage={
|
|
1779
|
+
"input_tokens": llm_input,
|
|
1780
|
+
"output_tokens": llm_output,
|
|
1781
|
+
"cached_tokens": llm_cached,
|
|
1782
|
+
"cache_read_tokens": llm_cache_read,
|
|
1783
|
+
"cache_creation_tokens": llm_cache_creation,
|
|
1784
|
+
},
|
|
1785
|
+
)
|
|
1786
|
+
except asyncio.CancelledError:
|
|
1787
|
+
for aev in stream_mapper.finish():
|
|
1788
|
+
yield aev
|
|
1789
|
+
await _fire_after_provider_response(
|
|
1790
|
+
hook_manager,
|
|
1791
|
+
ctx=ctx,
|
|
1792
|
+
inner_turn=turn_index,
|
|
1793
|
+
provider_error="Request cancelled",
|
|
1794
|
+
)
|
|
1795
|
+
yield Error(request_id=ctx.request_id, error="Request cancelled")
|
|
1796
|
+
needs_followup_after_tools = False
|
|
1797
|
+
return
|
|
1798
|
+
except Exception as exc:
|
|
1799
|
+
for aev in stream_mapper.finish():
|
|
1800
|
+
yield aev
|
|
1801
|
+
logger.exception(
|
|
1802
|
+
"provider stream failed %s",
|
|
1803
|
+
kv(request_id=ctx.request_id, thread_id=ctx.thread_id, model=ctx.model),
|
|
1804
|
+
)
|
|
1805
|
+
await _fire_after_provider_response(
|
|
1806
|
+
hook_manager,
|
|
1807
|
+
ctx=ctx,
|
|
1808
|
+
inner_turn=turn_index,
|
|
1809
|
+
provider_error=str(exc),
|
|
1810
|
+
)
|
|
1811
|
+
yield Error(request_id=ctx.request_id, error=str(exc))
|
|
1812
|
+
needs_followup_after_tools = False
|
|
1813
|
+
return
|
|
1814
|
+
|
|
1815
|
+
if cancelled is not None and cancelled.is_set():
|
|
1816
|
+
yield Error(request_id=ctx.request_id, error="Request cancelled")
|
|
1817
|
+
needs_followup_after_tools = False
|
|
1818
|
+
break
|
|
1819
|
+
|
|
1820
|
+
if not pending:
|
|
1821
|
+
cleaned_text = (assistant_text or "").strip()
|
|
1822
|
+
if cleaned_text:
|
|
1823
|
+
# Fire-and-forget: this is the final assistant turn (we break
|
|
1824
|
+
# right after), so nothing reads it again in-loop. Keeping it
|
|
1825
|
+
# off the await path means the Firestore write does not delay
|
|
1826
|
+
# TurnComplete / the user-visible reply. Awaited at the tail.
|
|
1827
|
+
assistant_write_task = asyncio.create_task(
|
|
1828
|
+
history.append(
|
|
1829
|
+
ctx.thread_id,
|
|
1830
|
+
Message(role="assistant", content=[Text(text=cleaned_text)]),
|
|
1831
|
+
)
|
|
1832
|
+
)
|
|
1833
|
+
last_assistant[0] = cleaned_text
|
|
1834
|
+
turn_output_text = cleaned_text
|
|
1835
|
+
needs_followup_after_tools = False
|
|
1836
|
+
break
|
|
1837
|
+
# Model returned no text after tool results (or only whitespace). Without another
|
|
1838
|
+
# provider round the user sees tools then silence — retry until turn budget.
|
|
1839
|
+
rows = await _load_agent_chat_history(history, ctx.thread_id)
|
|
1840
|
+
owes_tool_followup = needs_followup_after_tools or (
|
|
1841
|
+
bool(rows)
|
|
1842
|
+
and rows[-1].role == "user"
|
|
1843
|
+
and any(isinstance(b, ToolResponse) for b in rows[-1].content)
|
|
1844
|
+
)
|
|
1845
|
+
if owes_tool_followup and turn_index < effective_max:
|
|
1846
|
+
continue
|
|
1847
|
+
needs_followup_after_tools = False
|
|
1848
|
+
break
|
|
1849
|
+
|
|
1850
|
+
ordered = sorted(pending.values(), key=lambda c: c.call_id)
|
|
1851
|
+
assist_blocks: list[ContentBlock] = []
|
|
1852
|
+
if thinking_text.strip():
|
|
1853
|
+
assist_blocks.append(
|
|
1854
|
+
ThinkingBlock(thinking=thinking_text, signature=thinking_signature or "")
|
|
1855
|
+
)
|
|
1856
|
+
prose = (assistant_text or "").strip()
|
|
1857
|
+
if prose:
|
|
1858
|
+
assist_blocks.append(Text(text=prose))
|
|
1859
|
+
for c in ordered:
|
|
1860
|
+
assist_blocks.append(
|
|
1861
|
+
ToolRequest(
|
|
1862
|
+
id=c.call_id,
|
|
1863
|
+
name=c.name,
|
|
1864
|
+
args=dict(c.args),
|
|
1865
|
+
parse_error=c.parse_error,
|
|
1866
|
+
metadata=dict(c.metadata) if c.metadata else None,
|
|
1867
|
+
)
|
|
1868
|
+
)
|
|
1869
|
+
await history.append(
|
|
1870
|
+
ctx.thread_id,
|
|
1871
|
+
Message(role="assistant", content=assist_blocks),
|
|
1872
|
+
)
|
|
1873
|
+
|
|
1874
|
+
needs_followup_after_tools = True
|
|
1875
|
+
|
|
1876
|
+
# One user Message for the whole model tool-call turn (all chunks).
|
|
1877
|
+
# Gemini 400s when functionResponse count != functionCall count on the
|
|
1878
|
+
# preceding model turn; serial non-task tools are separate chunks but
|
|
1879
|
+
# must still share a single user row.
|
|
1880
|
+
all_tool_responses: list[ContentBlock] = []
|
|
1881
|
+
mcp_registry_mutated = False
|
|
1882
|
+
# Computed once per batch and reused for both chunking and dispatch
|
|
1883
|
+
# so the two stay consistent even if ctx.tools were ever mutated
|
|
1884
|
+
# mid-batch (e.g. by an MCP registry reload).
|
|
1885
|
+
safe_names = _parallel_safe_names(ctx.tools)
|
|
1886
|
+
reject_batch = _should_reject_tool_batch(ordered, truncated=stream_truncated)
|
|
1887
|
+
if reject_batch:
|
|
1888
|
+
logger.warning(
|
|
1889
|
+
"rejecting truncated/incomplete tool batch %s",
|
|
1890
|
+
kv(
|
|
1891
|
+
request_id=ctx.request_id,
|
|
1892
|
+
thread_id=ctx.thread_id,
|
|
1893
|
+
truncated=stream_truncated,
|
|
1894
|
+
n_calls=len(ordered),
|
|
1895
|
+
),
|
|
1896
|
+
)
|
|
1897
|
+
for call in ordered:
|
|
1898
|
+
err = _rejected_tool_batch_error(call, truncated=stream_truncated)
|
|
1899
|
+
yield ToolCallStarted(
|
|
1900
|
+
request_id=ctx.request_id,
|
|
1901
|
+
tool=call.name,
|
|
1902
|
+
label=call.name,
|
|
1903
|
+
args=dict(call.args),
|
|
1904
|
+
parse_error=call.parse_error,
|
|
1905
|
+
)
|
|
1906
|
+
result_evt, tool_resp = _finish_tool(
|
|
1907
|
+
call, ToolExecutionResult.err(err)
|
|
1908
|
+
)
|
|
1909
|
+
yield result_evt
|
|
1910
|
+
all_tool_responses.append(tool_resp)
|
|
1911
|
+
|
|
1912
|
+
for chunk in (() if reject_batch else _chunk_tool_calls(
|
|
1913
|
+
ordered, parallel_safe=safe_names
|
|
1914
|
+
)):
|
|
1915
|
+
if cancelled is not None and cancelled.is_set():
|
|
1916
|
+
yield Error(request_id=ctx.request_id, error="Request cancelled")
|
|
1917
|
+
needs_followup_after_tools = False
|
|
1918
|
+
return
|
|
1919
|
+
|
|
1920
|
+
allowed_exec: list[ToolCall] = []
|
|
1921
|
+
# Collect ToolResponse blocks for this chunk; merged into
|
|
1922
|
+
# ``all_tool_responses`` after every chunk completes.
|
|
1923
|
+
chunk_responses: list[ContentBlock] = []
|
|
1924
|
+
|
|
1925
|
+
for call in chunk:
|
|
1926
|
+
if cancelled is not None and cancelled.is_set():
|
|
1927
|
+
yield Error(request_id=ctx.request_id, error="Request cancelled")
|
|
1928
|
+
needs_followup_after_tools = False
|
|
1929
|
+
return
|
|
1930
|
+
|
|
1931
|
+
# A provider couldn't parse the streamed tool JSON: args are
|
|
1932
|
+
# empty and ``parse_error`` holds the reason. Surface it as a
|
|
1933
|
+
# tool error result so the model can self-correct instead of
|
|
1934
|
+
# executing the tool with empty arguments.
|
|
1935
|
+
if call.parse_error:
|
|
1936
|
+
yield ToolCallStarted(
|
|
1937
|
+
request_id=ctx.request_id,
|
|
1938
|
+
tool=call.name,
|
|
1939
|
+
label=call.name,
|
|
1940
|
+
args=dict(call.args),
|
|
1941
|
+
parse_error=call.parse_error,
|
|
1942
|
+
call_id=call.call_id,
|
|
1943
|
+
)
|
|
1944
|
+
result_evt, tool_resp = _finish_tool(
|
|
1945
|
+
call, ToolExecutionResult.err(call.parse_error)
|
|
1946
|
+
)
|
|
1947
|
+
yield result_evt
|
|
1948
|
+
chunk_responses.append(tool_resp)
|
|
1949
|
+
continue
|
|
1950
|
+
|
|
1951
|
+
inspector_call = InspectorToolCall(
|
|
1952
|
+
call_id=call.call_id, name=call.name, args=dict(call.args)
|
|
1953
|
+
)
|
|
1954
|
+
allowed = True
|
|
1955
|
+
denial_message: str | None = None
|
|
1956
|
+
for insp in inspectors:
|
|
1957
|
+
decision = await insp.check(inspector_call, ctx)
|
|
1958
|
+
match decision.kind:
|
|
1959
|
+
case "allow":
|
|
1960
|
+
pass
|
|
1961
|
+
case "deny":
|
|
1962
|
+
allowed = False
|
|
1963
|
+
denial_message = decision.message
|
|
1964
|
+
logger.debug(
|
|
1965
|
+
"tool inspector deny %s",
|
|
1966
|
+
kv(
|
|
1967
|
+
request_id=ctx.request_id,
|
|
1968
|
+
thread_id=ctx.thread_id,
|
|
1969
|
+
tool=call.name,
|
|
1970
|
+
call_id=call.call_id,
|
|
1971
|
+
decision="deny",
|
|
1972
|
+
),
|
|
1973
|
+
)
|
|
1974
|
+
break
|
|
1975
|
+
case "confirm":
|
|
1976
|
+
if ctx.sse_bus is None:
|
|
1977
|
+
allowed = False
|
|
1978
|
+
denial_message = (
|
|
1979
|
+
decision.message
|
|
1980
|
+
or "Confirmation required but no SSE session is available"
|
|
1981
|
+
)
|
|
1982
|
+
break
|
|
1983
|
+
bus = ctx.sse_bus
|
|
1984
|
+
fut = bus.register_pending(call.call_id)
|
|
1985
|
+
yield ToolConfirmationRequestEvent(
|
|
1986
|
+
request_id=ctx.request_id,
|
|
1987
|
+
tool_call_id=call.call_id,
|
|
1988
|
+
tool_name=call.name,
|
|
1989
|
+
arguments=dict(call.args),
|
|
1990
|
+
prompt=decision.message,
|
|
1991
|
+
)
|
|
1992
|
+
try:
|
|
1993
|
+
payload = await _await_user_response_any(
|
|
1994
|
+
bus, fut, call.call_id, timeout_sec=None
|
|
1995
|
+
)
|
|
1996
|
+
except asyncio.CancelledError:
|
|
1997
|
+
# Re-raise so turn cancellation (client disconnect /
|
|
1998
|
+
# abort) propagates; do not continue the tool loop
|
|
1999
|
+
# on a dead session.
|
|
2000
|
+
raise
|
|
2001
|
+
if payload.get("_timeout"):
|
|
2002
|
+
allowed = False
|
|
2003
|
+
to = int(
|
|
2004
|
+
float(os.environ.get("PENDING_RESPONSE_TIMEOUT_SEC", "300"))
|
|
2005
|
+
)
|
|
2006
|
+
denial_message = f"user did not respond within {to}s"
|
|
2007
|
+
break
|
|
2008
|
+
if payload.get("approved"):
|
|
2009
|
+
allowed = True
|
|
2010
|
+
if payload.get("always"):
|
|
2011
|
+
remember_always_approval(
|
|
2012
|
+
bus, call.name, resource_for_call(inspector_call)
|
|
2013
|
+
)
|
|
2014
|
+
logger.debug(
|
|
2015
|
+
"tool inspector confirm %s",
|
|
2016
|
+
kv(
|
|
2017
|
+
request_id=ctx.request_id,
|
|
2018
|
+
thread_id=ctx.thread_id,
|
|
2019
|
+
tool=call.name,
|
|
2020
|
+
call_id=call.call_id,
|
|
2021
|
+
decision="confirm_always",
|
|
2022
|
+
),
|
|
2023
|
+
)
|
|
2024
|
+
logger.debug(
|
|
2025
|
+
"tool inspector confirm %s",
|
|
2026
|
+
kv(
|
|
2027
|
+
request_id=ctx.request_id,
|
|
2028
|
+
thread_id=ctx.thread_id,
|
|
2029
|
+
tool=call.name,
|
|
2030
|
+
call_id=call.call_id,
|
|
2031
|
+
decision="confirm_approved",
|
|
2032
|
+
),
|
|
2033
|
+
)
|
|
2034
|
+
else:
|
|
2035
|
+
allowed = False
|
|
2036
|
+
reason_raw = payload.get("reason")
|
|
2037
|
+
denial_message = (
|
|
2038
|
+
(reason_raw if isinstance(reason_raw, str) else None)
|
|
2039
|
+
or "denied by user"
|
|
2040
|
+
)
|
|
2041
|
+
logger.debug(
|
|
2042
|
+
"tool inspector confirm %s",
|
|
2043
|
+
kv(
|
|
2044
|
+
request_id=ctx.request_id,
|
|
2045
|
+
thread_id=ctx.thread_id,
|
|
2046
|
+
tool=call.name,
|
|
2047
|
+
call_id=call.call_id,
|
|
2048
|
+
decision="confirm_denied",
|
|
2049
|
+
),
|
|
2050
|
+
)
|
|
2051
|
+
break
|
|
2052
|
+
|
|
2053
|
+
if not allowed:
|
|
2054
|
+
msg = denial_message or "tool call denied"
|
|
2055
|
+
yield ToolCallStarted(
|
|
2056
|
+
request_id=ctx.request_id,
|
|
2057
|
+
tool=call.name,
|
|
2058
|
+
label=call.name,
|
|
2059
|
+
args=dict(call.args),
|
|
2060
|
+
call_id=call.call_id,
|
|
2061
|
+
)
|
|
2062
|
+
result_evt, tool_resp = _finish_tool(
|
|
2063
|
+
call, ToolExecutionResult.err(msg)
|
|
2064
|
+
)
|
|
2065
|
+
yield result_evt
|
|
2066
|
+
chunk_responses.append(tool_resp)
|
|
2067
|
+
continue
|
|
2068
|
+
|
|
2069
|
+
yield ToolCallStarted(
|
|
2070
|
+
request_id=ctx.request_id,
|
|
2071
|
+
tool=call.name,
|
|
2072
|
+
label=call.name,
|
|
2073
|
+
args=dict(call.args),
|
|
2074
|
+
call_id=call.call_id,
|
|
2075
|
+
)
|
|
2076
|
+
allowed_exec.append(call)
|
|
2077
|
+
|
|
2078
|
+
if not allowed_exec:
|
|
2079
|
+
all_tool_responses.extend(chunk_responses)
|
|
2080
|
+
continue
|
|
2081
|
+
|
|
2082
|
+
# ToolCallStarted already published via async-gen consumer;
|
|
2083
|
+
# PRE_TOOL is awaited inside _execute_one_tool_call.
|
|
2084
|
+
parallel_chunk = (
|
|
2085
|
+
all(c.name == "task" for c in allowed_exec)
|
|
2086
|
+
or (
|
|
2087
|
+
len(allowed_exec) > 1
|
|
2088
|
+
and all(c.name in safe_names for c in allowed_exec)
|
|
2089
|
+
)
|
|
2090
|
+
)
|
|
2091
|
+
dispatch_mode = "parallel" if parallel_chunk else "serial"
|
|
2092
|
+
logger.debug(
|
|
2093
|
+
"tool dispatch %s",
|
|
2094
|
+
kv(
|
|
2095
|
+
request_id=ctx.request_id,
|
|
2096
|
+
thread_id=ctx.thread_id,
|
|
2097
|
+
turn=turn_index,
|
|
2098
|
+
mode=dispatch_mode,
|
|
2099
|
+
chunk_size=len(allowed_exec),
|
|
2100
|
+
),
|
|
2101
|
+
)
|
|
2102
|
+
|
|
2103
|
+
async def _execute_one_tool_call(
|
|
2104
|
+
call: ToolCall,
|
|
2105
|
+
*,
|
|
2106
|
+
_ctx: TurnContext = ctx,
|
|
2107
|
+
) -> ToolExecutionResult:
|
|
2108
|
+
nonlocal pre_tool_extra_next
|
|
2109
|
+
logger.debug(
|
|
2110
|
+
"tool execute %s",
|
|
2111
|
+
kv(
|
|
2112
|
+
request_id=_ctx.request_id,
|
|
2113
|
+
thread_id=_ctx.thread_id,
|
|
2114
|
+
tool=call.name,
|
|
2115
|
+
call_id=call.call_id,
|
|
2116
|
+
),
|
|
2117
|
+
)
|
|
2118
|
+
async with span_tool(
|
|
2119
|
+
tool_name=call.name,
|
|
2120
|
+
tool_call_id=call.call_id,
|
|
2121
|
+
thread_id=_ctx.thread_id,
|
|
2122
|
+
request_id=_ctx.request_id,
|
|
2123
|
+
args=dict(call.args),
|
|
2124
|
+
):
|
|
2125
|
+
pre_tool_payload = await _fire_hook(
|
|
2126
|
+
hook_manager,
|
|
2127
|
+
event=HookEvent.PRE_TOOL,
|
|
2128
|
+
ctx=_ctx,
|
|
2129
|
+
timeout_s=_HOOK_PRE_TOOL_TIMEOUT_S,
|
|
2130
|
+
tool_name=call.name,
|
|
2131
|
+
tool_args=dict(call.args),
|
|
2132
|
+
)
|
|
2133
|
+
if pre_tool_payload is not None and pre_tool_payload.inject_text:
|
|
2134
|
+
pre_tool_extra_next = _combine_extras(
|
|
2135
|
+
pre_tool_extra_next, pre_tool_payload.inject_text
|
|
2136
|
+
)
|
|
2137
|
+
_record_tool_hook_span_event("pre_tool", call.name)
|
|
2138
|
+
try:
|
|
2139
|
+
tool_result = await tool_executor.execute(call=call, ctx=_ctx)
|
|
2140
|
+
except asyncio.CancelledError:
|
|
2141
|
+
raise
|
|
2142
|
+
except Exception as exc:
|
|
2143
|
+
logger.warning(
|
|
2144
|
+
"tool execution failed %s",
|
|
2145
|
+
kv(
|
|
2146
|
+
request_id=_ctx.request_id,
|
|
2147
|
+
thread_id=_ctx.thread_id,
|
|
2148
|
+
tool=call.name,
|
|
2149
|
+
call_id=call.call_id,
|
|
2150
|
+
),
|
|
2151
|
+
exc_info=True,
|
|
2152
|
+
)
|
|
2153
|
+
tool_result = ToolExecutionResult.err(str(exc))
|
|
2154
|
+
result_summary = (
|
|
2155
|
+
_blocks_to_sse_summary(tool_result.blocks)
|
|
2156
|
+
if tool_result.error is None
|
|
2157
|
+
else None
|
|
2158
|
+
)
|
|
2159
|
+
record_tool_outcome(result_summary, tool_result.error)
|
|
2160
|
+
await _fire_hook(
|
|
2161
|
+
hook_manager,
|
|
2162
|
+
event=HookEvent.POST_TOOL,
|
|
2163
|
+
ctx=_ctx,
|
|
2164
|
+
timeout_s=0,
|
|
2165
|
+
tool_name=call.name,
|
|
2166
|
+
tool_args=dict(call.args),
|
|
2167
|
+
tool_result=result_summary,
|
|
2168
|
+
tool_error=tool_result.error,
|
|
2169
|
+
)
|
|
2170
|
+
_record_tool_hook_span_event("post_tool", call.name)
|
|
2171
|
+
return tool_result
|
|
2172
|
+
|
|
2173
|
+
if not parallel_chunk:
|
|
2174
|
+
call = allowed_exec[0]
|
|
2175
|
+
try:
|
|
2176
|
+
tool_result = await _execute_one_tool_call(call)
|
|
2177
|
+
except asyncio.CancelledError:
|
|
2178
|
+
yield Error(request_id=ctx.request_id, error="Request cancelled")
|
|
2179
|
+
needs_followup_after_tools = False
|
|
2180
|
+
return
|
|
2181
|
+
|
|
2182
|
+
event, response = _finish_tool(call, tool_result)
|
|
2183
|
+
yield event
|
|
2184
|
+
for img_evt in _image_events(ctx.request_id, call.call_id, tool_result):
|
|
2185
|
+
yield img_evt
|
|
2186
|
+
chunk_responses.append(response)
|
|
2187
|
+
if (
|
|
2188
|
+
tool_result.error is None
|
|
2189
|
+
and call.name in MCP_REGISTRY_MUTATING_TOOLS
|
|
2190
|
+
):
|
|
2191
|
+
mcp_registry_mutated = True
|
|
2192
|
+
else:
|
|
2193
|
+
if cancelled is not None and cancelled.is_set():
|
|
2194
|
+
yield Error(request_id=ctx.request_id, error="Request cancelled")
|
|
2195
|
+
needs_followup_after_tools = False
|
|
2196
|
+
return
|
|
2197
|
+
|
|
2198
|
+
conc = (
|
|
2199
|
+
_MAX_CONCURRENT_SUBAGENTS
|
|
2200
|
+
if all(c.name == "task" for c in allowed_exec)
|
|
2201
|
+
else _parallel_tool_concurrency()
|
|
2202
|
+
)
|
|
2203
|
+
sem = asyncio.Semaphore(conc)
|
|
2204
|
+
|
|
2205
|
+
async def _run_parallel(
|
|
2206
|
+
call: ToolCall,
|
|
2207
|
+
*,
|
|
2208
|
+
_sem: asyncio.Semaphore = sem,
|
|
2209
|
+
_ctx: TurnContext = ctx,
|
|
2210
|
+
) -> tuple[ToolCall, ToolExecutionResult]:
|
|
2211
|
+
async with _sem:
|
|
2212
|
+
return (call, await _execute_one_tool_call(call, _ctx=_ctx))
|
|
2213
|
+
|
|
2214
|
+
try:
|
|
2215
|
+
outcomes = await asyncio.gather(
|
|
2216
|
+
*[_run_parallel(c) for c in allowed_exec],
|
|
2217
|
+
return_exceptions=True,
|
|
2218
|
+
)
|
|
2219
|
+
except asyncio.CancelledError:
|
|
2220
|
+
yield Error(request_id=ctx.request_id, error="Request cancelled")
|
|
2221
|
+
needs_followup_after_tools = False
|
|
2222
|
+
return
|
|
2223
|
+
|
|
2224
|
+
for idx, outcome in enumerate(outcomes):
|
|
2225
|
+
call = allowed_exec[idx]
|
|
2226
|
+
if isinstance(outcome, asyncio.CancelledError):
|
|
2227
|
+
yield Error(request_id=ctx.request_id, error="Request cancelled")
|
|
2228
|
+
needs_followup_after_tools = False
|
|
2229
|
+
return
|
|
2230
|
+
if isinstance(outcome, Exception):
|
|
2231
|
+
logger.warning(
|
|
2232
|
+
"tool execution failed %s",
|
|
2233
|
+
kv(
|
|
2234
|
+
request_id=ctx.request_id,
|
|
2235
|
+
thread_id=ctx.thread_id,
|
|
2236
|
+
tool=call.name,
|
|
2237
|
+
call_id=call.call_id,
|
|
2238
|
+
),
|
|
2239
|
+
exc_info=(type(outcome), outcome, outcome.__traceback__),
|
|
2240
|
+
)
|
|
2241
|
+
tool_result = ToolExecutionResult.err(str(outcome))
|
|
2242
|
+
else:
|
|
2243
|
+
_, tool_result = cast(tuple[ToolCall, ToolExecutionResult], outcome)
|
|
2244
|
+
|
|
2245
|
+
event, response = _finish_tool(call, tool_result)
|
|
2246
|
+
yield event
|
|
2247
|
+
for img_evt in _image_events(ctx.request_id, call.call_id, tool_result):
|
|
2248
|
+
yield img_evt
|
|
2249
|
+
chunk_responses.append(response)
|
|
2250
|
+
if (
|
|
2251
|
+
tool_result.error is None
|
|
2252
|
+
and call.name in MCP_REGISTRY_MUTATING_TOOLS
|
|
2253
|
+
):
|
|
2254
|
+
mcp_registry_mutated = True
|
|
2255
|
+
|
|
2256
|
+
all_tool_responses.extend(chunk_responses)
|
|
2257
|
+
doom_msg = doom_tracker.take_error()
|
|
2258
|
+
if doom_msg is not None:
|
|
2259
|
+
logger.warning(
|
|
2260
|
+
"doom loop detected %s",
|
|
2261
|
+
kv(
|
|
2262
|
+
request_id=ctx.request_id,
|
|
2263
|
+
thread_id=ctx.thread_id,
|
|
2264
|
+
tool=doom_tracker.triggered_tool,
|
|
2265
|
+
threshold=doom_tracker.threshold,
|
|
2266
|
+
),
|
|
2267
|
+
)
|
|
2268
|
+
yield Error(request_id=ctx.request_id, error=doom_msg)
|
|
2269
|
+
|
|
2270
|
+
if all_tool_responses:
|
|
2271
|
+
chat_for_budget = await _load_agent_chat_history(history, ctx.thread_id)
|
|
2272
|
+
budget_used = usage.estimated_prompt_tokens
|
|
2273
|
+
try:
|
|
2274
|
+
budget_used = await _prompt_input_tokens_for_history(
|
|
2275
|
+
ctx=ctx,
|
|
2276
|
+
chat_messages=chat_for_budget,
|
|
2277
|
+
provider=provider,
|
|
2278
|
+
attachment_store=attachment_store,
|
|
2279
|
+
attachment_catalog=attachment_catalog,
|
|
2280
|
+
memory_selection=memory_selection,
|
|
2281
|
+
extra_system_text=pre_turn_extra,
|
|
2282
|
+
vertex_google_search=vertex_google_search,
|
|
2283
|
+
epoch=epoch_tracker,
|
|
2284
|
+
)
|
|
2285
|
+
except Exception:
|
|
2286
|
+
logger.warning(
|
|
2287
|
+
"tool budget token recount failed %s; using estimated_prompt_tokens",
|
|
2288
|
+
kv(request_id=ctx.request_id, thread_id=ctx.thread_id),
|
|
2289
|
+
exc_info=True,
|
|
2290
|
+
)
|
|
2291
|
+
usage.estimated_prompt_tokens = max(
|
|
2292
|
+
usage.estimated_prompt_tokens, budget_used
|
|
2293
|
+
)
|
|
2294
|
+
budgeter = ContextBudgeter.from_env(
|
|
2295
|
+
window_tokens=ctx.context_window_tokens,
|
|
2296
|
+
used_tokens=budget_used,
|
|
2297
|
+
)
|
|
2298
|
+
async for budget_evt in _append_budgeted_tool_responses(
|
|
2299
|
+
chunk_responses=all_tool_responses,
|
|
2300
|
+
ctx=ctx,
|
|
2301
|
+
history=history,
|
|
2302
|
+
usage=usage,
|
|
2303
|
+
budgeter=budgeter,
|
|
2304
|
+
provider=provider,
|
|
2305
|
+
vertex_google_search=vertex_google_search,
|
|
2306
|
+
epoch=epoch_tracker,
|
|
2307
|
+
):
|
|
2308
|
+
yield budget_evt
|
|
2309
|
+
|
|
2310
|
+
if mcp_registry_mutated:
|
|
2311
|
+
mcp_client = getattr(tool_executor, "mcp", None)
|
|
2312
|
+
if mcp_client is None:
|
|
2313
|
+
logger.error(
|
|
2314
|
+
"MCP registry mutated but tool executor has no mcp client %s",
|
|
2315
|
+
kv(request_id=ctx.request_id, thread_id=ctx.thread_id),
|
|
2316
|
+
)
|
|
2317
|
+
raise RuntimeError(
|
|
2318
|
+
"tool executor missing mcp client after MCP registry mutation"
|
|
2319
|
+
)
|
|
2320
|
+
ctx = refresh_tools_after_mcp_change(ctx, mcp_client)
|
|
2321
|
+
tools_dirty = True
|
|
2322
|
+
doom_tracker.exempt_names = _doom_loop_exempt_names(ctx.tools)
|
|
2323
|
+
logger.info(
|
|
2324
|
+
"refreshed ctx.tools after MCP registry change %s",
|
|
2325
|
+
kv(
|
|
2326
|
+
request_id=ctx.request_id,
|
|
2327
|
+
thread_id=ctx.thread_id,
|
|
2328
|
+
tool_count=len(ctx.tools),
|
|
2329
|
+
),
|
|
2330
|
+
)
|
|
2331
|
+
|
|
2332
|
+
finally:
|
|
2333
|
+
set_turn_prompt_tokens(usage.estimated_prompt_tokens)
|
|
2334
|
+
if turn_output_text:
|
|
2335
|
+
set_turn_io(output_value=turn_output_text)
|
|
2336
|
+
end_turn_span(turn_span)
|
|
2337
|
+
if needs_followup_after_tools:
|
|
2338
|
+
logger.warning(
|
|
2339
|
+
"max turns exceeded %s",
|
|
2340
|
+
kv(
|
|
2341
|
+
request_id=ctx.request_id,
|
|
2342
|
+
thread_id=ctx.thread_id,
|
|
2343
|
+
max_turns=effective_max,
|
|
2344
|
+
),
|
|
2345
|
+
)
|
|
2346
|
+
yield Error(request_id=ctx.request_id, error="Max turns exceeded")
|
|
2347
|
+
|
|
2348
|
+
# Ensure the backgrounded assistant write has landed before any load/reset
|
|
2349
|
+
# below (freeze) so the assistant row is durable and not overwritten.
|
|
2350
|
+
await _await_history_write(assistant_write_task)
|
|
2351
|
+
|
|
2352
|
+
descriptor_events = await freeze_attachments_in_history(
|
|
2353
|
+
thread_id=ctx.thread_id,
|
|
2354
|
+
history=history,
|
|
2355
|
+
catalog=attachment_catalog,
|
|
2356
|
+
last_assistant_text=last_assistant[0],
|
|
2357
|
+
)
|
|
2358
|
+
for desc_evt in descriptor_events:
|
|
2359
|
+
yield AttachmentDescriptorEvent(
|
|
2360
|
+
request_id=ctx.request_id,
|
|
2361
|
+
attachment_id=desc_evt.attachment_id,
|
|
2362
|
+
mime_type=desc_evt.mime_type,
|
|
2363
|
+
filename=desc_evt.filename,
|
|
2364
|
+
description=desc_evt.description,
|
|
2365
|
+
)
|
|
2366
|
+
|
|
2367
|
+
await _fire_hook(
|
|
2368
|
+
hook_manager,
|
|
2369
|
+
event=HookEvent.POST_TURN,
|
|
2370
|
+
ctx=ctx,
|
|
2371
|
+
timeout_s=0,
|
|
2372
|
+
)
|
|
2373
|
+
# Settlement for POST_TURN / lingering POST_TOOL runs in run() finally
|
|
2374
|
+
# before TurnComplete — do not drain twice here.
|