返回 JoyAI-Echo
runner.py
1 """Shared execution loop for tool-using agents."""
2
3 from __future__ import annotations
4
5 import asyncio
6 from dataclasses import dataclass, field
7 import inspect
8 from pathlib import Path
9 from typing import Any
10
11 from loguru import logger
12
13 from nanobot.agent.hook import AgentHook, AgentHookContext
14 from nanobot.agent.event_stacker import EventStacker
15 from nanobot.agent.prompt_stacker import PromptStacker
16 from nanobot.utils.prompt_templates import render_template
17 from nanobot.agent.tools.registry import ToolRegistry
18 from nanobot.providers.base import LLMProvider, ToolCallRequest
19 from nanobot.utils.helpers import (
20 build_assistant_message,
21 estimate_message_tokens,
22 estimate_prompt_tokens_chain,
23 find_legal_message_start,
24 maybe_persist_tool_result,
25 truncate_text,
26 )
27 from nanobot.utils.runtime import (
28 EMPTY_FINAL_RESPONSE_MESSAGE,
29 build_finalization_retry_message,
30 build_length_recovery_message,
31 ensure_nonempty_tool_result,
32 is_blank_text,
33 repeated_external_lookup_error,
34 )
35
36 _DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
37 _PERSISTED_MODEL_ERROR_PLACEHOLDER = "[Assistant reply unavailable due to model error.]"
38 _MAX_EMPTY_RETRIES = 2
39 _MAX_LENGTH_RECOVERIES = 3
40 _MAX_INJECTIONS_PER_TURN = 3
41 _MAX_INJECTION_CYCLES = 5
42 _SNIP_SAFETY_BUFFER = 1024
43 _MICROCOMPACT_KEEP_RECENT = 10
44 _MICROCOMPACT_MIN_CHARS = 500
45 _COMPACTABLE_TOOLS = frozenset({
46 "read_file", "exec", "grep", "glob",
47 "web_search", "web_fetch", "list_dir",
48 })
49 _BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
50
51
52
53 @dataclass(slots=True)
54 class AgentRunSpec:
55 """Configuration for a single agent execution."""
56
57 initial_messages: list[dict[str, Any]]
58 tools: ToolRegistry
59 model: str
60 max_iterations: int
61 max_tool_result_chars: int
62 temperature: float | None = None
63 max_tokens: int | None = None
64 reasoning_effort: str | None = None
65 hook: AgentHook | None = None
66 error_message: str | None = _DEFAULT_ERROR_MESSAGE
67 max_iterations_message: str | None = None
68 concurrent_tools: bool = False
69 fail_on_tool_error: bool = False
70 workspace: Path | None = None
71 session_key: str | None = None
72 context_window_tokens: int | None = None
73 context_block_limit: int | None = None
74 provider_retry_mode: str = "standard"
75 progress_callback: Any | None = None
76 retry_wait_callback: Any | None = None
77 checkpoint_callback: Any | None = None
78 injection_callback: Any | None = None
79 session_metadata: dict[str, Any] | None = None
80
81
82 @dataclass(slots=True)
83 class AgentRunResult:
84 """Outcome of a shared agent execution."""
85
86 final_content: str | None
87 messages: list[dict[str, Any]]
88 tools_used: list[str] = field(default_factory=list)
89 usage: dict[str, int] = field(default_factory=dict)
90 stop_reason: str = "completed"
91 error: str | None = None
92 tool_events: list[dict[str, str]] = field(default_factory=list)
93 had_injections: bool = False
94
95
96 def _ask_user_sent_in_turn(spec: AgentRunSpec) -> bool:
97 """True when ask_user already delivered a card in this run."""
98 getter = getattr(spec.tools, "get", None)
99 if not callable(getter):
100 return False
101 from nanobot.agent.tools.ask_user import AskUserTool
102
103 ask = getter("ask_user")
104 return isinstance(ask, AskUserTool) and bool(ask._sent_in_turn)
105
106
107 class AgentRunner:
108 """Run a tool-capable LLM loop without product-layer concerns."""
109
110 def __init__(self, provider: LLMProvider):
111 self.provider = provider
112
113 @staticmethod
114 def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
115 if isinstance(left, str) and isinstance(right, str):
116 return f"{left}\n\n{right}" if left else right
117
118 def _to_blocks(value: Any) -> list[dict[str, Any]]:
119 if isinstance(value, list):
120 return [
121 item if isinstance(item, dict) else {"type": "text", "text": str(item)}
122 for item in value
123 ]
124 if value is None:
125 return []
126 return [{"type": "text", "text": str(value)}]
127
128 return _to_blocks(left) + _to_blocks(right)
129
130 @classmethod
131 def _append_injected_messages(
132 cls,
133 messages: list[dict[str, Any]],
134 injections: list[dict[str, Any]],
135 ) -> None:
136 """Append injected user messages while preserving role alternation."""
137 for injection in injections:
138 if (
139 messages
140 and injection.get("role") == "user"
141 and messages[-1].get("role") == "user"
142 ):
143 merged = dict(messages[-1])
144 merged["content"] = cls._merge_message_content(
145 merged.get("content"),
146 injection.get("content"),
147 )
148 messages[-1] = merged
149 continue
150 messages.append(injection)
151
152 @staticmethod
153 def _strip_private_message_fields(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
154 """Remove internal bookkeeping fields before provider requests."""
155 cleaned: list[dict[str, Any]] = []
156 for message in messages:
157 cleaned.append({k: v for k, v in message.items() if not k.startswith("_")})
158 return cleaned
159
160 async def _try_drain_injections(
161 self,
162 spec: AgentRunSpec,
163 messages: list[dict[str, Any]],
164 assistant_message: dict[str, Any] | None,
165 injection_cycles: int,
166 *,
167 phase: str = "after error",
168 iteration: int | None = None,
169 ) -> tuple[bool, int]:
170 """Drain pending injections. Returns (should_continue, updated_cycles).
171
172 If injections are found and we haven't exceeded _MAX_INJECTION_CYCLES,
173 append them to *messages* (and emit a checkpoint if *assistant_message*
174 and *iteration* are both provided) and return (True, cycles+1) so the
175 caller continues the iteration loop. Otherwise return (False, cycles).
176 """
177 if injection_cycles >= _MAX_INJECTION_CYCLES:
178 return False, injection_cycles
179 injections = await self._drain_injections(spec)
180 if not injections:
181 return False, injection_cycles
182 EventStacker.emit("injection", {
183 "count": len(injections),
184 "phase": phase,
185 "cycle": injection_cycles + 1,
186 "messages": injections,
187 })
188 injection_cycles += 1
189 hidden_takeover = any(message.get("_hidden") for message in injections)
190 if assistant_message is not None and not hidden_takeover:
191 messages.append(assistant_message)
192 if iteration is not None:
193 await self._emit_checkpoint(
194 spec,
195 {
196 "phase": "final_response",
197 "iteration": iteration,
198 "model": spec.model,
199 "assistant_message": assistant_message,
200 "completed_tool_results": [],
201 "pending_tool_calls": [],
202 },
203 )
204 elif hidden_takeover and assistant_message is not None:
205 logger.info(
206 "Discarding user-facing filler; hidden workplace injection takes over ({})",
207 phase,
208 )
209 self._append_injected_messages(messages, injections)
210 logger.info(
211 "Injected {} follow-up message(s) {} ({}/{})",
212 len(injections), phase, injection_cycles, _MAX_INJECTION_CYCLES,
213 )
214 return True, injection_cycles
215
216 async def _drain_injections(self, spec: AgentRunSpec) -> list[dict[str, Any]]:
217 """Drain pending user messages via the injection callback.
218
219 Returns normalized user messages (capped by
220 ``_MAX_INJECTIONS_PER_TURN``), or an empty list when there is
221 nothing to inject. Messages beyond the cap are logged so they
222 are not silently lost.
223 """
224 if spec.injection_callback is None:
225 return []
226 try:
227 signature = inspect.signature(spec.injection_callback)
228 accepts_limit = (
229 "limit" in signature.parameters
230 or any(
231 parameter.kind is inspect.Parameter.VAR_KEYWORD
232 for parameter in signature.parameters.values()
233 )
234 )
235 if accepts_limit:
236 items = await spec.injection_callback(limit=_MAX_INJECTIONS_PER_TURN)
237 else:
238 items = await spec.injection_callback()
239 except Exception:
240 logger.exception("injection_callback failed")
241 return []
242 if not items:
243 return []
244 injected_messages: list[dict[str, Any]] = []
245 for item in items:
246 if isinstance(item, dict) and item.get("role") == "user" and "content" in item:
247 injected_messages.append(item)
248 continue
249 text = getattr(item, "content", str(item))
250 if text.strip():
251 injected_messages.append({"role": "user", "content": text})
252 if len(injected_messages) > _MAX_INJECTIONS_PER_TURN:
253 dropped = len(injected_messages) - _MAX_INJECTIONS_PER_TURN
254 logger.warning(
255 "Injection callback returned {} messages, capping to {} ({} dropped)",
256 len(injected_messages), _MAX_INJECTIONS_PER_TURN, dropped,
257 )
258 injected_messages = injected_messages[:_MAX_INJECTIONS_PER_TURN]
259 return injected_messages
260
261 async def run(self, spec: AgentRunSpec) -> AgentRunResult:
262 hook = spec.hook or AgentHook()
263 messages = list(spec.initial_messages)
264 final_content: str | None = None
265 tools_used: list[str] = []
266 usage: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0}
267 error: str | None = None
268 stop_reason = "completed"
269 tool_events: list[dict[str, str]] = []
270 external_lookup_counts: dict[str, int] = {}
271 empty_content_retries = 0
272 length_recovery_count = 0
273 had_injections = False
274 injection_cycles = 0
275
276 for iteration in range(spec.max_iterations):
277 pre_governance_count = len(messages)
278 try:
279 # Keep the persisted conversation untouched. Context governance
280 # may repair or compact historical messages for the model, but
281 # those synthetic edits must not shift the append boundary used
282 # later when the caller saves only the new turn.
283 messages_for_model = self._drop_orphan_tool_results(messages)
284 messages_for_model = self._backfill_missing_tool_results(messages_for_model)
285 messages_for_model = self._microcompact(messages_for_model)
286 messages_for_model = self._apply_tool_result_budget(spec, messages_for_model)
287 messages_for_model = self._snip_history(spec, messages_for_model)
288 # Snipping may have created new orphans; clean them up.
289 messages_for_model = self._drop_orphan_tool_results(messages_for_model)
290 messages_for_model = self._backfill_missing_tool_results(messages_for_model)
291 messages_for_model = self._strip_private_message_fields(messages_for_model)
292 except Exception as exc:
293 logger.warning(
294 "Context governance failed on turn {} for {}: {}; applying minimal repair",
295 iteration,
296 spec.session_key or "default",
297 exc,
298 )
299 try:
300 messages_for_model = self._drop_orphan_tool_results(messages)
301 messages_for_model = self._backfill_missing_tool_results(messages_for_model)
302 messages_for_model = self._strip_private_message_fields(messages_for_model)
303 except Exception:
304 messages_for_model = self._strip_private_message_fields(messages)
305
306 post_governance_count = len(messages_for_model)
307 input_chars = sum(len(str(m.get("content", ""))) for m in messages)
308 output_chars = sum(len(str(m.get("content", ""))) for m in messages_for_model)
309 EventStacker.emit("governance", {
310 "iteration": iteration,
311 "input_messages": pre_governance_count,
312 "output_messages": post_governance_count,
313 "input_chars": input_chars,
314 "output_chars": output_chars,
315 })
316
317 context = AgentHookContext(iteration=iteration, messages=messages)
318 await hook.before_iteration(context)
319 PromptStacker.begin_iteration(iteration, spec.model)
320 PromptStacker.log("messages_to_model", messages_for_model)
321
322 tool_defs = spec.tools.get_definitions(spec.session_metadata)
323 EventStacker.emit("model_request", {
324 "iteration": iteration,
325 "model": spec.model,
326 "messages_count": len(messages_for_model),
327 "total_chars": output_chars,
328 "temperature": spec.temperature,
329 "max_tokens": spec.max_tokens,
330 "reasoning_effort": spec.reasoning_effort,
331 "tools": tool_defs,
332 "tools_count": len(tool_defs or []),
333 "messages": messages_for_model,
334 })
335
336 response = await self._request_model(spec, messages_for_model, hook, context)
337 raw_usage = self._usage_dict(response.usage)
338 PromptStacker.commit(
339 messages=messages_for_model,
340 response=response,
341 usage=raw_usage,
342 iteration=iteration,
343 )
344
345 EventStacker.emit("model_response", {
346 "iteration": iteration,
347 "finish_reason": getattr(response, "finish_reason", ""),
348 "content": getattr(response, "content", "") or "",
349 "tool_calls": [
350 {"name": tc.name, "arguments": tc.arguments}
351 for tc in (response.tool_calls or [])
352 ],
353 "reasoning_content": getattr(response, "reasoning_content", "") or "",
354 "usage": raw_usage,
355 })
356
357 context.response = response
358 context.usage = dict(raw_usage)
359 context.tool_calls = list(response.tool_calls)
360 self._accumulate_usage(usage, raw_usage)
361
362 if response.should_execute_tools:
363 if hook.wants_streaming():
364 await hook.on_stream_end(context, resuming=True)
365
366 assistant_message = build_assistant_message(
367 response.content or "",
368 tool_calls=[tc.to_openai_tool_call() for tc in response.tool_calls],
369 reasoning_content=response.reasoning_content,
370 thinking_blocks=response.thinking_blocks,
371 )
372 messages.append(assistant_message)
373 tools_used.extend(tc.name for tc in response.tool_calls)
374 await self._emit_checkpoint(
375 spec,
376 {
377 "phase": "awaiting_tools",
378 "iteration": iteration,
379 "model": spec.model,
380 "assistant_message": assistant_message,
381 "completed_tool_results": [],
382 "pending_tool_calls": [tc.to_openai_tool_call() for tc in response.tool_calls],
383 },
384 )
385
386 await hook.before_execute_tools(context)
387
388 results, new_events, fatal_error = await self._execute_tools(
389 spec,
390 response.tool_calls,
391 external_lookup_counts,
392 )
393 tool_events.extend(new_events)
394 context.tool_results = list(results)
395 context.tool_events = list(new_events)
396 completed_tool_results: list[dict[str, Any]] = []
397 for tool_call, result in zip(response.tool_calls, results):
398 normalized = self._normalize_tool_result(
399 spec,
400 tool_call.id,
401 tool_call.name,
402 result,
403 )
404 tool_message = {
405 "role": "tool",
406 "tool_call_id": tool_call.id,
407 "name": tool_call.name,
408 "content": normalized,
409 }
410 messages.append(tool_message)
411 completed_tool_results.append(tool_message)
412 EventStacker.emit("tool_exec", {
413 "name": tool_call.name,
414 "call_id": tool_call.id,
415 "arguments": tool_call.arguments or "",
416 "result": normalized,
417 "result_chars": len(str(normalized)),
418 })
419 if fatal_error is not None:
420 error = f"Error: {type(fatal_error).__name__}: {fatal_error}"
421 final_content = error
422 stop_reason = "tool_error"
423 EventStacker.emit("error", {
424 "type": "tool_fatal",
425 "message": error,
426 "iteration": iteration,
427 })
428 self._append_final_message(messages, final_content)
429 context.final_content = final_content
430 context.error = error
431 context.stop_reason = stop_reason
432 await hook.after_iteration(context)
433 should_continue, injection_cycles = await self._try_drain_injections(
434 spec, messages, None, injection_cycles,
435 phase="after tool error",
436 )
437 if should_continue:
438 had_injections = True
439 continue
440 break
441 await self._emit_checkpoint(
442 spec,
443 {
444 "phase": "tools_completed",
445 "iteration": iteration,
446 "model": spec.model,
447 "assistant_message": assistant_message,
448 "completed_tool_results": completed_tool_results,
449 "pending_tool_calls": [],
450 },
451 )
452 empty_content_retries = 0
453 length_recovery_count = 0
454 # Checkpoint 1: drain injections after tools, before next LLM call
455 _drained, injection_cycles = await self._try_drain_injections(
456 spec, messages, None, injection_cycles,
457 phase="after tool execution",
458 )
459 if _drained:
460 had_injections = True
461 await hook.after_iteration(context)
462 continue
463
464 if response.has_tool_calls:
465 logger.warning(
466 "Ignoring tool calls under finish_reason='{}' for {}",
467 response.finish_reason,
468 spec.session_key or "default",
469 )
470
471 clean = hook.finalize_content(context, response.content)
472 if clean != response.content:
473 EventStacker.emit("content_transform", {
474 "stage": "finalize_content",
475 "iteration": iteration,
476 "original_chars": len(response.content or ""),
477 "transformed_chars": len(clean or ""),
478 "transformed": clean or "",
479 })
480 if response.finish_reason != "error" and is_blank_text(clean):
481 if _ask_user_sent_in_turn(spec) and not had_injections:
482 should_continue, injection_cycles = await self._try_drain_injections(
483 spec, messages, None, injection_cycles,
484 phase="after ask_user empty",
485 iteration=iteration,
486 )
487 if should_continue:
488 had_injections = True
489 if hook.wants_streaming():
490 await hook.on_stream_end(context, resuming=True)
491 await hook.after_iteration(context)
492 continue
493 logger.info(
494 "Empty response after ask_user on turn {} for {}; ending turn",
495 iteration,
496 spec.session_key or "default",
497 )
498 if hook.wants_streaming():
499 await hook.on_stream_end(context, resuming=False)
500 stop_reason = "ask_user"
501 final_content = ""
502 context.final_content = final_content
503 context.stop_reason = stop_reason
504 await hook.after_iteration(context)
505 break
506 empty_content_retries += 1
507 if empty_content_retries < _MAX_EMPTY_RETRIES:
508 EventStacker.emit("retry", {
509 "type": "empty_response",
510 "iteration": iteration,
511 "attempt": empty_content_retries,
512 })
513 logger.warning(
514 "Empty response on turn {} for {} ({}/{}); retrying",
515 iteration,
516 spec.session_key or "default",
517 empty_content_retries,
518 _MAX_EMPTY_RETRIES,
519 )
520 if hook.wants_streaming():
521 await hook.on_stream_end(context, resuming=False)
522 await hook.after_iteration(context)
523 continue
524 should_continue, injection_cycles = await self._try_drain_injections(
525 spec, messages, None, injection_cycles,
526 phase="before finalization",
527 iteration=iteration,
528 )
529 if should_continue:
530 had_injections = True
531 if hook.wants_streaming():
532 await hook.on_stream_end(context, resuming=True)
533 await hook.after_iteration(context)
534 continue
535 logger.warning(
536 "Empty response on turn {} for {} after {} retries; attempting finalization",
537 iteration,
538 spec.session_key or "default",
539 empty_content_retries,
540 )
541 if hook.wants_streaming():
542 await hook.on_stream_end(context, resuming=False)
543 finalization_prompt = build_finalization_retry_message()
544 EventStacker.emit("retry", {
545 "type": "finalization",
546 "iteration": iteration,
547 "attempt": empty_content_retries,
548 })
549 EventStacker.emit("model_request", {
550 "iteration": iteration,
551 "model": spec.model,
552 "type": "finalization_retry",
553 "injected_prompt": finalization_prompt["content"],
554 "messages_count": len(messages_for_model) + 1,
555 "temperature": spec.temperature,
556 "max_tokens": spec.max_tokens,
557 "tools_count": 0,
558 })
559 response = await self._request_finalization_retry(spec, messages_for_model)
560 retry_usage = self._usage_dict(response.usage)
561 self._accumulate_usage(usage, retry_usage)
562 raw_usage = self._merge_usage(raw_usage, retry_usage)
563 EventStacker.emit("model_response", {
564 "iteration": iteration,
565 "finish_reason": getattr(response, "finish_reason", ""),
566 "content": getattr(response, "content", "") or "",
567 "tool_calls": [
568 {"name": tc.name, "arguments": tc.arguments}
569 for tc in (response.tool_calls or [])
570 ],
571 "reasoning_content": getattr(response, "reasoning_content", "") or "",
572 "usage": retry_usage,
573 })
574 context.response = response
575 context.usage = dict(raw_usage)
576 context.tool_calls = list(response.tool_calls)
577 clean = hook.finalize_content(context, response.content)
578 if clean != response.content:
579 EventStacker.emit("content_transform", {
580 "stage": "finalize_content_retry",
581 "iteration": iteration,
582 "original_chars": len(response.content or ""),
583 "transformed_chars": len(clean or ""),
584 "transformed": clean or "",
585 })
586
587 if response.finish_reason == "length" and not is_blank_text(clean):
588 length_recovery_count += 1
589 if length_recovery_count <= _MAX_LENGTH_RECOVERIES:
590 EventStacker.emit("retry", {
591 "type": "length_recovery",
592 "iteration": iteration,
593 "count": length_recovery_count,
594 })
595 logger.info(
596 "Output truncated on turn {} for {} ({}/{}); continuing",
597 iteration,
598 spec.session_key or "default",
599 length_recovery_count,
600 _MAX_LENGTH_RECOVERIES,
601 )
602 if hook.wants_streaming():
603 await hook.on_stream_end(context, resuming=True)
604 messages.append(build_assistant_message(
605 clean,
606 reasoning_content=response.reasoning_content,
607 thinking_blocks=response.thinking_blocks,
608 ))
609 recovery_msg = build_length_recovery_message()
610 EventStacker.emit("injection", {
611 "type": "length_recovery_prompt",
612 "iteration": iteration,
613 "content": recovery_msg["content"],
614 })
615 messages.append(recovery_msg)
616 await hook.after_iteration(context)
617 continue
618
619 assistant_message: dict[str, Any] | None = None
620 if response.finish_reason != "error" and not is_blank_text(clean):
621 assistant_message = build_assistant_message(
622 clean,
623 reasoning_content=response.reasoning_content,
624 thinking_blocks=response.thinking_blocks,
625 )
626
627 # Check for mid-turn injections BEFORE signaling stream end.
628 # If injections are found we keep the stream alive (resuming=True)
629 # so streaming channels don't prematurely finalize the card.
630 should_continue, injection_cycles = await self._try_drain_injections(
631 spec, messages, assistant_message, injection_cycles,
632 phase="after final response",
633 iteration=iteration,
634 )
635 if should_continue:
636 had_injections = True
637
638 if hook.wants_streaming():
639 await hook.on_stream_end(context, resuming=should_continue)
640
641 if should_continue:
642 await hook.after_iteration(context)
643 continue
644
645 if response.finish_reason == "error":
646 final_content = clean or spec.error_message or _DEFAULT_ERROR_MESSAGE
647 stop_reason = "error"
648 error = final_content
649 EventStacker.emit("error", {
650 "type": "model_error",
651 "message": error,
652 "iteration": iteration,
653 })
654 self._append_model_error_placeholder(messages)
655 context.final_content = final_content
656 context.error = error
657 context.stop_reason = stop_reason
658 await hook.after_iteration(context)
659 should_continue, injection_cycles = await self._try_drain_injections(
660 spec, messages, None, injection_cycles,
661 phase="after LLM error",
662 )
663 if should_continue:
664 had_injections = True
665 continue
666 break
667 if is_blank_text(clean):
668 final_content = EMPTY_FINAL_RESPONSE_MESSAGE
669 stop_reason = "empty_final_response"
670 error = final_content
671 self._append_final_message(messages, final_content)
672 context.final_content = final_content
673 context.error = error
674 context.stop_reason = stop_reason
675 await hook.after_iteration(context)
676 should_continue, injection_cycles = await self._try_drain_injections(
677 spec, messages, None, injection_cycles,
678 phase="after empty response",
679 )
680 if should_continue:
681 had_injections = True
682 continue
683 break
684
685 messages.append(assistant_message or build_assistant_message(
686 clean,
687 reasoning_content=response.reasoning_content,
688 thinking_blocks=response.thinking_blocks,
689 ))
690 await self._emit_checkpoint(
691 spec,
692 {
693 "phase": "final_response",
694 "iteration": iteration,
695 "model": spec.model,
696 "assistant_message": messages[-1],
697 "completed_tool_results": [],
698 "pending_tool_calls": [],
699 },
700 )
701 final_content = clean
702 context.final_content = final_content
703 context.stop_reason = stop_reason
704 await hook.after_iteration(context)
705 break
706 else:
707 stop_reason = "max_iterations"
708 if spec.max_iterations_message:
709 final_content = spec.max_iterations_message.format(
710 max_iterations=spec.max_iterations,
711 )
712 else:
713 final_content = render_template(
714 "agent/max_iterations_message.md",
715 strip=True,
716 max_iterations=spec.max_iterations,
717 )
718 self._append_final_message(messages, final_content)
719 # Drain any remaining injections so they are appended to the
720 # conversation history instead of being re-published as
721 # independent inbound messages by _dispatch's finally block.
722 # We ignore should_continue here because the for-loop has already
723 # exhausted all iterations.
724 drained_after_max_iterations, injection_cycles = await self._try_drain_injections(
725 spec, messages, None, injection_cycles,
726 phase="after max_iterations",
727 )
728 if drained_after_max_iterations:
729 had_injections = True
730
731 return AgentRunResult(
732 final_content=final_content,
733 messages=messages,
734 tools_used=tools_used,
735 usage=usage,
736 stop_reason=stop_reason,
737 error=error,
738 tool_events=tool_events,
739 had_injections=had_injections,
740 )
741
742 def _build_request_kwargs(
743 self,
744 spec: AgentRunSpec,
745 messages: list[dict[str, Any]],
746 *,
747 tools: list[dict[str, Any]] | None,
748 ) -> dict[str, Any]:
749 kwargs: dict[str, Any] = {
750 "messages": messages,
751 "tools": tools,
752 "model": spec.model,
753 "retry_mode": spec.provider_retry_mode,
754 "on_retry_wait": spec.retry_wait_callback,
755 }
756 if spec.temperature is not None:
757 kwargs["temperature"] = spec.temperature
758 if spec.max_tokens is not None:
759 kwargs["max_tokens"] = spec.max_tokens
760 if spec.reasoning_effort is not None:
761 kwargs["reasoning_effort"] = spec.reasoning_effort
762 return kwargs
763
764 async def _request_model(
765 self,
766 spec: AgentRunSpec,
767 messages: list[dict[str, Any]],
768 hook: AgentHook,
769 context: AgentHookContext,
770 ):
771 kwargs = self._build_request_kwargs(
772 spec,
773 messages,
774 tools=spec.tools.get_definitions(spec.session_metadata),
775 )
776 if hook.wants_streaming():
777 async def _stream(delta: str) -> None:
778 await hook.on_stream(context, delta)
779
780 return await self.provider.chat_stream_with_retry(
781 **kwargs,
782 on_content_delta=_stream,
783 )
784 return await self.provider.chat_with_retry(**kwargs)
785
786 async def _request_finalization_retry(
787 self,
788 spec: AgentRunSpec,
789 messages: list[dict[str, Any]],
790 ):
791 retry_messages = list(messages)
792 retry_messages.append(build_finalization_retry_message())
793 kwargs = self._build_request_kwargs(spec, retry_messages, tools=None)
794 return await self.provider.chat_with_retry(**kwargs)
795
796 @staticmethod
797 def _usage_dict(usage: dict[str, Any] | None) -> dict[str, int]:
798 if not usage:
799 return {}
800 result: dict[str, int] = {}
801 for key, value in usage.items():
802 try:
803 result[key] = int(value or 0)
804 except (TypeError, ValueError):
805 continue
806 return result
807
808 @staticmethod
809 def _accumulate_usage(target: dict[str, int], addition: dict[str, int]) -> None:
810 for key, value in addition.items():
811 target[key] = target.get(key, 0) + value
812
813 @staticmethod
814 def _merge_usage(left: dict[str, int], right: dict[str, int]) -> dict[str, int]:
815 merged = dict(left)
816 for key, value in right.items():
817 merged[key] = merged.get(key, 0) + value
818 return merged
819
820 async def _execute_tools(
821 self,
822 spec: AgentRunSpec,
823 tool_calls: list[ToolCallRequest],
824 external_lookup_counts: dict[str, int],
825 ) -> tuple[list[Any], list[dict[str, str]], BaseException | None]:
826 batches = self._partition_tool_batches(spec, tool_calls)
827 tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
828 for batch in batches:
829 if spec.concurrent_tools and len(batch) > 1:
830 tool_results.extend(await asyncio.gather(*(
831 self._run_tool(spec, tool_call, external_lookup_counts)
832 for tool_call in batch
833 )))
834 else:
835 for tool_call in batch:
836 tool_results.append(await self._run_tool(spec, tool_call, external_lookup_counts))
837
838 results: list[Any] = []
839 events: list[dict[str, str]] = []
840 fatal_error: BaseException | None = None
841 for result, event, error in tool_results:
842 results.append(result)
843 events.append(event)
844 if error is not None and fatal_error is None:
845 fatal_error = error
846 return results, events, fatal_error
847
848 async def _run_tool(
849 self,
850 spec: AgentRunSpec,
851 tool_call: ToolCallRequest,
852 external_lookup_counts: dict[str, int],
853 ) -> tuple[Any, dict[str, str], BaseException | None]:
854 _HINT = "\n\n[Analyze the error above and try a different approach.]"
855 lookup_error = repeated_external_lookup_error(
856 tool_call.name,
857 tool_call.arguments,
858 external_lookup_counts,
859 )
860 if lookup_error:
861 event = {
862 "name": tool_call.name,
863 "status": "error",
864 "detail": "repeated external lookup blocked",
865 }
866 if spec.fail_on_tool_error:
867 return lookup_error + _HINT, event, RuntimeError(lookup_error)
868 return lookup_error + _HINT, event, None
869 prepare_call = getattr(spec.tools, "prepare_call", None)
870 tool, params, prep_error = None, tool_call.arguments, None
871 if callable(prepare_call):
872 try:
873 prepared = prepare_call(tool_call.name, tool_call.arguments,spec.session_metadata)
874 if isinstance(prepared, tuple) and len(prepared) == 3:
875 tool, params, prep_error = prepared
876 except Exception:
877 pass
878 if prep_error:
879 event = {
880 "name": tool_call.name,
881 "status": "error",
882 "detail": prep_error.split(": ", 1)[-1][:120],
883 }
884 return prep_error + _HINT, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None
885 if tool_call.name == "ask_user":
886 from nanobot.agent.tools.ask_user import AskUserTool
887
888 ask_tool = spec.tools.get("ask_user")
889 if isinstance(ask_tool, AskUserTool):
890 ask_tool.set_tool_call_id(tool_call.id)
891 try:
892 if tool is not None:
893 result = await tool.execute(**params)
894 else:
895 result = await spec.tools.execute(tool_call.name, params)
896 except asyncio.CancelledError:
897 raise
898 except BaseException as exc:
899 event = {
900 "name": tool_call.name,
901 "status": "error",
902 "detail": str(exc),
903 }
904 if spec.fail_on_tool_error:
905 return f"Error: {type(exc).__name__}: {exc}", event, exc
906 return f"Error: {type(exc).__name__}: {exc}", event, None
907
908 if isinstance(result, str) and result.startswith("Error"):
909 event = {
910 "name": tool_call.name,
911 "status": "error",
912 "detail": result.replace("\n", " ").strip()[:120],
913 }
914 if spec.fail_on_tool_error:
915 return result + _HINT, event, RuntimeError(result)
916 return result + _HINT, event, None
917
918 detail = "" if result is None else str(result)
919 detail = detail.replace("\n", " ").strip()
920 if not detail:
921 detail = "(empty)"
922 elif len(detail) > 120:
923 detail = detail[:120] + "..."
924 return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None
925
926 async def _emit_checkpoint(
927 self,
928 spec: AgentRunSpec,
929 payload: dict[str, Any],
930 ) -> None:
931 callback = spec.checkpoint_callback
932 if callback is not None:
933 await callback(payload)
934
935 @staticmethod
936 def _append_final_message(messages: list[dict[str, Any]], content: str | None) -> None:
937 if not content:
938 return
939 if (
940 messages
941 and messages[-1].get("role") == "assistant"
942 and not messages[-1].get("tool_calls")
943 ):
944 if messages[-1].get("content") == content:
945 return
946 messages[-1] = build_assistant_message(content)
947 return
948 messages.append(build_assistant_message(content))
949
950 @staticmethod
951 def _append_model_error_placeholder(messages: list[dict[str, Any]]) -> None:
952 if messages and messages[-1].get("role") == "assistant" and not messages[-1].get("tool_calls"):
953 return
954 messages.append(build_assistant_message(_PERSISTED_MODEL_ERROR_PLACEHOLDER))
955
956 def _normalize_tool_result(
957 self,
958 spec: AgentRunSpec,
959 tool_call_id: str,
960 tool_name: str,
961 result: Any,
962 ) -> Any:
963 result = ensure_nonempty_tool_result(tool_name, result)
964 try:
965 content = maybe_persist_tool_result(
966 spec.workspace,
967 spec.session_key,
968 tool_call_id,
969 result,
970 max_chars=spec.max_tool_result_chars,
971 )
972 except Exception as exc:
973 logger.warning(
974 "Tool result persist failed for {} in {}: {}; using raw result",
975 tool_call_id,
976 spec.session_key or "default",
977 exc,
978 )
979 content = result
980 if isinstance(content, str) and len(content) > spec.max_tool_result_chars:
981 return truncate_text(content, spec.max_tool_result_chars)
982 return content
983
984 @staticmethod
985 def _drop_orphan_tool_results(
986 messages: list[dict[str, Any]],
987 ) -> list[dict[str, Any]]:
988 """Drop tool results that have no matching assistant tool_call earlier in the history."""
989 declared: set[str] = set()
990 updated: list[dict[str, Any]] | None = None
991 for idx, msg in enumerate(messages):
992 role = msg.get("role")
993 if role == "assistant":
994 for tc in msg.get("tool_calls") or []:
995 if isinstance(tc, dict) and tc.get("id"):
996 declared.add(str(tc["id"]))
997 if role == "tool":
998 tid = msg.get("tool_call_id")
999 if tid and str(tid) not in declared:
1000 if updated is None:
1001 updated = [dict(m) for m in messages[:idx]]
1002 continue
1003 if updated is not None:
1004 updated.append(dict(msg))
1005
1006 if updated is None:
1007 return messages
1008 return updated
1009
1010 @staticmethod
1011 def _backfill_missing_tool_results(
1012 messages: list[dict[str, Any]],
1013 ) -> list[dict[str, Any]]:
1014 """Insert synthetic error results for orphaned tool_use blocks."""
1015 declared: list[tuple[int, str, str]] = [] # (assistant_idx, call_id, name)
1016 fulfilled: set[str] = set()
1017 for idx, msg in enumerate(messages):
1018 role = msg.get("role")
1019 if role == "assistant":
1020 for tc in msg.get("tool_calls") or []:
1021 if isinstance(tc, dict) and tc.get("id"):
1022 name = ""
1023 func = tc.get("function")
1024 if isinstance(func, dict):
1025 name = func.get("name", "")
1026 declared.append((idx, str(tc["id"]), name))
1027 elif role == "tool":
1028 tid = msg.get("tool_call_id")
1029 if tid:
1030 fulfilled.add(str(tid))
1031
1032 missing = [(ai, cid, name) for ai, cid, name in declared if cid not in fulfilled]
1033 if not missing:
1034 return messages
1035
1036 updated = list(messages)
1037 offset = 0
1038 for assistant_idx, call_id, name in missing:
1039 insert_at = assistant_idx + 1 + offset
1040 while insert_at < len(updated) and updated[insert_at].get("role") == "tool":
1041 insert_at += 1
1042 updated.insert(insert_at, {
1043 "role": "tool",
1044 "tool_call_id": call_id,
1045 "name": name,
1046 "content": _BACKFILL_CONTENT,
1047 })
1048 offset += 1
1049 return updated
1050
1051 @staticmethod
1052 def _microcompact(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
1053 """Replace old compactable tool results with one-line summaries."""
1054 compactable_indices: list[int] = []
1055 for idx, msg in enumerate(messages):
1056 if msg.get("role") == "tool" and msg.get("name") in _COMPACTABLE_TOOLS:
1057 compactable_indices.append(idx)
1058
1059 if len(compactable_indices) <= _MICROCOMPACT_KEEP_RECENT:
1060 return messages
1061
1062 stale = compactable_indices[: len(compactable_indices) - _MICROCOMPACT_KEEP_RECENT]
1063 updated: list[dict[str, Any]] | None = None
1064 for idx in stale:
1065 msg = messages[idx]
1066 content = msg.get("content")
1067 if not isinstance(content, str) or len(content) < _MICROCOMPACT_MIN_CHARS:
1068 continue
1069 name = msg.get("name", "tool")
1070 summary = f"[{name} result omitted from context]"
1071 if updated is None:
1072 updated = [dict(m) for m in messages]
1073 updated[idx]["content"] = summary
1074
1075 return updated if updated is not None else messages
1076
1077 def _apply_tool_result_budget(
1078 self,
1079 spec: AgentRunSpec,
1080 messages: list[dict[str, Any]],
1081 ) -> list[dict[str, Any]]:
1082 updated = messages
1083 for idx, message in enumerate(messages):
1084 if message.get("role") != "tool":
1085 continue
1086 normalized = self._normalize_tool_result(
1087 spec,
1088 str(message.get("tool_call_id") or f"tool_{idx}"),
1089 str(message.get("name") or "tool"),
1090 message.get("content"),
1091 )
1092 if normalized != message.get("content"):
1093 if updated is messages:
1094 updated = [dict(m) for m in messages]
1095 updated[idx]["content"] = normalized
1096 return updated
1097
1098 def _snip_history(
1099 self,
1100 spec: AgentRunSpec,
1101 messages: list[dict[str, Any]],
1102 ) -> list[dict[str, Any]]:
1103 if not messages or not spec.context_window_tokens:
1104 return messages
1105
1106 provider_max_tokens = getattr(getattr(self.provider, "generation", None), "max_tokens", 4096)
1107 max_output = spec.max_tokens if isinstance(spec.max_tokens, int) else (
1108 provider_max_tokens if isinstance(provider_max_tokens, int) else 4096
1109 )
1110 budget = spec.context_block_limit or (
1111 spec.context_window_tokens - max_output - _SNIP_SAFETY_BUFFER
1112 )
1113 if budget <= 0:
1114 return messages
1115
1116 estimate, _ = estimate_prompt_tokens_chain(
1117 self.provider,
1118 spec.model,
1119 messages,
1120 spec.tools.get_definitions(spec.session_metadata),
1121 )
1122 if estimate <= budget:
1123 return messages
1124
1125 system_messages = [dict(msg) for msg in messages if msg.get("role") == "system"]
1126 non_system = [dict(msg) for msg in messages if msg.get("role") != "system"]
1127 if not non_system:
1128 return messages
1129
1130 system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages)
1131 remaining_budget = max(128, budget - system_tokens)
1132 kept: list[dict[str, Any]] = []
1133 kept_tokens = 0
1134 for message in reversed(non_system):
1135 msg_tokens = estimate_message_tokens(message)
1136 if kept and kept_tokens + msg_tokens > remaining_budget:
1137 break
1138 kept.append(message)
1139 kept_tokens += msg_tokens
1140 kept.reverse()
1141
1142 if kept:
1143 for i, message in enumerate(kept):
1144 if message.get("role") == "user":
1145 kept = kept[i:]
1146 break
1147 else:
1148 # Recover nearest user message from outside the kept window;
1149 # GLM rejects system→assistant (error 1214). Budget is
1150 # intentionally exceeded — oversized beats invalid.
1151 for idx in range(len(non_system) - 1, -1, -1):
1152 if non_system[idx].get("role") == "user":
1153 kept = non_system[idx:]
1154 break
1155 # If no user exists at all, _enforce_role_alternation
1156 # will insert a synthetic one as a safety net.
1157 start = find_legal_message_start(kept)
1158 if start:
1159 kept = kept[start:]
1160 if not kept:
1161 kept = non_system[-min(len(non_system), 4) :]
1162 start = find_legal_message_start(kept)
1163 if start:
1164 kept = kept[start:]
1165 return system_messages + kept
1166
1167 def _partition_tool_batches(
1168 self,
1169 spec: AgentRunSpec,
1170 tool_calls: list[ToolCallRequest],
1171 ) -> list[list[ToolCallRequest]]:
1172 if not spec.concurrent_tools:
1173 return [[tool_call] for tool_call in tool_calls]
1174
1175 batches: list[list[ToolCallRequest]] = []
1176 current: list[ToolCallRequest] = []
1177 for tool_call in tool_calls:
1178 get_tool = getattr(spec.tools, "get", None)
1179 tool = get_tool(tool_call.name) if callable(get_tool) else None
1180 can_batch = bool(tool and tool.concurrency_safe)
1181 if can_batch:
1182 current.append(tool_call)
1183 continue
1184 if current:
1185 batches.append(current)
1186 current = []
1187 batches.append([tool_call])
1188 if current:
1189 batches.append(current)
1190 return batches
1191
1192
1192 lines PYTHON