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.
- {stirrup-0.1.4 → stirrup-0.1.6}/PKG-INFO +3 -3
- {stirrup-0.1.4 → stirrup-0.1.6}/README.md +2 -2
- {stirrup-0.1.4 → stirrup-0.1.6}/pyproject.toml +1 -1
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/core/agent.py +105 -34
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/code_backends/base.py +71 -12
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/code_backends/docker.py +114 -59
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/code_backends/e2b.py +62 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/code_backends/local.py +43 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/LICENSE +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/__init__.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/clients/__init__.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/clients/chat_completions_client.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/clients/litellm_client.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/clients/open_responses_client.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/clients/utils.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/constants.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/core/__init__.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/core/cache.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/core/exceptions.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/core/models.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/prompts/__init__.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/prompts/base_system_prompt.txt +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/prompts/message_summarizer.txt +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/prompts/message_summarizer_bridge.txt +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/py.typed +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/skills/__init__.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/skills/skills.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/__init__.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/browser_use.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/calculator.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/code_backends/__init__.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/finish.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/mcp.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/user_input.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/view_image.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/tools/web.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/utils/__init__.py +0 -0
- {stirrup-0.1.4 → stirrup-0.1.6}/src/stirrup/utils/logging.py +0 -0
- {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.
|
|
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> <!--
|
|
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> <!--
|
|
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> <!--
|
|
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> <!--
|
|
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
|
|
@@ -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
|
-
#
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
|
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:
|
|
771
|
-
if state.
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
path_str
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
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
|
|
391
|
+
# Handle absolute host paths, absolute container paths, and relative paths
|
|
430
392
|
if source_path.is_absolute():
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
if str(source_path).startswith(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
598
|
-
Relative paths are resolved against the container
|
|
599
|
-
Absolute container paths starting with working_dir are
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|