stirrup 0.1.4__tar.gz → 0.1.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {stirrup-0.1.4 → stirrup-0.1.6}/PKG-INFO +3 -3
  2. {stirrup-0.1.4 → stirrup-0.1.6}/README.md +2 -2
  3. {stirrup-0.1.4 → stirrup-0.1.6}/pyproject.toml +1 -1
  4. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/core/agent.py +105 -34
  5. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/code_backends/base.py +71 -12
  6. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/code_backends/docker.py +114 -59
  7. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/code_backends/e2b.py +62 -0
  8. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/code_backends/local.py +43 -0
  9. {stirrup-0.1.4 → stirrup-0.1.6}/LICENSE +0 -0
  10. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/__init__.py +0 -0
  11. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/clients/__init__.py +0 -0
  12. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/clients/chat_completions_client.py +0 -0
  13. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/clients/litellm_client.py +0 -0
  14. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/clients/open_responses_client.py +0 -0
  15. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/clients/utils.py +0 -0
  16. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/constants.py +0 -0
  17. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/core/__init__.py +0 -0
  18. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/core/cache.py +0 -0
  19. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/core/exceptions.py +0 -0
  20. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/core/models.py +0 -0
  21. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/prompts/__init__.py +0 -0
  22. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/prompts/base_system_prompt.txt +0 -0
  23. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/prompts/message_summarizer.txt +0 -0
  24. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/prompts/message_summarizer_bridge.txt +0 -0
  25. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/py.typed +0 -0
  26. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/skills/__init__.py +0 -0
  27. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/skills/skills.py +0 -0
  28. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/__init__.py +0 -0
  29. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/browser_use.py +0 -0
  30. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/calculator.py +0 -0
  31. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/code_backends/__init__.py +0 -0
  32. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/finish.py +0 -0
  33. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/mcp.py +0 -0
  34. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/user_input.py +0 -0
  35. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/view_image.py +0 -0
  36. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/web.py +0 -0
  37. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/utils/__init__.py +0 -0
  38. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/utils/logging.py +0 -0
  39. {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/utils/text.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: stirrup
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: The lightweight foundation for building agents
5
5
  Keywords: ai,agent,llm,openai,anthropic,tools,framework
6
6
  Author: Artificial Analysis, Inc.
@@ -77,20 +77,20 @@ Description-Content-Type: text/markdown
77
77
  <br>
78
78
  </div>
79
79
 
80
-
81
80
  <p align="center">
82
81
  <a href="https://pypi.python.org/pypi/stirrup"><img src="https://img.shields.io/pypi/v/stirrup" alt="PyPI version" /></a>&nbsp;<!--
83
82
  --><a href="https://github.com/ArtificialAnalysis/Stirrup/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ArtificialAnalysis/Stirrup" alt="License" /></a>&nbsp;<!--
84
83
  --><a href="https://stirrup.artificialanalysis.ai"><img src="https://img.shields.io/badge/MkDocs-4F46E5?logo=materialformkdocs&logoColor=fff" alt="MkDocs" /></a>
85
84
  </p>
86
85
 
87
-
88
86
  Stirrup is a lightweight framework, or starting point template, for building agents. It differs from other agent frameworks by:
89
87
 
90
88
  - **Working with the model, not against it:** Stirrup gets out of the way and lets the model choose its own approach to completing tasks (similar to Claude Code). Many frameworks impose rigid workflows that can degrade results.
91
89
  - **Best practices and tools built-in:** We analyzed the leading agents (Claude Code, Codex, and others) to understand and incorporate best practices relating to topics like context management and foundational tools (e.g., code execution).
92
90
  - **Fully customizable:** Use Stirrup as a package or as a starting template to build your own fully customized agents.
93
91
 
92
+ > **Note:** This is the Python implementation, [StirrupJS](https://github.com/ArtificialAnalysis/StirrupJS) is the Typescript implementation.
93
+
94
94
  ## Features
95
95
 
96
96
  - 🧪 **Code execution:** Run code locally, in Docker, or in an E2B sandbox
@@ -9,20 +9,20 @@
9
9
  <br>
10
10
  </div>
11
11
 
12
-
13
12
  <p align="center">
14
13
  <a href="https://pypi.python.org/pypi/stirrup"><img src="https://img.shields.io/pypi/v/stirrup" alt="PyPI version" /></a>&nbsp;<!--
15
14
  --><a href="https://github.com/ArtificialAnalysis/Stirrup/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ArtificialAnalysis/Stirrup" alt="License" /></a>&nbsp;<!--
16
15
  --><a href="https://stirrup.artificialanalysis.ai"><img src="https://img.shields.io/badge/MkDocs-4F46E5?logo=materialformkdocs&logoColor=fff" alt="MkDocs" /></a>
17
16
  </p>
18
17
 
19
-
20
18
  Stirrup is a lightweight framework, or starting point template, for building agents. It differs from other agent frameworks by:
21
19
 
22
20
  - **Working with the model, not against it:** Stirrup gets out of the way and lets the model choose its own approach to completing tasks (similar to Claude Code). Many frameworks impose rigid workflows that can degrade results.
23
21
  - **Best practices and tools built-in:** We analyzed the leading agents (Claude Code, Codex, and others) to understand and incorporate best practices relating to topics like context management and foundational tools (e.g., code execution).
24
22
  - **Fully customizable:** Use Stirrup as a package or as a starting template to build your own fully customized agents.
25
23
 
24
+ > **Note:** This is the Python implementation, [StirrupJS](https://github.com/ArtificialAnalysis/StirrupJS) is the Typescript implementation.
25
+
26
26
  ## Features
27
27
 
28
28
  - 🧪 **Code execution:** Run code locally, in Docker, or in an E2B sandbox
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stirrup"
3
- version = "0.1.4"
3
+ version = "0.1.6"
4
4
  description = "The lightweight foundation for building agents"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -65,6 +65,9 @@ class SessionState:
65
65
  - depth: Agent depth (0 = root, >0 = subagent)
66
66
  - output_dir: For root agent, this is a local filesystem path. For subagents,
67
67
  this is a path within the parent's exec env.
68
+ - exec_env_owned: Whether this session owns the exec_env and should clean it up.
69
+ When share_parent_exec_env=True, the subagent borrows the parent's exec_env
70
+ and exec_env_owned=False to prevent cleanup on subagent exit.
68
71
  """
69
72
 
70
73
  exit_stack: AsyncExitStack
@@ -72,6 +75,7 @@ class SessionState:
72
75
  output_dir: str | None = None # String path (contextual: local for root, in parent env for subagent)
73
76
  parent_exec_env: CodeExecToolProvider | None = None
74
77
  depth: int = 0
78
+ exec_env_owned: bool = True # Whether this session owns (and should cleanup) the exec_env
75
79
  uploaded_file_paths: list[str] = field(default_factory=list) # Paths of files uploaded to exec_env
76
80
  skills_metadata: list[SkillMetadata] = field(default_factory=list) # Loaded skills metadata
77
81
  logger: AgentLoggerBase | None = None # Logger for pause/resume during user input
@@ -184,6 +188,8 @@ class Agent[FinishParams: BaseModel, FinishMeta]:
184
188
  turns_remaining_warning_threshold: int = TURNS_REMAINING_WARNING_THRESHOLD,
185
189
  run_sync_in_thread: bool = True,
186
190
  text_only_tool_responses: bool = True,
191
+ # Subagent options
192
+ share_parent_exec_env: bool = False,
187
193
  # Logging
188
194
  logger: AgentLoggerBase | None = None,
189
195
  ) -> None:
@@ -203,6 +209,11 @@ class Agent[FinishParams: BaseModel, FinishMeta]:
203
209
  context_summarization_cutoff: Fraction of context window (0-1) at which to trigger summarization
204
210
  run_sync_in_thread: Execute synchronous tool executors in a separate thread
205
211
  text_only_tool_responses: Extract images from tool responses as separate user messages
212
+ share_parent_exec_env: When True and used as a subagent, share the parent's code
213
+ execution environment instead of creating a new one. This
214
+ provides better performance (no file copying) and allows
215
+ the subagent to see all files in the parent's environment.
216
+ Only effective when the agent is used as a subagent via to_tool().
206
217
  logger: Optional logger instance. If None, creates AgentLogger() internally.
207
218
 
208
219
  """
@@ -224,6 +235,7 @@ class Agent[FinishParams: BaseModel, FinishMeta]:
224
235
  self._turns_remaining_warning_threshold = turns_remaining_warning_threshold
225
236
  self._run_sync_in_thread = run_sync_in_thread
226
237
  self._text_only_tool_responses = text_only_tool_responses
238
+ self._share_parent_exec_env = share_parent_exec_env
227
239
 
228
240
  # Logger (can be passed in or created here)
229
241
  self._logger: AgentLoggerBase = logger if logger is not None else AgentLogger()
@@ -572,22 +584,47 @@ class Agent[FinishParams: BaseModel, FinishMeta]:
572
584
  # (like ViewImageToolProvider) can access state.exec_env in second pass.
573
585
  active_tools: list[Tool] = []
574
586
 
575
- # First pass: Initialize CodeExecToolProvider (at most one allowed)
576
- code_exec_providers = [t for t in self._tools if isinstance(t, CodeExecToolProvider)]
577
- if len(code_exec_providers) > 1:
578
- raise ValueError(
579
- f"Agent can only have one CodeExecToolProvider, found {len(code_exec_providers)}: "
580
- f"{[type(p).__name__ for p in code_exec_providers]}"
587
+ # Check if we should share parent's exec_env (subagent with share_parent_exec_env=True)
588
+ should_share_exec_env = (
589
+ self._share_parent_exec_env
590
+ and current_depth > 0
591
+ and parent_state is not None
592
+ and parent_state.exec_env is not None
593
+ )
594
+
595
+ if should_share_exec_env:
596
+ # SHARED EXEC ENV: Use parent's exec_env directly, don't create new one
597
+ state.exec_env = parent_state.exec_env # type: ignore[union-attr]
598
+ state.exec_env_owned = False
599
+ logger.debug(
600
+ "[%s __aenter__] Sharing parent's exec_env: %s (temp_dir=%s)",
601
+ self._name,
602
+ type(state.exec_env).__name__,
603
+ getattr(state.exec_env, "_temp_dir", "N/A"),
581
604
  )
605
+ # Skip CodeExecToolProvider initialization but still need to add code exec tool
606
+ # Create the tool from the shared exec_env using get_code_exec_tool()
607
+ # (the exec_env is already entered by parent, so we just create the tool wrapper)
608
+ code_exec_tool = state.exec_env.get_code_exec_tool()
609
+ active_tools.append(code_exec_tool)
610
+ else:
611
+ # OWNED EXEC ENV: Initialize our own CodeExecToolProvider (at most one allowed)
612
+ code_exec_providers = [t for t in self._tools if isinstance(t, CodeExecToolProvider)]
613
+ if len(code_exec_providers) > 1:
614
+ raise ValueError(
615
+ f"Agent can only have one CodeExecToolProvider, found {len(code_exec_providers)}: "
616
+ f"{[type(p).__name__ for p in code_exec_providers]}"
617
+ )
582
618
 
583
- if code_exec_providers:
584
- provider = code_exec_providers[0]
585
- result = await exit_stack.enter_async_context(provider)
586
- if isinstance(result, list):
587
- active_tools.extend(result)
588
- else:
589
- active_tools.append(result)
590
- state.exec_env = provider
619
+ if code_exec_providers:
620
+ provider = code_exec_providers[0]
621
+ result = await exit_stack.enter_async_context(provider)
622
+ if isinstance(result, list):
623
+ active_tools.extend(result)
624
+ else:
625
+ active_tools.append(result)
626
+ state.exec_env = provider
627
+ state.exec_env_owned = True
591
628
 
592
629
  # Second pass: Initialize remaining ToolProviders and static Tools
593
630
  for tool in self._tools:
@@ -620,35 +657,57 @@ class Agent[FinishParams: BaseModel, FinishMeta]:
620
657
  raise ValueError("input_files specified but no CodeExecToolProvider configured")
621
658
 
622
659
  logger.debug(
623
- "[%s __aenter__] Uploading input files: %s, depth=%d, parent_exec_env=%s, parent_exec_env._temp_dir=%s",
660
+ "[%s __aenter__] Uploading input files: %s, depth=%d, parent_exec_env=%s, parent_exec_env._temp_dir=%s, exec_env_owned=%s",
624
661
  self._name,
625
662
  self._pending_input_files,
626
663
  state.depth,
627
664
  type(state.parent_exec_env).__name__ if state.parent_exec_env else None,
628
665
  getattr(state.parent_exec_env, "_temp_dir", "N/A") if state.parent_exec_env else None,
666
+ state.exec_env_owned,
629
667
  )
630
668
 
631
669
  if state.depth > 0 and state.parent_exec_env:
632
- # SUBAGENT: Read files from parent's exec env, write to subagent's exec env
633
- # input_files are paths within the parent's environment
634
- result = await state.exec_env.upload_files(
635
- *self._pending_input_files,
636
- source_env=state.parent_exec_env,
637
- )
670
+ if not state.exec_env_owned:
671
+ # SHARED EXEC ENV: Files already accessible - no transfer needed
672
+ # Just record the paths as "uploaded" for system prompt
673
+ if isinstance(self._pending_input_files, (str, Path)):
674
+ state.uploaded_file_paths = [str(self._pending_input_files)]
675
+ else:
676
+ state.uploaded_file_paths = [str(p) for p in self._pending_input_files]
677
+ logger.debug(
678
+ "[%s __aenter__] Shared exec_env - files already accessible: %s",
679
+ self._name,
680
+ state.uploaded_file_paths,
681
+ )
682
+ else:
683
+ # SEPARATE EXEC ENV: Read files from parent's exec env, write to subagent's exec env
684
+ # input_files are paths within the parent's environment
685
+ result = await state.exec_env.upload_files(
686
+ *self._pending_input_files,
687
+ source_env=state.parent_exec_env,
688
+ )
689
+ logger.debug(
690
+ "[%s __aenter__] Upload result: uploaded=%s, failed=%s",
691
+ self._name,
692
+ result.uploaded,
693
+ result.failed,
694
+ )
695
+ state.uploaded_file_paths = [uf.dest_path for uf in result.uploaded]
696
+ if result.failed:
697
+ raise RuntimeError(f"Failed to upload files: {result.failed}")
638
698
  else:
639
699
  # ROOT AGENT: Read files from local filesystem
640
700
  resolved = self._resolve_input_files(self._pending_input_files)
641
701
  result = await state.exec_env.upload_files(*resolved)
642
-
643
- logger.debug(
644
- "[%s __aenter__] Upload result: uploaded=%s, failed=%s", self._name, result.uploaded, result.failed
645
- )
646
-
647
- # Store uploaded paths for system prompt
648
- state.uploaded_file_paths = [uf.dest_path for uf in result.uploaded]
649
-
650
- if result.failed:
651
- raise RuntimeError(f"Failed to upload files: {result.failed}")
702
+ logger.debug(
703
+ "[%s __aenter__] Upload result: uploaded=%s, failed=%s",
704
+ self._name,
705
+ result.uploaded,
706
+ result.failed,
707
+ )
708
+ state.uploaded_file_paths = [uf.dest_path for uf in result.uploaded]
709
+ if result.failed:
710
+ raise RuntimeError(f"Failed to upload files: {result.failed}")
652
711
  self._pending_input_files = None # Clear pending state
653
712
 
654
713
  # Upload skills directory if it exists and load metadata
@@ -667,7 +726,8 @@ class Agent[FinishParams: BaseModel, FinishMeta]:
667
726
  state.skills_metadata = parent_state.skills_metadata
668
727
  logger.debug("[%s __aenter__] Inherited %d skills from parent", self._name, len(state.skills_metadata))
669
728
  # Transfer skills directory from parent's exec_env to sub-agent's exec_env
670
- if state.exec_env and parent_state.exec_env:
729
+ # (only if we have a separate exec_env)
730
+ if state.exec_env and parent_state.exec_env and state.exec_env_owned:
671
731
  await state.exec_env.upload_files("skills", source_env=parent_state.exec_env)
672
732
 
673
733
  # Configure and enter logger context
@@ -767,8 +827,19 @@ class Agent[FinishParams: BaseModel, FinishMeta]:
767
827
  len(result.failed),
768
828
  )
769
829
  else:
770
- # SUBAGENT: Transfer to parent's exec env
771
- if state.parent_exec_env:
830
+ # SUBAGENT: Handle file transfer based on exec_env ownership
831
+ if not state.exec_env_owned:
832
+ # SHARED EXEC ENV: Files already in parent's env - no transfer needed
833
+ # Just record the paths for reporting to parent
834
+ self._transferred_paths = list(paths)
835
+ logger.debug(
836
+ "[%s] SUBAGENT (depth=%d, shared_exec_env): Files already in parent env: %s",
837
+ self._name,
838
+ state.depth,
839
+ self._transferred_paths,
840
+ )
841
+ elif state.parent_exec_env:
842
+ # SEPARATE EXEC ENV: Transfer to parent's exec env
772
843
  logger.debug(
773
844
  "[%s] SUBAGENT (depth=%d): Transferring %d file(s) to parent exec env: %s -> %s",
774
845
  self._name,
@@ -245,6 +245,39 @@ class CodeExecToolProvider(ToolProvider, ABC):
245
245
  """
246
246
  ...
247
247
 
248
+ @abstractmethod
249
+ async def is_directory(self, path: str) -> bool:
250
+ """Check if a path is a directory in this execution environment.
251
+
252
+ Args:
253
+ path: Path within this execution environment.
254
+
255
+ Returns:
256
+ True if the path exists and is a directory, False otherwise.
257
+
258
+ Raises:
259
+ RuntimeError: If execution environment not started.
260
+
261
+ """
262
+ ...
263
+
264
+ @abstractmethod
265
+ async def list_files(self, path: str) -> list[str]:
266
+ """List all files recursively in a directory within this execution environment.
267
+
268
+ Args:
269
+ path: Directory path within this execution environment.
270
+
271
+ Returns:
272
+ List of file paths (relative to the given path) for all files in the directory.
273
+ Returns an empty list if the path is a file or doesn't exist.
274
+
275
+ Raises:
276
+ RuntimeError: If execution environment not started.
277
+
278
+ """
279
+ ...
280
+
248
281
  async def save_output_files(
249
282
  self,
250
283
  paths: list[str],
@@ -334,18 +367,44 @@ class CodeExecToolProvider(ToolProvider, ABC):
334
367
  try:
335
368
  if source_env:
336
369
  # Cross-environment transfer: read from source_env
337
- content = await source_env.read_file_bytes(path_str)
338
- filename = Path(path_str).name
339
- dest_path = f"{dest_dir_str}/{filename}" if dest_dir_str else filename
340
- logger.debug(
341
- "UPLOAD CROSS-ENV: %s (%d bytes) from %s -> %s",
342
- path_str,
343
- len(content),
344
- type(source_env).__name__,
345
- dest_path,
346
- )
347
- await self.write_file_bytes(dest_path, content)
348
- result.uploaded.append(UploadedFile(Path(path_str), dest_path, len(content)))
370
+ # Check if it's a directory first
371
+ if await source_env.is_directory(path_str):
372
+ # Handle directory recursively
373
+ # Preserve directory name when dest_dir not specified
374
+ dir_name = Path(path_str).name
375
+ files = await source_env.list_files(path_str)
376
+ for rel_file_path in files:
377
+ src_file_path = f"{path_str}/{rel_file_path}"
378
+ # If dest_dir specified, put files directly there
379
+ # Otherwise, preserve the source directory name
380
+ if dest_dir_str:
381
+ dest_path = f"{dest_dir_str}/{rel_file_path}"
382
+ else:
383
+ dest_path = f"{dir_name}/{rel_file_path}"
384
+ content = await source_env.read_file_bytes(src_file_path)
385
+ logger.debug(
386
+ "UPLOAD CROSS-ENV (dir): %s (%d bytes) from %s -> %s",
387
+ src_file_path,
388
+ len(content),
389
+ type(source_env).__name__,
390
+ dest_path,
391
+ )
392
+ await self.write_file_bytes(dest_path, content)
393
+ result.uploaded.append(UploadedFile(Path(src_file_path), dest_path, len(content)))
394
+ else:
395
+ # Single file transfer
396
+ content = await source_env.read_file_bytes(path_str)
397
+ filename = Path(path_str).name
398
+ dest_path = f"{dest_dir_str}/{filename}" if dest_dir_str else filename
399
+ logger.debug(
400
+ "UPLOAD CROSS-ENV: %s (%d bytes) from %s -> %s",
401
+ path_str,
402
+ len(content),
403
+ type(source_env).__name__,
404
+ dest_path,
405
+ )
406
+ await self.write_file_bytes(dest_path, content)
407
+ result.uploaded.append(UploadedFile(Path(path_str), dest_path, len(content)))
349
408
  else:
350
409
  # Local filesystem upload - must be handled by subclass
351
410
  # This is a fallback that reads from local fs and writes to env
@@ -3,6 +3,7 @@
3
3
  import contextlib
4
4
  import hashlib
5
5
  import os
6
+ import shlex
6
7
  import shutil
7
8
  import tempfile
8
9
  from pathlib import Path
@@ -106,7 +107,7 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
106
107
  self._source = source
107
108
  self._is_dockerfile = is_dockerfile
108
109
  self._dockerfile_context = dockerfile_context
109
- self._working_dir = working_dir
110
+ self._working_dir = working_dir.rstrip("/")
110
111
  self._temp_base_dir = temp_base_dir
111
112
  self._env_vars = env_vars
112
113
 
@@ -125,51 +126,6 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
125
126
  """Return the container short ID, or None if not started."""
126
127
  return self._container.short_id if self._container else None
127
128
 
128
- def _resolve_file_path(self, path: str) -> Path:
129
- """Resolve a container path string to a validated host file path.
130
-
131
- Args:
132
- path: Path to file (relative to working directory, or absolute container path).
133
-
134
- Returns:
135
- Resolved absolute host Path to the file.
136
-
137
- Raises:
138
- RuntimeError: If execution environment not started.
139
- ValueError: If path is outside mounted directory or is not a file.
140
- FileNotFoundError: If file does not exist.
141
-
142
- """
143
- if self._temp_dir is None:
144
- raise RuntimeError("ExecutionEnvironment not started. Use 'async with exec_env.create()' first.")
145
-
146
- file_path = Path(path)
147
-
148
- # Handle both absolute container paths and relative paths
149
- if file_path.is_absolute():
150
- # Convert container absolute path to host path
151
- # e.g., /workspace/image.png -> <temp_dir>/image.png
152
- if str(file_path).startswith(self._working_dir):
153
- relative = file_path.relative_to(self._working_dir)
154
- file_path = self._temp_dir / relative
155
- else:
156
- raise ValueError(f"Path is outside mounted directory: {path}")
157
- else:
158
- file_path = self._temp_dir / file_path
159
-
160
- # Security check: ensure path is within temp directory
161
- try:
162
- file_path.resolve().relative_to(self._temp_dir.resolve())
163
- except ValueError:
164
- raise ValueError(f"Path is outside execution environment directory: {path}") from None
165
-
166
- if not file_path.exists():
167
- raise FileNotFoundError(f"File not found: {path}")
168
- if not file_path.is_file():
169
- raise ValueError(f"Path is not a file: {path}")
170
-
171
- return file_path
172
-
173
129
  @classmethod
174
130
  def from_image(
175
131
  cls,
@@ -379,6 +335,10 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
379
335
  exc_tb: object,
380
336
  ) -> None:
381
337
  """Stop container and cleanup temp directory."""
338
+ # Fix ownership of all files before cleanup (prevents permission errors on nested directories)
339
+ if self._container and self._temp_dir:
340
+ await self._fix_file_ownership()
341
+
382
342
  # Stop and remove container
383
343
  if self._container:
384
344
  container = self._container # Capture for lambda type narrowing
@@ -408,10 +368,12 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
408
368
  self._temp_dir = None
409
369
 
410
370
  def _container_path_to_host(self, path: str) -> Path:
411
- """Convert a container path to the corresponding host path.
371
+ """Convert a container or host path to the corresponding host path.
412
372
 
413
373
  Args:
414
- path: Path in the container (relative or absolute).
374
+ path: Path to resolve. Can be relative to the container working directory,
375
+ an absolute container path (starting with working_dir), or an absolute
376
+ host path already within the temp directory.
415
377
 
416
378
  Returns:
417
379
  Resolved Path on the host filesystem.
@@ -426,11 +388,16 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
426
388
 
427
389
  source_path = Path(path)
428
390
 
429
- # Handle both absolute container paths and relative paths
391
+ # Handle absolute host paths, absolute container paths, and relative paths
430
392
  if source_path.is_absolute():
431
- # Convert container absolute path to host path
432
- # e.g., /workspace/output.txt -> <temp_dir>/output.txt
433
- if str(source_path).startswith(self._working_dir):
393
+ temp_dir_prefix = str(self._temp_dir) + os.sep
394
+ working_dir_prefix = self._working_dir + "/"
395
+ if str(source_path).startswith(temp_dir_prefix):
396
+ # Already a valid host path within the temp directory
397
+ host_path = source_path
398
+ elif str(source_path).startswith(working_dir_prefix):
399
+ # Convert container absolute path to host path
400
+ # e.g., /workspace/output.txt -> <temp_dir>/output.txt
434
401
  relative = source_path.relative_to(self._working_dir)
435
402
  host_path = self._temp_dir / relative
436
403
  else:
@@ -452,7 +419,7 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
452
419
  Since files are volume-mounted, reads directly from the host temp directory.
453
420
 
454
421
  Args:
455
- path: File path (relative or absolute container path).
422
+ path: File path (relative, absolute container, or absolute host path).
456
423
 
457
424
  Returns:
458
425
  File contents as bytes.
@@ -474,7 +441,7 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
474
441
  Since files are volume-mounted, writes directly to the host temp directory.
475
442
 
476
443
  Args:
477
- path: Destination path (relative or absolute container path).
444
+ path: Destination path (relative, absolute container, or absolute host path).
478
445
  content: File contents to write.
479
446
 
480
447
  Raises:
@@ -492,7 +459,7 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
492
459
  Since files are volume-mounted, checks directly on the host temp directory.
493
460
 
494
461
  Args:
495
- path: File path (relative or absolute container path).
462
+ path: File path (relative, absolute container, or absolute host path).
496
463
 
497
464
  Returns:
498
465
  True if the file exists, False otherwise.
@@ -505,6 +472,53 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
505
472
  host_path = self._container_path_to_host(path)
506
473
  return host_path.exists() and host_path.is_file()
507
474
 
475
+ async def is_directory(self, path: str) -> bool:
476
+ """Check if a path is a directory in the container.
477
+
478
+ Since files are volume-mounted, checks directly on the host temp directory.
479
+
480
+ Args:
481
+ path: Path (relative, absolute container, or absolute host path).
482
+
483
+ Returns:
484
+ True if the path exists and is a directory, False otherwise.
485
+
486
+ Raises:
487
+ RuntimeError: If environment not started.
488
+ ValueError: If path is outside mounted directory.
489
+
490
+ """
491
+ host_path = self._container_path_to_host(path)
492
+ return host_path.exists() and host_path.is_dir()
493
+
494
+ async def list_files(self, path: str) -> list[str]:
495
+ """List all files recursively in a directory within the container.
496
+
497
+ Since files are volume-mounted, lists directly from the host temp directory.
498
+
499
+ Args:
500
+ path: Directory path (relative, absolute container, or absolute host path).
501
+
502
+ Returns:
503
+ List of file paths (relative to the given path) for all files in the directory.
504
+ Returns an empty list if the path is a file or doesn't exist.
505
+
506
+ Raises:
507
+ RuntimeError: If environment not started.
508
+ ValueError: If path is outside mounted directory.
509
+
510
+ """
511
+ host_path = self._container_path_to_host(path)
512
+ if not host_path.exists() or not host_path.is_dir():
513
+ return []
514
+
515
+ files = []
516
+ for file_path in host_path.rglob("*"):
517
+ if file_path.is_file():
518
+ rel_path = file_path.relative_to(host_path)
519
+ files.append(str(rel_path))
520
+ return files
521
+
508
522
  async def run_command(self, cmd: str, *, timeout: int = SHELL_TIMEOUT) -> CommandResult:
509
523
  """Execute a shell command in the Docker container.
510
524
 
@@ -576,6 +590,42 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
576
590
  error_kind="execution_error",
577
591
  )
578
592
 
593
+ async def _fix_file_ownership(self, paths: list[str] | None = None) -> None:
594
+ """Fix ownership of files created by the container.
595
+
596
+ Files and directories created inside the Docker container run as root,
597
+ which causes permission issues when trying to move/delete them from the host.
598
+ This method runs chown inside the container to fix ownership.
599
+
600
+ Args:
601
+ paths: Specific paths to fix. If None, fixes all files in working_dir.
602
+ Paths should be container paths (absolute or relative to working_dir).
603
+ """
604
+ if self._container is None:
605
+ return
606
+
607
+ try:
608
+ # Get the host user ID to chown to
609
+ host_uid = os.getuid()
610
+ host_gid = os.getgid()
611
+
612
+ if paths:
613
+ # Normalize paths - handle both relative and absolute
614
+ container_paths = [
615
+ f"{self._working_dir}/{path}" if not path.startswith("/") else path for path in paths
616
+ ]
617
+ quoted_paths = " ".join(shlex.quote(p) for p in container_paths)
618
+ chown_cmd = f"chown -R {host_uid}:{host_gid} {quoted_paths} 2>/dev/null || true"
619
+ await self.run_command(chown_cmd, timeout=10)
620
+ else:
621
+ # Fix all files in working directory
622
+ chown_cmd = f"chown -R {host_uid}:{host_gid} {shlex.quote(self._working_dir)} 2>/dev/null || true"
623
+ await self.run_command(chown_cmd, timeout=10)
624
+
625
+ except Exception as exc:
626
+ # Don't fail the operation if chown fails, just log warning
627
+ logger.warning("Failed to fix file ownership: %s", exc)
628
+
579
629
  async def save_output_files(
580
630
  self,
581
631
  paths: list[str],
@@ -594,9 +644,11 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
594
644
  using the base class implementation via read/write primitives.
595
645
 
596
646
  Args:
597
- paths: List of file paths in the execution environment (relative or absolute container paths).
598
- Relative paths are resolved against the container working directory.
599
- Absolute container paths starting with working_dir are mapped to the host.
647
+ paths: List of file paths in the execution environment (relative, absolute container,
648
+ or absolute host paths). Relative paths are resolved against the container
649
+ working directory. Absolute container paths starting with working_dir are
650
+ mapped to the host. Absolute host paths within the temp directory are
651
+ accepted as-is.
600
652
  output_dir: Directory path to save files to.
601
653
  dest_env: If provided, output_dir is interpreted as a path within dest_env
602
654
  (cross-environment transfer). If None, output_dir is a local
@@ -615,6 +667,9 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
615
667
  if dest_env is not None:
616
668
  return await super().save_output_files(paths, output_dir, dest_env)
617
669
 
670
+ # Fix ownership of files before moving them (solves permission issues with nested directories)
671
+ await self._fix_file_ownership(paths)
672
+
618
673
  # Local filesystem - use optimized move operation
619
674
  output_dir_path = Path(output_dir)
620
675
  output_dir_path.mkdir(parents=True, exist_ok=True)
@@ -768,7 +823,7 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
768
823
  """Read and return an image file from the Docker execution environment.
769
824
 
770
825
  Args:
771
- path: Path to image file (relative to working directory, or absolute container path).
826
+ path: Path to image file (relative, absolute container, or absolute host path).
772
827
 
773
828
  Returns:
774
829
  ImageContentBlock containing the image data.
@@ -150,6 +150,68 @@ class E2BCodeExecToolProvider(CodeExecToolProvider):
150
150
 
151
151
  return await self._sbx.files.exists(path)
152
152
 
153
+ async def is_directory(self, path: str) -> bool:
154
+ """Check if a path is a directory in the E2B sandbox.
155
+
156
+ Args:
157
+ path: Path within the sandbox.
158
+
159
+ Returns:
160
+ True if the path exists and is a directory, False otherwise.
161
+
162
+ Raises:
163
+ RuntimeError: If environment not started.
164
+
165
+ """
166
+ if self._sbx is None:
167
+ raise RuntimeError("ExecutionEnvironment not started.")
168
+
169
+ if not await self._sbx.files.exists(path):
170
+ return False
171
+
172
+ info = await self._sbx.files.get_info(path)
173
+ return info.type == FileType.DIR
174
+
175
+ async def list_files(self, path: str) -> list[str]:
176
+ """List all files recursively in a directory within the E2B sandbox.
177
+
178
+ Args:
179
+ path: Directory path within the sandbox.
180
+
181
+ Returns:
182
+ List of file paths (relative to the given path) for all files in the directory.
183
+ Returns an empty list if the path is a file or doesn't exist.
184
+
185
+ Raises:
186
+ RuntimeError: If environment not started.
187
+
188
+ """
189
+ if self._sbx is None:
190
+ raise RuntimeError("ExecutionEnvironment not started.")
191
+
192
+ if not await self._sbx.files.exists(path):
193
+ return []
194
+
195
+ info = await self._sbx.files.get_info(path)
196
+ if info.type != FileType.DIR:
197
+ return []
198
+
199
+ # Use find command to list all files recursively
200
+ result = await self.run_command(f"find {path} -type f")
201
+ if result.exit_code != 0:
202
+ return []
203
+
204
+ files = []
205
+ for line in result.stdout.strip().split("\n"):
206
+ if line:
207
+ # Convert absolute path to relative path
208
+ rel_path = line.removeprefix(f"{path}/").removeprefix(path)
209
+ if rel_path.startswith("/"):
210
+ rel_path = rel_path[1:]
211
+ if rel_path:
212
+ files.append(rel_path)
213
+ return files
214
+
153
215
  async def run_command(self, cmd: str, *, timeout: int = SHELL_TIMEOUT) -> CommandResult:
154
216
  """Execute command in E2B execution environment, returning raw CommandResult."""
155
217
  if self._sbx is None:
@@ -222,6 +222,49 @@ class LocalCodeExecToolProvider(CodeExecToolProvider):
222
222
  resolved = self._resolve_and_validate_path(path)
223
223
  return resolved.exists() and resolved.is_file()
224
224
 
225
+ async def is_directory(self, path: str) -> bool:
226
+ """Check if a path is a directory in the temp directory.
227
+
228
+ Args:
229
+ path: Path (relative or absolute within the temp dir).
230
+
231
+ Returns:
232
+ True if the path exists and is a directory, False otherwise.
233
+
234
+ Raises:
235
+ RuntimeError: If environment not started.
236
+ ValueError: If path is outside temp directory.
237
+
238
+ """
239
+ resolved = self._resolve_and_validate_path(path)
240
+ return resolved.exists() and resolved.is_dir()
241
+
242
+ async def list_files(self, path: str) -> list[str]:
243
+ """List all files recursively in a directory within the temp directory.
244
+
245
+ Args:
246
+ path: Directory path (relative or absolute within the temp dir).
247
+
248
+ Returns:
249
+ List of file paths (relative to the given path) for all files in the directory.
250
+ Returns an empty list if the path is a file or doesn't exist.
251
+
252
+ Raises:
253
+ RuntimeError: If environment not started.
254
+ ValueError: If path is outside temp directory.
255
+
256
+ """
257
+ resolved = self._resolve_and_validate_path(path)
258
+ if not resolved.exists() or not resolved.is_dir():
259
+ return []
260
+
261
+ files = []
262
+ for file_path in resolved.rglob("*"):
263
+ if file_path.is_file():
264
+ rel_path = file_path.relative_to(resolved)
265
+ files.append(str(rel_path))
266
+ return files
267
+
225
268
  async def run_command(self, cmd: str, *, timeout: int = SHELL_TIMEOUT) -> CommandResult:
226
269
  """Execute command in the temp directory.
227
270
 
File without changes
File without changes
File without changes