stirrup 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stirrup/__init__.py +76 -0
- stirrup/clients/__init__.py +14 -0
- stirrup/clients/chat_completions_client.py +219 -0
- stirrup/clients/litellm_client.py +141 -0
- stirrup/clients/utils.py +161 -0
- stirrup/constants.py +14 -0
- stirrup/core/__init__.py +1 -0
- stirrup/core/agent.py +1097 -0
- stirrup/core/exceptions.py +7 -0
- stirrup/core/models.py +599 -0
- stirrup/prompts/__init__.py +22 -0
- stirrup/prompts/base_system_prompt.txt +1 -0
- stirrup/prompts/message_summarizer.txt +27 -0
- stirrup/prompts/message_summarizer_bridge.txt +11 -0
- stirrup/py.typed +0 -0
- stirrup/tools/__init__.py +77 -0
- stirrup/tools/calculator.py +32 -0
- stirrup/tools/code_backends/__init__.py +38 -0
- stirrup/tools/code_backends/base.py +454 -0
- stirrup/tools/code_backends/docker.py +752 -0
- stirrup/tools/code_backends/e2b.py +359 -0
- stirrup/tools/code_backends/local.py +481 -0
- stirrup/tools/finish.py +23 -0
- stirrup/tools/mcp.py +500 -0
- stirrup/tools/view_image.py +83 -0
- stirrup/tools/web.py +336 -0
- stirrup/utils/__init__.py +10 -0
- stirrup/utils/logging.py +944 -0
- stirrup/utils/text.py +11 -0
- stirrup-0.1.0.dist-info/METADATA +318 -0
- stirrup-0.1.0.dist-info/RECORD +32 -0
- stirrup-0.1.0.dist-info/WHEEL +4 -0
stirrup/core/agent.py
ADDED
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
# Context var for passing parent depth to sub-agent executors
|
|
2
|
+
import contextvars
|
|
3
|
+
import glob as glob_module
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from contextlib import AsyncExitStack
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from itertools import chain, takewhile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Annotated, Any, Self
|
|
14
|
+
|
|
15
|
+
import anyio
|
|
16
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
17
|
+
|
|
18
|
+
from stirrup.constants import (
|
|
19
|
+
AGENT_MAX_TURNS,
|
|
20
|
+
CONTEXT_SUMMARIZATION_CUTOFF,
|
|
21
|
+
FINISH_TOOL_NAME,
|
|
22
|
+
)
|
|
23
|
+
from stirrup.core.models import (
|
|
24
|
+
AssistantMessage,
|
|
25
|
+
ChatMessage,
|
|
26
|
+
ImageContentBlock,
|
|
27
|
+
LLMClient,
|
|
28
|
+
SubAgentMetadata,
|
|
29
|
+
SystemMessage,
|
|
30
|
+
TokenUsage,
|
|
31
|
+
Tool,
|
|
32
|
+
ToolCall,
|
|
33
|
+
ToolMessage,
|
|
34
|
+
ToolProvider,
|
|
35
|
+
ToolResult,
|
|
36
|
+
UserMessage,
|
|
37
|
+
)
|
|
38
|
+
from stirrup.prompts import MESSAGE_SUMMARIZER, MESSAGE_SUMMARIZER_BRIDGE_TEMPLATE
|
|
39
|
+
from stirrup.tools import DEFAULT_TOOLS
|
|
40
|
+
from stirrup.tools.code_backends.base import CodeExecToolProvider
|
|
41
|
+
from stirrup.tools.code_backends.local import LocalCodeExecToolProvider
|
|
42
|
+
from stirrup.tools.finish import SIMPLE_FINISH_TOOL
|
|
43
|
+
from stirrup.utils.logging import AgentLogger, AgentLoggerBase
|
|
44
|
+
|
|
45
|
+
_PARENT_DEPTH: contextvars.ContextVar[int] = contextvars.ContextVar("parent_depth", default=0)
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class SessionState:
|
|
52
|
+
"""Per-session state for resource lifecycle management.
|
|
53
|
+
|
|
54
|
+
Kept minimal - only contains resources that need async lifecycle management
|
|
55
|
+
(exit_stack, exec_env) and session-specific configuration (output_dir).
|
|
56
|
+
|
|
57
|
+
Tool availability is managed via Agent._active_tools (instance-scoped),
|
|
58
|
+
and run results are stored on the agent instance temporarily.
|
|
59
|
+
|
|
60
|
+
For subagent file transfer:
|
|
61
|
+
- parent_exec_env: Reference to the parent's exec env (for cross-env transfers)
|
|
62
|
+
- depth: Agent depth (0 = root, >0 = subagent)
|
|
63
|
+
- output_dir: For root agent, this is a local filesystem path. For subagents,
|
|
64
|
+
this is a path within the parent's exec env.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
exit_stack: AsyncExitStack
|
|
68
|
+
exec_env: CodeExecToolProvider | None = None
|
|
69
|
+
output_dir: str | None = None # String path (contextual: local for root, in parent env for subagent)
|
|
70
|
+
parent_exec_env: CodeExecToolProvider | None = None
|
|
71
|
+
depth: int = 0
|
|
72
|
+
uploaded_file_paths: list[str] = field(default_factory=list) # Paths of files uploaded to exec_env
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_SESSION_STATE: contextvars.ContextVar[SessionState] = contextvars.ContextVar("session_state")
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"Agent",
|
|
79
|
+
"SubAgentParams",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
LOGGER = logging.getLogger(__name__)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _num_turns_remaining_msg(number_of_turns_remaining: int) -> UserMessage:
|
|
86
|
+
"""Create a user message warning the agent about remaining turns before max_turns is reached."""
|
|
87
|
+
if number_of_turns_remaining == 1:
|
|
88
|
+
return UserMessage(content="This is the last turn. Please finish the task by calling the finish tool.")
|
|
89
|
+
return UserMessage(
|
|
90
|
+
content=f"You have {number_of_turns_remaining} turns remaining to complete the task. Please continue. Remember you will need a separate turn to finish the task.",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _handle_text_only_tool_responses(tool_messages: list[ToolMessage]) -> tuple[list[ToolMessage], list[UserMessage]]:
|
|
95
|
+
"""Extract image blocks from tool messages and convert them to user messages for text-only models."""
|
|
96
|
+
user_messages: list[UserMessage] = []
|
|
97
|
+
for tm in tool_messages:
|
|
98
|
+
if isinstance(tm.content, list):
|
|
99
|
+
for idx, block in enumerate(tm.content):
|
|
100
|
+
if isinstance(block, ImageContentBlock):
|
|
101
|
+
user_messages.append(
|
|
102
|
+
UserMessage(content=[f"Here is the image for tool call {tm.tool_call_id}", block]),
|
|
103
|
+
)
|
|
104
|
+
tm.content[idx] = f"Done! The User will provide the image for tool call {tm.tool_call_id}"
|
|
105
|
+
elif isinstance(block, str):
|
|
106
|
+
continue
|
|
107
|
+
else:
|
|
108
|
+
raise NotImplementedError(f"Unsupported content block: {type(block)}")
|
|
109
|
+
|
|
110
|
+
return tool_messages, user_messages
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _get_total_token_usage(messages: list[list[ChatMessage]]) -> TokenUsage:
|
|
114
|
+
"""Aggregate token usage across all assistant messages in grouped conversation history.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
messages: List of message groups, where each group represents a segment of conversation.
|
|
118
|
+
|
|
119
|
+
"""
|
|
120
|
+
return sum(
|
|
121
|
+
[msg.token_usage for msg in chain.from_iterable(messages) if isinstance(msg, AssistantMessage)],
|
|
122
|
+
start=TokenUsage(),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class SubAgentParams(BaseModel):
|
|
127
|
+
"""Parameters for sub-agent tool invocation."""
|
|
128
|
+
|
|
129
|
+
task: Annotated[str, Field(description="The task/prompt for the sub-agent to complete")]
|
|
130
|
+
input_files: Annotated[
|
|
131
|
+
list[str],
|
|
132
|
+
Field(
|
|
133
|
+
default_factory=list,
|
|
134
|
+
description="List of file paths to upload to the sub-agent's execution environment. "
|
|
135
|
+
"Use paths from output_dir (e.g., files saved by previous sub-agents).",
|
|
136
|
+
),
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
DEFAULT_SUB_AGENT_DESCRIPTION = "A sub agent that can be used to handle a contained, specific task."
|
|
141
|
+
|
|
142
|
+
# Agent name validation pattern: alphanumeric, underscores, hyphens, 1-128 chars
|
|
143
|
+
AGENT_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Agent[FinishParams: BaseModel, FinishMeta]:
|
|
147
|
+
"""Agent that executes tool-using loops with automatic context management.
|
|
148
|
+
|
|
149
|
+
Runs up to max_turns iterations of: LLM generation → tool execution → message accumulation.
|
|
150
|
+
When conversation history exceeds context window limits, older messages are automatically
|
|
151
|
+
condensed into a summary to preserve working memory.
|
|
152
|
+
|
|
153
|
+
The Agent can be used as an async context manager via .session() for automatic tool
|
|
154
|
+
lifecycle management, logging, and file saving:
|
|
155
|
+
|
|
156
|
+
from stirrup.clients.chat_completions_client import ChatCompletionsClient
|
|
157
|
+
|
|
158
|
+
# Create client and agent
|
|
159
|
+
client = ChatCompletionsClient(model="gpt-5")
|
|
160
|
+
agent = Agent(client=client, name="assistant")
|
|
161
|
+
|
|
162
|
+
async with agent.session(output_dir="./output") as session:
|
|
163
|
+
finish_params, history, metadata = await session.run("Your task here")
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
client: LLMClient,
|
|
169
|
+
name: str,
|
|
170
|
+
*,
|
|
171
|
+
max_turns: int = AGENT_MAX_TURNS,
|
|
172
|
+
system_prompt: str | None = None,
|
|
173
|
+
tools: list[Tool | ToolProvider] | None = None,
|
|
174
|
+
finish_tool: Tool[FinishParams, FinishMeta] | None = None,
|
|
175
|
+
# Agent options
|
|
176
|
+
context_summarization_cutoff: float = CONTEXT_SUMMARIZATION_CUTOFF,
|
|
177
|
+
run_sync_in_thread: bool = True,
|
|
178
|
+
text_only_tool_responses: bool = True,
|
|
179
|
+
# Logging
|
|
180
|
+
logger: AgentLoggerBase | None = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Initialize the agent with an LLM client and configuration.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
client: LLM client for generating responses. Use ChatCompletionsClient for
|
|
186
|
+
OpenAI/OpenAI-compatible APIs, or LiteLLMClient for other providers.
|
|
187
|
+
name: Name of the agent (used for logging purposes)
|
|
188
|
+
max_turns: Maximum number of turns before stopping
|
|
189
|
+
system_prompt: System prompt to prepend to all runs (when using string prompts)
|
|
190
|
+
tools: List of Tools and/or ToolProviders available to the agent.
|
|
191
|
+
If None, uses DEFAULT_TOOLS. ToolProviders are automatically
|
|
192
|
+
set up and torn down by Agent.session().
|
|
193
|
+
Use [*DEFAULT_TOOLS, extra_tool] to extend defaults.
|
|
194
|
+
finish_tool: Tool used to signal task completion. Defaults to SIMPLE_FINISH_TOOL.
|
|
195
|
+
context_summarization_cutoff: Fraction of context window (0-1) at which to trigger summarization
|
|
196
|
+
run_sync_in_thread: Execute synchronous tool executors in a separate thread
|
|
197
|
+
text_only_tool_responses: Extract images from tool responses as separate user messages
|
|
198
|
+
logger: Optional logger instance. If None, creates AgentLogger() internally.
|
|
199
|
+
|
|
200
|
+
"""
|
|
201
|
+
# Validate agent name
|
|
202
|
+
if not AGENT_NAME_PATTERN.match(name):
|
|
203
|
+
raise ValueError(
|
|
204
|
+
f"Invalid agent name '{name}'. "
|
|
205
|
+
"Agent names must match pattern '^[a-zA-Z0-9_-]{1,128}$' "
|
|
206
|
+
"(alphanumeric, underscores, hyphens only, 1-128 characters)."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
self._client: LLMClient = client
|
|
210
|
+
self._name = name
|
|
211
|
+
self._max_turns = max_turns
|
|
212
|
+
self._system_prompt = system_prompt
|
|
213
|
+
self._tools = tools if tools is not None else DEFAULT_TOOLS
|
|
214
|
+
self._finish_tool: Tool = finish_tool if finish_tool is not None else SIMPLE_FINISH_TOOL
|
|
215
|
+
self._context_summarization_cutoff = context_summarization_cutoff
|
|
216
|
+
self._run_sync_in_thread = run_sync_in_thread
|
|
217
|
+
self._text_only_tool_responses = text_only_tool_responses
|
|
218
|
+
|
|
219
|
+
# Logger (can be passed in or created here)
|
|
220
|
+
self._logger: AgentLoggerBase = logger if logger is not None else AgentLogger()
|
|
221
|
+
|
|
222
|
+
# Session configuration (set during session(), used in __aenter__)
|
|
223
|
+
self._pending_output_dir: Path | None = None
|
|
224
|
+
self._pending_input_files: str | Path | list[str | Path] | None = None
|
|
225
|
+
|
|
226
|
+
# Instance-scoped state (populated during __aenter__, isolated per agent instance)
|
|
227
|
+
self._active_tools: dict[str, Tool] = {}
|
|
228
|
+
self._last_finish_params: Any = None # FinishParams type parameter
|
|
229
|
+
self._last_run_metadata: dict[str, list[Any]] = {}
|
|
230
|
+
self._transferred_paths: list[str] = [] # Paths transferred to parent (for subagents)
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def name(self) -> str:
|
|
234
|
+
"""The name of this agent."""
|
|
235
|
+
return self._name
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def client(self) -> LLMClient:
|
|
239
|
+
"""The LLM client used by this agent."""
|
|
240
|
+
return self._client
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def tools(self) -> dict[str, Tool]:
|
|
244
|
+
"""Currently active tools (available after entering session context)."""
|
|
245
|
+
return self._active_tools
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def finish_tool(self) -> Tool:
|
|
249
|
+
"""The finish tool used to signal task completion."""
|
|
250
|
+
return self._finish_tool
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def logger(self) -> AgentLoggerBase:
|
|
254
|
+
"""The logger instance used by this agent."""
|
|
255
|
+
return self._logger
|
|
256
|
+
|
|
257
|
+
def session(
|
|
258
|
+
self,
|
|
259
|
+
output_dir: Path | str | None = None,
|
|
260
|
+
input_files: str | Path | list[str | Path] | None = None,
|
|
261
|
+
) -> Self:
|
|
262
|
+
"""Configure a session and return self for use as async context manager.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
output_dir: Directory to save output files from finish_params.paths
|
|
266
|
+
input_files: Files to upload to the execution environment at session start.
|
|
267
|
+
Accepts a single path or list of paths. Supports:
|
|
268
|
+
- File paths (str or Path)
|
|
269
|
+
- Directory paths (uploaded recursively)
|
|
270
|
+
- Glob patterns (e.g., "data/*.csv", "**/*.py")
|
|
271
|
+
Raises ValueError if no CodeExecToolProvider is configured
|
|
272
|
+
or if a glob pattern matches no files.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Self, for use with `async with agent.session(...) as session:`
|
|
276
|
+
|
|
277
|
+
Example:
|
|
278
|
+
async with agent.session(output_dir="./output", input_files="data/*.csv") as session:
|
|
279
|
+
result = await session.run("Analyze the CSV files")
|
|
280
|
+
|
|
281
|
+
Note:
|
|
282
|
+
Multiple concurrent sessions from the same Agent instance are supported.
|
|
283
|
+
Each session maintains isolated state via ContextVar.
|
|
284
|
+
|
|
285
|
+
"""
|
|
286
|
+
self._pending_output_dir = Path(output_dir) if output_dir else None
|
|
287
|
+
self._pending_input_files = input_files
|
|
288
|
+
return self
|
|
289
|
+
|
|
290
|
+
def _resolve_input_files(self, input_files: str | Path | list[str | Path]) -> list[Path]:
|
|
291
|
+
"""Resolve input file paths, expanding globs and normalizing to Path objects.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
input_files: Single path or list of paths (strings, Paths, or glob patterns)
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
List of resolved Path objects
|
|
298
|
+
|
|
299
|
+
Raises:
|
|
300
|
+
ValueError: If a glob pattern matches no files
|
|
301
|
+
|
|
302
|
+
"""
|
|
303
|
+
# Normalize to list
|
|
304
|
+
paths = [input_files] if isinstance(input_files, str | Path) else list(input_files)
|
|
305
|
+
|
|
306
|
+
resolved: list[Path] = []
|
|
307
|
+
for path in paths:
|
|
308
|
+
path_str = str(path)
|
|
309
|
+
|
|
310
|
+
# Check if it looks like a glob pattern
|
|
311
|
+
if any(c in path_str for c in ("*", "?", "[")):
|
|
312
|
+
# Expand glob pattern
|
|
313
|
+
matches = glob_module.glob(path_str, recursive=True)
|
|
314
|
+
if not matches:
|
|
315
|
+
raise ValueError(f"Glob pattern '{path_str}' matched no files")
|
|
316
|
+
resolved.extend(Path(m) for m in matches)
|
|
317
|
+
else:
|
|
318
|
+
# Regular path - add as-is (upload_files will handle non-existent)
|
|
319
|
+
resolved.append(Path(path))
|
|
320
|
+
|
|
321
|
+
return resolved
|
|
322
|
+
|
|
323
|
+
def _collect_all_tools(self) -> list[Tool | ToolProvider]:
|
|
324
|
+
"""Collect all tools from this agent and any sub-agents recursively."""
|
|
325
|
+
all_tools: list[Tool | ToolProvider] = list(self._tools)
|
|
326
|
+
|
|
327
|
+
for tool in self._tools:
|
|
328
|
+
# Check if this tool wraps a sub-agent (created via to_tool())
|
|
329
|
+
if isinstance(tool, Tool) and hasattr(tool, "executor"):
|
|
330
|
+
# Check if the executor is a closure that captured an Agent
|
|
331
|
+
closure = getattr(tool.executor, "__closure__", None)
|
|
332
|
+
if closure:
|
|
333
|
+
for cell in closure:
|
|
334
|
+
try:
|
|
335
|
+
cell_contents = cell.cell_contents
|
|
336
|
+
if isinstance(cell_contents, Agent):
|
|
337
|
+
# Recursively collect from sub-agent
|
|
338
|
+
all_tools.extend(cell_contents._collect_all_tools()) # noqa: SLF001
|
|
339
|
+
except ValueError:
|
|
340
|
+
# cell_contents can raise ValueError if empty
|
|
341
|
+
pass
|
|
342
|
+
|
|
343
|
+
return all_tools
|
|
344
|
+
|
|
345
|
+
def _collect_warnings(self) -> list[str]:
|
|
346
|
+
"""Collect warnings about agent configuration."""
|
|
347
|
+
warnings = []
|
|
348
|
+
|
|
349
|
+
# Collect all tools including from sub-agents
|
|
350
|
+
all_tools = self._collect_all_tools()
|
|
351
|
+
|
|
352
|
+
# Check for LocalCodeExecToolProvider (security risk) - only in top-level agent
|
|
353
|
+
for tool in self._tools:
|
|
354
|
+
if isinstance(tool, LocalCodeExecToolProvider):
|
|
355
|
+
warnings.append(
|
|
356
|
+
"LocalCodeExecToolProvider can access your local filesystem. "
|
|
357
|
+
"Consider using DockerCodeExecToolProvider or E2BCodeExecToolProvider for sandboxed execution.",
|
|
358
|
+
)
|
|
359
|
+
break
|
|
360
|
+
|
|
361
|
+
# Check for missing default tools (across entire agent tree)
|
|
362
|
+
for default_tool in DEFAULT_TOOLS:
|
|
363
|
+
default_type = type(default_tool)
|
|
364
|
+
|
|
365
|
+
# Special case: For code exec providers, check if ANY CodeExecToolProvider is present
|
|
366
|
+
if isinstance(default_tool, CodeExecToolProvider):
|
|
367
|
+
found = any(isinstance(t, CodeExecToolProvider) for t in all_tools)
|
|
368
|
+
else:
|
|
369
|
+
found = any(isinstance(t, default_type) for t in all_tools)
|
|
370
|
+
|
|
371
|
+
if not found:
|
|
372
|
+
warnings.append(f"Missing default tool: {default_type.__name__}")
|
|
373
|
+
|
|
374
|
+
# Check for code execution tool per-agent (including sub-agents)
|
|
375
|
+
agents_without_code_exec = self._collect_agents_without_code_exec()
|
|
376
|
+
warnings.extend(
|
|
377
|
+
f"Agent '{agent_name}' has no code execution tool. It will not be able to save files to the output directory."
|
|
378
|
+
for agent_name in agents_without_code_exec
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Check for code execution without output directory
|
|
382
|
+
state = _SESSION_STATE.get(None)
|
|
383
|
+
if state and state.exec_env and not state.output_dir:
|
|
384
|
+
warnings.append(
|
|
385
|
+
"Code execution environment is configured but no output_dir is set. "
|
|
386
|
+
"Files created by the agent will be lost when the session ends.",
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
return warnings
|
|
390
|
+
|
|
391
|
+
def _build_system_prompt(self) -> str:
|
|
392
|
+
"""Build the complete system prompt: base + input files + user instructions.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Complete system prompt string combining base prompt, input file listing,
|
|
396
|
+
and user's custom system_prompt (if provided).
|
|
397
|
+
"""
|
|
398
|
+
from stirrup.prompts import BASE_SYSTEM_PROMPT_TEMPLATE
|
|
399
|
+
|
|
400
|
+
parts: list[str] = []
|
|
401
|
+
|
|
402
|
+
# Base prompt with max_turns
|
|
403
|
+
parts.append(BASE_SYSTEM_PROMPT_TEMPLATE.format(max_turns=self._max_turns))
|
|
404
|
+
|
|
405
|
+
# Input files section (if any were uploaded)
|
|
406
|
+
state = _SESSION_STATE.get(None)
|
|
407
|
+
if state and state.uploaded_file_paths:
|
|
408
|
+
files_section = "\n\nThe following input files have been provided for this task:"
|
|
409
|
+
for file_path in state.uploaded_file_paths:
|
|
410
|
+
files_section += f"\n- {file_path}"
|
|
411
|
+
parts.append(files_section)
|
|
412
|
+
|
|
413
|
+
# User's custom system prompt (if provided)
|
|
414
|
+
if self._system_prompt:
|
|
415
|
+
parts.append(f"\n\nFollow these instructions from the User:\n{self._system_prompt}")
|
|
416
|
+
|
|
417
|
+
return "".join(parts)
|
|
418
|
+
|
|
419
|
+
def _collect_agents_without_code_exec(self) -> list[str]:
|
|
420
|
+
"""Collect names of agents (including self and sub-agents) that lack a code execution tool."""
|
|
421
|
+
agents_missing: list[str] = []
|
|
422
|
+
|
|
423
|
+
# Check if this agent has a code execution tool
|
|
424
|
+
has_code_exec = any(isinstance(t, CodeExecToolProvider) for t in self._tools)
|
|
425
|
+
if not has_code_exec:
|
|
426
|
+
agents_missing.append(self._name)
|
|
427
|
+
|
|
428
|
+
# Recursively check sub-agents
|
|
429
|
+
for tool in self._tools:
|
|
430
|
+
if isinstance(tool, Tool) and hasattr(tool, "executor"):
|
|
431
|
+
closure = getattr(tool.executor, "__closure__", None)
|
|
432
|
+
if closure:
|
|
433
|
+
for cell in closure:
|
|
434
|
+
try:
|
|
435
|
+
cell_contents = cell.cell_contents
|
|
436
|
+
if isinstance(cell_contents, Agent):
|
|
437
|
+
agents_missing.extend(cell_contents._collect_agents_without_code_exec()) # noqa: SLF001
|
|
438
|
+
except ValueError:
|
|
439
|
+
pass
|
|
440
|
+
|
|
441
|
+
return agents_missing
|
|
442
|
+
|
|
443
|
+
def _validate_subagent_code_exec_requirements(self) -> None:
|
|
444
|
+
"""Validate that if any subagent has code exec, the parent must also have code exec.
|
|
445
|
+
|
|
446
|
+
This validation ensures proper file transfer chain - subagent files transfer to
|
|
447
|
+
parent's exec env, so parent must have one to receive them.
|
|
448
|
+
|
|
449
|
+
Raises:
|
|
450
|
+
ValueError: If a subagent has code exec but this parent doesn't.
|
|
451
|
+
|
|
452
|
+
"""
|
|
453
|
+
parent_has_code_exec = any(isinstance(t, CodeExecToolProvider) for t in self._tools)
|
|
454
|
+
|
|
455
|
+
for tool in self._tools:
|
|
456
|
+
if isinstance(tool, Tool) and hasattr(tool, "executor"):
|
|
457
|
+
closure = getattr(tool.executor, "__closure__", None)
|
|
458
|
+
if closure:
|
|
459
|
+
for cell in closure:
|
|
460
|
+
try:
|
|
461
|
+
cell_contents = cell.cell_contents
|
|
462
|
+
if isinstance(cell_contents, Agent):
|
|
463
|
+
subagent = cell_contents
|
|
464
|
+
subagent_has_code_exec = any(
|
|
465
|
+
isinstance(t, CodeExecToolProvider)
|
|
466
|
+
for t in subagent._tools # noqa: SLF001
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
if subagent_has_code_exec and not parent_has_code_exec:
|
|
470
|
+
raise ValueError(
|
|
471
|
+
f"Subagent '{subagent._name}' has a code execution tool, " # noqa: SLF001
|
|
472
|
+
f"but parent agent '{self._name}' does not. "
|
|
473
|
+
f"Parent must have a code execution tool to receive files from subagent."
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# Recursively validate nested subagents
|
|
477
|
+
subagent._validate_subagent_code_exec_requirements() # noqa: SLF001
|
|
478
|
+
except ValueError as e:
|
|
479
|
+
if "code execution tool" in str(e):
|
|
480
|
+
raise
|
|
481
|
+
# cell_contents can raise ValueError if empty - ignore
|
|
482
|
+
|
|
483
|
+
async def __aenter__(self) -> Self:
|
|
484
|
+
"""Enter session context: set up tools, logging, and resources.
|
|
485
|
+
|
|
486
|
+
Creates a new SessionState and stores it in the _SESSION_STATE ContextVar,
|
|
487
|
+
allowing concurrent sessions from the same Agent instance.
|
|
488
|
+
"""
|
|
489
|
+
exit_stack = AsyncExitStack()
|
|
490
|
+
await exit_stack.__aenter__()
|
|
491
|
+
|
|
492
|
+
# Get parent state if exists (for subagent file transfer)
|
|
493
|
+
parent_state = _SESSION_STATE.get(None)
|
|
494
|
+
|
|
495
|
+
current_depth = _PARENT_DEPTH.get()
|
|
496
|
+
|
|
497
|
+
# Create session state and store in ContextVar
|
|
498
|
+
state = SessionState(
|
|
499
|
+
exit_stack=exit_stack,
|
|
500
|
+
output_dir=str(self._pending_output_dir) if self._pending_output_dir else None,
|
|
501
|
+
parent_exec_env=parent_state.exec_env if parent_state else None,
|
|
502
|
+
depth=current_depth,
|
|
503
|
+
)
|
|
504
|
+
_SESSION_STATE.set(state)
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
# === TWO-PASS TOOL INITIALIZATION ===
|
|
508
|
+
# First pass initializes CodeExecToolProvider so that dependent tools
|
|
509
|
+
# (like ViewImageToolProvider) can access state.exec_env in second pass.
|
|
510
|
+
active_tools: list[Tool] = []
|
|
511
|
+
|
|
512
|
+
# First pass: Initialize CodeExecToolProvider (at most one allowed)
|
|
513
|
+
code_exec_providers = [t for t in self._tools if isinstance(t, CodeExecToolProvider)]
|
|
514
|
+
if len(code_exec_providers) > 1:
|
|
515
|
+
raise ValueError(
|
|
516
|
+
f"Agent can only have one CodeExecToolProvider, found {len(code_exec_providers)}: "
|
|
517
|
+
f"{[type(p).__name__ for p in code_exec_providers]}"
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
if code_exec_providers:
|
|
521
|
+
provider = code_exec_providers[0]
|
|
522
|
+
result = await exit_stack.enter_async_context(provider)
|
|
523
|
+
if isinstance(result, list):
|
|
524
|
+
active_tools.extend(result)
|
|
525
|
+
else:
|
|
526
|
+
active_tools.append(result)
|
|
527
|
+
state.exec_env = provider
|
|
528
|
+
|
|
529
|
+
# Second pass: Initialize remaining ToolProviders and static Tools
|
|
530
|
+
for tool in self._tools:
|
|
531
|
+
if isinstance(tool, CodeExecToolProvider):
|
|
532
|
+
continue # Already processed in first pass
|
|
533
|
+
|
|
534
|
+
if isinstance(tool, ToolProvider):
|
|
535
|
+
# ToolProvider: enter context and get returned tool(s)
|
|
536
|
+
result = await exit_stack.enter_async_context(tool)
|
|
537
|
+
# Handle both single Tool and list[Tool] returns (e.g., MCPToolProvider)
|
|
538
|
+
if isinstance(result, list):
|
|
539
|
+
active_tools.extend(result)
|
|
540
|
+
else:
|
|
541
|
+
active_tools.append(result)
|
|
542
|
+
else:
|
|
543
|
+
# Static Tool, use directly
|
|
544
|
+
active_tools.append(tool)
|
|
545
|
+
|
|
546
|
+
# Build active tools dict with finish tool (stored on instance, not session)
|
|
547
|
+
self._active_tools = {FINISH_TOOL_NAME: self._finish_tool}
|
|
548
|
+
self._active_tools.update({t.name: t for t in active_tools})
|
|
549
|
+
|
|
550
|
+
# Validate subagent code exec requirements (only at root level)
|
|
551
|
+
if current_depth == 0:
|
|
552
|
+
self._validate_subagent_code_exec_requirements()
|
|
553
|
+
|
|
554
|
+
# Upload input files to exec_env if specified
|
|
555
|
+
if self._pending_input_files:
|
|
556
|
+
if not state.exec_env:
|
|
557
|
+
raise ValueError("input_files specified but no CodeExecToolProvider configured")
|
|
558
|
+
|
|
559
|
+
logger.debug(
|
|
560
|
+
"[%s __aenter__] Uploading input files: %s, depth=%d, parent_exec_env=%s, parent_exec_env._temp_dir=%s",
|
|
561
|
+
self._name,
|
|
562
|
+
self._pending_input_files,
|
|
563
|
+
state.depth,
|
|
564
|
+
type(state.parent_exec_env).__name__ if state.parent_exec_env else None,
|
|
565
|
+
getattr(state.parent_exec_env, "_temp_dir", "N/A") if state.parent_exec_env else None,
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
if state.depth > 0 and state.parent_exec_env:
|
|
569
|
+
# SUBAGENT: Read files from parent's exec env, write to subagent's exec env
|
|
570
|
+
# input_files are paths within the parent's environment
|
|
571
|
+
result = await state.exec_env.upload_files(
|
|
572
|
+
*self._pending_input_files,
|
|
573
|
+
source_env=state.parent_exec_env,
|
|
574
|
+
)
|
|
575
|
+
else:
|
|
576
|
+
# ROOT AGENT: Read files from local filesystem
|
|
577
|
+
resolved = self._resolve_input_files(self._pending_input_files)
|
|
578
|
+
result = await state.exec_env.upload_files(*resolved)
|
|
579
|
+
|
|
580
|
+
logger.debug(
|
|
581
|
+
"[%s __aenter__] Upload result: uploaded=%s, failed=%s", self._name, result.uploaded, result.failed
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
# Store uploaded paths for system prompt
|
|
585
|
+
state.uploaded_file_paths = [uf.dest_path for uf in result.uploaded]
|
|
586
|
+
|
|
587
|
+
if result.failed:
|
|
588
|
+
raise RuntimeError(f"Failed to upload files: {result.failed}")
|
|
589
|
+
self._pending_input_files = None # Clear pending state
|
|
590
|
+
|
|
591
|
+
# Configure and enter logger context
|
|
592
|
+
self._logger.name = self._name
|
|
593
|
+
self._logger.model = self._client.model_slug
|
|
594
|
+
self._logger.max_turns = self._max_turns
|
|
595
|
+
# depth is already set (0 for main agent, passed in for sub-agents)
|
|
596
|
+
self._logger.__enter__()
|
|
597
|
+
|
|
598
|
+
return self
|
|
599
|
+
|
|
600
|
+
except Exception:
|
|
601
|
+
await exit_stack.__aexit__(None, None, None)
|
|
602
|
+
raise
|
|
603
|
+
|
|
604
|
+
async def __aexit__(
|
|
605
|
+
self,
|
|
606
|
+
exc_type: type[BaseException] | None,
|
|
607
|
+
exc_val: BaseException | None,
|
|
608
|
+
exc_tb: TracebackType | None,
|
|
609
|
+
) -> None:
|
|
610
|
+
"""Exit session context: save files, cleanup resources.
|
|
611
|
+
|
|
612
|
+
File handling is depth-aware:
|
|
613
|
+
- Root agent (depth=0): Saves files to local filesystem output_dir
|
|
614
|
+
- Subagent (depth>0): Transfers files to parent's exec env at output_dir path
|
|
615
|
+
"""
|
|
616
|
+
state = _SESSION_STATE.get()
|
|
617
|
+
|
|
618
|
+
try:
|
|
619
|
+
# Save files from finish_params.paths based on depth
|
|
620
|
+
if state.output_dir and self._last_finish_params and state.exec_env:
|
|
621
|
+
paths = getattr(self._last_finish_params, "paths", None)
|
|
622
|
+
if paths:
|
|
623
|
+
if state.depth == 0:
|
|
624
|
+
# ROOT AGENT: Save to local filesystem
|
|
625
|
+
output_path = Path(state.output_dir)
|
|
626
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
627
|
+
logger.debug(
|
|
628
|
+
"[%s] ROOT AGENT (depth=0): Saving %d file(s) to local filesystem: %s -> %s",
|
|
629
|
+
self._name,
|
|
630
|
+
len(paths),
|
|
631
|
+
paths,
|
|
632
|
+
output_path,
|
|
633
|
+
)
|
|
634
|
+
result = await state.exec_env.save_output_files(paths, output_path, dest_env=None)
|
|
635
|
+
logger.debug(
|
|
636
|
+
"[%s] ROOT AGENT: Saved %d file(s), failed %d",
|
|
637
|
+
self._name,
|
|
638
|
+
len(result.saved),
|
|
639
|
+
len(result.failed),
|
|
640
|
+
)
|
|
641
|
+
else:
|
|
642
|
+
# SUBAGENT: Transfer to parent's exec env
|
|
643
|
+
if state.parent_exec_env:
|
|
644
|
+
logger.debug(
|
|
645
|
+
"[%s] SUBAGENT (depth=%d): Transferring %d file(s) to parent exec env: %s -> %s",
|
|
646
|
+
self._name,
|
|
647
|
+
state.depth,
|
|
648
|
+
len(paths),
|
|
649
|
+
paths,
|
|
650
|
+
state.output_dir,
|
|
651
|
+
)
|
|
652
|
+
result = await state.exec_env.save_output_files(
|
|
653
|
+
paths, state.output_dir, dest_env=state.parent_exec_env
|
|
654
|
+
)
|
|
655
|
+
# Store transferred paths for returning to parent
|
|
656
|
+
self._transferred_paths = [str(sf.output_path) for sf in result.saved]
|
|
657
|
+
logger.debug(
|
|
658
|
+
"[%s] SUBAGENT: Transferred %d file(s) to parent, failed %d. Paths: %s",
|
|
659
|
+
self._name,
|
|
660
|
+
len(result.saved),
|
|
661
|
+
len(result.failed),
|
|
662
|
+
self._transferred_paths,
|
|
663
|
+
)
|
|
664
|
+
if result.failed:
|
|
665
|
+
logger.warning("Failed to transfer some files to parent env: %s", result.failed)
|
|
666
|
+
else:
|
|
667
|
+
logger.warning(
|
|
668
|
+
"Subagent at depth %d has exec_env but no parent_exec_env. "
|
|
669
|
+
"Files will not be transferred.",
|
|
670
|
+
state.depth,
|
|
671
|
+
)
|
|
672
|
+
finally:
|
|
673
|
+
# Exit logger context
|
|
674
|
+
self._logger.finish_params = self._last_finish_params
|
|
675
|
+
self._logger.run_metadata = self._last_run_metadata
|
|
676
|
+
self._logger.output_dir = str(state.output_dir) if state.output_dir else None
|
|
677
|
+
self._logger.__exit__(exc_type, exc_val, exc_tb)
|
|
678
|
+
|
|
679
|
+
# Cleanup all async resources
|
|
680
|
+
await state.exit_stack.__aexit__(exc_type, exc_val, exc_tb)
|
|
681
|
+
|
|
682
|
+
async def run_tool(self, tool_call: ToolCall, run_metadata: dict[str, list[Any]]) -> ToolMessage:
|
|
683
|
+
"""Execute a single tool call with error handling for invalid JSON/arguments.
|
|
684
|
+
|
|
685
|
+
Returns a ToolMessage containing either the tool output or an error description.
|
|
686
|
+
Metadata from the tool result is stored in the provided run_metadata dict.
|
|
687
|
+
"""
|
|
688
|
+
tool = self._active_tools.get(tool_call.name)
|
|
689
|
+
result: ToolResult
|
|
690
|
+
args_valid = True
|
|
691
|
+
|
|
692
|
+
# Ensure tool is tracked in metadata dict (even if no metadata returned)
|
|
693
|
+
if tool_call.name not in run_metadata:
|
|
694
|
+
run_metadata[tool_call.name] = []
|
|
695
|
+
|
|
696
|
+
if tool:
|
|
697
|
+
try:
|
|
698
|
+
# Parse parameters if the tool has them, otherwise use None
|
|
699
|
+
params = (
|
|
700
|
+
tool.parameters.model_validate_json(tool_call.arguments) if tool.parameters is not None else None
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
# Set parent depth for sub-agent tools to read
|
|
704
|
+
prev_depth = _PARENT_DEPTH.set(self._logger.depth)
|
|
705
|
+
try:
|
|
706
|
+
if inspect.iscoroutinefunction(tool.executor):
|
|
707
|
+
result = await tool.executor(params) # ty: ignore[invalid-await]
|
|
708
|
+
elif self._run_sync_in_thread:
|
|
709
|
+
# ty: ignore - type checker doesn't understand iscoroutinefunction narrowing
|
|
710
|
+
result = await anyio.to_thread.run_sync(tool.executor, params) # ty: ignore[unresolved-attribute]
|
|
711
|
+
else:
|
|
712
|
+
# ty: ignore - iscoroutinefunction check above ensures this is sync
|
|
713
|
+
result = tool.executor(params) # ty: ignore[invalid-assignment]
|
|
714
|
+
finally:
|
|
715
|
+
_PARENT_DEPTH.reset(prev_depth)
|
|
716
|
+
|
|
717
|
+
# Store metadata if present
|
|
718
|
+
if result.metadata is not None:
|
|
719
|
+
run_metadata[tool_call.name].append(result.metadata)
|
|
720
|
+
except ValidationError:
|
|
721
|
+
LOGGER.debug(
|
|
722
|
+
"LLMClient tried to use the tool %s but the tool arguments are not valid: %r",
|
|
723
|
+
tool_call.name,
|
|
724
|
+
tool_call.arguments,
|
|
725
|
+
)
|
|
726
|
+
result = ToolResult(content="Tool arguments are not valid")
|
|
727
|
+
args_valid = False
|
|
728
|
+
else:
|
|
729
|
+
LOGGER.debug(f"LLMClient tried to use the tool {tool_call.name} which is not in the tools list")
|
|
730
|
+
result = ToolResult(content=f"{tool_call.name} is not a valid tool")
|
|
731
|
+
|
|
732
|
+
return ToolMessage(
|
|
733
|
+
content=result.content,
|
|
734
|
+
tool_call_id=tool_call.tool_call_id,
|
|
735
|
+
name=tool_call.name,
|
|
736
|
+
args_was_valid=args_valid,
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
async def step(
|
|
740
|
+
self,
|
|
741
|
+
messages: list[ChatMessage],
|
|
742
|
+
run_metadata: dict[str, list[Any]],
|
|
743
|
+
turn: int = 0,
|
|
744
|
+
max_turns: int = 0,
|
|
745
|
+
) -> tuple[AssistantMessage, list[ToolMessage], ToolCall | None]:
|
|
746
|
+
"""Execute one agent step: generate assistant message and run any requested tool calls.
|
|
747
|
+
|
|
748
|
+
Args:
|
|
749
|
+
messages: Current conversation messages
|
|
750
|
+
run_metadata: Metadata storage for tool results
|
|
751
|
+
turn: Current turn number (1-indexed) for logging
|
|
752
|
+
max_turns: Maximum turns for logging
|
|
753
|
+
|
|
754
|
+
Returns the assistant message, tool execution results, and finish tool call (if present).
|
|
755
|
+
|
|
756
|
+
"""
|
|
757
|
+
assistant_message = await self._client.generate(messages, self._active_tools)
|
|
758
|
+
|
|
759
|
+
# Log assistant message immediately
|
|
760
|
+
if turn > 0:
|
|
761
|
+
self._logger.assistant_message(turn, max_turns, assistant_message)
|
|
762
|
+
|
|
763
|
+
tool_messages: list[ToolMessage] = []
|
|
764
|
+
finish_call: ToolCall | None = None
|
|
765
|
+
|
|
766
|
+
if assistant_message.tool_calls:
|
|
767
|
+
finish_call = next(
|
|
768
|
+
(tc for tc in assistant_message.tool_calls if tc.name == FINISH_TOOL_NAME),
|
|
769
|
+
None,
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
tool_messages = []
|
|
773
|
+
for tool_call in assistant_message.tool_calls:
|
|
774
|
+
tool_message = await self.run_tool(tool_call, run_metadata)
|
|
775
|
+
tool_messages.append(tool_message)
|
|
776
|
+
|
|
777
|
+
# Log tool result immediately
|
|
778
|
+
self._logger.tool_result(tool_message)
|
|
779
|
+
|
|
780
|
+
return assistant_message, tool_messages, finish_call
|
|
781
|
+
|
|
782
|
+
async def summarize_messages(self, messages: list[ChatMessage]) -> list[ChatMessage]:
|
|
783
|
+
"""Condense message history using LLM to stay within context window."""
|
|
784
|
+
task_context: list[ChatMessage] = list(takewhile(lambda m: not isinstance(m, AssistantMessage), messages))
|
|
785
|
+
|
|
786
|
+
summary_prompt = [*messages, UserMessage(content=MESSAGE_SUMMARIZER)]
|
|
787
|
+
|
|
788
|
+
# We need to pass the tools to the client so that it has context of tools used in the conversation
|
|
789
|
+
summary = await self._client.generate(summary_prompt, self._active_tools)
|
|
790
|
+
|
|
791
|
+
summary_bridge_prompt = MESSAGE_SUMMARIZER_BRIDGE_TEMPLATE.format(summary=summary.content)
|
|
792
|
+
summary_bridge = UserMessage(content=summary_bridge_prompt)
|
|
793
|
+
acknowledgement_msg = UserMessage(content="Got it, thanks!")
|
|
794
|
+
|
|
795
|
+
# Log the completed summary
|
|
796
|
+
summary_content = summary.content if isinstance(summary.content, str) else str(summary.content)
|
|
797
|
+
self._logger.context_summarization_complete(summary_content, summary_bridge_prompt)
|
|
798
|
+
|
|
799
|
+
return [*task_context, summary_bridge, acknowledgement_msg]
|
|
800
|
+
|
|
801
|
+
async def run(
|
|
802
|
+
self,
|
|
803
|
+
init_msgs: str | list[ChatMessage],
|
|
804
|
+
*,
|
|
805
|
+
depth: int | None = None,
|
|
806
|
+
) -> tuple[FinishParams | None, list[list[ChatMessage]], dict[str, list[Any]]]:
|
|
807
|
+
"""Execute the agent loop until finish tool is called or max_turns reached.
|
|
808
|
+
|
|
809
|
+
A base system prompt is automatically prepended to all runs, including:
|
|
810
|
+
- Agent purpose and max_turns info
|
|
811
|
+
- List of input files (if provided via session())
|
|
812
|
+
- User's custom system_prompt (if configured in __init__)
|
|
813
|
+
|
|
814
|
+
Args:
|
|
815
|
+
init_msgs: Either a string prompt (converted to UserMessage) or a list of
|
|
816
|
+
ChatMessage to extend the conversation after the system prompt.
|
|
817
|
+
depth: Logging depth for sub-agent runs. If provided, updates logger.depth for this run.
|
|
818
|
+
|
|
819
|
+
Returns:
|
|
820
|
+
Tuple of (finish params, message history, run metadata).
|
|
821
|
+
finish params is None if max_turns reached.
|
|
822
|
+
run metadata maps tool/agent names to lists of metadata returned by each call.
|
|
823
|
+
|
|
824
|
+
Example:
|
|
825
|
+
# Simple string prompt
|
|
826
|
+
await agent.run("Analyze this data and create a report")
|
|
827
|
+
|
|
828
|
+
# Multiple messages
|
|
829
|
+
await agent.run([
|
|
830
|
+
UserMessage(content="First, read the data"),
|
|
831
|
+
AssistantMessage(content="I've read the data file..."),
|
|
832
|
+
UserMessage(content="Now analyze it"),
|
|
833
|
+
])
|
|
834
|
+
|
|
835
|
+
"""
|
|
836
|
+
msgs: list[ChatMessage] = []
|
|
837
|
+
|
|
838
|
+
# Build the complete system prompt (base + input files + user instructions)
|
|
839
|
+
full_system_prompt = self._build_system_prompt()
|
|
840
|
+
msgs.append(SystemMessage(content=full_system_prompt))
|
|
841
|
+
|
|
842
|
+
if isinstance(init_msgs, str):
|
|
843
|
+
msgs.append(UserMessage(content=init_msgs))
|
|
844
|
+
else:
|
|
845
|
+
msgs.extend(init_msgs)
|
|
846
|
+
|
|
847
|
+
# Set logger depth if provided (for sub-agent runs)
|
|
848
|
+
if depth is not None:
|
|
849
|
+
self._logger.depth = depth
|
|
850
|
+
|
|
851
|
+
# Log the task at run start
|
|
852
|
+
self._logger.task_message(msgs[-1].content)
|
|
853
|
+
|
|
854
|
+
# Show warnings (top-level only, if logger supports it)
|
|
855
|
+
if self._logger.depth == 0 and isinstance(self._logger, AgentLogger):
|
|
856
|
+
run_warnings = self._collect_warnings()
|
|
857
|
+
if run_warnings:
|
|
858
|
+
self._logger.warnings_message(run_warnings)
|
|
859
|
+
|
|
860
|
+
# Use logger callback if available and not overridden
|
|
861
|
+
step_callback = self._logger.on_step
|
|
862
|
+
|
|
863
|
+
# Local metadata storage - isolated per run() invocation for thread safety
|
|
864
|
+
run_metadata: dict[str, list[Any]] = {}
|
|
865
|
+
|
|
866
|
+
full_msg_history: list[list[ChatMessage]] = []
|
|
867
|
+
finish_params: FinishParams | None = None
|
|
868
|
+
|
|
869
|
+
# Cumulative stats for spinner
|
|
870
|
+
total_tool_calls = 0
|
|
871
|
+
total_input_tokens = 0
|
|
872
|
+
total_output_tokens = 0
|
|
873
|
+
|
|
874
|
+
for i in range(self._max_turns):
|
|
875
|
+
if self._max_turns - i <= 30 and i != 0:
|
|
876
|
+
num_turns_remaining_msg = _num_turns_remaining_msg(self._max_turns - i)
|
|
877
|
+
msgs.append(num_turns_remaining_msg)
|
|
878
|
+
self._logger.user_message(num_turns_remaining_msg)
|
|
879
|
+
|
|
880
|
+
# Pass turn info to step() for real-time logging
|
|
881
|
+
assistant_message, tool_messages, finish_call = await self.step(
|
|
882
|
+
msgs,
|
|
883
|
+
run_metadata,
|
|
884
|
+
turn=i + 1,
|
|
885
|
+
max_turns=self._max_turns,
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
# Update cumulative stats
|
|
889
|
+
total_tool_calls += len(tool_messages)
|
|
890
|
+
total_input_tokens += assistant_message.token_usage.input
|
|
891
|
+
total_output_tokens += assistant_message.token_usage.output
|
|
892
|
+
|
|
893
|
+
# Call progress callback after step completes
|
|
894
|
+
if step_callback:
|
|
895
|
+
step_callback(i + 1, total_tool_calls, total_input_tokens, total_output_tokens)
|
|
896
|
+
|
|
897
|
+
user_messages: list[UserMessage] = []
|
|
898
|
+
if self._text_only_tool_responses:
|
|
899
|
+
tool_messages, user_messages = _handle_text_only_tool_responses(tool_messages)
|
|
900
|
+
|
|
901
|
+
# Log user messages (e.g., image content extracted from tool responses)
|
|
902
|
+
for user_msg in user_messages:
|
|
903
|
+
self._logger.user_message(user_msg)
|
|
904
|
+
|
|
905
|
+
msgs.extend([assistant_message, *tool_messages, *user_messages])
|
|
906
|
+
|
|
907
|
+
if finish_call:
|
|
908
|
+
try:
|
|
909
|
+
finish_arguments = json.loads(finish_call.arguments)
|
|
910
|
+
if self._finish_tool.parameters is not None:
|
|
911
|
+
finish_params = self._finish_tool.parameters.model_validate(finish_arguments)
|
|
912
|
+
break
|
|
913
|
+
except (json.JSONDecodeError, ValidationError, TypeError):
|
|
914
|
+
LOGGER.debug(
|
|
915
|
+
"Agent tried to use the finish tool but the tool call is not valid: %r",
|
|
916
|
+
finish_call.arguments,
|
|
917
|
+
)
|
|
918
|
+
# continue until the finish tool call is valid
|
|
919
|
+
|
|
920
|
+
pct_context_used = assistant_message.token_usage.total / self._client.max_tokens
|
|
921
|
+
if pct_context_used >= self._context_summarization_cutoff and i + 1 != self._max_turns:
|
|
922
|
+
self._logger.context_summarization_start(pct_context_used, self._context_summarization_cutoff)
|
|
923
|
+
full_msg_history.append(msgs)
|
|
924
|
+
msgs = await self.summarize_messages(msgs)
|
|
925
|
+
else:
|
|
926
|
+
LOGGER.error(
|
|
927
|
+
f"Maximum number of turns reached: {self._max_turns}. The agent was not able to finish the task. Consider increasing the max_turns parameter.",
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
full_msg_history.append(msgs)
|
|
931
|
+
|
|
932
|
+
# Add agent's own token usage to run_metadata under "token_usage" key
|
|
933
|
+
agent_token_usage = _get_total_token_usage(full_msg_history)
|
|
934
|
+
if "token_usage" not in run_metadata:
|
|
935
|
+
run_metadata["token_usage"] = []
|
|
936
|
+
run_metadata["token_usage"].append(agent_token_usage)
|
|
937
|
+
|
|
938
|
+
# Store for __aexit__ to access (on instance for this agent)
|
|
939
|
+
self._last_finish_params = finish_params
|
|
940
|
+
self._last_run_metadata = run_metadata
|
|
941
|
+
|
|
942
|
+
return finish_params, full_msg_history, run_metadata
|
|
943
|
+
|
|
944
|
+
def to_tool(
|
|
945
|
+
self,
|
|
946
|
+
*,
|
|
947
|
+
description: str = DEFAULT_SUB_AGENT_DESCRIPTION,
|
|
948
|
+
system_prompt: str | None = None,
|
|
949
|
+
) -> Tool[SubAgentParams, SubAgentMetadata]:
|
|
950
|
+
"""Convert this Agent to a Tool for use as a sub-agent.
|
|
951
|
+
|
|
952
|
+
Args:
|
|
953
|
+
description: Tool description shown to the parent agent
|
|
954
|
+
system_prompt: Optional system prompt to prepend when running
|
|
955
|
+
|
|
956
|
+
Returns:
|
|
957
|
+
Tool that executes this agent when called, returning SubAgentMetadata
|
|
958
|
+
containing token usage, message history, and any metadata from tools
|
|
959
|
+
the sub-agent used.
|
|
960
|
+
|
|
961
|
+
"""
|
|
962
|
+
agent = self # Capture self for closure
|
|
963
|
+
|
|
964
|
+
async def sub_agent_executor(params: SubAgentParams) -> ToolResult[SubAgentMetadata]:
|
|
965
|
+
"""Execute the sub-agent with the given task.
|
|
966
|
+
|
|
967
|
+
Sub-agents enter their own full session to ensure:
|
|
968
|
+
1. Tool isolation - each agent only sees its own tools (fixes recursive sub-agent bug)
|
|
969
|
+
2. Proper ToolProvider lifecycle - sub-agent's ToolProviders are initialized
|
|
970
|
+
3. Correct logging - logger context is entered for proper output formatting
|
|
971
|
+
"""
|
|
972
|
+
# Get parent's depth and calculate subagent depth
|
|
973
|
+
parent_depth = _PARENT_DEPTH.get()
|
|
974
|
+
sub_agent_depth = parent_depth + 1
|
|
975
|
+
|
|
976
|
+
# Save parent's session state so we can restore it after subagent completes
|
|
977
|
+
# This ensures sibling subagents see the parent's state, not a previous sibling's stale state
|
|
978
|
+
parent_session_state = _SESSION_STATE.get(None)
|
|
979
|
+
logger.debug(
|
|
980
|
+
"[%s] PRE-SESSION: _SESSION_STATE=%s, exec_env=%s, exec_env._temp_dir=%s",
|
|
981
|
+
agent.name,
|
|
982
|
+
id(parent_session_state) if parent_session_state else None,
|
|
983
|
+
type(parent_session_state.exec_env).__name__
|
|
984
|
+
if parent_session_state and parent_session_state.exec_env
|
|
985
|
+
else None,
|
|
986
|
+
getattr(parent_session_state.exec_env, "_temp_dir", "N/A")
|
|
987
|
+
if parent_session_state and parent_session_state.exec_env
|
|
988
|
+
else None,
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
# Set _PARENT_DEPTH to subagent's depth BEFORE entering session
|
|
992
|
+
# so that __aenter__ reads the correct depth for SessionState.depth
|
|
993
|
+
prev_depth = _PARENT_DEPTH.set(sub_agent_depth)
|
|
994
|
+
try:
|
|
995
|
+
init_msgs: list[ChatMessage] = []
|
|
996
|
+
if system_prompt:
|
|
997
|
+
init_msgs.append(SystemMessage(content=system_prompt))
|
|
998
|
+
init_msgs.append(UserMessage(content=params.task))
|
|
999
|
+
|
|
1000
|
+
# Sub-agent enters its own full session for tool isolation and proper lifecycle
|
|
1001
|
+
# output_dir is a path within the parent's exec env (not local filesystem)
|
|
1002
|
+
# Files are transferred to parent's env at __aexit__ via save_output_files(dest_env=parent)
|
|
1003
|
+
async with agent.session(
|
|
1004
|
+
output_dir=".", # Path in parent's exec env
|
|
1005
|
+
input_files=list(params.input_files) if params.input_files else None, # ty: ignore[invalid-argument-type]
|
|
1006
|
+
) as agent_session:
|
|
1007
|
+
# Override logger depth for proper indentation in console output
|
|
1008
|
+
agent_session._logger.depth = sub_agent_depth # noqa: SLF001
|
|
1009
|
+
|
|
1010
|
+
finish_params, msg_history, run_metadata = await agent_session.run(init_msgs)
|
|
1011
|
+
|
|
1012
|
+
# Extract the last assistant message with actual content (not just tool calls)
|
|
1013
|
+
last_assistant_msg: AssistantMessage | None = None
|
|
1014
|
+
for msg_group in reversed(msg_history):
|
|
1015
|
+
for msg in reversed(msg_group):
|
|
1016
|
+
if isinstance(msg, AssistantMessage) and msg.content:
|
|
1017
|
+
last_assistant_msg = msg
|
|
1018
|
+
break
|
|
1019
|
+
if last_assistant_msg:
|
|
1020
|
+
break
|
|
1021
|
+
|
|
1022
|
+
# Build content from the assistant message and/or finish params
|
|
1023
|
+
content_parts: list[str] = []
|
|
1024
|
+
|
|
1025
|
+
if last_assistant_msg and last_assistant_msg.content:
|
|
1026
|
+
content = last_assistant_msg.content
|
|
1027
|
+
if isinstance(content, list):
|
|
1028
|
+
content = "\n".join(str(block) for block in content)
|
|
1029
|
+
content_parts.append(content)
|
|
1030
|
+
|
|
1031
|
+
# Include finish params if available (they often contain the actual result)
|
|
1032
|
+
if finish_params is not None:
|
|
1033
|
+
finish_dict = finish_params.model_dump()
|
|
1034
|
+
if finish_dict:
|
|
1035
|
+
content_parts.append(f"Finish params: {finish_dict}")
|
|
1036
|
+
|
|
1037
|
+
# Report files transferred to parent's exec env (set in __aexit__)
|
|
1038
|
+
transferred_paths = agent_session._transferred_paths # noqa: SLF001
|
|
1039
|
+
if transferred_paths:
|
|
1040
|
+
content_parts.append(f"Files available in your environment: {transferred_paths}")
|
|
1041
|
+
|
|
1042
|
+
if not content_parts:
|
|
1043
|
+
result_content = "<sub_agent_result>\n<error>No assistant message or finish params found</error>\n</sub_agent_result>"
|
|
1044
|
+
else:
|
|
1045
|
+
content = "\n".join(content_parts)
|
|
1046
|
+
result_content = (
|
|
1047
|
+
f"<sub_agent_result>"
|
|
1048
|
+
f"\n<response>{content}</response>"
|
|
1049
|
+
f"\n<finished>{finish_params is not None}</finished>"
|
|
1050
|
+
f"\n</sub_agent_result>"
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
# Create subagent metadata with token usage, message history, and run metadata
|
|
1054
|
+
sub_metadata = SubAgentMetadata(
|
|
1055
|
+
message_history=msg_history,
|
|
1056
|
+
run_metadata=run_metadata,
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
return ToolResult(content=result_content, metadata=sub_metadata)
|
|
1060
|
+
|
|
1061
|
+
except Exception as e:
|
|
1062
|
+
# On error, return empty metadata
|
|
1063
|
+
error_metadata = SubAgentMetadata(
|
|
1064
|
+
message_history=[],
|
|
1065
|
+
run_metadata={},
|
|
1066
|
+
)
|
|
1067
|
+
return ToolResult(
|
|
1068
|
+
content=f"<sub_agent_result>\n<error>{e!s}</error>\n</sub_agent_result>",
|
|
1069
|
+
metadata=error_metadata,
|
|
1070
|
+
)
|
|
1071
|
+
finally:
|
|
1072
|
+
# DEBUG: Log SESSION_STATE after subagent session
|
|
1073
|
+
post_session_state = _SESSION_STATE.get(None)
|
|
1074
|
+
logger.debug(
|
|
1075
|
+
"[%s] POST-SESSION: _SESSION_STATE=%s, exec_env=%s, exec_env._temp_dir=%s",
|
|
1076
|
+
agent.name,
|
|
1077
|
+
id(post_session_state) if post_session_state else None,
|
|
1078
|
+
type(post_session_state.exec_env).__name__
|
|
1079
|
+
if post_session_state and post_session_state.exec_env
|
|
1080
|
+
else None,
|
|
1081
|
+
getattr(post_session_state.exec_env, "_temp_dir", "N/A")
|
|
1082
|
+
if post_session_state and post_session_state.exec_env
|
|
1083
|
+
else None,
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
# Restore parent's depth
|
|
1087
|
+
_PARENT_DEPTH.reset(prev_depth)
|
|
1088
|
+
# Restore parent's session state so next sibling subagent sees it
|
|
1089
|
+
if parent_session_state is not None:
|
|
1090
|
+
_SESSION_STATE.set(parent_session_state)
|
|
1091
|
+
|
|
1092
|
+
return Tool[SubAgentParams, SubAgentMetadata](
|
|
1093
|
+
name=self._name,
|
|
1094
|
+
description=description,
|
|
1095
|
+
parameters=SubAgentParams,
|
|
1096
|
+
executor=sub_agent_executor, # ty: ignore[invalid-argument-type]
|
|
1097
|
+
)
|