mycode-coding-agent 0.1.0__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.
- mycode/__init__.py +0 -0
- mycode/adapters/__init__.py +21 -0
- mycode/adapters/jsonl.py +692 -0
- mycode/agent/__init__.py +25 -0
- mycode/agent/events.py +111 -0
- mycode/agent/outcome.py +103 -0
- mycode/agent/progress.py +373 -0
- mycode/agent/runner.py +1481 -0
- mycode/application/__init__.py +38 -0
- mycode/application/agent_session.py +367 -0
- mycode/application/events.py +59 -0
- mycode/application/runtime.py +211 -0
- mycode/application/sessions.py +180 -0
- mycode/cli.py +840 -0
- mycode/config.py +355 -0
- mycode/context/__init__.py +1 -0
- mycode/context/artifacts.py +672 -0
- mycode/context/budget.py +752 -0
- mycode/context/builder.py +112 -0
- mycode/context/compact.py +795 -0
- mycode/context/tool_result_format.py +199 -0
- mycode/context/tool_result_retention.py +261 -0
- mycode/conversation.py +78 -0
- mycode/error_handling.py +481 -0
- mycode/event_format.py +147 -0
- mycode/instructions.py +285 -0
- mycode/llm.py +771 -0
- mycode/mcp/__init__.py +41 -0
- mycode/mcp/client.py +44 -0
- mycode/mcp/config.py +207 -0
- mycode/mcp/errors.py +302 -0
- mycode/mcp/manager.py +339 -0
- mycode/mcp/models.py +20 -0
- mycode/mcp/result_adapter.py +58 -0
- mycode/mcp/tool_adapter.py +145 -0
- mycode/mcp/trust.py +313 -0
- mycode/memory.py +570 -0
- mycode/memory_context.py +245 -0
- mycode/messages.py +63 -0
- mycode/observability.py +28 -0
- mycode/permissions.py +262 -0
- mycode/persistence/__init__.py +1 -0
- mycode/persistence/filesystem.py +291 -0
- mycode/persistence/project_storage.py +208 -0
- mycode/persistence/session_lock.py +138 -0
- mycode/persistence/session_store.py +503 -0
- mycode/presentation/__init__.py +1 -0
- mycode/presentation/cli/__init__.py +14 -0
- mycode/presentation/cli/confirmer.py +116 -0
- mycode/presentation/cli/mcp_trust.py +61 -0
- mycode/presentation/cli/presenter.py +320 -0
- mycode/presentation/cli/session_menu.py +146 -0
- mycode/presentation/cli/subagent_observer.py +124 -0
- mycode/presentation/command_format.py +90 -0
- mycode/presentation/commands.py +95 -0
- mycode/presentation/tui/__init__.py +6 -0
- mycode/presentation/tui/app.py +1351 -0
- mycode/presentation/tui/interactions.py +253 -0
- mycode/presentation/tui/presenter.py +266 -0
- mycode/presentation/tui/screens.py +305 -0
- mycode/presentation/tui/widgets.py +214 -0
- mycode/project.py +22 -0
- mycode/prompts.py +181 -0
- mycode/reasoning.py +40 -0
- mycode/session.py +86 -0
- mycode/skills/__init__.py +27 -0
- mycode/skills/builtin/database-recovery/SKILL.md +138 -0
- mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
- mycode/skills/registry.py +295 -0
- mycode/skills/state.py +68 -0
- mycode/subagents/__init__.py +1 -0
- mycode/subagents/audit.py +212 -0
- mycode/subagents/concurrency.py +124 -0
- mycode/subagents/contracts.py +421 -0
- mycode/subagents/delegate.py +80 -0
- mycode/subagents/delegation.py +128 -0
- mycode/subagents/lifecycle.py +86 -0
- mycode/subagents/limits.py +7 -0
- mycode/subagents/observability.py +150 -0
- mycode/subagents/persistence.py +152 -0
- mycode/subagents/profiles.py +184 -0
- mycode/subagents/prompts.py +67 -0
- mycode/subagents/results.py +178 -0
- mycode/subagents/runtime.py +528 -0
- mycode/subagents/snapshots.py +211 -0
- mycode/subagents/tool_batch.py +260 -0
- mycode/tools/__init__.py +81 -0
- mycode/tools/base.py +222 -0
- mycode/tools/bounds.py +14 -0
- mycode/tools/command_executor.py +167 -0
- mycode/tools/command_output.py +166 -0
- mycode/tools/command_risk.py +596 -0
- mycode/tools/defaults.py +59 -0
- mycode/tools/edit_file.py +524 -0
- mycode/tools/file_mutation.py +30 -0
- mycode/tools/glob.py +247 -0
- mycode/tools/grep.py +324 -0
- mycode/tools/ignore.py +122 -0
- mycode/tools/inspect_changes.py +269 -0
- mycode/tools/load_skill.py +92 -0
- mycode/tools/memory.py +264 -0
- mycode/tools/path_permissions.py +78 -0
- mycode/tools/patterns.py +48 -0
- mycode/tools/permission_metadata.py +27 -0
- mycode/tools/process_tree.py +166 -0
- mycode/tools/read_file.py +242 -0
- mycode/tools/read_skill_resource.py +93 -0
- mycode/tools/registry.py +279 -0
- mycode/tools/run_command.py +237 -0
- mycode/tools/run_skill_script.py +206 -0
- mycode/tools/run_validation.py +107 -0
- mycode/tools/submit_result.py +93 -0
- mycode/tools/text.py +15 -0
- mycode/tools/validation_command.py +377 -0
- mycode/tools/workspace.py +33 -0
- mycode/tools/write_file.py +169 -0
- mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
- mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
- mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
mycode/agent/runner.py
ADDED
|
@@ -0,0 +1,1481 @@
|
|
|
1
|
+
from dataclasses import dataclass, field, replace
|
|
2
|
+
from collections.abc import Callable, Generator, Iterator, Sequence
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import random
|
|
6
|
+
from time import sleep
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from mycode.agent.events import (
|
|
12
|
+
AgentEvent,
|
|
13
|
+
AgentModelRetry,
|
|
14
|
+
AgentModelResponse,
|
|
15
|
+
AgentProgressSnapshot,
|
|
16
|
+
AgentToolCall,
|
|
17
|
+
AgentWarning,
|
|
18
|
+
)
|
|
19
|
+
from mycode.context.artifacts import (
|
|
20
|
+
ToolResultArtifactStore,
|
|
21
|
+
artifact_externalization_failure_content,
|
|
22
|
+
artifact_failure_reason,
|
|
23
|
+
artifact_reference_info,
|
|
24
|
+
)
|
|
25
|
+
from mycode.context.compact import ConversationCompactor
|
|
26
|
+
from mycode.context.tool_result_retention import ToolResultRetentionPolicy, TurnLocalFullGroup
|
|
27
|
+
from mycode.context.builder import ContextBuilder
|
|
28
|
+
from mycode.error_handling import classify_model_error, format_model_error
|
|
29
|
+
from mycode.context.budget import (
|
|
30
|
+
ContextBudget,
|
|
31
|
+
ModelContext,
|
|
32
|
+
TokenEstimator,
|
|
33
|
+
TokenUsage,
|
|
34
|
+
format_model_context_stats,
|
|
35
|
+
model_context_needs_notice,
|
|
36
|
+
)
|
|
37
|
+
from mycode.conversation import Conversation
|
|
38
|
+
from mycode.llm import LLMClient
|
|
39
|
+
from mycode.memory_context import MemoryRecall, MemoryRecallProvider
|
|
40
|
+
from mycode.messages import Message
|
|
41
|
+
from mycode.observability import ObservationSink, emit_observation
|
|
42
|
+
from mycode.skills import ActiveSkillState
|
|
43
|
+
from mycode.reasoning import ReasoningState
|
|
44
|
+
from mycode.agent.progress import (
|
|
45
|
+
DEFAULT_MAX_TURNS,
|
|
46
|
+
MAIN_NEAR_LIMIT_PROMPT,
|
|
47
|
+
MAIN_NEAR_LIMIT_REMAINING_TURNS,
|
|
48
|
+
MAX_TURNS_FINALIZATION_PROMPT,
|
|
49
|
+
PolicyDecision,
|
|
50
|
+
RuntimeObservation,
|
|
51
|
+
RuntimePolicy,
|
|
52
|
+
RuntimeState,
|
|
53
|
+
decide_runtime_policy,
|
|
54
|
+
observe_tool_result,
|
|
55
|
+
normalize_run_checkpoint,
|
|
56
|
+
resume_guidance,
|
|
57
|
+
)
|
|
58
|
+
from mycode.tools import ToolArgumentValidationError, ToolRegistry, ToolResult, Workspace
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
DEFAULT_REPEATED_TOOL_CALL_LIMIT = 3
|
|
62
|
+
DEFAULT_MAX_TOOL_CALLS_PER_RESPONSE = 32
|
|
63
|
+
DEFAULT_MAX_CONCURRENT_SAFE_TOOLS = 4
|
|
64
|
+
DEFAULT_MODEL_MAX_RETRIES = 2
|
|
65
|
+
MODEL_RETRY_DELAY_MIN_SECONDS = 2.0
|
|
66
|
+
MODEL_RETRY_DELAY_MAX_SECONDS = 3.0
|
|
67
|
+
EMPTY_RESPONSE_RETRY_PROMPT = (
|
|
68
|
+
"Your previous response contained neither tool calls nor a final answer. "
|
|
69
|
+
"Continue the task or provide a final response."
|
|
70
|
+
)
|
|
71
|
+
EMPTY_RESPONSE_ERROR = (
|
|
72
|
+
"模型响应错误(empty_response):连续两次响应都没有工具调用或非空最终回答。"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _model_retry_delay(
|
|
77
|
+
retry_attempt: int,
|
|
78
|
+
retry_after_seconds: float | None,
|
|
79
|
+
) -> float:
|
|
80
|
+
if retry_after_seconds is not None:
|
|
81
|
+
return max(0.0, retry_after_seconds)
|
|
82
|
+
multiplier = 2 ** max(0, retry_attempt - 1)
|
|
83
|
+
return random.uniform(
|
|
84
|
+
MODEL_RETRY_DELAY_MIN_SECONDS * multiplier,
|
|
85
|
+
MODEL_RETRY_DELAY_MAX_SECONDS * multiplier,
|
|
86
|
+
)
|
|
87
|
+
_OBSERVABLE_BOUNDED_READ_FIELDS = {
|
|
88
|
+
"read_artifact": "max_chars",
|
|
89
|
+
"read_file": "max_lines",
|
|
90
|
+
"grep": "max_results",
|
|
91
|
+
"glob": "max_results",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ToolBatchContractError(ValueError):
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class ToolCallExecution:
|
|
101
|
+
tool_call: AgentToolCall
|
|
102
|
+
result: ToolResult
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class ToolBatchExecution:
|
|
107
|
+
executions: tuple[ToolCallExecution, ...]
|
|
108
|
+
stop_response: AgentModelResponse | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
ToolBatchHandler = Callable[
|
|
112
|
+
[ToolRegistry, list[AgentToolCall]],
|
|
113
|
+
ToolBatchExecution,
|
|
114
|
+
]
|
|
115
|
+
SerialToolExecutor = Callable[[AgentToolCall], ToolResult]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def execute_tool_batch(
|
|
119
|
+
registry: ToolRegistry,
|
|
120
|
+
tool_calls: list[AgentToolCall],
|
|
121
|
+
*,
|
|
122
|
+
serial_executor: SerialToolExecutor | None = None,
|
|
123
|
+
) -> ToolBatchExecution:
|
|
124
|
+
return asyncio.run(
|
|
125
|
+
execute_tool_batch_async(
|
|
126
|
+
registry,
|
|
127
|
+
tool_calls,
|
|
128
|
+
serial_executor=serial_executor,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def execute_tool_batch_async(
|
|
134
|
+
registry: ToolRegistry,
|
|
135
|
+
tool_calls: list[AgentToolCall],
|
|
136
|
+
*,
|
|
137
|
+
serial_executor: SerialToolExecutor | None = None,
|
|
138
|
+
) -> ToolBatchExecution:
|
|
139
|
+
executions: list[ToolCallExecution] = []
|
|
140
|
+
concurrent_calls: list[AgentToolCall] = []
|
|
141
|
+
permission_lock = asyncio.Lock()
|
|
142
|
+
executable_calls, overflow_executions = partition_tool_calls_by_limit(tool_calls)
|
|
143
|
+
|
|
144
|
+
for tool_call in executable_calls:
|
|
145
|
+
if registry.is_concurrency_safe(tool_call.name):
|
|
146
|
+
concurrent_calls.append(tool_call)
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
executions.extend(
|
|
150
|
+
await _execute_concurrent_calls(
|
|
151
|
+
registry,
|
|
152
|
+
concurrent_calls,
|
|
153
|
+
permission_lock,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
concurrent_calls.clear()
|
|
157
|
+
executions.append(
|
|
158
|
+
await _execute_serial_call(
|
|
159
|
+
registry,
|
|
160
|
+
tool_call,
|
|
161
|
+
serial_executor=serial_executor,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
executions.extend(
|
|
166
|
+
await _execute_concurrent_calls(
|
|
167
|
+
registry,
|
|
168
|
+
concurrent_calls,
|
|
169
|
+
permission_lock,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
executions.extend(overflow_executions)
|
|
174
|
+
return ToolBatchExecution(executions=tuple(executions))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def partition_tool_calls_by_limit(
|
|
178
|
+
tool_calls: list[AgentToolCall],
|
|
179
|
+
) -> tuple[list[AgentToolCall], tuple[ToolCallExecution, ...]]:
|
|
180
|
+
executable_calls = tool_calls[:DEFAULT_MAX_TOOL_CALLS_PER_RESPONSE]
|
|
181
|
+
overflow_executions = tuple(
|
|
182
|
+
_tool_call_limit_failure(tool_call, index)
|
|
183
|
+
for index, tool_call in enumerate(
|
|
184
|
+
tool_calls[DEFAULT_MAX_TOOL_CALLS_PER_RESPONSE:],
|
|
185
|
+
start=DEFAULT_MAX_TOOL_CALLS_PER_RESPONSE,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
return executable_calls, overflow_executions
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def append_tool_call_limit_failures(
|
|
192
|
+
batch: ToolBatchExecution,
|
|
193
|
+
overflow_executions: tuple[ToolCallExecution, ...],
|
|
194
|
+
) -> ToolBatchExecution:
|
|
195
|
+
if not overflow_executions:
|
|
196
|
+
return batch
|
|
197
|
+
return ToolBatchExecution(
|
|
198
|
+
executions=(*batch.executions, *overflow_executions),
|
|
199
|
+
stop_response=batch.stop_response,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def _execute_concurrent_calls(
|
|
204
|
+
registry: ToolRegistry,
|
|
205
|
+
tool_calls: list[AgentToolCall],
|
|
206
|
+
permission_lock: asyncio.Lock,
|
|
207
|
+
) -> list[ToolCallExecution]:
|
|
208
|
+
if not tool_calls:
|
|
209
|
+
return []
|
|
210
|
+
|
|
211
|
+
executions: list[ToolCallExecution] = []
|
|
212
|
+
for start in range(0, len(tool_calls), DEFAULT_MAX_CONCURRENT_SAFE_TOOLS):
|
|
213
|
+
chunk = tool_calls[start : start + DEFAULT_MAX_CONCURRENT_SAFE_TOOLS]
|
|
214
|
+
results = await asyncio.gather(
|
|
215
|
+
*(
|
|
216
|
+
registry.run_tool_async(
|
|
217
|
+
tool_call.name,
|
|
218
|
+
tool_call.arguments,
|
|
219
|
+
permission_lock=permission_lock,
|
|
220
|
+
)
|
|
221
|
+
for tool_call in chunk
|
|
222
|
+
),
|
|
223
|
+
return_exceptions=True,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
for tool_call, result in zip(chunk, results, strict=True):
|
|
227
|
+
if isinstance(result, ToolResult):
|
|
228
|
+
tool_result = result
|
|
229
|
+
elif isinstance(result, Exception):
|
|
230
|
+
tool_result = ToolResult.failure(
|
|
231
|
+
error=f"Tool execution failed: {result}",
|
|
232
|
+
metadata={"exception_type": type(result).__name__},
|
|
233
|
+
)
|
|
234
|
+
else:
|
|
235
|
+
raise result
|
|
236
|
+
|
|
237
|
+
executions.append(
|
|
238
|
+
ToolCallExecution(
|
|
239
|
+
tool_call=tool_call,
|
|
240
|
+
result=tool_result,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
return executions
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _tool_call_limit_failure(
|
|
247
|
+
tool_call: AgentToolCall,
|
|
248
|
+
index: int,
|
|
249
|
+
) -> ToolCallExecution:
|
|
250
|
+
return ToolCallExecution(
|
|
251
|
+
tool_call=tool_call,
|
|
252
|
+
result=ToolResult.failure(
|
|
253
|
+
error=(
|
|
254
|
+
"Tool call was not executed because the model response exceeded "
|
|
255
|
+
f"the maximum of {DEFAULT_MAX_TOOL_CALLS_PER_RESPONSE} tool calls."
|
|
256
|
+
),
|
|
257
|
+
metadata={
|
|
258
|
+
"reason": "tool_call_response_limit",
|
|
259
|
+
"tool_call_index": index,
|
|
260
|
+
"max_tool_calls": DEFAULT_MAX_TOOL_CALLS_PER_RESPONSE,
|
|
261
|
+
},
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def _execute_serial_call(
|
|
267
|
+
registry: ToolRegistry,
|
|
268
|
+
tool_call: AgentToolCall,
|
|
269
|
+
*,
|
|
270
|
+
serial_executor: SerialToolExecutor | None = None,
|
|
271
|
+
) -> ToolCallExecution:
|
|
272
|
+
try:
|
|
273
|
+
result = (
|
|
274
|
+
await registry.run_tool_async(
|
|
275
|
+
tool_call.name,
|
|
276
|
+
tool_call.arguments,
|
|
277
|
+
permission_lock=asyncio.Lock(),
|
|
278
|
+
)
|
|
279
|
+
if serial_executor is None
|
|
280
|
+
else serial_executor(tool_call)
|
|
281
|
+
)
|
|
282
|
+
except Exception as error:
|
|
283
|
+
result = ToolResult.failure(
|
|
284
|
+
error=f"Tool execution failed: {error}",
|
|
285
|
+
metadata={"exception_type": type(error).__name__},
|
|
286
|
+
)
|
|
287
|
+
return ToolCallExecution(tool_call=tool_call, result=result)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@dataclass
|
|
291
|
+
class AgentRunner:
|
|
292
|
+
llm_client: LLMClient
|
|
293
|
+
tool_registry: ToolRegistry
|
|
294
|
+
conversation: Conversation = field(default_factory=Conversation)
|
|
295
|
+
max_turns: int = DEFAULT_MAX_TURNS
|
|
296
|
+
near_limit_remaining_turns: int | None = MAIN_NEAR_LIMIT_REMAINING_TURNS
|
|
297
|
+
near_limit_prompt: str | None = MAIN_NEAR_LIMIT_PROMPT
|
|
298
|
+
repeated_tool_call_limit: int = DEFAULT_REPEATED_TOOL_CALL_LIMIT
|
|
299
|
+
finalize_on_max_turns: bool = True
|
|
300
|
+
context_budget: ContextBudget = field(default_factory=ContextBudget)
|
|
301
|
+
token_estimator: TokenEstimator = field(default_factory=TokenEstimator)
|
|
302
|
+
last_model_context: ModelContext | None = field(default=None, init=False)
|
|
303
|
+
last_token_usage: TokenUsage | None = field(default=None, init=False)
|
|
304
|
+
run_token_usage: TokenUsage | None = field(default=None, init=False)
|
|
305
|
+
last_reasoning_char_count: int = field(default=0, init=False)
|
|
306
|
+
instruction_sources: tuple[str, ...] = ()
|
|
307
|
+
instruction_warnings: tuple[str, ...] = ()
|
|
308
|
+
skill_warnings: tuple[str, ...] = ()
|
|
309
|
+
active_skill_state: ActiveSkillState | None = None
|
|
310
|
+
memory_context_selector: MemoryRecallProvider | None = None
|
|
311
|
+
last_memory_recall: MemoryRecall | None = field(default=None, init=False)
|
|
312
|
+
tool_batch_handler: ToolBatchHandler = execute_tool_batch
|
|
313
|
+
compactor: ConversationCompactor | None = None
|
|
314
|
+
tool_result_artifact_store: ToolResultArtifactStore | None = None
|
|
315
|
+
observability_sink: ObservationSink | None = field(default=None, repr=False)
|
|
316
|
+
observability_scope: str = "main"
|
|
317
|
+
observability_run_id: str | None = None
|
|
318
|
+
last_artifact_error: str | None = field(default=None, init=False)
|
|
319
|
+
artifact_failure_count: int = field(default=0, init=False)
|
|
320
|
+
last_runtime_state: RuntimeState | None = field(default=None, init=False)
|
|
321
|
+
_pending_artifact_warnings: list[AgentWarning] = field(
|
|
322
|
+
default_factory=list,
|
|
323
|
+
init=False,
|
|
324
|
+
repr=False,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def __post_init__(self) -> None:
|
|
328
|
+
if self.max_turns < 1:
|
|
329
|
+
raise ValueError("max_turns must be at least 1.")
|
|
330
|
+
if self.repeated_tool_call_limit < 1:
|
|
331
|
+
raise ValueError("repeated_tool_call_limit must be at least 1.")
|
|
332
|
+
if (self.near_limit_remaining_turns is None) != (
|
|
333
|
+
self.near_limit_prompt is None
|
|
334
|
+
):
|
|
335
|
+
raise ValueError(
|
|
336
|
+
"near_limit_remaining_turns and near_limit_prompt must both be set "
|
|
337
|
+
"or both be None."
|
|
338
|
+
)
|
|
339
|
+
if self.near_limit_remaining_turns is None:
|
|
340
|
+
return
|
|
341
|
+
if not 0 < self.near_limit_remaining_turns < self.max_turns:
|
|
342
|
+
raise ValueError(
|
|
343
|
+
"near_limit_remaining_turns must be greater than 0 and less than "
|
|
344
|
+
"max_turns."
|
|
345
|
+
)
|
|
346
|
+
if not self.near_limit_prompt or not self.near_limit_prompt.strip():
|
|
347
|
+
raise ValueError("near_limit_prompt must not be blank.")
|
|
348
|
+
|
|
349
|
+
def run(self, user_message: str) -> Iterator[AgentEvent]:
|
|
350
|
+
pending_full_group: TurnLocalFullGroup | None = None
|
|
351
|
+
self._pending_artifact_warnings.clear()
|
|
352
|
+
self.tool_registry.scoped_approvals.begin_task()
|
|
353
|
+
try:
|
|
354
|
+
if self.active_skill_state is not None:
|
|
355
|
+
self.active_skill_state.clear()
|
|
356
|
+
self.run_token_usage = None
|
|
357
|
+
_start_tool_batch_run(self.tool_batch_handler)
|
|
358
|
+
continuation_guidance = resume_guidance(self.conversation, user_message)
|
|
359
|
+
self._recall_memory(user_message)
|
|
360
|
+
self.conversation.add_user_message(user_message)
|
|
361
|
+
|
|
362
|
+
previous_tool_call_signature: str | None = None
|
|
363
|
+
repeated_tool_call_count = 0
|
|
364
|
+
pending_runtime_decision = PolicyDecision(
|
|
365
|
+
policy=RuntimePolicy.NO_INTERVENTION
|
|
366
|
+
)
|
|
367
|
+
near_limit_guidance_sent = False
|
|
368
|
+
runtime_state = RuntimeState()
|
|
369
|
+
self.last_runtime_state = runtime_state
|
|
370
|
+
reported_context_state: (
|
|
371
|
+
tuple[
|
|
372
|
+
int,
|
|
373
|
+
int,
|
|
374
|
+
bool,
|
|
375
|
+
int,
|
|
376
|
+
int,
|
|
377
|
+
str | None,
|
|
378
|
+
bool,
|
|
379
|
+
str | None,
|
|
380
|
+
int,
|
|
381
|
+
int,
|
|
382
|
+
int,
|
|
383
|
+
]
|
|
384
|
+
| None
|
|
385
|
+
) = None
|
|
386
|
+
|
|
387
|
+
for turn_index in range(self.max_turns):
|
|
388
|
+
guidance = pending_runtime_decision.guidance
|
|
389
|
+
notice = pending_runtime_decision.notice
|
|
390
|
+
pending_runtime_decision = PolicyDecision(
|
|
391
|
+
policy=RuntimePolicy.NO_INTERVENTION
|
|
392
|
+
)
|
|
393
|
+
if not guidance and turn_index == 0 and continuation_guidance:
|
|
394
|
+
guidance = (continuation_guidance,)
|
|
395
|
+
notice = "已载入上次检查点,将直接衔接剩余工作"
|
|
396
|
+
remaining_turns = self.max_turns - turn_index
|
|
397
|
+
if (
|
|
398
|
+
self.near_limit_remaining_turns is not None
|
|
399
|
+
and self.near_limit_prompt is not None
|
|
400
|
+
and remaining_turns == self.near_limit_remaining_turns
|
|
401
|
+
and not near_limit_guidance_sent
|
|
402
|
+
):
|
|
403
|
+
guidance = (*guidance, self.near_limit_prompt)
|
|
404
|
+
near_limit_guidance_sent = True
|
|
405
|
+
near_limit_notice = (
|
|
406
|
+
f"距离运行轮次上限还有 {self.near_limit_remaining_turns} 轮,"
|
|
407
|
+
"已发送一次接近上限提醒"
|
|
408
|
+
)
|
|
409
|
+
notice = (
|
|
410
|
+
near_limit_notice
|
|
411
|
+
if not notice
|
|
412
|
+
else f"{notice};{near_limit_notice}"
|
|
413
|
+
)
|
|
414
|
+
tools = self.tool_registry.get_schemas()
|
|
415
|
+
yield AgentEvent(
|
|
416
|
+
type="turn",
|
|
417
|
+
content=notice,
|
|
418
|
+
turn_number=turn_index + 1,
|
|
419
|
+
max_turns=self.max_turns,
|
|
420
|
+
)
|
|
421
|
+
turn_local_full_group = pending_full_group
|
|
422
|
+
pending_full_group = None
|
|
423
|
+
for response_attempt in range(2):
|
|
424
|
+
request_guidance = guidance
|
|
425
|
+
if response_attempt == 1:
|
|
426
|
+
request_guidance = (
|
|
427
|
+
*request_guidance,
|
|
428
|
+
EMPTY_RESPONSE_RETRY_PROMPT,
|
|
429
|
+
)
|
|
430
|
+
model_context = self._model_context(
|
|
431
|
+
tools,
|
|
432
|
+
guidance=request_guidance,
|
|
433
|
+
turn_local_full_group=turn_local_full_group,
|
|
434
|
+
observability_turn=turn_index + 1,
|
|
435
|
+
)
|
|
436
|
+
for warning in self._drain_artifact_warnings():
|
|
437
|
+
yield AgentEvent(
|
|
438
|
+
type="artifact_warning",
|
|
439
|
+
content=warning.content,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
context_state = _context_state(model_context)
|
|
443
|
+
should_report_context = reported_context_state is None or (
|
|
444
|
+
model_context_needs_notice(model_context)
|
|
445
|
+
and context_state != reported_context_state
|
|
446
|
+
)
|
|
447
|
+
if should_report_context:
|
|
448
|
+
yield _context_event(
|
|
449
|
+
model_context,
|
|
450
|
+
previous_token_usage=self.last_token_usage,
|
|
451
|
+
)
|
|
452
|
+
reported_context_state = context_state
|
|
453
|
+
|
|
454
|
+
self._emit_context_snapshot(
|
|
455
|
+
model_context,
|
|
456
|
+
turn=turn_index + 1,
|
|
457
|
+
call_kind="agent_tools",
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
if model_context.estimate.over_budget:
|
|
461
|
+
yield AgentEvent(
|
|
462
|
+
type="error",
|
|
463
|
+
error=_context_overflow_message(model_context),
|
|
464
|
+
)
|
|
465
|
+
yield AgentEvent(
|
|
466
|
+
type="stop",
|
|
467
|
+
stop_reason="context_overflow",
|
|
468
|
+
)
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
for model_attempt in range(DEFAULT_MODEL_MAX_RETRIES + 1):
|
|
472
|
+
content_parts: list[str] = []
|
|
473
|
+
reasoning_parts: list[str] = []
|
|
474
|
+
reasoning_state: ReasoningState = "absent"
|
|
475
|
+
tool_calls: list[AgentToolCall] = []
|
|
476
|
+
observable_tool_call_events: list[AgentEvent] = []
|
|
477
|
+
yield AgentEvent(type="model_start")
|
|
478
|
+
|
|
479
|
+
try:
|
|
480
|
+
for event in self.llm_client.stream_with_tools(
|
|
481
|
+
Conversation.from_messages(
|
|
482
|
+
list(model_context.messages)
|
|
483
|
+
),
|
|
484
|
+
tools,
|
|
485
|
+
):
|
|
486
|
+
if event.type == "reasoning_delta":
|
|
487
|
+
reasoning_parts.append(event.reasoning_content)
|
|
488
|
+
reasoning_state = "present_nonempty"
|
|
489
|
+
continue
|
|
490
|
+
|
|
491
|
+
if event.type == "reasoning_state":
|
|
492
|
+
reasoning_state = event.reasoning_state
|
|
493
|
+
continue
|
|
494
|
+
|
|
495
|
+
if event.type == "text_delta":
|
|
496
|
+
content_parts.append(event.content)
|
|
497
|
+
yield event
|
|
498
|
+
continue
|
|
499
|
+
|
|
500
|
+
if (
|
|
501
|
+
event.type == "tool_call"
|
|
502
|
+
and event.tool_call is not None
|
|
503
|
+
):
|
|
504
|
+
tool_calls.append(event.tool_call)
|
|
505
|
+
observable_tool_call_events.append(
|
|
506
|
+
replace(
|
|
507
|
+
event,
|
|
508
|
+
tool_call=_observable_tool_call(
|
|
509
|
+
self.tool_registry,
|
|
510
|
+
event.tool_call,
|
|
511
|
+
),
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
continue
|
|
515
|
+
|
|
516
|
+
if event.type == "error":
|
|
517
|
+
self._observe_token_usage(model_context)
|
|
518
|
+
self._emit_model_response(
|
|
519
|
+
turn=turn_index + 1,
|
|
520
|
+
call_kind="agent_tools",
|
|
521
|
+
content="".join(content_parts),
|
|
522
|
+
tool_calls=tool_calls,
|
|
523
|
+
)
|
|
524
|
+
yield event
|
|
525
|
+
yield AgentEvent(
|
|
526
|
+
type="stop",
|
|
527
|
+
stop_reason="model_error",
|
|
528
|
+
)
|
|
529
|
+
return
|
|
530
|
+
except Exception as error:
|
|
531
|
+
self._emit_model_response(
|
|
532
|
+
turn=turn_index + 1,
|
|
533
|
+
call_kind="agent_tools",
|
|
534
|
+
content="".join(content_parts),
|
|
535
|
+
tool_calls=tool_calls,
|
|
536
|
+
fallback_error_type=type(error).__name__,
|
|
537
|
+
)
|
|
538
|
+
classified = classify_model_error(error)
|
|
539
|
+
if (
|
|
540
|
+
classified.retryable is True
|
|
541
|
+
and model_attempt < DEFAULT_MODEL_MAX_RETRIES
|
|
542
|
+
):
|
|
543
|
+
retry_attempt = model_attempt + 1
|
|
544
|
+
delay_seconds = _model_retry_delay(
|
|
545
|
+
retry_attempt,
|
|
546
|
+
classified.retry_after_seconds,
|
|
547
|
+
)
|
|
548
|
+
retry = self._model_retry(
|
|
549
|
+
turn=turn_index + 1,
|
|
550
|
+
call_kind="agent_tools",
|
|
551
|
+
attempt=retry_attempt,
|
|
552
|
+
error=error,
|
|
553
|
+
error_code=classified.code,
|
|
554
|
+
delay_seconds=delay_seconds,
|
|
555
|
+
fallback_partial_output_chars=len(
|
|
556
|
+
"".join(content_parts)
|
|
557
|
+
),
|
|
558
|
+
)
|
|
559
|
+
yield AgentEvent(
|
|
560
|
+
type="model_retry",
|
|
561
|
+
model_retry=retry,
|
|
562
|
+
)
|
|
563
|
+
sleep(delay_seconds)
|
|
564
|
+
continue
|
|
565
|
+
if (
|
|
566
|
+
classified.retryable is True
|
|
567
|
+
and model_attempt == DEFAULT_MODEL_MAX_RETRIES
|
|
568
|
+
):
|
|
569
|
+
self._emit_model_retry_outcome(
|
|
570
|
+
turn=turn_index + 1,
|
|
571
|
+
call_kind="agent_tools",
|
|
572
|
+
outcome="exhausted",
|
|
573
|
+
retries=DEFAULT_MODEL_MAX_RETRIES,
|
|
574
|
+
final_error_type=type(error).__name__,
|
|
575
|
+
)
|
|
576
|
+
yield AgentEvent(
|
|
577
|
+
type="error",
|
|
578
|
+
error=format_model_error(
|
|
579
|
+
error,
|
|
580
|
+
operation="模型流式请求失败",
|
|
581
|
+
),
|
|
582
|
+
)
|
|
583
|
+
yield AgentEvent(
|
|
584
|
+
type="stop",
|
|
585
|
+
stop_reason="model_error",
|
|
586
|
+
)
|
|
587
|
+
return
|
|
588
|
+
|
|
589
|
+
self._observe_token_usage(model_context)
|
|
590
|
+
content = "".join(content_parts)
|
|
591
|
+
terminal_empty_response = (
|
|
592
|
+
not tool_calls
|
|
593
|
+
and not content.strip()
|
|
594
|
+
and response_attempt == 1
|
|
595
|
+
)
|
|
596
|
+
self._emit_model_response(
|
|
597
|
+
turn=turn_index + 1,
|
|
598
|
+
call_kind="agent_tools",
|
|
599
|
+
content=content,
|
|
600
|
+
tool_calls=tool_calls,
|
|
601
|
+
error_type_override=(
|
|
602
|
+
"empty_response"
|
|
603
|
+
if terminal_empty_response
|
|
604
|
+
else None
|
|
605
|
+
),
|
|
606
|
+
)
|
|
607
|
+
if model_attempt > 0:
|
|
608
|
+
self._emit_model_retry_outcome(
|
|
609
|
+
turn=turn_index + 1,
|
|
610
|
+
call_kind="agent_tools",
|
|
611
|
+
outcome="recovered",
|
|
612
|
+
retries=model_attempt,
|
|
613
|
+
recovered_on_retry=model_attempt,
|
|
614
|
+
)
|
|
615
|
+
yield from observable_tool_call_events
|
|
616
|
+
break
|
|
617
|
+
|
|
618
|
+
empty_response = not tool_calls and not content.strip()
|
|
619
|
+
if not empty_response:
|
|
620
|
+
break
|
|
621
|
+
if response_attempt == 0:
|
|
622
|
+
continue
|
|
623
|
+
yield AgentEvent(
|
|
624
|
+
type="error",
|
|
625
|
+
error=EMPTY_RESPONSE_ERROR,
|
|
626
|
+
)
|
|
627
|
+
yield AgentEvent(
|
|
628
|
+
type="stop",
|
|
629
|
+
stop_reason="model_error",
|
|
630
|
+
)
|
|
631
|
+
return
|
|
632
|
+
|
|
633
|
+
turn_local_full_group = None
|
|
634
|
+
if not tool_calls:
|
|
635
|
+
runtime_state.last_reason = "final_answer"
|
|
636
|
+
self.conversation.add_assistant_message(content)
|
|
637
|
+
yield _progress_event(runtime_state)
|
|
638
|
+
yield AgentEvent(type="stop", stop_reason="final_answer")
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
repeated_response = _check_repeated_tool_calls(
|
|
642
|
+
tool_calls,
|
|
643
|
+
previous_tool_call_signature=previous_tool_call_signature,
|
|
644
|
+
repeated_tool_call_count=repeated_tool_call_count,
|
|
645
|
+
repeated_tool_call_limit=self.repeated_tool_call_limit,
|
|
646
|
+
)
|
|
647
|
+
if repeated_response.stop_response is not None:
|
|
648
|
+
yield AgentEvent(
|
|
649
|
+
type="stop",
|
|
650
|
+
content=repeated_response.stop_response.content,
|
|
651
|
+
stop_reason=repeated_response.stop_response.stop_reason,
|
|
652
|
+
)
|
|
653
|
+
return
|
|
654
|
+
|
|
655
|
+
previous_tool_call_signature = (
|
|
656
|
+
repeated_response.previous_tool_call_signature
|
|
657
|
+
)
|
|
658
|
+
repeated_tool_call_count = repeated_response.repeated_tool_call_count
|
|
659
|
+
self.conversation.add_assistant_tool_calls(
|
|
660
|
+
content=content,
|
|
661
|
+
tool_calls=tool_calls,
|
|
662
|
+
reasoning_content="".join(reasoning_parts) or None,
|
|
663
|
+
reasoning_state=reasoning_state,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
batch = self.tool_batch_handler(self.tool_registry, tool_calls)
|
|
667
|
+
_validate_tool_batch(tool_calls, batch)
|
|
668
|
+
_observe_tool_turn_progress(
|
|
669
|
+
runtime_state,
|
|
670
|
+
registry=self.tool_registry,
|
|
671
|
+
batch=batch,
|
|
672
|
+
)
|
|
673
|
+
pending_runtime_decision = decide_runtime_policy(runtime_state)
|
|
674
|
+
pending_full_group = yield from self._persist_tool_results(batch)
|
|
675
|
+
yield _progress_event(runtime_state)
|
|
676
|
+
if batch.stop_response is not None:
|
|
677
|
+
pending_full_group = None
|
|
678
|
+
yield AgentEvent(
|
|
679
|
+
type="stop",
|
|
680
|
+
content=batch.stop_response.content,
|
|
681
|
+
stop_reason=batch.stop_response.stop_reason,
|
|
682
|
+
)
|
|
683
|
+
return
|
|
684
|
+
del batch
|
|
685
|
+
|
|
686
|
+
yield _progress_event(runtime_state)
|
|
687
|
+
if self.finalize_on_max_turns:
|
|
688
|
+
finalization_events = self._stream_finalization_after_max_turns(
|
|
689
|
+
turn_local_full_group=pending_full_group,
|
|
690
|
+
)
|
|
691
|
+
pending_full_group = None
|
|
692
|
+
yield from finalization_events
|
|
693
|
+
return
|
|
694
|
+
pending_full_group = None
|
|
695
|
+
yield AgentEvent(
|
|
696
|
+
type="stop",
|
|
697
|
+
content="Agent stopped because it reached the maximum number of turns.",
|
|
698
|
+
stop_reason="max_turns",
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
finally:
|
|
702
|
+
self.tool_registry.scoped_approvals.end_task()
|
|
703
|
+
pending_full_group = None
|
|
704
|
+
self._pending_artifact_warnings.clear()
|
|
705
|
+
if self.active_skill_state is not None:
|
|
706
|
+
self.active_skill_state.clear()
|
|
707
|
+
|
|
708
|
+
def _stream_finalization_after_max_turns(
|
|
709
|
+
self, *, turn_local_full_group: TurnLocalFullGroup | None = None,
|
|
710
|
+
) -> Iterator[AgentEvent]:
|
|
711
|
+
try:
|
|
712
|
+
finalization, model_context = self._max_turns_finalization_context(
|
|
713
|
+
turn_local_full_group=turn_local_full_group,
|
|
714
|
+
)
|
|
715
|
+
finally:
|
|
716
|
+
turn_local_full_group = None
|
|
717
|
+
if finalization is None:
|
|
718
|
+
yield AgentEvent(
|
|
719
|
+
type="stop",
|
|
720
|
+
content=(
|
|
721
|
+
f"本轮已达到 {self.max_turns} 轮上限,最终整理时上下文"
|
|
722
|
+
"超过模型可用范围,未能生成阶段性结果。"
|
|
723
|
+
),
|
|
724
|
+
stop_reason="max_turns",
|
|
725
|
+
)
|
|
726
|
+
return
|
|
727
|
+
|
|
728
|
+
self._emit_context_snapshot(
|
|
729
|
+
model_context,
|
|
730
|
+
turn=self.max_turns + 1,
|
|
731
|
+
call_kind="max_turns_finalization",
|
|
732
|
+
)
|
|
733
|
+
for model_attempt in range(DEFAULT_MODEL_MAX_RETRIES + 1):
|
|
734
|
+
content_parts: list[str] = []
|
|
735
|
+
yield AgentEvent(type="model_start")
|
|
736
|
+
try:
|
|
737
|
+
for chunk in self.llm_client.stream_complete(finalization):
|
|
738
|
+
if chunk == "":
|
|
739
|
+
continue
|
|
740
|
+
content_parts.append(chunk)
|
|
741
|
+
yield AgentEvent(type="text_delta", content=chunk)
|
|
742
|
+
except Exception as error:
|
|
743
|
+
self._emit_model_response(
|
|
744
|
+
turn=self.max_turns + 1,
|
|
745
|
+
call_kind="max_turns_finalization",
|
|
746
|
+
content="".join(content_parts),
|
|
747
|
+
tool_calls=(),
|
|
748
|
+
fallback_error_type=type(error).__name__,
|
|
749
|
+
)
|
|
750
|
+
classified = classify_model_error(error)
|
|
751
|
+
if (
|
|
752
|
+
classified.retryable is True
|
|
753
|
+
and model_attempt < DEFAULT_MODEL_MAX_RETRIES
|
|
754
|
+
):
|
|
755
|
+
retry_attempt = model_attempt + 1
|
|
756
|
+
delay_seconds = _model_retry_delay(
|
|
757
|
+
retry_attempt,
|
|
758
|
+
classified.retry_after_seconds,
|
|
759
|
+
)
|
|
760
|
+
retry = self._model_retry(
|
|
761
|
+
turn=self.max_turns + 1,
|
|
762
|
+
call_kind="max_turns_finalization",
|
|
763
|
+
attempt=retry_attempt,
|
|
764
|
+
error=error,
|
|
765
|
+
error_code=classified.code,
|
|
766
|
+
delay_seconds=delay_seconds,
|
|
767
|
+
fallback_partial_output_chars=len("".join(content_parts)),
|
|
768
|
+
)
|
|
769
|
+
yield AgentEvent(type="model_retry", model_retry=retry)
|
|
770
|
+
sleep(delay_seconds)
|
|
771
|
+
continue
|
|
772
|
+
if (
|
|
773
|
+
classified.retryable is True
|
|
774
|
+
and model_attempt == DEFAULT_MODEL_MAX_RETRIES
|
|
775
|
+
):
|
|
776
|
+
self._emit_model_retry_outcome(
|
|
777
|
+
turn=self.max_turns + 1,
|
|
778
|
+
call_kind="max_turns_finalization",
|
|
779
|
+
outcome="exhausted",
|
|
780
|
+
retries=DEFAULT_MODEL_MAX_RETRIES,
|
|
781
|
+
final_error_type=type(error).__name__,
|
|
782
|
+
)
|
|
783
|
+
yield AgentEvent(
|
|
784
|
+
type="error",
|
|
785
|
+
error=format_model_error(
|
|
786
|
+
error,
|
|
787
|
+
operation="最终整理请求失败",
|
|
788
|
+
),
|
|
789
|
+
)
|
|
790
|
+
yield AgentEvent(
|
|
791
|
+
type="stop",
|
|
792
|
+
content=(
|
|
793
|
+
f"本轮已达到 {self.max_turns} 轮上限,且未能生成"
|
|
794
|
+
"阶段性结果。"
|
|
795
|
+
),
|
|
796
|
+
stop_reason="max_turns",
|
|
797
|
+
)
|
|
798
|
+
return
|
|
799
|
+
|
|
800
|
+
self._observe_token_usage(model_context)
|
|
801
|
+
self._emit_model_response(
|
|
802
|
+
turn=self.max_turns + 1,
|
|
803
|
+
call_kind="max_turns_finalization",
|
|
804
|
+
content="".join(content_parts),
|
|
805
|
+
tool_calls=(),
|
|
806
|
+
)
|
|
807
|
+
if model_attempt > 0:
|
|
808
|
+
self._emit_model_retry_outcome(
|
|
809
|
+
turn=self.max_turns + 1,
|
|
810
|
+
call_kind="max_turns_finalization",
|
|
811
|
+
outcome="recovered",
|
|
812
|
+
retries=model_attempt,
|
|
813
|
+
recovered_on_retry=model_attempt,
|
|
814
|
+
)
|
|
815
|
+
break
|
|
816
|
+
|
|
817
|
+
content = "".join(content_parts).strip()
|
|
818
|
+
if content == "":
|
|
819
|
+
yield AgentEvent(
|
|
820
|
+
type="stop",
|
|
821
|
+
content=(
|
|
822
|
+
f"本轮已达到 {self.max_turns} 轮上限,模型没有返回"
|
|
823
|
+
"可用的阶段性结果。"
|
|
824
|
+
),
|
|
825
|
+
stop_reason="max_turns",
|
|
826
|
+
)
|
|
827
|
+
return
|
|
828
|
+
|
|
829
|
+
content = normalize_run_checkpoint(content)
|
|
830
|
+
self.conversation.add_assistant_message(content)
|
|
831
|
+
yield AgentEvent(
|
|
832
|
+
type="stop",
|
|
833
|
+
content=(
|
|
834
|
+
f"本轮已达到 {self.max_turns} 轮上限;上面是基于现有信息"
|
|
835
|
+
"整理的阶段性结果。"
|
|
836
|
+
),
|
|
837
|
+
stop_reason="max_turns",
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
def _max_turns_finalization_context(
|
|
841
|
+
self, *, turn_local_full_group: TurnLocalFullGroup | None = None,
|
|
842
|
+
) -> tuple[Conversation | None, ModelContext]:
|
|
843
|
+
finalization_context = self._model_context(
|
|
844
|
+
[],
|
|
845
|
+
turn_local_full_group=turn_local_full_group,
|
|
846
|
+
observability_turn=self.max_turns + 1,
|
|
847
|
+
request_messages=(
|
|
848
|
+
Message(role="user", content=MAX_TURNS_FINALIZATION_PROMPT),
|
|
849
|
+
),
|
|
850
|
+
)
|
|
851
|
+
if finalization_context.estimate.over_budget:
|
|
852
|
+
return None, finalization_context
|
|
853
|
+
return (
|
|
854
|
+
Conversation.from_messages(list(finalization_context.messages)),
|
|
855
|
+
finalization_context,
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
def _model_context(
|
|
859
|
+
self,
|
|
860
|
+
tools: list[dict[str, object]],
|
|
861
|
+
*,
|
|
862
|
+
guidance: tuple[str, ...] = (),
|
|
863
|
+
request_messages: tuple[Message, ...] = (),
|
|
864
|
+
turn_local_full_group: TurnLocalFullGroup | None = None,
|
|
865
|
+
observability_turn: int | None = None,
|
|
866
|
+
compaction_mode: Literal["auto", "preview", "force"] = "auto",
|
|
867
|
+
update_last_model_context: bool = True,
|
|
868
|
+
) -> ModelContext:
|
|
869
|
+
memory_recall = self.last_memory_recall
|
|
870
|
+
result = ContextBuilder(
|
|
871
|
+
budget=self.context_budget,
|
|
872
|
+
token_estimator=self.token_estimator,
|
|
873
|
+
compactor=self.compactor,
|
|
874
|
+
retention_policy=ToolResultRetentionPolicy(
|
|
875
|
+
self.context_budget, self.tool_result_artifact_store,
|
|
876
|
+
self._historical_artifact_failure_content,
|
|
877
|
+
),
|
|
878
|
+
).build(
|
|
879
|
+
self.conversation,
|
|
880
|
+
tools=tools,
|
|
881
|
+
memory_message=None if memory_recall is None else memory_recall.message,
|
|
882
|
+
memory_stats=None if memory_recall is None else memory_recall.stats,
|
|
883
|
+
guidance=guidance,
|
|
884
|
+
request_messages=request_messages,
|
|
885
|
+
persistent_system_messages=(
|
|
886
|
+
()
|
|
887
|
+
if self.active_skill_state is None
|
|
888
|
+
else tuple(
|
|
889
|
+
Message(role="system", content=content)
|
|
890
|
+
for content in self.active_skill_state.to_system_contexts()
|
|
891
|
+
)
|
|
892
|
+
),
|
|
893
|
+
turn_local_full_group=turn_local_full_group,
|
|
894
|
+
observability_turn=observability_turn,
|
|
895
|
+
compaction_mode=compaction_mode,
|
|
896
|
+
)
|
|
897
|
+
if compaction_mode == "auto":
|
|
898
|
+
self._accumulate_run_token_usage(result.compact_attempt_token_usage)
|
|
899
|
+
if update_last_model_context:
|
|
900
|
+
self.last_model_context = result.context
|
|
901
|
+
return result.context
|
|
902
|
+
|
|
903
|
+
def inspect_context(self) -> ModelContext:
|
|
904
|
+
"""Recompute the current model-visible context without model calls."""
|
|
905
|
+
return self._model_context(
|
|
906
|
+
self.tool_registry.get_schemas(),
|
|
907
|
+
compaction_mode="preview",
|
|
908
|
+
update_last_model_context=False,
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
def compact_context(self) -> ModelContext:
|
|
912
|
+
"""Run one user-requested Compact while preserving all safety gates."""
|
|
913
|
+
return self._model_context(
|
|
914
|
+
self.tool_registry.get_schemas(),
|
|
915
|
+
compaction_mode="force",
|
|
916
|
+
update_last_model_context=False,
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
def _persist_tool_results(
|
|
920
|
+
self, batch: ToolBatchExecution,
|
|
921
|
+
) -> Generator[AgentEvent, None, TurnLocalFullGroup | None]:
|
|
922
|
+
"""Persist immediately, handing off only this batch's successfully stored Fulls."""
|
|
923
|
+
assistant = self.conversation.get_messages()[-1]
|
|
924
|
+
results: dict[str, tuple[str, str]] = {}
|
|
925
|
+
externalized_count = 0
|
|
926
|
+
for execution in batch.executions:
|
|
927
|
+
yield AgentEvent(type="tool_result", tool_result=execution.result)
|
|
928
|
+
content = format_tool_result(execution.result)
|
|
929
|
+
persisted_content = self._tool_result_content(execution, content=content)
|
|
930
|
+
for warning in self._drain_artifact_warnings():
|
|
931
|
+
yield AgentEvent(type="artifact_warning", content=warning.content)
|
|
932
|
+
self.conversation.add_tool_result_message(
|
|
933
|
+
tool_call_id=execution.tool_call.id,
|
|
934
|
+
content=persisted_content,
|
|
935
|
+
)
|
|
936
|
+
artifact_reference = (
|
|
937
|
+
artifact_reference_info(
|
|
938
|
+
tool_name=execution.tool_call.name,
|
|
939
|
+
tool_call_id=execution.tool_call.id,
|
|
940
|
+
content=persisted_content,
|
|
941
|
+
) is not None
|
|
942
|
+
)
|
|
943
|
+
if persisted_content != content and artifact_reference:
|
|
944
|
+
externalized_count += 1
|
|
945
|
+
if (
|
|
946
|
+
self.context_budget.recent_tool_result_groups_to_keep > 0
|
|
947
|
+
and persisted_content != content
|
|
948
|
+
and artifact_reference
|
|
949
|
+
):
|
|
950
|
+
results[execution.tool_call.id] = (persisted_content, content)
|
|
951
|
+
if not results and externalized_count == 0:
|
|
952
|
+
return None
|
|
953
|
+
return TurnLocalFullGroup(
|
|
954
|
+
assistant,
|
|
955
|
+
results,
|
|
956
|
+
externalized_count=externalized_count,
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
def _tool_result_content(self, execution: ToolCallExecution, *, content: str) -> str:
|
|
960
|
+
store = self.tool_result_artifact_store
|
|
961
|
+
if store is None:
|
|
962
|
+
return content
|
|
963
|
+
try:
|
|
964
|
+
externalized = store.externalize(
|
|
965
|
+
tool_name=execution.tool_call.name,
|
|
966
|
+
tool_call_id=execution.tool_call.id,
|
|
967
|
+
content=content,
|
|
968
|
+
)
|
|
969
|
+
except Exception as error:
|
|
970
|
+
reason = self._record_artifact_failure(
|
|
971
|
+
error,
|
|
972
|
+
phase="tool_result",
|
|
973
|
+
tool_name=execution.tool_call.name,
|
|
974
|
+
)
|
|
975
|
+
return artifact_externalization_failure_content(
|
|
976
|
+
tool_name=execution.tool_call.name,
|
|
977
|
+
tool_call_id=execution.tool_call.id,
|
|
978
|
+
original_content=content,
|
|
979
|
+
reason=reason,
|
|
980
|
+
)
|
|
981
|
+
return externalized
|
|
982
|
+
|
|
983
|
+
def _historical_artifact_failure_content(
|
|
984
|
+
self,
|
|
985
|
+
tool_name: str,
|
|
986
|
+
tool_call_id: str,
|
|
987
|
+
content: str,
|
|
988
|
+
error: Exception,
|
|
989
|
+
) -> str:
|
|
990
|
+
reason = self._record_artifact_failure(
|
|
991
|
+
error,
|
|
992
|
+
phase="history_projection",
|
|
993
|
+
tool_name=tool_name,
|
|
994
|
+
)
|
|
995
|
+
return artifact_externalization_failure_content(
|
|
996
|
+
tool_name=tool_name,
|
|
997
|
+
tool_call_id=tool_call_id,
|
|
998
|
+
original_content=content,
|
|
999
|
+
reason=reason,
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
def _record_artifact_failure(
|
|
1003
|
+
self,
|
|
1004
|
+
error: Exception,
|
|
1005
|
+
*,
|
|
1006
|
+
phase: str,
|
|
1007
|
+
tool_name: str,
|
|
1008
|
+
) -> str:
|
|
1009
|
+
reason = artifact_failure_reason(error)
|
|
1010
|
+
self.artifact_failure_count += 1
|
|
1011
|
+
self.last_artifact_error = reason
|
|
1012
|
+
safe_tool_name = _safe_event_tool_name(tool_name)
|
|
1013
|
+
self._pending_artifact_warnings.append(
|
|
1014
|
+
AgentWarning(
|
|
1015
|
+
type="artifact_externalization",
|
|
1016
|
+
content=(
|
|
1017
|
+
f"phase={phase}, reason={reason}, tool={safe_tool_name}, "
|
|
1018
|
+
f"failures={self.artifact_failure_count}; "
|
|
1019
|
+
"tool body was not persisted"
|
|
1020
|
+
),
|
|
1021
|
+
)
|
|
1022
|
+
)
|
|
1023
|
+
return reason
|
|
1024
|
+
|
|
1025
|
+
def _drain_artifact_warnings(self) -> tuple[AgentWarning, ...]:
|
|
1026
|
+
warnings = tuple(self._pending_artifact_warnings)
|
|
1027
|
+
self._pending_artifact_warnings.clear()
|
|
1028
|
+
return warnings
|
|
1029
|
+
|
|
1030
|
+
def _recall_memory(self, user_message: str) -> None:
|
|
1031
|
+
if self.memory_context_selector is None:
|
|
1032
|
+
self.last_memory_recall = None
|
|
1033
|
+
return
|
|
1034
|
+
self.last_memory_recall = self.memory_context_selector.recall(user_message)
|
|
1035
|
+
|
|
1036
|
+
def _observe_token_usage(self, context: ModelContext) -> None:
|
|
1037
|
+
usage = getattr(self.llm_client, "last_token_usage", None)
|
|
1038
|
+
self.last_token_usage = usage
|
|
1039
|
+
self.last_reasoning_char_count = getattr(
|
|
1040
|
+
self.llm_client,
|
|
1041
|
+
"last_reasoning_char_count",
|
|
1042
|
+
0,
|
|
1043
|
+
)
|
|
1044
|
+
self._accumulate_run_token_usage(usage)
|
|
1045
|
+
self.token_estimator.observe(context.estimate, usage)
|
|
1046
|
+
|
|
1047
|
+
def _model_retry(
|
|
1048
|
+
self,
|
|
1049
|
+
*,
|
|
1050
|
+
turn: int,
|
|
1051
|
+
call_kind: str,
|
|
1052
|
+
attempt: int,
|
|
1053
|
+
error: Exception,
|
|
1054
|
+
error_code: str,
|
|
1055
|
+
delay_seconds: float,
|
|
1056
|
+
fallback_partial_output_chars: int,
|
|
1057
|
+
) -> AgentModelRetry:
|
|
1058
|
+
model_response = getattr(self.llm_client, "last_model_response", None)
|
|
1059
|
+
stream_chunk_count = None
|
|
1060
|
+
partial_output_chars = fallback_partial_output_chars
|
|
1061
|
+
if isinstance(model_response, dict):
|
|
1062
|
+
stream_chunk_count = model_response.get("stream_chunk_count")
|
|
1063
|
+
observed_chars = model_response.get("content_chars")
|
|
1064
|
+
if isinstance(observed_chars, int):
|
|
1065
|
+
partial_output_chars = observed_chars
|
|
1066
|
+
retry = AgentModelRetry(
|
|
1067
|
+
attempt=attempt,
|
|
1068
|
+
max_retries=DEFAULT_MODEL_MAX_RETRIES,
|
|
1069
|
+
delay_seconds=delay_seconds,
|
|
1070
|
+
error_type=type(error).__name__,
|
|
1071
|
+
error_code=error_code,
|
|
1072
|
+
retryable=True,
|
|
1073
|
+
call_kind=call_kind,
|
|
1074
|
+
stream_started=(
|
|
1075
|
+
isinstance(stream_chunk_count, int) and stream_chunk_count > 0
|
|
1076
|
+
),
|
|
1077
|
+
partial_output_chars=partial_output_chars,
|
|
1078
|
+
)
|
|
1079
|
+
emit_observation(
|
|
1080
|
+
self.observability_sink,
|
|
1081
|
+
"model_retry",
|
|
1082
|
+
{
|
|
1083
|
+
"run_scope": self.observability_scope,
|
|
1084
|
+
"run_id": self.observability_run_id,
|
|
1085
|
+
"turn": turn,
|
|
1086
|
+
"call_kind": retry.call_kind,
|
|
1087
|
+
"attempt": retry.attempt,
|
|
1088
|
+
"max_retries": retry.max_retries,
|
|
1089
|
+
"error_type": retry.error_type,
|
|
1090
|
+
"error_code": retry.error_code,
|
|
1091
|
+
"retryable": retry.retryable,
|
|
1092
|
+
"delay_seconds": retry.delay_seconds,
|
|
1093
|
+
"stream_started": retry.stream_started,
|
|
1094
|
+
"partial_output_chars": retry.partial_output_chars,
|
|
1095
|
+
},
|
|
1096
|
+
)
|
|
1097
|
+
return retry
|
|
1098
|
+
|
|
1099
|
+
def _emit_model_retry_outcome(
|
|
1100
|
+
self,
|
|
1101
|
+
*,
|
|
1102
|
+
turn: int,
|
|
1103
|
+
call_kind: str,
|
|
1104
|
+
outcome: str,
|
|
1105
|
+
retries: int,
|
|
1106
|
+
recovered_on_retry: int | None = None,
|
|
1107
|
+
final_error_type: str | None = None,
|
|
1108
|
+
) -> None:
|
|
1109
|
+
emit_observation(
|
|
1110
|
+
self.observability_sink,
|
|
1111
|
+
"model_retry_outcome",
|
|
1112
|
+
{
|
|
1113
|
+
"run_scope": self.observability_scope,
|
|
1114
|
+
"run_id": self.observability_run_id,
|
|
1115
|
+
"turn": turn,
|
|
1116
|
+
"call_kind": call_kind,
|
|
1117
|
+
"outcome": outcome,
|
|
1118
|
+
"retries": retries,
|
|
1119
|
+
"recovered_on_retry": recovered_on_retry,
|
|
1120
|
+
"final_error_type": final_error_type,
|
|
1121
|
+
},
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
def _emit_model_response(
|
|
1125
|
+
self,
|
|
1126
|
+
*,
|
|
1127
|
+
turn: int | None,
|
|
1128
|
+
call_kind: str,
|
|
1129
|
+
content: str,
|
|
1130
|
+
tool_calls: Sequence[AgentToolCall],
|
|
1131
|
+
fallback_error_type: str | None = None,
|
|
1132
|
+
error_type_override: str | None = None,
|
|
1133
|
+
) -> None:
|
|
1134
|
+
observation = getattr(self.llm_client, "last_model_response", None)
|
|
1135
|
+
if not isinstance(observation, dict):
|
|
1136
|
+
usage = getattr(self.llm_client, "last_token_usage", None)
|
|
1137
|
+
observation = {
|
|
1138
|
+
"model": getattr(self.llm_client, "model", None),
|
|
1139
|
+
"request_id": None,
|
|
1140
|
+
"provider_request_id": None,
|
|
1141
|
+
"finish_reason": None,
|
|
1142
|
+
"stop_reason": None,
|
|
1143
|
+
"content_chars": len(content),
|
|
1144
|
+
"content_non_whitespace_chars": sum(
|
|
1145
|
+
not character.isspace() for character in content
|
|
1146
|
+
),
|
|
1147
|
+
"tool_call_count": len(tool_calls),
|
|
1148
|
+
"tool_names": [call.name for call in tool_calls],
|
|
1149
|
+
"reasoning_field_present": False,
|
|
1150
|
+
"reasoning_chars": getattr(
|
|
1151
|
+
self.llm_client,
|
|
1152
|
+
"last_reasoning_char_count",
|
|
1153
|
+
0,
|
|
1154
|
+
),
|
|
1155
|
+
"prompt_tokens": None if usage is None else usage.prompt_tokens,
|
|
1156
|
+
"completion_tokens": (
|
|
1157
|
+
None if usage is None else usage.completion_tokens
|
|
1158
|
+
),
|
|
1159
|
+
"total_tokens": None if usage is None else usage.total_tokens,
|
|
1160
|
+
"latency_ms": None,
|
|
1161
|
+
"first_token_latency_ms": None,
|
|
1162
|
+
"stream_chunk_count": None,
|
|
1163
|
+
"retry_count": None,
|
|
1164
|
+
"error_type": error_type_override or fallback_error_type,
|
|
1165
|
+
"http_status": None,
|
|
1166
|
+
"empty_response": not content.strip() and not tool_calls,
|
|
1167
|
+
}
|
|
1168
|
+
elif error_type_override is not None:
|
|
1169
|
+
observation = {
|
|
1170
|
+
**observation,
|
|
1171
|
+
"error_type": error_type_override,
|
|
1172
|
+
}
|
|
1173
|
+
emit_observation(
|
|
1174
|
+
self.observability_sink,
|
|
1175
|
+
"model_response",
|
|
1176
|
+
{
|
|
1177
|
+
"run_scope": self.observability_scope,
|
|
1178
|
+
"run_id": self.observability_run_id,
|
|
1179
|
+
"turn": turn,
|
|
1180
|
+
"call_kind": call_kind,
|
|
1181
|
+
**observation,
|
|
1182
|
+
},
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
def _emit_context_snapshot(
|
|
1186
|
+
self,
|
|
1187
|
+
context: ModelContext,
|
|
1188
|
+
*,
|
|
1189
|
+
turn: int | None,
|
|
1190
|
+
call_kind: str,
|
|
1191
|
+
) -> None:
|
|
1192
|
+
compact = context.compact_stats
|
|
1193
|
+
retention = context.retention_stats
|
|
1194
|
+
active_skill_names = (
|
|
1195
|
+
[]
|
|
1196
|
+
if self.active_skill_state is None
|
|
1197
|
+
else [skill.name for skill in self.active_skill_state.get_active()]
|
|
1198
|
+
)
|
|
1199
|
+
emit_observation(
|
|
1200
|
+
self.observability_sink,
|
|
1201
|
+
"context_snapshot",
|
|
1202
|
+
{
|
|
1203
|
+
"run_scope": self.observability_scope,
|
|
1204
|
+
"run_id": self.observability_run_id,
|
|
1205
|
+
"turn": turn,
|
|
1206
|
+
"call_kind": call_kind,
|
|
1207
|
+
"estimated_input_tokens": context.estimate.estimated_input_tokens,
|
|
1208
|
+
"max_input_tokens": context.estimate.max_input_tokens,
|
|
1209
|
+
"selected_message_count": context.selected_message_count,
|
|
1210
|
+
"source_message_count": context.source_message_count,
|
|
1211
|
+
"over_budget": context.estimate.over_budget,
|
|
1212
|
+
"active_skill_count": len(active_skill_names),
|
|
1213
|
+
"active_skill_names": active_skill_names,
|
|
1214
|
+
"turn_local_full_hit": (
|
|
1215
|
+
0 if retention is None else retention.turn_local_full_groups
|
|
1216
|
+
),
|
|
1217
|
+
"artifact_rehydrate_count": (
|
|
1218
|
+
0 if retention is None else retention.artifact_rehydrate_count
|
|
1219
|
+
),
|
|
1220
|
+
"artifact_externalized_count": (
|
|
1221
|
+
0 if retention is None else retention.artifact_externalized_count
|
|
1222
|
+
),
|
|
1223
|
+
"compact_triggered": (
|
|
1224
|
+
compact is not None and compact.status == "compacted"
|
|
1225
|
+
),
|
|
1226
|
+
"compact_status": None if compact is None else compact.status,
|
|
1227
|
+
"retention": {
|
|
1228
|
+
"full_groups": 0 if retention is None else retention.full_groups,
|
|
1229
|
+
"turn_local_full_groups": (
|
|
1230
|
+
0 if retention is None else retention.turn_local_full_groups
|
|
1231
|
+
),
|
|
1232
|
+
"artifact_rehydrated_groups": (
|
|
1233
|
+
0
|
|
1234
|
+
if retention is None
|
|
1235
|
+
else retention.artifact_rehydrated_groups
|
|
1236
|
+
),
|
|
1237
|
+
"artifact_groups": (
|
|
1238
|
+
0 if retention is None else retention.artifact_groups
|
|
1239
|
+
),
|
|
1240
|
+
"metadata_groups": (
|
|
1241
|
+
0 if retention is None else retention.metadata_groups
|
|
1242
|
+
),
|
|
1243
|
+
"budget_downgraded_groups": (
|
|
1244
|
+
0
|
|
1245
|
+
if retention is None
|
|
1246
|
+
else retention.budget_downgraded_groups
|
|
1247
|
+
),
|
|
1248
|
+
"rehydration_failures": (
|
|
1249
|
+
0 if retention is None else retention.rehydration_failures
|
|
1250
|
+
),
|
|
1251
|
+
},
|
|
1252
|
+
},
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
def _accumulate_run_token_usage(self, usage: TokenUsage | None) -> None:
|
|
1256
|
+
if usage is None:
|
|
1257
|
+
return
|
|
1258
|
+
previous = self.run_token_usage
|
|
1259
|
+
self.run_token_usage = TokenUsage(
|
|
1260
|
+
prompt_tokens=usage.prompt_tokens + (
|
|
1261
|
+
0 if previous is None else previous.prompt_tokens
|
|
1262
|
+
),
|
|
1263
|
+
completion_tokens=usage.completion_tokens + (
|
|
1264
|
+
0 if previous is None else previous.completion_tokens
|
|
1265
|
+
),
|
|
1266
|
+
total_tokens=usage.total_tokens + (
|
|
1267
|
+
0 if previous is None else previous.total_tokens
|
|
1268
|
+
),
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
@dataclass(frozen=True)
|
|
1273
|
+
class _RepeatedToolCallCheck:
|
|
1274
|
+
previous_tool_call_signature: str | None
|
|
1275
|
+
repeated_tool_call_count: int
|
|
1276
|
+
stop_response: AgentModelResponse | None = None
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def format_tool_result(result: ToolResult) -> str:
|
|
1280
|
+
status = "OK" if result.ok else "ERROR"
|
|
1281
|
+
body = result.content if result.ok else result.error or ""
|
|
1282
|
+
metadata = json.dumps(result.metadata, ensure_ascii=False, sort_keys=True, default=str)
|
|
1283
|
+
|
|
1284
|
+
return f"{status}\n{body}\n\nMETADATA\n{metadata}"
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def _context_event(
|
|
1288
|
+
context: ModelContext,
|
|
1289
|
+
*,
|
|
1290
|
+
previous_token_usage: TokenUsage | None,
|
|
1291
|
+
) -> AgentEvent:
|
|
1292
|
+
return AgentEvent(
|
|
1293
|
+
type="context",
|
|
1294
|
+
content=format_model_context_stats(
|
|
1295
|
+
context,
|
|
1296
|
+
previous_prompt_tokens=(
|
|
1297
|
+
previous_token_usage.prompt_tokens
|
|
1298
|
+
if previous_token_usage is not None
|
|
1299
|
+
else None
|
|
1300
|
+
),
|
|
1301
|
+
),
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def _progress_event(runtime_state: RuntimeState) -> AgentEvent:
|
|
1306
|
+
return AgentEvent(
|
|
1307
|
+
type="progress",
|
|
1308
|
+
progress=AgentProgressSnapshot(
|
|
1309
|
+
stagnation_turns=runtime_state.stagnation_turns,
|
|
1310
|
+
same_tool_repeat=runtime_state.same_tool_repeat,
|
|
1311
|
+
same_result_repeat=runtime_state.same_result_repeat,
|
|
1312
|
+
resource_repeat=runtime_state.resource_repeat,
|
|
1313
|
+
convergence_guided=runtime_state.convergence_guided,
|
|
1314
|
+
reason=runtime_state.last_reason,
|
|
1315
|
+
),
|
|
1316
|
+
)
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def _context_state(
|
|
1320
|
+
context: ModelContext,
|
|
1321
|
+
) -> tuple[int, int, bool, int, int, str | None, bool, str | None, int, int, int]:
|
|
1322
|
+
memory = context.memory_stats
|
|
1323
|
+
compact = context.compact_stats
|
|
1324
|
+
return (
|
|
1325
|
+
context.trimmed_message_count,
|
|
1326
|
+
context.compressed_tool_result_count,
|
|
1327
|
+
context.estimate.over_budget,
|
|
1328
|
+
0 if memory is None else memory.included_entry_count,
|
|
1329
|
+
0 if memory is None else memory.issue_count,
|
|
1330
|
+
None if compact is None else compact.boundary_id,
|
|
1331
|
+
False if compact is None else compact.summary_visible,
|
|
1332
|
+
None if compact is None else compact.status,
|
|
1333
|
+
0 if compact is None else compact.consecutive_failure_count,
|
|
1334
|
+
0 if context.retention_stats is None else context.retention_stats.budget_downgraded_groups,
|
|
1335
|
+
0 if context.retention_stats is None else context.retention_stats.rehydration_failures,
|
|
1336
|
+
)
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def _context_overflow_message(context: ModelContext) -> str:
|
|
1340
|
+
return (
|
|
1341
|
+
"Model request was not sent because the estimated input context exceeds "
|
|
1342
|
+
f"the configured budget ({context.estimate.estimated_input_tokens}/"
|
|
1343
|
+
f"{context.estimate.max_input_tokens} tokens)."
|
|
1344
|
+
)
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
def _observable_tool_call(
|
|
1348
|
+
registry: ToolRegistry,
|
|
1349
|
+
tool_call: AgentToolCall,
|
|
1350
|
+
) -> AgentToolCall:
|
|
1351
|
+
field_name = _OBSERVABLE_BOUNDED_READ_FIELDS.get(tool_call.name)
|
|
1352
|
+
if field_name is None or field_name not in tool_call.arguments:
|
|
1353
|
+
return tool_call
|
|
1354
|
+
|
|
1355
|
+
tool = registry.get(tool_call.name)
|
|
1356
|
+
if tool is None:
|
|
1357
|
+
return tool_call
|
|
1358
|
+
try:
|
|
1359
|
+
args = tool.parse_arguments(tool_call.arguments)
|
|
1360
|
+
except (ValidationError, ToolArgumentValidationError):
|
|
1361
|
+
return tool_call
|
|
1362
|
+
arguments = dict(tool_call.arguments)
|
|
1363
|
+
arguments[field_name] = args.model_dump()[field_name]
|
|
1364
|
+
return replace(tool_call, arguments=arguments)
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def _observe_tool_turn_progress(
|
|
1368
|
+
runtime_state: RuntimeState,
|
|
1369
|
+
*,
|
|
1370
|
+
registry: ToolRegistry,
|
|
1371
|
+
batch: ToolBatchExecution,
|
|
1372
|
+
) -> None:
|
|
1373
|
+
runtime_state.observe_tool_turn(
|
|
1374
|
+
_batch_tool_observations(registry, batch),
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
def _batch_tool_observations(
|
|
1379
|
+
registry: ToolRegistry,
|
|
1380
|
+
batch: ToolBatchExecution,
|
|
1381
|
+
) -> tuple[RuntimeObservation, ...]:
|
|
1382
|
+
observations: list[RuntimeObservation] = []
|
|
1383
|
+
for execution in batch.executions:
|
|
1384
|
+
tool = registry.get(execution.tool_call.name)
|
|
1385
|
+
workspace = None if tool is None else getattr(tool, "workspace", None)
|
|
1386
|
+
observations.append(
|
|
1387
|
+
observe_tool_result(
|
|
1388
|
+
tool_name=execution.tool_call.name,
|
|
1389
|
+
capability=(
|
|
1390
|
+
tool.get_permission_profile().capability
|
|
1391
|
+
if tool is not None
|
|
1392
|
+
else None
|
|
1393
|
+
),
|
|
1394
|
+
arguments=execution.tool_call.arguments,
|
|
1395
|
+
ok=execution.result.ok,
|
|
1396
|
+
content=execution.result.content,
|
|
1397
|
+
error=execution.result.error,
|
|
1398
|
+
metadata=execution.result.metadata,
|
|
1399
|
+
workspace=workspace if isinstance(workspace, Workspace) else None,
|
|
1400
|
+
)
|
|
1401
|
+
)
|
|
1402
|
+
return tuple(observations)
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _check_repeated_tool_calls(
|
|
1406
|
+
tool_calls: list[AgentToolCall],
|
|
1407
|
+
*,
|
|
1408
|
+
previous_tool_call_signature: str | None,
|
|
1409
|
+
repeated_tool_call_count: int,
|
|
1410
|
+
repeated_tool_call_limit: int,
|
|
1411
|
+
) -> _RepeatedToolCallCheck:
|
|
1412
|
+
for tool_call in tool_calls:
|
|
1413
|
+
signature = _tool_call_signature(tool_call)
|
|
1414
|
+
if signature == previous_tool_call_signature:
|
|
1415
|
+
repeated_tool_call_count += 1
|
|
1416
|
+
else:
|
|
1417
|
+
previous_tool_call_signature = signature
|
|
1418
|
+
repeated_tool_call_count = 1
|
|
1419
|
+
|
|
1420
|
+
if repeated_tool_call_count >= repeated_tool_call_limit:
|
|
1421
|
+
return _RepeatedToolCallCheck(
|
|
1422
|
+
previous_tool_call_signature=previous_tool_call_signature,
|
|
1423
|
+
repeated_tool_call_count=repeated_tool_call_count,
|
|
1424
|
+
stop_response=AgentModelResponse(
|
|
1425
|
+
content=(
|
|
1426
|
+
"Agent stopped because the model repeated the same tool call "
|
|
1427
|
+
"too many times."
|
|
1428
|
+
),
|
|
1429
|
+
stop_reason="repeated_tool_call",
|
|
1430
|
+
),
|
|
1431
|
+
)
|
|
1432
|
+
|
|
1433
|
+
return _RepeatedToolCallCheck(
|
|
1434
|
+
previous_tool_call_signature=previous_tool_call_signature,
|
|
1435
|
+
repeated_tool_call_count=repeated_tool_call_count,
|
|
1436
|
+
)
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _tool_call_signature(tool_call: AgentToolCall) -> str:
|
|
1440
|
+
arguments = json.dumps(
|
|
1441
|
+
tool_call.arguments,
|
|
1442
|
+
ensure_ascii=False,
|
|
1443
|
+
sort_keys=True,
|
|
1444
|
+
default=str,
|
|
1445
|
+
)
|
|
1446
|
+
return f"{tool_call.name}:{arguments}"
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def _validate_tool_batch(
|
|
1450
|
+
tool_calls: list[AgentToolCall],
|
|
1451
|
+
batch: ToolBatchExecution,
|
|
1452
|
+
) -> None:
|
|
1453
|
+
executed_calls = tuple(execution.tool_call for execution in batch.executions)
|
|
1454
|
+
if executed_calls != tuple(tool_calls):
|
|
1455
|
+
raise ToolBatchContractError(
|
|
1456
|
+
"Tool batch handler must return exactly one ordered result for every "
|
|
1457
|
+
"model tool call."
|
|
1458
|
+
)
|
|
1459
|
+
if (
|
|
1460
|
+
batch.stop_response is not None
|
|
1461
|
+
and batch.stop_response.stop_reason == "tool_calls"
|
|
1462
|
+
):
|
|
1463
|
+
raise ToolBatchContractError(
|
|
1464
|
+
"Tool batch stop response must use a terminal stop reason."
|
|
1465
|
+
)
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def _start_tool_batch_run(handler: ToolBatchHandler) -> None:
|
|
1469
|
+
start_run = getattr(handler, "start_run", None)
|
|
1470
|
+
if callable(start_run):
|
|
1471
|
+
start_run()
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def _safe_event_tool_name(tool_name: str) -> str:
|
|
1475
|
+
normalized = "".join(
|
|
1476
|
+
character
|
|
1477
|
+
for character in tool_name[:64]
|
|
1478
|
+
if character.isascii()
|
|
1479
|
+
and (character.isalnum() or character in {"_", "-"})
|
|
1480
|
+
)
|
|
1481
|
+
return normalized or "unknown"
|