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.
Files changed (121) hide show
  1. mycode/__init__.py +0 -0
  2. mycode/adapters/__init__.py +21 -0
  3. mycode/adapters/jsonl.py +692 -0
  4. mycode/agent/__init__.py +25 -0
  5. mycode/agent/events.py +111 -0
  6. mycode/agent/outcome.py +103 -0
  7. mycode/agent/progress.py +373 -0
  8. mycode/agent/runner.py +1481 -0
  9. mycode/application/__init__.py +38 -0
  10. mycode/application/agent_session.py +367 -0
  11. mycode/application/events.py +59 -0
  12. mycode/application/runtime.py +211 -0
  13. mycode/application/sessions.py +180 -0
  14. mycode/cli.py +840 -0
  15. mycode/config.py +355 -0
  16. mycode/context/__init__.py +1 -0
  17. mycode/context/artifacts.py +672 -0
  18. mycode/context/budget.py +752 -0
  19. mycode/context/builder.py +112 -0
  20. mycode/context/compact.py +795 -0
  21. mycode/context/tool_result_format.py +199 -0
  22. mycode/context/tool_result_retention.py +261 -0
  23. mycode/conversation.py +78 -0
  24. mycode/error_handling.py +481 -0
  25. mycode/event_format.py +147 -0
  26. mycode/instructions.py +285 -0
  27. mycode/llm.py +771 -0
  28. mycode/mcp/__init__.py +41 -0
  29. mycode/mcp/client.py +44 -0
  30. mycode/mcp/config.py +207 -0
  31. mycode/mcp/errors.py +302 -0
  32. mycode/mcp/manager.py +339 -0
  33. mycode/mcp/models.py +20 -0
  34. mycode/mcp/result_adapter.py +58 -0
  35. mycode/mcp/tool_adapter.py +145 -0
  36. mycode/mcp/trust.py +313 -0
  37. mycode/memory.py +570 -0
  38. mycode/memory_context.py +245 -0
  39. mycode/messages.py +63 -0
  40. mycode/observability.py +28 -0
  41. mycode/permissions.py +262 -0
  42. mycode/persistence/__init__.py +1 -0
  43. mycode/persistence/filesystem.py +291 -0
  44. mycode/persistence/project_storage.py +208 -0
  45. mycode/persistence/session_lock.py +138 -0
  46. mycode/persistence/session_store.py +503 -0
  47. mycode/presentation/__init__.py +1 -0
  48. mycode/presentation/cli/__init__.py +14 -0
  49. mycode/presentation/cli/confirmer.py +116 -0
  50. mycode/presentation/cli/mcp_trust.py +61 -0
  51. mycode/presentation/cli/presenter.py +320 -0
  52. mycode/presentation/cli/session_menu.py +146 -0
  53. mycode/presentation/cli/subagent_observer.py +124 -0
  54. mycode/presentation/command_format.py +90 -0
  55. mycode/presentation/commands.py +95 -0
  56. mycode/presentation/tui/__init__.py +6 -0
  57. mycode/presentation/tui/app.py +1351 -0
  58. mycode/presentation/tui/interactions.py +253 -0
  59. mycode/presentation/tui/presenter.py +266 -0
  60. mycode/presentation/tui/screens.py +305 -0
  61. mycode/presentation/tui/widgets.py +214 -0
  62. mycode/project.py +22 -0
  63. mycode/prompts.py +181 -0
  64. mycode/reasoning.py +40 -0
  65. mycode/session.py +86 -0
  66. mycode/skills/__init__.py +27 -0
  67. mycode/skills/builtin/database-recovery/SKILL.md +138 -0
  68. mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
  69. mycode/skills/registry.py +295 -0
  70. mycode/skills/state.py +68 -0
  71. mycode/subagents/__init__.py +1 -0
  72. mycode/subagents/audit.py +212 -0
  73. mycode/subagents/concurrency.py +124 -0
  74. mycode/subagents/contracts.py +421 -0
  75. mycode/subagents/delegate.py +80 -0
  76. mycode/subagents/delegation.py +128 -0
  77. mycode/subagents/lifecycle.py +86 -0
  78. mycode/subagents/limits.py +7 -0
  79. mycode/subagents/observability.py +150 -0
  80. mycode/subagents/persistence.py +152 -0
  81. mycode/subagents/profiles.py +184 -0
  82. mycode/subagents/prompts.py +67 -0
  83. mycode/subagents/results.py +178 -0
  84. mycode/subagents/runtime.py +528 -0
  85. mycode/subagents/snapshots.py +211 -0
  86. mycode/subagents/tool_batch.py +260 -0
  87. mycode/tools/__init__.py +81 -0
  88. mycode/tools/base.py +222 -0
  89. mycode/tools/bounds.py +14 -0
  90. mycode/tools/command_executor.py +167 -0
  91. mycode/tools/command_output.py +166 -0
  92. mycode/tools/command_risk.py +596 -0
  93. mycode/tools/defaults.py +59 -0
  94. mycode/tools/edit_file.py +524 -0
  95. mycode/tools/file_mutation.py +30 -0
  96. mycode/tools/glob.py +247 -0
  97. mycode/tools/grep.py +324 -0
  98. mycode/tools/ignore.py +122 -0
  99. mycode/tools/inspect_changes.py +269 -0
  100. mycode/tools/load_skill.py +92 -0
  101. mycode/tools/memory.py +264 -0
  102. mycode/tools/path_permissions.py +78 -0
  103. mycode/tools/patterns.py +48 -0
  104. mycode/tools/permission_metadata.py +27 -0
  105. mycode/tools/process_tree.py +166 -0
  106. mycode/tools/read_file.py +242 -0
  107. mycode/tools/read_skill_resource.py +93 -0
  108. mycode/tools/registry.py +279 -0
  109. mycode/tools/run_command.py +237 -0
  110. mycode/tools/run_skill_script.py +206 -0
  111. mycode/tools/run_validation.py +107 -0
  112. mycode/tools/submit_result.py +93 -0
  113. mycode/tools/text.py +15 -0
  114. mycode/tools/validation_command.py +377 -0
  115. mycode/tools/workspace.py +33 -0
  116. mycode/tools/write_file.py +169 -0
  117. mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
  118. mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
  119. mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  120. mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  121. 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"