agent2-core 0.1.3.25__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.
agent2/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
2
+
3
+ from agent2.agent import Agent, BaseAgent, PlannerAgent, ReActAgent
4
+ from agent2.llm import Message, create_llm
5
+ from agent2.tools import tool
6
+ from agent2.utils.config import Settings
7
+
8
+ try:
9
+ from importlib.metadata import PackageNotFoundError, version
10
+ try:
11
+ __version__ = version("agent2")
12
+ except PackageNotFoundError:
13
+ __version__ = version("agent2-core")
14
+ except PackageNotFoundError:
15
+ __version__ = "0.1.3.25"
16
+
17
+ __all__ = [
18
+ "__version__",
19
+ "Settings",
20
+ "Agent",
21
+ "BaseAgent",
22
+ "ReActAgent",
23
+ "PlannerAgent",
24
+ "create_llm",
25
+ "Message",
26
+ "tool",
27
+ ]
28
+
@@ -0,0 +1,25 @@
1
+ """Agent module — reasoning cores for LLM-powered agents.
2
+
3
+ Available agent types:
4
+
5
+ - :class:`ReActAgent` — Thought → Action → Observation loop
6
+ - :class:`PlannerAgent` — Plan-and-Execute pattern
7
+ - :class:`ReflectionMixin` — Self-critique mixin for any agent
8
+ """
9
+
10
+ from agent2.agent.base import BaseAgent, MaxIterationsExceeded
11
+ from agent2.agent.react import ReActAgent
12
+ from agent2.agent.planner import PlannerAgent
13
+ from agent2.agent.reflection import ReflectionMixin
14
+
15
+ Agent = ReActAgent
16
+
17
+ __all__ = [
18
+ "Agent",
19
+ "BaseAgent",
20
+ "MaxIterationsExceeded",
21
+ "ReActAgent",
22
+ "PlannerAgent",
23
+ "ReflectionMixin",
24
+ ]
25
+
agent2/agent/base.py ADDED
@@ -0,0 +1,652 @@
1
+ """Base Agent class — the foundation for all agent types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging as _logging
6
+
7
+ import copy
8
+ import os
9
+ from abc import ABC, abstractmethod
10
+ from pathlib import Path
11
+ from typing import Any, Self, TextIO
12
+
13
+ from agent2.llm.base import BaseLLM
14
+ from agent2.llm import create_llm
15
+ from agent2.llm.message import Message, Role
16
+ from agent2.tools.base import Tool
17
+ from agent2.tools.registry import ToolRegistry
18
+ from agent2.utils.config import settings
19
+ from agent2.utils.logging import AgentLogger
20
+
21
+
22
+ class BaseAgent(ABC):
23
+ """Abstract base class for all agents.
24
+
25
+ An agent combines an LLM with tools and a system prompt to perform
26
+ tasks through an iterative reasoning loop.
27
+
28
+ Parameters
29
+ ----------
30
+ name : str
31
+ Human-readable agent name. Defaults to ``"agent"``.
32
+ llm : BaseLLM | None
33
+ The language model to use for reasoning. Defaults to ``create_llm()``.
34
+ system_prompt : str
35
+ Instructions defining the agent's role and behaviour.
36
+ tools : list[Tool] | None
37
+ Tools available to this agent.
38
+ max_iterations : int
39
+ Safety limit for the reasoning loop.
40
+ verbose : bool
41
+ Enable detailed logging of the reasoning process.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ name: str = "agent",
47
+ *,
48
+ llm: BaseLLM | None = None,
49
+ system_prompt: str = "You are a helpful AI assistant.",
50
+ tools: list[Tool] | None = None,
51
+ max_iterations: int | None = None,
52
+ verbose: bool | None = None,
53
+ ) -> None:
54
+ self.name = name
55
+ self.llm = llm if llm is not None else create_llm()
56
+ self.system_prompt = system_prompt
57
+ if max_iterations is not None:
58
+ self.max_iterations = max_iterations
59
+ elif "AGENT2_AGENT_MAX_ITERATIONS" in os.environ:
60
+ self.max_iterations = settings.agent_max_iterations
61
+ else:
62
+ try:
63
+ from agent2.app.config import load_config
64
+
65
+ self.max_iterations = load_config().max_iterations
66
+ except (KeyError, ValueError, FileNotFoundError, ImportError) as exc:
67
+ _logging.getLogger(__name__).warning(
68
+ "Failed to load max_iterations from config (%s), using default", exc
69
+ )
70
+ self.max_iterations = settings.agent_max_iterations
71
+ self.verbose = verbose if verbose is not None else settings.agent_verbose
72
+
73
+ # Set up tool registry
74
+ self.tool_registry = ToolRegistry()
75
+ if tools:
76
+ for t in tools:
77
+ self.tool_registry.register(t)
78
+
79
+ # Set up logger
80
+ self.log = AgentLogger(name, verbose=self.verbose)
81
+
82
+ # Conversation history for the current run
83
+ self._messages: list[Message] = []
84
+
85
+ # ── Properties & State Management ───────────────────────────────
86
+
87
+ @property
88
+ def messages(self) -> list[Message]:
89
+ """Return the current conversation message history."""
90
+ return list(self._messages)
91
+
92
+ def set_rule(self, rule: str | Message) -> Self:
93
+ """Set or update the system prompt / rule for the agent.
94
+
95
+ Parameters
96
+ ----------
97
+ rule : str | Message
98
+ The system prompt text or a system Message object.
99
+
100
+ Returns
101
+ -------
102
+ Self
103
+ Returns self to allow method chaining.
104
+ """
105
+ content = rule.content if isinstance(rule, Message) else str(rule)
106
+ content = content or ""
107
+ self.system_prompt = content
108
+
109
+ if self._messages:
110
+ if self._messages[0].role == Role.SYSTEM:
111
+ self._messages[0] = Message.system(content)
112
+ else:
113
+ self._messages.insert(0, Message.system(content))
114
+ else:
115
+ self._messages = [Message.system(content)]
116
+ return self
117
+
118
+ def reset(self) -> None:
119
+ """Reset the conversation history to initial state."""
120
+ self._messages = [Message.system(self.system_prompt)] if self.system_prompt else []
121
+
122
+ def fork(self, name: str | None = None) -> Self:
123
+ """Fork this agent into an independent clone with the same state and history.
124
+
125
+ The forked agent inherits conversation history, tools, system prompt,
126
+ and configuration, but subsequent operations on either agent remain isolated.
127
+
128
+ Parameters
129
+ ----------
130
+ name : str | None
131
+ New name for the forked agent. Defaults to ``f"{self.name}_fork"``.
132
+
133
+ Returns
134
+ -------
135
+ Self
136
+ A new agent instance with cloned state and independent history.
137
+ """
138
+ forked_name = name or f"{self.name}_fork"
139
+ new_agent = copy.copy(self)
140
+ new_agent.name = forked_name
141
+ new_agent.log = AgentLogger(forked_name, verbose=self.verbose)
142
+ new_agent.tool_registry = self.tool_registry.copy()
143
+ new_agent._messages = [m.model_copy(deep=True) for m in self._messages]
144
+ return new_agent
145
+
146
+ def rewind(self, turns: int = 1) -> list[Message]:
147
+ """Rewind the conversation history by a given number of turns (default 1).
148
+
149
+ A turn starts with a user message and includes all subsequent assistant
150
+ responses and tool executions.
151
+
152
+ If *turns* exceeds the number of user messages in the history, all
153
+ available turns are removed (i.e. the request is silently truncated).
154
+
155
+ Parameters
156
+ ----------
157
+ turns : int
158
+ Number of turns to rewind. Defaults to 1.
159
+
160
+ Returns
161
+ -------
162
+ list[Message]
163
+ The messages that were removed.
164
+ """
165
+ if turns < 1:
166
+ return []
167
+ removed: list[Message] = []
168
+ for _ in range(turns):
169
+ last_user_idx = None
170
+ for i in range(len(self._messages) - 1, -1, -1):
171
+ if self._messages[i].role == Role.USER:
172
+ last_user_idx = i
173
+ break
174
+ if last_user_idx is None:
175
+ break
176
+ removed = self._messages[last_user_idx:] + removed
177
+ self._messages = self._messages[:last_user_idx]
178
+ return removed
179
+
180
+ def rewind_to(self, index: int, *, inclusive: bool = False) -> list[Message]:
181
+ """Rewind conversation history to a specific message index.
182
+
183
+ Parameters
184
+ ----------
185
+ index : int
186
+ The target message index in ``self._messages``.
187
+ inclusive : bool
188
+ If True, keep the message at index and remove messages after it.
189
+ If False, remove the message at index and all messages after it.
190
+
191
+ Returns
192
+ -------
193
+ list[Message]
194
+ The messages that were removed.
195
+ """
196
+ cutoff = index + 1 if inclusive else index
197
+ if cutoff < 0 or cutoff >= len(self._messages):
198
+ return []
199
+ removed = self._messages[cutoff:]
200
+ self._messages = self._messages[:cutoff]
201
+ return removed
202
+
203
+ async def compact(self, *, keep_recent_turns: int = 1) -> dict[str, Any]:
204
+ """Compact conversation history by semantically summarizing past turns.
205
+
206
+ Preserves system prompt and the specified number of most recent turns
207
+ (default 1), replacing earlier rounds and tool executions with a concise
208
+ semantic summary produced by the LLM.
209
+
210
+ Parameters
211
+ ----------
212
+ keep_recent_turns : int
213
+ Number of recent user turns to retain uncompressed.
214
+
215
+ Returns
216
+ -------
217
+ dict[str, Any]
218
+ Compaction summary with status, messages_before, messages_after,
219
+ and the summary text.
220
+ """
221
+ user_indices = [
222
+ i for i, m in enumerate(self._messages)
223
+ if m.role == Role.USER
224
+ ]
225
+ if not user_indices or len(user_indices) <= keep_recent_turns:
226
+ return {
227
+ "status": "skipped",
228
+ "reason": "Not enough turns to compact",
229
+ "messages_before": len(self._messages),
230
+ "messages_after": len(self._messages),
231
+ "summary": "",
232
+ }
233
+
234
+ split_idx = user_indices[-keep_recent_turns]
235
+ sys_offset = 1 if (self._messages and self._messages[0].role == Role.SYSTEM) else 0
236
+
237
+ to_summarize = self._messages[sys_offset:split_idx]
238
+ to_keep = self._messages[split_idx:]
239
+
240
+ if not to_summarize:
241
+ return {
242
+ "status": "skipped",
243
+ "reason": "No messages to summarize",
244
+ "messages_before": len(self._messages),
245
+ "messages_after": len(self._messages),
246
+ "summary": "",
247
+ }
248
+
249
+ # Build transcript of messages to summarize
250
+ transcript_lines: list[str] = []
251
+ for m in to_summarize:
252
+ role_label = m.role.value.upper()
253
+ if m.role == Role.TOOL and m.tool_result:
254
+ content = m.tool_result.content
255
+ if len(content) > 1000:
256
+ content = content[:1000] + " ...[truncated]"
257
+ transcript_lines.append(f"[TOOL RESULT ({m.tool_result.tool_call_id})]: {content}")
258
+ elif m.role == Role.ASSISTANT and m.tool_calls:
259
+ call_descs = [f"{tc.name}({tc.arguments})" for tc in m.tool_calls]
260
+ text = m.content or ""
261
+ transcript_lines.append(f"[ASSISTANT]: {text}\n[CALLS]: {', '.join(call_descs)}")
262
+ elif m.content:
263
+ content = m.content
264
+ if len(content) > 2000:
265
+ content = content[:2000] + " ...[truncated]"
266
+ transcript_lines.append(f"[{role_label}]: {content}")
267
+
268
+ history_text = "\n\n".join(transcript_lines)
269
+
270
+ summary_prompt = (
271
+ "You are an expert conversation summarizer for an AI assistant.\n"
272
+ "Summarize the preceding conversation history into a structured, concise brief.\n"
273
+ "Preserve:\n"
274
+ "1. User goals, instructions, and constraints.\n"
275
+ "2. Key actions taken, tools invoked, and files modified or inspected.\n"
276
+ "3. Crucial findings, conclusions, and decisions reached.\n"
277
+ "4. Current pending tasks or next steps.\n\n"
278
+ "--- Conversation History ---\n"
279
+ f"{history_text}\n"
280
+ "--- End History ---\n\n"
281
+ "Provide only the concise summary."
282
+ )
283
+
284
+ try:
285
+ summary_response = await self.llm.chat([
286
+ Message.system("You are a concise conversation summarizer."),
287
+ Message.user(summary_prompt),
288
+ ])
289
+ summary_content = (summary_response.content or "").strip()
290
+ except Exception as exc:
291
+ summary_content = f"[Compacted {len(to_summarize)} messages: summary generation failed ({exc})]"
292
+
293
+ if not summary_content:
294
+ summary_content = f"[Compacted {len(to_summarize)} messages from earlier conversation]"
295
+
296
+ # Reassemble messages
297
+ new_messages: list[Message] = []
298
+ if sys_offset > 0:
299
+ new_messages.append(self._messages[0])
300
+
301
+ new_messages.append(Message.user(
302
+ f"--- Context Summary of Previous Conversation (compacted) ---\n"
303
+ f"{summary_content}\n"
304
+ f"--- End Summary ---"
305
+ ))
306
+ new_messages.append(Message.assistant(
307
+ "Understood. I have preserved the context from our previous discussion and am ready to proceed."
308
+ ))
309
+ new_messages.extend(to_keep)
310
+
311
+ before_count = len(self._messages)
312
+ self._messages = new_messages
313
+ after_count = len(self._messages)
314
+
315
+ return {
316
+ "status": "compacted",
317
+ "messages_before": before_count,
318
+ "messages_after": after_count,
319
+ "summary": summary_content,
320
+ }
321
+
322
+ # ── Serialization & Persistence ─────────────────────────────────
323
+
324
+ def _get_extra_state(self) -> dict[str, Any]:
325
+ """Hook for subclasses to persist additional state."""
326
+ return {}
327
+
328
+ def _load_extra_state(self, extra: dict[str, Any]) -> None:
329
+ """Hook for subclasses to restore additional state."""
330
+ pass
331
+
332
+ def to_dict(self) -> dict[str, Any]:
333
+ """Serialize the agent state to a dictionary.
334
+
335
+ Returns
336
+ -------
337
+ dict[str, Any]
338
+ Serialized agent data including message history.
339
+ """
340
+ return {
341
+ "agent_type": self.__class__.__name__,
342
+ "name": self.name,
343
+ "system_prompt": self.system_prompt,
344
+ "max_iterations": self.max_iterations,
345
+ "verbose": self.verbose,
346
+ "messages": [m.model_dump(exclude_none=True) for m in self._messages],
347
+ "tools": [t.name for t in self.tool_registry.list_tools()],
348
+ "extra": self._get_extra_state(),
349
+ }
350
+
351
+ def to_json(self, indent: int = 2) -> str:
352
+ """Serialize the agent state to a JSON-formatted string."""
353
+ import json
354
+ return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
355
+
356
+ def save(self, path_or_fp: str | Path | TextIO) -> None:
357
+ """Save the serialized agent state to a file or stream.
358
+
359
+ Parameters
360
+ ----------
361
+ path_or_fp : str | Path | TextIO
362
+ File path string, pathlib.Path, or open text file object.
363
+ """
364
+ import json
365
+ from pathlib import Path
366
+
367
+ data = self.to_dict()
368
+ if isinstance(path_or_fp, (str, Path)):
369
+ p = Path(path_or_fp)
370
+ p.parent.mkdir(parents=True, exist_ok=True)
371
+ with open(p, "w", encoding="utf-8") as f:
372
+ json.dump(data, f, ensure_ascii=False, indent=2)
373
+ else:
374
+ json.dump(data, path_or_fp, ensure_ascii=False, indent=2)
375
+
376
+ @classmethod
377
+ def _resolve_agent_class(cls, agent_type: str | None) -> type[BaseAgent]:
378
+ """Resolve agent subclass by type name."""
379
+ if cls is not BaseAgent:
380
+ return cls
381
+ if agent_type == "PlannerAgent":
382
+ from agent2.agent.planner import PlannerAgent
383
+ return PlannerAgent
384
+ from agent2.agent.react import ReActAgent
385
+ return ReActAgent
386
+
387
+ @classmethod
388
+ def from_dict(
389
+ cls,
390
+ data: dict[str, Any],
391
+ *,
392
+ llm: BaseLLM | None = None,
393
+ tools: list[Tool] | None = None,
394
+ **kwargs: Any,
395
+ ) -> BaseAgent:
396
+ """Restore an agent instance from a dictionary.
397
+
398
+ Parameters
399
+ ----------
400
+ data : dict[str, Any]
401
+ Serialized agent data dictionary.
402
+ llm : BaseLLM | None
403
+ Optional LLM instance. If not provided, a default LLM is created.
404
+ tools : list[Tool] | None
405
+ Optional list of Tool instances to bind to the restored agent.
406
+ **kwargs
407
+ Additional arguments passed to the agent constructor.
408
+
409
+ Returns
410
+ -------
411
+ BaseAgent
412
+ Restored agent instance with conversation history loaded.
413
+ """
414
+ agent_cls = cls._resolve_agent_class(data.get("agent_type"))
415
+ name = kwargs.pop("name", data.get("name", "agent"))
416
+ system_prompt = kwargs.pop("system_prompt", data.get("system_prompt", "You are a helpful AI assistant."))
417
+ max_iterations = kwargs.pop("max_iterations", data.get("max_iterations"))
418
+ verbose = kwargs.pop("verbose", data.get("verbose"))
419
+
420
+ resolved_tools: list[Tool] = []
421
+ if tools:
422
+ resolved_tools.extend(tools)
423
+ else:
424
+ tool_names = set(data.get("tools", []))
425
+ if tool_names:
426
+ try:
427
+ import agent2.tools.builtin as builtin_module
428
+ seen_names: set[str] = set()
429
+ for attr_name in dir(builtin_module):
430
+ val = getattr(builtin_module, attr_name)
431
+ if (
432
+ isinstance(val, Tool)
433
+ and val.name in tool_names
434
+ and val.name not in seen_names
435
+ ):
436
+ resolved_tools.append(val)
437
+ seen_names.add(val.name)
438
+ except (ImportError, AttributeError) as exc:
439
+ import logging
440
+ logging.getLogger(__name__).warning("Failed to load builtin tools: %s", exc)
441
+
442
+ agent = agent_cls(
443
+ name=name,
444
+ llm=llm,
445
+ system_prompt=system_prompt,
446
+ tools=resolved_tools if resolved_tools else None,
447
+ max_iterations=max_iterations,
448
+ verbose=verbose,
449
+ **kwargs,
450
+ )
451
+
452
+ extra = data.get("extra", {})
453
+ if extra and hasattr(agent, "_load_extra_state"):
454
+ agent._load_extra_state(extra)
455
+
456
+ messages_raw = data.get("messages", [])
457
+ agent._messages = [Message.model_validate(m) for m in messages_raw]
458
+ agent._repair_tool_messages()
459
+ return agent
460
+
461
+ @classmethod
462
+ def from_json(
463
+ cls,
464
+ json_str: str,
465
+ *,
466
+ llm: BaseLLM | None = None,
467
+ tools: list[Tool] | None = None,
468
+ **kwargs: Any,
469
+ ) -> BaseAgent:
470
+ """Restore an agent from a JSON string."""
471
+ import json
472
+ data = json.loads(json_str)
473
+ return cls.from_dict(data, llm=llm, tools=tools, **kwargs)
474
+
475
+ @classmethod
476
+ def load(
477
+ cls,
478
+ path_or_fp: str | Path | TextIO,
479
+ *,
480
+ llm: BaseLLM | None = None,
481
+ tools: list[Tool] | None = None,
482
+ **kwargs: Any,
483
+ ) -> BaseAgent:
484
+ """Load an agent from a JSON file or file-like object.
485
+
486
+ Parameters
487
+ ----------
488
+ path_or_fp : str | Path | TextIO
489
+ File path string, pathlib.Path, or open text file object.
490
+ llm : BaseLLM | None
491
+ Optional LLM instance.
492
+ tools : list[Tool] | None
493
+ Optional list of Tool instances.
494
+ **kwargs
495
+ Additional arguments passed to the agent constructor.
496
+
497
+ Returns
498
+ -------
499
+ BaseAgent
500
+ Restored agent instance with full conversation history.
501
+ """
502
+ import json
503
+ from pathlib import Path
504
+
505
+ if isinstance(path_or_fp, (str, Path)):
506
+ with open(path_or_fp, "r", encoding="utf-8") as f:
507
+ data = json.load(f)
508
+ else:
509
+ data = json.load(path_or_fp)
510
+ return cls.from_dict(data, llm=llm, tools=tools, **kwargs)
511
+
512
+ # ── Public API ──────────────────────────────────────────────────
513
+
514
+ async def chat(self, msg: str | Message) -> str:
515
+ """Send a message in a multi-turn conversation and return the assistant response.
516
+
517
+ Maintains conversation history across calls.
518
+
519
+ Parameters
520
+ ----------
521
+ msg : str | Message
522
+ The user prompt or Message instance.
523
+
524
+ Returns
525
+ -------
526
+ str
527
+ The agent's response.
528
+ """
529
+ content = msg if isinstance(msg, str) else (msg.content or "")
530
+ self.log.start(content)
531
+
532
+ if not self._messages and self.system_prompt:
533
+ self._messages.append(Message.system(self.system_prompt))
534
+
535
+ user_msg = Message.user(msg) if isinstance(msg, str) else msg
536
+ self._messages.append(user_msg)
537
+
538
+ self._repair_tool_messages()
539
+
540
+ try:
541
+ result = await self._run_loop()
542
+ except MaxIterationsExceeded:
543
+ result = (
544
+ f"I was unable to complete the task within {self.max_iterations} steps. "
545
+ f"Here is what I've done so far based on the conversation."
546
+ )
547
+ self.log.observation(result, is_error=True)
548
+
549
+ if not self._messages or self._messages[-1].role != Role.ASSISTANT or self._messages[-1].content != result:
550
+ self._messages.append(Message.assistant(result))
551
+
552
+ self.log.finish()
553
+ return result
554
+
555
+ async def run(self, task: str) -> str:
556
+ """Execute a standalone task and return the final answer (resets history).
557
+
558
+ Parameters
559
+ ----------
560
+ task : str
561
+ The user's task or question.
562
+
563
+ Returns
564
+ -------
565
+ str
566
+ The agent's final response.
567
+ """
568
+ self.reset()
569
+ return await self.chat(task)
570
+
571
+
572
+ # ── Abstract method for subclasses ──────────────────────────────
573
+
574
+ @abstractmethod
575
+ async def _run_loop(self) -> str:
576
+ """The core reasoning loop. Subclasses implement this.
577
+
578
+ Returns the final answer string.
579
+ """
580
+ ...
581
+
582
+ # ── Helpers ─────────────────────────────────────────────────────
583
+
584
+ def _repair_tool_messages(self) -> None:
585
+ """Ensure every assistant ``tool_calls`` message has tool responses.
586
+
587
+ OpenAI-compatible APIs require each ``tool_call_id`` from an assistant
588
+ message to be answered by a following ``tool`` message. This repairs
589
+ histories that may have been interrupted by an error/cancellation or
590
+ loaded from an older/incomplete session file.
591
+ """
592
+ messages = self._messages
593
+ i = 0
594
+ while i < len(messages):
595
+ msg = messages[i]
596
+ if msg.role == Role.ASSISTANT and msg.tool_calls:
597
+ expected = {tc.id for tc in msg.tool_calls}
598
+ j = i + 1
599
+ found: set[str] = set()
600
+ while j < len(messages) and messages[j].role == Role.TOOL:
601
+ if messages[j].tool_result is not None:
602
+ found.add(messages[j].tool_result.tool_call_id)
603
+ j += 1
604
+ missing = expected - found
605
+ if missing:
606
+ insert_at = j
607
+ for tool_call_id in sorted(missing):
608
+ messages.insert(
609
+ insert_at,
610
+ Message.tool(
611
+ tool_call_id,
612
+ "Tool execution did not return a result.",
613
+ is_error=True,
614
+ ),
615
+ )
616
+ insert_at += 1
617
+ i = insert_at
618
+ continue
619
+ i = j
620
+ else:
621
+ i += 1
622
+
623
+ async def _execute_tool_calls(self, tool_calls: list[Any]) -> list[Message]:
624
+ """Execute a list of tool calls and return result messages.
625
+
626
+ Every tool call always produces a tool result, even when execution
627
+ raises, so the conversation history remains valid for OpenAI-compatible
628
+ APIs (an assistant ``tool_calls`` message must be followed by one tool
629
+ message per ``tool_call_id``).
630
+ """
631
+ results: list[Message] = []
632
+ for tc in tool_calls:
633
+ self.log.action(tc.name, tc.arguments)
634
+ try:
635
+ output = await self.tool_registry.execute(tc.name, **tc.arguments)
636
+ except Exception as exc:
637
+ output = f"Error executing {tc.name}: {exc}"
638
+ is_error = True
639
+ else:
640
+ is_error = output.startswith("Error")
641
+ self.log.observation(output, is_error=is_error)
642
+ results.append(Message.tool(tc.id, output, is_error=is_error))
643
+ return results
644
+
645
+ def __repr__(self) -> str:
646
+ tools = [t.name for t in self.tool_registry.list_tools()]
647
+ return f"{self.__class__.__name__}(name={self.name!r}, llm={self.llm!r}, tools={tools})"
648
+
649
+
650
+ class MaxIterationsExceeded(Exception):
651
+ """Raised when agent exceeds its maximum iteration count."""
652
+ pass