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/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
+ )