stirrup 0.1.5__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.5 → stirrup-0.1.6}/PKG-INFO +1 -1
  2. {stirrup-0.1.5 → stirrup-0.1.6}/pyproject.toml +1 -1
  3. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/core/agent.py +105 -34
  4. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/tools/code_backends/docker.py +69 -61
  5. {stirrup-0.1.5 → stirrup-0.1.6}/LICENSE +0 -0
  6. {stirrup-0.1.5 → stirrup-0.1.6}/README.md +0 -0
  7. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/__init__.py +0 -0
  8. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/clients/__init__.py +0 -0
  9. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/clients/chat_completions_client.py +0 -0
  10. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/clients/litellm_client.py +0 -0
  11. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/clients/open_responses_client.py +0 -0
  12. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/clients/utils.py +0 -0
  13. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/constants.py +0 -0
  14. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/core/__init__.py +0 -0
  15. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/core/cache.py +0 -0
  16. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/core/exceptions.py +0 -0
  17. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/core/models.py +0 -0
  18. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/prompts/__init__.py +0 -0
  19. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/prompts/base_system_prompt.txt +0 -0
  20. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/prompts/message_summarizer.txt +0 -0
  21. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/prompts/message_summarizer_bridge.txt +0 -0
  22. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/py.typed +0 -0
  23. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/skills/__init__.py +0 -0
  24. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/skills/skills.py +0 -0
  25. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/tools/__init__.py +0 -0
  26. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/tools/browser_use.py +0 -0
  27. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/tools/calculator.py +0 -0
  28. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/tools/code_backends/__init__.py +0 -0
  29. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/tools/code_backends/base.py +0 -0
  30. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/tools/code_backends/e2b.py +0 -0
  31. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/tools/code_backends/local.py +0 -0
  32. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/tools/finish.py +0 -0
  33. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/tools/mcp.py +0 -0
  34. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/tools/user_input.py +0 -0
  35. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/tools/view_image.py +0 -0
  36. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/tools/web.py +0 -0
  37. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/utils/__init__.py +0 -0
  38. {stirrup-0.1.5 → stirrup-0.1.6}/src/stirrup/utils/logging.py +0 -0
  39. {stirrup-0.1.5 → 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.5
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stirrup"
3
- version = "0.1.5"
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,
@@ -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.
@@ -511,7 +478,7 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
511
478
  Since files are volume-mounted, checks directly on the host temp directory.
512
479
 
513
480
  Args:
514
- path: Path (relative or absolute container path).
481
+ path: Path (relative, absolute container, or absolute host path).
515
482
 
516
483
  Returns:
517
484
  True if the path exists and is a directory, False otherwise.
@@ -530,7 +497,7 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
530
497
  Since files are volume-mounted, lists directly from the host temp directory.
531
498
 
532
499
  Args:
533
- path: Directory path (relative or absolute container path).
500
+ path: Directory path (relative, absolute container, or absolute host path).
534
501
 
535
502
  Returns:
536
503
  List of file paths (relative to the given path) for all files in the directory.
@@ -623,6 +590,42 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
623
590
  error_kind="execution_error",
624
591
  )
625
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
+
626
629
  async def save_output_files(
627
630
  self,
628
631
  paths: list[str],
@@ -641,9 +644,11 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
641
644
  using the base class implementation via read/write primitives.
642
645
 
643
646
  Args:
644
- paths: List of file paths in the execution environment (relative or absolute container paths).
645
- Relative paths are resolved against the container working directory.
646
- 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.
647
652
  output_dir: Directory path to save files to.
648
653
  dest_env: If provided, output_dir is interpreted as a path within dest_env
649
654
  (cross-environment transfer). If None, output_dir is a local
@@ -662,6 +667,9 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
662
667
  if dest_env is not None:
663
668
  return await super().save_output_files(paths, output_dir, dest_env)
664
669
 
670
+ # Fix ownership of files before moving them (solves permission issues with nested directories)
671
+ await self._fix_file_ownership(paths)
672
+
665
673
  # Local filesystem - use optimized move operation
666
674
  output_dir_path = Path(output_dir)
667
675
  output_dir_path.mkdir(parents=True, exist_ok=True)
@@ -815,7 +823,7 @@ class DockerCodeExecToolProvider(CodeExecToolProvider):
815
823
  """Read and return an image file from the Docker execution environment.
816
824
 
817
825
  Args:
818
- 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).
819
827
 
820
828
  Returns:
821
829
  ImageContentBlock containing the image data.
File without changes
File without changes
File without changes
File without changes