deep-agent-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. agent/__init__.py +42 -0
  2. agent/attachments.py +303 -0
  3. agent/bootstrap.py +44 -0
  4. agent/cancel.py +107 -0
  5. agent/cli/__init__.py +5 -0
  6. agent/cli/app.py +1768 -0
  7. agent/cli/clipboard.py +224 -0
  8. agent/cli/commands.py +94 -0
  9. agent/cli/gitinfo.py +84 -0
  10. agent/cli/input.py +65 -0
  11. agent/cli/interactions.py +187 -0
  12. agent/cli/main.py +124 -0
  13. agent/cli/previews.py +710 -0
  14. agent/cli/rendering.py +770 -0
  15. agent/cli/session_controller.py +221 -0
  16. agent/cli/state.py +326 -0
  17. agent/config.example.yaml +76 -0
  18. agent/config.py +528 -0
  19. agent/control.py +171 -0
  20. agent/factory.py +232 -0
  21. agent/file_mutation.py +5 -0
  22. agent/llm.py +339 -0
  23. agent/middleware/__init__.py +9 -0
  24. agent/middleware/attachments.py +31 -0
  25. agent/middleware/cancel_tools.py +39 -0
  26. agent/middleware/pause.py +18 -0
  27. agent/middleware/recovery.py +65 -0
  28. agent/middleware/steering.py +35 -0
  29. agent/middleware/tool_arg_hints.py +128 -0
  30. agent/middleware/workspace_filesystem.py +38 -0
  31. agent/middleware/write_operation.py +60 -0
  32. agent/network.py +30 -0
  33. agent/permission.py +80 -0
  34. agent/runner.py +1393 -0
  35. agent/sandbox.py +699 -0
  36. agent/session.py +431 -0
  37. agent/session_lock.py +223 -0
  38. agent/session_runtime.py +209 -0
  39. agent/stream.py +168 -0
  40. agent/tools/__init__.py +9 -0
  41. agent/tools/examples.py +30 -0
  42. agent/tools/execute.py +73 -0
  43. agent/tools/human_input.py +170 -0
  44. agent/tools/human_interaction.py +101 -0
  45. agent/tools/web_search.py +131 -0
  46. deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
  47. deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
  48. deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  49. deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  50. deep_agent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
