agentsdk-py 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. agentsdk_py-0.1.0/PKG-INFO +146 -0
  2. agentsdk_py-0.1.0/README.md +116 -0
  3. agentsdk_py-0.1.0/agentsdk/__init__.py +41 -0
  4. agentsdk_py-0.1.0/agentsdk/agent.py +321 -0
  5. agentsdk_py-0.1.0/agentsdk/cli.py +427 -0
  6. agentsdk_py-0.1.0/agentsdk/exceptions.py +54 -0
  7. agentsdk_py-0.1.0/agentsdk/graph/__init__.py +31 -0
  8. agentsdk_py-0.1.0/agentsdk/graph/bus.py +407 -0
  9. agentsdk_py-0.1.0/agentsdk/graph/graph.py +141 -0
  10. agentsdk_py-0.1.0/agentsdk/graph/node.py +121 -0
  11. agentsdk_py-0.1.0/agentsdk/graph/runner.py +140 -0
  12. agentsdk_py-0.1.0/agentsdk/llm.py +230 -0
  13. agentsdk_py-0.1.0/agentsdk/messages.py +289 -0
  14. agentsdk_py-0.1.0/agentsdk/observability/__init__.py +28 -0
  15. agentsdk_py-0.1.0/agentsdk/observability/middleware.py +295 -0
  16. agentsdk_py-0.1.0/agentsdk/observability/tracer.py +169 -0
  17. agentsdk_py-0.1.0/agentsdk/persistence/__init__.py +26 -0
  18. agentsdk_py-0.1.0/agentsdk/persistence/checkpoint.py +115 -0
  19. agentsdk_py-0.1.0/agentsdk/persistence/file_store.py +86 -0
  20. agentsdk_py-0.1.0/agentsdk/persistence/session.py +168 -0
  21. agentsdk_py-0.1.0/agentsdk/tools/__init__.py +20 -0
  22. agentsdk_py-0.1.0/agentsdk/tools/base.py +209 -0
  23. agentsdk_py-0.1.0/agentsdk/tools/builtin.py +158 -0
  24. agentsdk_py-0.1.0/agentsdk/tools/registry.py +85 -0
  25. agentsdk_py-0.1.0/agentsdk_py.egg-info/PKG-INFO +146 -0
  26. agentsdk_py-0.1.0/agentsdk_py.egg-info/SOURCES.txt +31 -0
  27. agentsdk_py-0.1.0/agentsdk_py.egg-info/dependency_links.txt +1 -0
  28. agentsdk_py-0.1.0/agentsdk_py.egg-info/entry_points.txt +2 -0
  29. agentsdk_py-0.1.0/agentsdk_py.egg-info/requires.txt +15 -0
  30. agentsdk_py-0.1.0/agentsdk_py.egg-info/top_level.txt +1 -0
  31. agentsdk_py-0.1.0/pyproject.toml +54 -0
  32. agentsdk_py-0.1.0/setup.cfg +4 -0
  33. agentsdk_py-0.1.0/tests/test_smoke.py +221 -0
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentsdk-py
3
+ Version: 0.1.0
4
+ Summary: A lightweight Python SDK for building AI agents with tools, memory, and multi-agent pipelines — powered by Groq
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/vishwa0198/agentsdk
7
+ Project-URL: Documentation, https://vishwa0198.github.io/agentsdk
8
+ Project-URL: Repository, https://github.com/vishwa0198/agentsdk
9
+ Project-URL: Changelog, https://github.com/vishwa0198/agentsdk/blob/main/CHANGELOG.md
10
+ Keywords: ai,agents,llm,groq,sdk,multi-agent,react-agent,tool-use
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: pydantic>=2.0
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: aiofiles>=23.0
20
+ Requires-Dist: groq>=0.9
21
+ Requires-Dist: typer>=0.12
22
+ Requires-Dist: rich>=13.0
23
+ Provides-Extra: otel
24
+ Requires-Dist: opentelemetry-api>=1.24; extra == "otel"
25
+ Requires-Dist: opentelemetry-sdk>=1.24; extra == "otel"
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8.0; extra == "dev"
28
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
29
+ Requires-Dist: python-dotenv>=1.0; extra == "dev"
30
+
31
+ # agentsdk
32
+
33
+ A lightweight Python SDK for building AI agents with tool use, multi-agent graphs, persistence, and tracing.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install agentsdk # core
39
+ pip install agentsdk[otel] # + OpenTelemetry tracing
40
+ pip install agentsdk[dev] # + pytest / dotenv
41
+ ```
42
+
43
+ ## Quickstart
44
+
45
+ ```python
46
+ import asyncio, os
47
+ from agentsdk import Agent, AgentConfig, GroqProvider
48
+
49
+ agent = Agent(
50
+ config=AgentConfig(
51
+ name="MyAgent",
52
+ system_prompt="You are a helpful assistant.",
53
+ ),
54
+ llm=GroqProvider(api_key=os.environ["GROQ_API_KEY"]),
55
+ )
56
+
57
+ async def main():
58
+ result = await agent.run("What is the capital of France?")
59
+ print(result.output)
60
+
61
+ asyncio.run(main())
62
+ ```
63
+
64
+ ## Tools
65
+
66
+ ```python
67
+ from agentsdk import tool, ToolRegistry, Agent, AgentConfig, GroqProvider
68
+
69
+ @tool
70
+ async def add(a: int, b: int) -> str:
71
+ """Add two integers."""
72
+ return str(a + b)
73
+
74
+ registry = ToolRegistry()
75
+ registry.register(add)
76
+
77
+ agent = Agent(config=AgentConfig(name="Calc", system_prompt="Use tools."),
78
+ llm=GroqProvider(...), registry=registry)
79
+ ```
80
+
81
+ ## Multi-agent Graph
82
+
83
+ ```python
84
+ from agentsdk import AgentNode, Edge, AgentGraph, GraphRunner
85
+
86
+ graph = AgentGraph()
87
+ graph.add_node(AgentNode("researcher", researcher_agent))
88
+ graph.add_node(AgentNode("writer", writer_agent))
89
+ graph.add_edge(Edge("researcher", "writer", data_map={"output": "input"}))
90
+ graph.set_entry("researcher"); graph.set_exit("writer")
91
+ result = await GraphRunner(graph).run({"input": "Explain black holes"})
92
+ ```
93
+
94
+ ## Persistence
95
+
96
+ ```python
97
+ from agentsdk import FileCheckpointStore, SessionManager, Agent
98
+
99
+ store = FileCheckpointStore(base_dir=".agentsdk/checkpoints")
100
+ session_mgr = SessionManager(store=store, agent_name="MyAgent")
101
+ agent = Agent(config=..., llm=..., session_manager=session_mgr)
102
+
103
+ # History is saved and reloaded automatically across runs:
104
+ await agent.run("My favourite language is Python.", session_id="user-001")
105
+ await agent.run("What language did I mention?", session_id="user-001")
106
+
107
+ # Fork a session to branch an agent run:
108
+ forked = await session_mgr.fork("user-001", "user-001-branch")
109
+ ```
110
+
111
+ ## Tracing (requires `agentsdk[otel]`)
112
+
113
+ ```python
114
+ from agentsdk.observability import SDKTracer, TracedLLMProvider, TracedAgent, print_trace
115
+
116
+ tracer = SDKTracer(service_name="myapp")
117
+ traced_llm = TracedLLMProvider(provider=GroqProvider(...), tracer=tracer)
118
+ agent = TracedAgent(config=..., llm=traced_llm, tracer=tracer)
119
+
120
+ result, ctx = await agent.run("Summarise the last quarter earnings.")
121
+ print_trace(ctx)
122
+ # ╔══ Trace: MyAgent ══════════════════════
123
+ # ║ Session : (none)
124
+ # ║ Trace ID : 6744d6eca33853c5bba0...
125
+ # ║ Duration : 3680ms
126
+ # ║ LLM calls : 2
127
+ # ║ Tool calls : 1
128
+ # ║ Tokens : 1292 in / 36 out
129
+ # ╚═════════════════════════════════════════
130
+ ```
131
+
132
+ ## CLI
133
+
134
+ ```bash
135
+ # Scaffold a new agent project
136
+ scaffold-agent new myproject
137
+
138
+ # Interactive REPL against any agent file
139
+ scaffold-agent run myproject/agents/main.py
140
+
141
+ # Inspect a saved checkpoint
142
+ scaffold-agent trace .agentsdk/checkpoints/MyAgent/user-001.json
143
+
144
+ # List all sessions for an agent
145
+ scaffold-agent list-sessions MyAgent
146
+ ```
@@ -0,0 +1,116 @@
1
+ # agentsdk
2
+
3
+ A lightweight Python SDK for building AI agents with tool use, multi-agent graphs, persistence, and tracing.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install agentsdk # core
9
+ pip install agentsdk[otel] # + OpenTelemetry tracing
10
+ pip install agentsdk[dev] # + pytest / dotenv
11
+ ```
12
+
13
+ ## Quickstart
14
+
15
+ ```python
16
+ import asyncio, os
17
+ from agentsdk import Agent, AgentConfig, GroqProvider
18
+
19
+ agent = Agent(
20
+ config=AgentConfig(
21
+ name="MyAgent",
22
+ system_prompt="You are a helpful assistant.",
23
+ ),
24
+ llm=GroqProvider(api_key=os.environ["GROQ_API_KEY"]),
25
+ )
26
+
27
+ async def main():
28
+ result = await agent.run("What is the capital of France?")
29
+ print(result.output)
30
+
31
+ asyncio.run(main())
32
+ ```
33
+
34
+ ## Tools
35
+
36
+ ```python
37
+ from agentsdk import tool, ToolRegistry, Agent, AgentConfig, GroqProvider
38
+
39
+ @tool
40
+ async def add(a: int, b: int) -> str:
41
+ """Add two integers."""
42
+ return str(a + b)
43
+
44
+ registry = ToolRegistry()
45
+ registry.register(add)
46
+
47
+ agent = Agent(config=AgentConfig(name="Calc", system_prompt="Use tools."),
48
+ llm=GroqProvider(...), registry=registry)
49
+ ```
50
+
51
+ ## Multi-agent Graph
52
+
53
+ ```python
54
+ from agentsdk import AgentNode, Edge, AgentGraph, GraphRunner
55
+
56
+ graph = AgentGraph()
57
+ graph.add_node(AgentNode("researcher", researcher_agent))
58
+ graph.add_node(AgentNode("writer", writer_agent))
59
+ graph.add_edge(Edge("researcher", "writer", data_map={"output": "input"}))
60
+ graph.set_entry("researcher"); graph.set_exit("writer")
61
+ result = await GraphRunner(graph).run({"input": "Explain black holes"})
62
+ ```
63
+
64
+ ## Persistence
65
+
66
+ ```python
67
+ from agentsdk import FileCheckpointStore, SessionManager, Agent
68
+
69
+ store = FileCheckpointStore(base_dir=".agentsdk/checkpoints")
70
+ session_mgr = SessionManager(store=store, agent_name="MyAgent")
71
+ agent = Agent(config=..., llm=..., session_manager=session_mgr)
72
+
73
+ # History is saved and reloaded automatically across runs:
74
+ await agent.run("My favourite language is Python.", session_id="user-001")
75
+ await agent.run("What language did I mention?", session_id="user-001")
76
+
77
+ # Fork a session to branch an agent run:
78
+ forked = await session_mgr.fork("user-001", "user-001-branch")
79
+ ```
80
+
81
+ ## Tracing (requires `agentsdk[otel]`)
82
+
83
+ ```python
84
+ from agentsdk.observability import SDKTracer, TracedLLMProvider, TracedAgent, print_trace
85
+
86
+ tracer = SDKTracer(service_name="myapp")
87
+ traced_llm = TracedLLMProvider(provider=GroqProvider(...), tracer=tracer)
88
+ agent = TracedAgent(config=..., llm=traced_llm, tracer=tracer)
89
+
90
+ result, ctx = await agent.run("Summarise the last quarter earnings.")
91
+ print_trace(ctx)
92
+ # ╔══ Trace: MyAgent ══════════════════════
93
+ # ║ Session : (none)
94
+ # ║ Trace ID : 6744d6eca33853c5bba0...
95
+ # ║ Duration : 3680ms
96
+ # ║ LLM calls : 2
97
+ # ║ Tool calls : 1
98
+ # ║ Tokens : 1292 in / 36 out
99
+ # ╚═════════════════════════════════════════
100
+ ```
101
+
102
+ ## CLI
103
+
104
+ ```bash
105
+ # Scaffold a new agent project
106
+ scaffold-agent new myproject
107
+
108
+ # Interactive REPL against any agent file
109
+ scaffold-agent run myproject/agents/main.py
110
+
111
+ # Inspect a saved checkpoint
112
+ scaffold-agent trace .agentsdk/checkpoints/MyAgent/user-001.json
113
+
114
+ # List all sessions for an agent
115
+ scaffold-agent list-sessions MyAgent
116
+ ```
@@ -0,0 +1,41 @@
1
+ """agentsdk — A lightweight Python SDK for building AI agents."""
2
+
3
+ from agentsdk.agent import Agent, AgentConfig, AgentResult
4
+ from agentsdk.messages import (
5
+ MessageHistory,
6
+ HumanMessage,
7
+ AIMessage,
8
+ SystemMessage,
9
+ ToolResultMessage,
10
+ )
11
+ from agentsdk.llm import GroqProvider, LLMResponse
12
+ from agentsdk.tools.base import tool, BaseTool
13
+ from agentsdk.tools.registry import ToolRegistry
14
+ from agentsdk.tools.builtin import DEFAULT_TOOLS
15
+ from agentsdk.graph.node import AgentNode, Edge
16
+ from agentsdk.graph.graph import AgentGraph
17
+ from agentsdk.graph.runner import GraphRunner
18
+ from agentsdk.graph.bus import MessageBus, BusAwareAgent, BusRunner
19
+ from agentsdk.persistence.file_store import FileCheckpointStore
20
+ from agentsdk.persistence.session import SessionManager
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = [
25
+ # Core agent
26
+ "Agent", "AgentConfig", "AgentResult",
27
+ # Messages
28
+ "MessageHistory", "HumanMessage", "AIMessage", "SystemMessage", "ToolResultMessage",
29
+ # LLM
30
+ "GroqProvider", "LLMResponse",
31
+ # Tools
32
+ "tool", "BaseTool", "ToolRegistry", "DEFAULT_TOOLS",
33
+ # Graph
34
+ "AgentNode", "Edge", "AgentGraph", "GraphRunner",
35
+ # Bus
36
+ "MessageBus", "BusAwareAgent", "BusRunner",
37
+ # Persistence
38
+ "FileCheckpointStore", "SessionManager",
39
+ # Meta
40
+ "__version__",
41
+ ]
@@ -0,0 +1,321 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING, Any
3
+ from pydantic import BaseModel, ConfigDict
4
+ from agentsdk.llm import LLMProvider, ToolSchema
5
+ from agentsdk.messages import AIMessage, Memory, MessageHistory, ToolCall
6
+ from agentsdk.tools.base import BaseTool
7
+ from agentsdk.tools.registry import ToolRegistry
8
+
9
+ if TYPE_CHECKING:
10
+ from agentsdk.persistence.session import SessionManager
11
+
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # AgentConfig
15
+ # ---------------------------------------------------------------------------
16
+
17
+
18
+ class AgentConfig(BaseModel):
19
+ """Immutable configuration for an Agent instance.
20
+
21
+ Attributes:
22
+ name: Human-readable agent name used in verbose output and tracing.
23
+ system_prompt: Injected as the first SystemMessage for every new session.
24
+ max_iterations: Hard stop preventing infinite tool-call loops. Default 10.
25
+ max_tokens: Forwarded to LLMProvider.complete() on every call. Default 1024.
26
+ tools_enabled: When False, tool schemas are withheld even if registered.
27
+ verbose: Print each iteration's thought and tool calls to stdout.
28
+
29
+ Example::
30
+
31
+ config = AgentConfig(
32
+ name="MyAgent",
33
+ system_prompt="You are a helpful assistant.",
34
+ max_iterations=5,
35
+ verbose=True,
36
+ )
37
+ """
38
+
39
+ model_config = ConfigDict(frozen=True)
40
+
41
+ name: str
42
+ """Human-readable agent name (used in verbose output and tracing)."""
43
+
44
+ system_prompt: str
45
+ """Injected as the first SystemMessage for every new session."""
46
+
47
+ max_iterations: int = 10
48
+ """Hard stop — prevents infinite tool-call loops."""
49
+
50
+ max_tokens: int = 1024
51
+ """Forwarded to LLMProvider.complete() on every call."""
52
+
53
+ tools_enabled: bool = True
54
+ """When False, tool schemas are withheld from the LLM even if tools are registered."""
55
+
56
+ verbose: bool = False
57
+ """Print each iteration's thought and tool calls to stdout (dev convenience)."""
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # StepResult
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ class StepResult(BaseModel):
66
+ """Snapshot of one ReAct iteration."""
67
+
68
+ model_config = ConfigDict(frozen=True)
69
+
70
+ iteration: int
71
+ thought: str
72
+ """Raw content of the AIMessage produced this step."""
73
+
74
+ tool_calls: list[ToolCall]
75
+ """Tool calls the model requested this step (empty if none)."""
76
+
77
+ stop_reason: str
78
+ """Forwarded from LLMResponse.stop_reason."""
79
+
80
+ is_final: bool
81
+ """True when this step caused the loop to terminate cleanly."""
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # AgentResult
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ class AgentResult(BaseModel):
90
+ """Full output of a single Agent.run() invocation."""
91
+
92
+ model_config = ConfigDict(frozen=True)
93
+
94
+ output: str
95
+ """Final assistant message content."""
96
+
97
+ steps: list[StepResult]
98
+ """Ordered trace of every ReAct iteration."""
99
+
100
+ total_input_tokens: int
101
+ total_output_tokens: int
102
+
103
+ stopped_by: str
104
+ """Why the loop ended: ``end_turn`` | ``max_iterations`` | ``error``."""
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Agent
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ class Agent:
113
+ """Runs a ReAct agent loop using an LLM provider and optional tools.
114
+
115
+ Args:
116
+ config: AgentConfig with name, system prompt, and settings.
117
+ llm: Any LLMProvider implementation — use GroqProvider.
118
+ tools: Optional flat list of BaseTool instances.
119
+ memory: Optional Memory backend (legacy; prefer session_manager).
120
+ registry: Optional ToolRegistry — merged with tools if both provided.
121
+ session_manager: Optional SessionManager for persistent sessions.
122
+
123
+ Example::
124
+
125
+ llm = GroqProvider(api_key="...")
126
+ config = AgentConfig(name="MyAgent", system_prompt="You are helpful.")
127
+ agent = Agent(config=config, llm=llm)
128
+ result = await agent.run("What is 2 + 2?")
129
+ """
130
+
131
+ def __init__(
132
+ self,
133
+ config: AgentConfig,
134
+ llm: LLMProvider,
135
+ tools: list[BaseTool] | None = None,
136
+ memory: Memory | None = None,
137
+ registry: ToolRegistry | None = None,
138
+ session_manager: SessionManager | None = None,
139
+ ) -> None:
140
+ self.config = config
141
+ self._llm = llm
142
+ self._memory = memory
143
+ self._session_manager = session_manager
144
+ self._tools: list[BaseTool] = list(tools or [])
145
+ if registry is not None:
146
+ self._tools.extend(registry.all())
147
+ # O(1) lookup by tool name during dispatch
148
+ self._tool_map: dict[str, BaseTool] = {t.schema.name: t for t in self._tools}
149
+
150
+ # ------------------------------------------------------------------
151
+ # Core run loop
152
+ # ------------------------------------------------------------------
153
+
154
+ async def run(
155
+ self,
156
+ user_input: str,
157
+ session_id: str | None = None,
158
+ ) -> AgentResult:
159
+ """Execute the ReAct loop for *user_input* and return a full trace.
160
+
161
+ Parameters
162
+ ----------
163
+ user_input:
164
+ The human message that starts this turn.
165
+ session_id:
166
+ When provided alongside a ``Memory`` backend, the conversation
167
+ is loaded before running and persisted afterwards.
168
+ """
169
+ # ── 1. Load or create history ──────────────────────────────────────
170
+ if self._session_manager and session_id:
171
+ history = await self._session_manager.load_history(session_id)
172
+ elif self._memory and session_id:
173
+ history = await self._memory.load(session_id)
174
+ else:
175
+ history = MessageHistory()
176
+
177
+ # Inject system prompt only for brand-new sessions.
178
+ if len(history) == 0:
179
+ history.add_system(self.config.system_prompt)
180
+
181
+ # Extension point: subclasses (e.g. BusAwareAgent) inject context here.
182
+ await self._pre_run_hook(history)
183
+
184
+ history.add_human(user_input)
185
+
186
+ # ── 2. Prepare tool schemas ────────────────────────────────────────
187
+ tool_schemas: list[ToolSchema] | None = None
188
+ if self.config.tools_enabled and self._tools:
189
+ tool_schemas = [t.schema for t in self._tools]
190
+
191
+ # ── 3. ReAct loop ──────────────────────────────────────────────────
192
+ steps: list[StepResult] = []
193
+ total_input = 0
194
+ total_output = 0
195
+ stopped_by = "max_iterations"
196
+ output = ""
197
+
198
+ try:
199
+ for iteration in range(1, self.config.max_iterations + 1):
200
+ response = await self._llm.complete(
201
+ history,
202
+ tools=tool_schemas,
203
+ max_tokens=self.config.max_tokens,
204
+ )
205
+
206
+ total_input += response.input_tokens
207
+ total_output += response.output_tokens
208
+
209
+ ai_message: AIMessage = response.message
210
+ history.add(ai_message)
211
+
212
+ has_tool_calls = bool(ai_message.tool_calls)
213
+ is_final = response.stop_reason == "end_turn" and not has_tool_calls
214
+
215
+ step = StepResult(
216
+ iteration=iteration,
217
+ thought=ai_message.content,
218
+ tool_calls=list(ai_message.tool_calls),
219
+ stop_reason=response.stop_reason,
220
+ is_final=is_final,
221
+ )
222
+ steps.append(step)
223
+
224
+ if self.config.verbose:
225
+ snippet = ai_message.content[:120].replace("\n", " ")
226
+ print(
227
+ f"[{self.config.name}] iter={iteration}"
228
+ f" stop={response.stop_reason}"
229
+ f" thought={snippet!r}"
230
+ )
231
+ for tc in ai_message.tool_calls:
232
+ print(f" → tool_call: {tc.name}({tc.arguments})")
233
+
234
+ # ── clean exit ─────────────────────────────────────────────
235
+ if is_final:
236
+ output = ai_message.content
237
+ stopped_by = "end_turn"
238
+ break
239
+
240
+ # ── tool dispatch ──────────────────────────────────────────
241
+ if has_tool_calls:
242
+ for tc in ai_message.tool_calls:
243
+ result_content, is_error = await self._dispatch_tool(tc)
244
+
245
+ if self.config.verbose:
246
+ status = "ERROR" if is_error else "OK"
247
+ print(f" ← tool_result [{status}]: {result_content[:120]!r}")
248
+
249
+ history.add_tool_result(
250
+ tool_call_id=tc.id,
251
+ content=result_content,
252
+ is_error=is_error,
253
+ )
254
+
255
+ # If stop_reason is "max_tokens" or another non-final reason
256
+ # with no tool calls, the loop continues to let the model
257
+ # recover in the next iteration.
258
+
259
+ except Exception as exc:
260
+ stopped_by = "error"
261
+ output = str(exc)
262
+ if self.config.verbose:
263
+ print(f"[{self.config.name}] ERROR: {exc}")
264
+
265
+ # Grab the last thought as output when the loop was exhausted.
266
+ if stopped_by == "max_iterations" and steps:
267
+ output = steps[-1].thought
268
+
269
+ # ── 4. Persist history ─────────────────────────────────────────────
270
+ if self._session_manager and session_id:
271
+ await self._session_manager.save_history(
272
+ session_id,
273
+ history,
274
+ iteration=len(steps),
275
+ metadata={"stopped_by": stopped_by},
276
+ )
277
+ elif self._memory and session_id:
278
+ await self._memory.save(session_id, history)
279
+
280
+ return AgentResult(
281
+ output=output,
282
+ steps=steps,
283
+ total_input_tokens=total_input,
284
+ total_output_tokens=total_output,
285
+ stopped_by=stopped_by,
286
+ )
287
+
288
+ # ------------------------------------------------------------------
289
+ # Convenience wrapper
290
+ # ------------------------------------------------------------------
291
+
292
+ async def chat(self, user_input: str, session_id: str = "default") -> str:
293
+ """Single-call interface — returns just the final response string."""
294
+ result = await self.run(user_input, session_id)
295
+ return result.output
296
+
297
+ # ------------------------------------------------------------------
298
+ # Extension hooks
299
+ # ------------------------------------------------------------------
300
+
301
+ async def _pre_run_hook(self, history: MessageHistory) -> None:
302
+ """Called after system-prompt injection, before the user message is added.
303
+
304
+ Override in subclasses to inject additional context into *history*
305
+ before the ReAct loop starts. The base implementation is a no-op.
306
+ """
307
+
308
+ # ------------------------------------------------------------------
309
+ # Internal helpers
310
+ # ------------------------------------------------------------------
311
+
312
+ async def _dispatch_tool(self, tc: ToolCall) -> tuple[str, bool]:
313
+ """Execute one tool call and return (result_content, is_error)."""
314
+ tool = self._tool_map.get(tc.name)
315
+ if tool is None:
316
+ return f"Tool not found: {tc.name}", True
317
+ try:
318
+ content = await tool.execute(**tc.arguments)
319
+ return content, False
320
+ except Exception as exc: # noqa: BLE001
321
+ return str(exc), True