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 +28 -0
- agent2/agent/__init__.py +25 -0
- agent2/agent/base.py +652 -0
- agent2/agent/planner.py +268 -0
- agent2/agent/react.py +107 -0
- agent2/agent/reflection.py +118 -0
- agent2/app/__init__.py +3 -0
- agent2/app/approval.py +104 -0
- agent2/app/chat.py +955 -0
- agent2/app/cli.py +19 -0
- agent2/app/common.py +73 -0
- agent2/app/config.py +530 -0
- agent2/context.py +295 -0
- agent2/crew/__init__.py +13 -0
- agent2/crew/base.py +65 -0
- agent2/crew/debate.py +120 -0
- agent2/crew/sequential.py +54 -0
- agent2/crew/supervisor.py +144 -0
- agent2/llm/__init__.py +138 -0
- agent2/llm/base.py +225 -0
- agent2/llm/message.py +185 -0
- agent2/llm/openai.py +341 -0
- agent2/llm/pricing.py +83 -0
- agent2/mcp.py +536 -0
- agent2/memory/__init__.py +11 -0
- agent2/memory/base.py +76 -0
- agent2/memory/longterm.py +245 -0
- agent2/memory/working.py +147 -0
- agent2/tools/__init__.py +18 -0
- agent2/tools/base.py +175 -0
- agent2/tools/builtin/__init__.py +23 -0
- agent2/tools/builtin/file_ops.py +75 -0
- agent2/tools/builtin/python_exec.py +78 -0
- agent2/tools/builtin/shell_exec.py +62 -0
- agent2/tools/builtin/web_search.py +45 -0
- agent2/tools/registry.py +78 -0
- agent2/utils/__init__.py +1 -0
- agent2/utils/config.py +58 -0
- agent2/utils/json_helpers.py +37 -0
- agent2/utils/logging.py +232 -0
- agent2_core-0.1.3.25.dist-info/METADATA +203 -0
- agent2_core-0.1.3.25.dist-info/RECORD +44 -0
- agent2_core-0.1.3.25.dist-info/WHEEL +4 -0
- agent2_core-0.1.3.25.dist-info/entry_points.txt +3 -0
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
|
+
|
agent2/agent/__init__.py
ADDED
|
@@ -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
|