agent/sandbox.py ADDED
@@ -0,0 +1,699 @@
1
+ from __future__ import annotations
2
+
3
+ from codecs import IncrementalDecoder, getincrementaldecoder
4
+ from dataclasses import dataclass, replace
5
+ from datetime import datetime
6
+ from enum import StrEnum
7
+ import logging
8
+ import os
9
+ from pathlib import Path, PurePosixPath
10
+ import re
11
+ import selectors
12
+ import shutil
13
+ import signal
14
+ import subprocess
15
+ import tempfile
16
+ import time
17
+ import uuid
18
+ import warnings
19
+
20
+ from agent.cancel import emit_tool_output, get_cancel_context
21
+
22
+ from deepagents.backends import CompositeBackend, FilesystemBackend, LocalShellBackend
23
+ from deepagents.backends.protocol import (
24
+ BackendProtocol,
25
+ DeleteResult,
26
+ EditResult,
27
+ ExecuteResponse,
28
+ FileDownloadResponse,
29
+ FileUploadResponse,
30
+ GlobResult,
31
+ GrepResult,
32
+ LsResult,
33
+ ReadResult,
34
+ SandboxBackendProtocol,
35
+ WriteResult,
36
+ )
37
+
38
+ from agent.config import SAFE_INHERITED_ENV, BindMount, SandboxConfig, default_skills_dir
39
+
40
+
41
+ LOG = logging.getLogger(__name__)
42
+ SANDBOX_ROOT = "/workspace"
43
+ SKILLS_ROOT = "/skills"
44
+ FIXED_PATH = "/usr/local/bin:/usr/bin:/bin"
45
+ RUNTIME_PATHS = ("/usr", "/bin", "/lib", "/lib64")
46
+ RUNTIME_FILES = (
47
+ "/etc/alternatives",
48
+ "/etc/group",
49
+ "/etc/ld.so.cache",
50
+ "/etc/localtime",
51
+ "/etc/nsswitch.conf",
52
+ "/etc/passwd",
53
+ )
54
+ NETWORK_FILES = ("/etc/hosts", "/etc/resolv.conf", "/etc/ssl")
55
+ ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
56
+ UNSANDBOXED_WARNING = (
57
+ "UNSANDBOXED MODE: bubblewrap is unavailable. Agent commands will run "
58
+ "directly on the host with the current user's permissions."
59
+ )
60
+
61
+
62
+ class ExecutionMode(StrEnum):
63
+ SANDBOXED = "SANDBOXED"
64
+ UNSANDBOXED = "UNSANDBOXED"
65
+ CUSTOM = "CUSTOM"
66
+
67
+
68
+ class SandboxUnavailableError(RuntimeError):
69
+ """Raised when sandboxing is unavailable and unsafe fallback was not authorized."""
70
+
71
+
72
+ @dataclass
73
+ class LocalExecuteResponse(ExecuteResponse):
74
+ """Execute result with local metadata for the UI's ToolMessage artifact."""
75
+
76
+ host_log_path: str | None = None
77
+ agent_log_path: str | None = None
78
+ log_error: str | None = None
79
+ termination_reason: str | None = None
80
+ max_output_bytes: int | None = None
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class BackendSelection:
85
+ backend: SandboxBackendProtocol
86
+ mode: ExecutionMode
87
+ warning: str = ""
88
+
89
+
90
+ _OUTSIDE_WORKSPACE_ERROR = "Permission denied: paths must be under /workspace or /skills"
91
+
92
+
93
+ class _OutsideWorkspaceBackend(BackendProtocol):
94
+ """Non-persistent default route that rejects paths outside /workspace."""
95
+
96
+ def ls(self, path: str) -> LsResult:
97
+ return LsResult(entries=[])
98
+
99
+ def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult:
100
+ return ReadResult(error=_OUTSIDE_WORKSPACE_ERROR)
101
+
102
+ def grep(
103
+ self,
104
+ pattern: str,
105
+ path: str | None = None,
106
+ glob: str | None = None,
107
+ *,
108
+ max_count: int | None = None,
109
+ ) -> GrepResult:
110
+ return GrepResult(matches=[])
111
+
112
+ def glob(self, pattern: str, path: str | None = None) -> GlobResult:
113
+ return GlobResult(matches=[])
114
+
115
+ def write(self, file_path: str, content: str) -> WriteResult:
116
+ return WriteResult(error=_OUTSIDE_WORKSPACE_ERROR)
117
+
118
+ def edit(
119
+ self,
120
+ file_path: str,
121
+ old_string: str,
122
+ new_string: str,
123
+ replace_all: bool = False,
124
+ ) -> EditResult:
125
+ return EditResult(error=_OUTSIDE_WORKSPACE_ERROR)
126
+
127
+ def delete(self, file_path: str) -> DeleteResult:
128
+ return DeleteResult(error=_OUTSIDE_WORKSPACE_ERROR)
129
+
130
+ def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
131
+ return [FileUploadResponse(path=path, error=_OUTSIDE_WORKSPACE_ERROR) for path, _ in files]
132
+
133
+ def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
134
+ return [FileDownloadResponse(path=path, error=_OUTSIDE_WORKSPACE_ERROR) for path in paths]
135
+
136
+
137
+ class _ReadOnlySkillsBackend(FilesystemBackend):
138
+ """Expose application skills to file tools without write operations."""
139
+
140
+ def __init__(self, root: Path) -> None:
141
+ super().__init__(root_dir=root, virtual_mode=True, max_file_size_mb=10)
142
+
143
+ def write(self, file_path: str, content: str) -> WriteResult:
144
+ return WriteResult(error="Permission denied: skills are read-only")
145
+
146
+ def edit(self, file_path: str, old_string: str, new_string: str,
147
+ replace_all: bool = False) -> EditResult:
148
+ return EditResult(error="Permission denied: skills are read-only")
149
+
150
+ def delete(self, file_path: str) -> DeleteResult:
151
+ return DeleteResult(error="Permission denied: skills are read-only")
152
+
153
+ def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
154
+ return [FileUploadResponse(path=path, error="Permission denied: skills are read-only")
155
+ for path, _ in files]
156
+
157
+
158
+ class WorkspaceCompositeBackend(CompositeBackend, SandboxBackendProtocol):
159
+ """Expose workspace and read-only skills while delegating execution."""
160
+
161
+ def __init__(self, executor: SandboxBackendProtocol, *, skills_dir: Path | None) -> None:
162
+ routes: dict[str, BackendProtocol] = {f"{SANDBOX_ROOT}/": executor}
163
+ if skills_dir is not None:
164
+ routes[f"{SKILLS_ROOT}/"] = _ReadOnlySkillsBackend(skills_dir)
165
+ super().__init__(
166
+ default=_OutsideWorkspaceBackend(),
167
+ routes=routes,
168
+ artifacts_root=f"{SANDBOX_ROOT}/.deepagents",
169
+ )
170
+ self.executor = executor
171
+ self.skills_dir = skills_dir
172
+
173
+ @property
174
+ def id(self) -> str:
175
+ return self.executor.id
176
+
177
+ def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
178
+ return self.executor.execute(command, timeout=timeout)
179
+
180
+ async def aexecute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
181
+ return await self.executor.aexecute(command, timeout=timeout)
182
+
183
+
184
+ def sandbox_environment(config: SandboxConfig, environ: dict[str, str] | None = None) -> dict[str, str]:
185
+ source = os.environ if environ is None else environ
186
+ result = {
187
+ "PATH": FIXED_PATH,
188
+ "HOME": "/home/agent",
189
+ "PWD": SANDBOX_ROOT,
190
+ "TMPDIR": "/tmp",
191
+ }
192
+ for name in SAFE_INHERITED_ENV:
193
+ if name in source:
194
+ result[name] = source[name]
195
+ for name, value in source.items():
196
+ if name.startswith("LC_"):
197
+ result[name] = value
198
+ for name in config.env_allowlist:
199
+ if name in source:
200
+ result[name] = source[name]
201
+ result.update(config.env_set)
202
+ return result
203
+
204
+
205
+ class BubblewrapBackend(FilesystemBackend, SandboxBackendProtocol):
206
+ """Filesystem backend with command execution isolated by bubblewrap."""
207
+
208
+ def __init__(self, config: SandboxConfig, *, executable: str) -> None:
209
+ workspace = _validate_config(config)
210
+ super().__init__(root_dir=workspace, virtual_mode=True, max_file_size_mb=10)
211
+ self.config = replace(config, workspace=workspace)
212
+ self.skills_dir = _validated_skills_dir(workspace)
213
+ self.executable = executable
214
+ self._env = sandbox_environment(config)
215
+ self._sandbox_id = f"bwrap-{uuid.uuid4().hex[:8]}"
216
+
217
+ @property
218
+ def id(self) -> str:
219
+ return self._sandbox_id
220
+
221
+ def command_args(self, command: str, *, network: bool = False) -> list[str]:
222
+ if not command or not isinstance(command, str):
223
+ raise ValueError("command must be a non-empty string")
224
+ args = [
225
+ self.executable,
226
+ "--die-with-parent",
227
+ "--new-session",
228
+ "--unshare-pid",
229
+ "--unshare-ipc",
230
+ "--unshare-uts",
231
+ ]
232
+ if not network:
233
+ args.append("--unshare-net")
234
+ args += [
235
+ "--proc", "/proc",
236
+ "--dev", "/dev",
237
+ "--tmpfs", "/tmp",
238
+ "--tmpfs", "/home",
239
+ "--dir", "/home/agent",
240
+ "--dir", "/etc",
241
+ "--bind", str(self.config.workspace), SANDBOX_ROOT,
242
+ ]
243
+ for path in (*RUNTIME_PATHS, *RUNTIME_FILES):
244
+ if Path(path).exists():
245
+ args += ["--ro-bind", path, path]
246
+ if network:
247
+ for path in NETWORK_FILES:
248
+ if Path(path).exists():
249
+ args += ["--ro-bind", path, path]
250
+ for mount in self.config.extra_read_only_mounts:
251
+ args += _mount_args("--ro-bind", mount)
252
+ for mount in self.config.extra_read_write_mounts:
253
+ args += _mount_args("--bind", mount)
254
+ if self.skills_dir is not None:
255
+ args += ["--ro-bind", str(self.skills_dir), SKILLS_ROOT]
256
+ args += ["--clearenv"]
257
+ for name, value in sorted(self._env.items()):
258
+ args += ["--setenv", name, value]
259
+ args += ["--chdir", SANDBOX_ROOT, "--", "/bin/sh", "-lc", command]
260
+ return args
261
+
262
+ def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
263
+ if not command or not isinstance(command, str):
264
+ return ExecuteResponse("Error: Command must be a non-empty string.", 1, False)
265
+ from agent.network import get_execute_network
266
+
267
+ effective_timeout = _effective_timeout(timeout, self.config.timeout_seconds)
268
+ return _run_process(
269
+ self.command_args(command, network=get_execute_network()),
270
+ timeout=effective_timeout,
271
+ max_output_bytes=self.config.max_output_bytes,
272
+ workspace=self.config.workspace,
273
+ )
274
+
275
+
276
+ class UnsandboxedShellBackend(LocalShellBackend):
277
+ """Explicitly authorized host execution with a sanitized, ephemeral environment."""
278
+
279
+ def __init__(self, config: SandboxConfig) -> None:
280
+ workspace = _validate_config(config)
281
+ super().__init__(
282
+ root_dir=workspace,
283
+ virtual_mode=True,
284
+ timeout=config.timeout_seconds or 120, # Inert parent default; execute() uses our optional cap.
285
+ max_output_bytes=config.max_output_bytes,
286
+ env={},
287
+ inherit_env=False,
288
+ )
289
+ self.config = replace(config, workspace=workspace)
290
+ self._base_env = sandbox_environment(config)
291
+
292
+ def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
293
+ if not command or not isinstance(command, str):
294
+ return ExecuteResponse("Error: Command must be a non-empty string.", 1, False)
295
+ effective_timeout = _effective_timeout(timeout, self.config.timeout_seconds)
296
+ with tempfile.TemporaryDirectory(prefix="deepagent-home-") as home_dir:
297
+ temp_dir = Path(home_dir) / "tmp"
298
+ temp_dir.mkdir()
299
+ env = dict(self._base_env)
300
+ env.update({"HOME": home_dir, "PWD": str(self.config.workspace), "TMPDIR": str(temp_dir)})
301
+ return _run_process(
302
+ command,
303
+ timeout=effective_timeout,
304
+ max_output_bytes=self.config.max_output_bytes,
305
+ workspace=self.config.workspace,
306
+ cwd=self.config.workspace,
307
+ env=env,
308
+ shell=True,
309
+ )
310
+
311
+
312
+ def select_backend(config: SandboxConfig, *, check: bool = True) -> BackendSelection:
313
+ workspace = _validate_config(config)
314
+ validated = replace(config, workspace=workspace)
315
+ executable = _resolve_executable(validated.bwrap_path)
316
+ reason = (
317
+ "Bubblewrap is required but `bwrap` was not found.\n"
318
+ "Ubuntu / Debian: sudo apt install bubblewrap\n"
319
+ "deep-agent currently supports Linux and WSL2"
320
+ )
321
+ if executable:
322
+ candidate = BubblewrapBackend(validated, executable=executable)
323
+ if not check:
324
+ return BackendSelection(_workspace_backend(candidate), ExecutionMode.SANDBOXED)
325
+ probe = candidate.execute(
326
+ "true", timeout=min(5, validated.timeout_seconds) if validated.timeout_seconds else 5,
327
+ )
328
+ if probe.exit_code == 0:
329
+ return BackendSelection(_workspace_backend(candidate), ExecutionMode.SANDBOXED)
330
+ reason = f"bubblewrap preflight failed: {probe.output}"
331
+ if not validated.allow_unsandboxed:
332
+ raise SandboxUnavailableError(
333
+ f"{reason}. Install bubblewrap or set sandbox.allow_unsandboxed: true "
334
+ "in ~/.deep-agent/config.yaml."
335
+ )
336
+ warning = f"{UNSANDBOXED_WARNING} Reason: {reason}"
337
+ warnings.warn(warning, RuntimeWarning, stacklevel=2)
338
+ LOG.warning(warning)
339
+ fallback = UnsandboxedShellBackend(validated)
340
+ return BackendSelection(_workspace_backend(fallback), ExecutionMode.UNSANDBOXED, warning)
341
+
342
+
343
+ def _workspace_backend(backend: SandboxBackendProtocol) -> WorkspaceCompositeBackend:
344
+ return WorkspaceCompositeBackend(backend, skills_dir=_validated_skills_dir(backend.config.workspace))
345
+
346
+
347
+ def _validated_skills_dir(workspace: Path) -> Path | None:
348
+ source = default_skills_dir()
349
+ if source.is_symlink():
350
+ raise ValueError(f"skills directory must not be a symlink: {source}")
351
+ if not source.exists():
352
+ return None
353
+ if not source.is_dir():
354
+ raise ValueError(f"skills directory must be a directory: {source}")
355
+ resolved = source.resolve()
356
+ if resolved.is_relative_to(workspace):
357
+ raise ValueError(f"skills directory must be outside workspace: {source}")
358
+ return resolved
359
+
360
+
361
+ def _validated_workspace(workspace: Path) -> Path:
362
+ resolved = workspace.expanduser().resolve()
363
+ if not resolved.is_dir():
364
+ raise ValueError(f"sandbox workspace must be an existing directory: {resolved}")
365
+ return resolved
366
+
367
+
368
+ def _validate_config(config: SandboxConfig) -> Path:
369
+ workspace = _validated_workspace(config.workspace)
370
+ if config.timeout_seconds is not None and config.timeout_seconds <= 0:
371
+ raise ValueError(f"timeout_seconds must be positive, got {config.timeout_seconds}")
372
+ if config.max_output_bytes <= 0:
373
+ raise ValueError(f"max_output_bytes must be positive, got {config.max_output_bytes}")
374
+ _validated_skills_dir(workspace)
375
+ for mount in config.extra_read_only_mounts:
376
+ _mount_args("--ro-bind", mount)
377
+ for mount in config.extra_read_write_mounts:
378
+ _mount_args("--bind", mount)
379
+ for name in (*config.env_allowlist, *config.env_set):
380
+ if not ENV_NAME.fullmatch(name):
381
+ raise ValueError(f"invalid environment variable name: {name!r}")
382
+ return workspace
383
+
384
+
385
+ def _resolve_executable(value: str) -> str | None:
386
+ if not value.strip():
387
+ raise ValueError("bwrap_path cannot be empty")
388
+ if "/" in value:
389
+ candidate = Path(value).expanduser().resolve()
390
+ return str(candidate) if candidate.is_file() and os.access(candidate, os.X_OK) else None
391
+ return shutil.which(value)
392
+
393
+
394
+ def _mount_args(flag: str, mount: BindMount) -> list[str]:
395
+ source = mount.source.expanduser().resolve()
396
+ destination = PurePosixPath(mount.destination)
397
+ if not source.exists():
398
+ raise ValueError(f"mount source does not exist: {source}")
399
+ if not destination.is_absolute() or ".." in destination.parts:
400
+ raise ValueError(f"mount destination must be an absolute sandbox path: {mount.destination!r}")
401
+ protected = {PurePosixPath(SANDBOX_ROOT), PurePosixPath("/tmp"), PurePosixPath("/home/agent")}
402
+ if destination in protected:
403
+ raise ValueError(f"extra mount cannot replace protected destination: {destination}")
404
+ if destination == PurePosixPath(SKILLS_ROOT) or PurePosixPath(SKILLS_ROOT) in destination.parents or destination in PurePosixPath(SKILLS_ROOT).parents:
405
+ raise ValueError(f"extra mount conflicts with skills destination: {destination}")
406
+ return [flag, str(source), str(destination)]
407
+
408
+
409
+ def _effective_timeout(requested: int | None, cap: int | None) -> int | None:
410
+ if requested is not None and requested <= 0:
411
+ raise ValueError(f"timeout must be positive, got {requested}")
412
+ if cap is None:
413
+ return requested
414
+ return min(requested, cap) if requested is not None else cap
415
+
416
+
417
+ def _create_exec_log(workspace: Path) -> tuple[int, int, str]:
418
+ """Create a private log below the workspace without following directory links."""
419
+ directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
420
+ directory_fd = os.open(workspace, directory_flags)
421
+ try:
422
+ for part in (".deep-agent", "logs", "exec"):
423
+ try:
424
+ os.mkdir(part, mode=0o700, dir_fd=directory_fd)
425
+ except FileExistsError:
426
+ pass
427
+ child_fd = os.open(part, directory_flags, dir_fd=directory_fd)
428
+ os.close(directory_fd)
429
+ directory_fd = child_fd
430
+ name = f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:12]}.log"
431
+ file_fd = os.open(
432
+ name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
433
+ 0o600, dir_fd=directory_fd,
434
+ )
435
+ return file_fd, directory_fd, name
436
+ except Exception:
437
+ os.close(directory_fd)
438
+ raise
439
+
440
+
441
+ def _write_all(fd: int, data: bytes | bytearray) -> None:
442
+ view = memoryview(data)
443
+ while view:
444
+ written = os.write(fd, view)
445
+ if written <= 0:
446
+ raise OSError("could not write command output log")
447
+ view = view[written:]
448
+
449
+
450
+ def _drop_partial_character_head(data: bytearray) -> None:
451
+ """Drop continuation bytes a window trim stranded at the head.
452
+
453
+ ``del data[:-max]`` can cut a multi-byte UTF-8 character in half; the
454
+ orphaned continuation bytes at the front would decode as U+FFFD in every
455
+ snapshot and in the final tail.
456
+ """
457
+ head = 0
458
+ while head < len(data) and (data[head] & 0xC0) == 0x80:
459
+ head += 1
460
+ del data[:head]
461
+
462
+
463
+ def _decode_tail_snapshot(data: bytes) -> str:
464
+ """Decode a rolling tail without flashing split characters.
465
+
466
+ A non-final incremental decode keeps a trailing partial multi-byte
467
+ character buffered instead of replacing it with U+FFFD; the next chunk
468
+ completes it and the following snapshot shows it whole.
469
+ """
470
+ return getincrementaldecoder("utf-8")(errors="replace").decode(data)
471
+
472
+
473
+ def _run_process(
474
+ command: list[str] | str,
475
+ *,
476
+ timeout: int | None,
477
+ max_output_bytes: int,
478
+ workspace: Path | None = None,
479
+ cwd: Path | None = None,
480
+ env: dict[str, str] | None = None,
481
+ shell: bool = False,
482
+ ) -> ExecuteResponse:
483
+ ctx = get_cancel_context()
484
+ tool_call_id = ctx.tool_call_id if ctx is not None else ""
485
+ try:
486
+ process = subprocess.Popen( # noqa: S603
487
+ command,
488
+ shell=shell,
489
+ executable="/bin/sh" if shell else None,
490
+ cwd=str(cwd) if cwd else None,
491
+ env=env,
492
+ stdin=subprocess.DEVNULL,
493
+ stdout=subprocess.PIPE,
494
+ stderr=subprocess.PIPE,
495
+ text=False,
496
+ start_new_session=True,
497
+ )
498
+ except Exception as exc: # noqa: BLE001
499
+ return LocalExecuteResponse(
500
+ f"Error executing command ({type(exc).__name__}): {exc}", 1, False,
501
+ termination_reason="spawn_error",
502
+ )
503
+
504
+ if ctx is not None:
505
+ ctx.register_process(process)
506
+
507
+ deadline = time.monotonic() + timeout if timeout is not None else None
508
+ decoder: IncrementalDecoder = getincrementaldecoder("utf-8")(errors="replace")
509
+ tail_bytes = bytearray()
510
+ total_bytes = 0
511
+ truncated = False
512
+ cancelled = False
513
+ timed_out = False
514
+ log_fd: int | None = None
515
+ log_directory_fd: int | None = None
516
+ log_name: str | None = None
517
+ log_error: str | None = None
518
+ workspace_root = (workspace or cwd or Path.cwd()).resolve()
519
+ post_exit_idle_since: float | None = None
520
+
521
+ assert process.stdout is not None
522
+ assert process.stderr is not None
523
+ selector = selectors.DefaultSelector()
524
+ selector.register(process.stdout, selectors.EVENT_READ, "stdout")
525
+ selector.register(process.stderr, selectors.EVENT_READ, "stderr")
526
+
527
+ def _discard_log(exc: OSError) -> None:
528
+ nonlocal log_fd, log_error
529
+ log_error = f"{type(exc).__name__}: {exc}"
530
+ if log_fd is not None:
531
+ try:
532
+ os.close(log_fd)
533
+ except OSError:
534
+ pass
535
+ log_fd = None
536
+ if log_directory_fd is not None and log_name is not None:
537
+ try:
538
+ os.unlink(log_name, dir_fd=log_directory_fd)
539
+ except OSError:
540
+ pass
541
+
542
+ def _append_chunk(raw: bytes) -> None:
543
+ nonlocal total_bytes, truncated
544
+ nonlocal log_fd, log_directory_fd, log_name, log_error
545
+ if not raw:
546
+ return
547
+ remaining = max(0, max_output_bytes - total_bytes)
548
+ accepted = raw[:remaining] if remaining > 0 else b""
549
+ if accepted:
550
+ text = decoder.decode(accepted)
551
+ if text:
552
+ emit_tool_output(tool_call_id, text, stream="merged")
553
+ if not truncated and total_bytes + len(raw) > max_output_bytes:
554
+ truncated = True
555
+ if log_fd is None and log_error is None:
556
+ try:
557
+ log_fd, log_directory_fd, log_name = _create_exec_log(workspace_root)
558
+ _write_all(log_fd, tail_bytes)
559
+ _write_all(log_fd, raw)
560
+ except OSError as exc:
561
+ _discard_log(exc)
562
+ elif log_fd is not None:
563
+ try:
564
+ _write_all(log_fd, raw)
565
+ except OSError as exc:
566
+ _discard_log(exc)
567
+ total_bytes += len(raw)
568
+ tail_bytes.extend(raw)
569
+ if len(tail_bytes) > max_output_bytes:
570
+ del tail_bytes[:-max_output_bytes]
571
+ _drop_partial_character_head(tail_bytes)
572
+ if truncated:
573
+ # Replace the bounded in-memory view with the latest bytes after
574
+ # every chunk. The full stream continues into the workspace log.
575
+ current_tail = _decode_tail_snapshot(bytes(tail_bytes))
576
+ emit_tool_output(tool_call_id, current_tail, stream="tail_snapshot")
577
+
578
+ try:
579
+ while True:
580
+ if ctx is not None and ctx.cancelled:
581
+ cancelled = True
582
+ _kill_process_group_pid(process.pid)
583
+ elif deadline is not None and not cancelled and time.monotonic() >= deadline:
584
+ timed_out = True
585
+ _kill_process_group_pid(process.pid)
586
+ now = time.monotonic()
587
+ stopping = cancelled or timed_out or process.poll() is not None
588
+ if stopping:
589
+ if not selector.get_map():
590
+ break
591
+ if post_exit_idle_since is None:
592
+ post_exit_idle_since = now
593
+ if now - post_exit_idle_since >= 0.1:
594
+ break
595
+ wait = 0.1
596
+ if deadline is not None and not cancelled and not timed_out:
597
+ wait = max(0.0, min(0.1, deadline - now))
598
+ if post_exit_idle_since is not None:
599
+ wait = min(wait, max(0.0, 0.1 - (now - post_exit_idle_since)))
600
+ if selector.get_map():
601
+ events = selector.select(timeout=wait)
602
+ else:
603
+ time.sleep(wait)
604
+ events = []
605
+ if not events:
606
+ if stopping and not selector.get_map():
607
+ break
608
+ continue
609
+ for key, _mask in events:
610
+ try:
611
+ chunk = key.fileobj.read1(4096) # type: ignore[union-attr]
612
+ except Exception: # noqa: BLE001
613
+ chunk = b""
614
+ if chunk:
615
+ _append_chunk(chunk)
616
+ if stopping or process.poll() is not None:
617
+ post_exit_idle_since = time.monotonic()
618
+ else:
619
+ try:
620
+ selector.unregister(key.fileobj)
621
+ except Exception: # noqa: BLE001
622
+ pass
623
+ if (cancelled or timed_out or process.poll() is not None) and not selector.get_map():
624
+ break
625
+ # Final decoder flush for partial multi-byte sequences.
626
+ if not truncated:
627
+ tail = decoder.decode(b"", final=True)
628
+ if tail:
629
+ emit_tool_output(tool_call_id, tail, stream="merged")
630
+ try:
631
+ process.wait(timeout=1)
632
+ except subprocess.TimeoutExpired:
633
+ _kill_process_group_pid(process.pid)
634
+ process.wait(timeout=1)
635
+ finally:
636
+ selector.close()
637
+ process.stdout.close()
638
+ process.stderr.close()
639
+ if ctx is not None:
640
+ ctx.clear_process()
641
+ if log_fd is not None:
642
+ try:
643
+ os.fsync(log_fd)
644
+ os.close(log_fd)
645
+ log_fd = None
646
+ except OSError as exc:
647
+ _discard_log(exc)
648
+ if log_directory_fd is not None:
649
+ os.close(log_directory_fd)
650
+
651
+ output = bytes(tail_bytes).decode("utf-8", errors="replace") if tail_bytes else ""
652
+ host_log_path: str | None = None
653
+ agent_log_path: str | None = None
654
+ if truncated:
655
+ detail = f"Output truncated: showing the last {max_output_bytes} bytes."
656
+ if log_error is not None:
657
+ detail += f"\nFull output could not be saved: {log_error}"
658
+ elif log_name is not None:
659
+ relative_log_path = Path(".deep-agent/logs/exec") / log_name
660
+ host_log_path = str(workspace_root / relative_log_path)
661
+ agent_log_path = f"{SANDBOX_ROOT}/{relative_log_path.as_posix()}"
662
+ detail += f"\nFull output saved to: {host_log_path}"
663
+ detail += f"\nAgent path: {agent_log_path}"
664
+ output = f"{output}\n\n[{detail}]"
665
+
666
+ result_metadata = {
667
+ "host_log_path": host_log_path,
668
+ "agent_log_path": agent_log_path,
669
+ "log_error": log_error,
670
+ "max_output_bytes": max_output_bytes,
671
+ }
672
+
673
+ if cancelled or (ctx is not None and ctx.cancelled):
674
+ return LocalExecuteResponse(
675
+ f"{output.rstrip()}\n\nCancelled by user.".strip(), 130, truncated,
676
+ termination_reason="cancelled", **result_metadata,
677
+ )
678
+
679
+ if timed_out:
680
+ return LocalExecuteResponse(
681
+ f"{output.rstrip()}\n\nError: Command timed out after {timeout} seconds.".strip(),
682
+ 124, truncated, termination_reason="timeout", **result_metadata,
683
+ )
684
+
685
+ if not output:
686
+ output = "<no output>"
687
+ exit_code = int(process.returncode or 0)
688
+ if exit_code:
689
+ output = f"{output.rstrip()}\n\nExit code: {exit_code}"
690
+ return LocalExecuteResponse(output, exit_code, truncated, **result_metadata)
691
+
692
+
693
+ def _kill_process_group_pid(pid: int | None) -> None:
694
+ if not pid:
695
+ return
696
+ try:
697
+ os.killpg(pid, signal.SIGKILL)
698
+ except (ProcessLookupError, PermissionError, OSError):
699
+ pass