mycode-coding-agent 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 (121) hide show
  1. mycode/__init__.py +0 -0
  2. mycode/adapters/__init__.py +21 -0
  3. mycode/adapters/jsonl.py +692 -0
  4. mycode/agent/__init__.py +25 -0
  5. mycode/agent/events.py +111 -0
  6. mycode/agent/outcome.py +103 -0
  7. mycode/agent/progress.py +373 -0
  8. mycode/agent/runner.py +1481 -0
  9. mycode/application/__init__.py +38 -0
  10. mycode/application/agent_session.py +367 -0
  11. mycode/application/events.py +59 -0
  12. mycode/application/runtime.py +211 -0
  13. mycode/application/sessions.py +180 -0
  14. mycode/cli.py +840 -0
  15. mycode/config.py +355 -0
  16. mycode/context/__init__.py +1 -0
  17. mycode/context/artifacts.py +672 -0
  18. mycode/context/budget.py +752 -0
  19. mycode/context/builder.py +112 -0
  20. mycode/context/compact.py +795 -0
  21. mycode/context/tool_result_format.py +199 -0
  22. mycode/context/tool_result_retention.py +261 -0
  23. mycode/conversation.py +78 -0
  24. mycode/error_handling.py +481 -0
  25. mycode/event_format.py +147 -0
  26. mycode/instructions.py +285 -0
  27. mycode/llm.py +771 -0
  28. mycode/mcp/__init__.py +41 -0
  29. mycode/mcp/client.py +44 -0
  30. mycode/mcp/config.py +207 -0
  31. mycode/mcp/errors.py +302 -0
  32. mycode/mcp/manager.py +339 -0
  33. mycode/mcp/models.py +20 -0
  34. mycode/mcp/result_adapter.py +58 -0
  35. mycode/mcp/tool_adapter.py +145 -0
  36. mycode/mcp/trust.py +313 -0
  37. mycode/memory.py +570 -0
  38. mycode/memory_context.py +245 -0
  39. mycode/messages.py +63 -0
  40. mycode/observability.py +28 -0
  41. mycode/permissions.py +262 -0
  42. mycode/persistence/__init__.py +1 -0
  43. mycode/persistence/filesystem.py +291 -0
  44. mycode/persistence/project_storage.py +208 -0
  45. mycode/persistence/session_lock.py +138 -0
  46. mycode/persistence/session_store.py +503 -0
  47. mycode/presentation/__init__.py +1 -0
  48. mycode/presentation/cli/__init__.py +14 -0
  49. mycode/presentation/cli/confirmer.py +116 -0
  50. mycode/presentation/cli/mcp_trust.py +61 -0
  51. mycode/presentation/cli/presenter.py +320 -0
  52. mycode/presentation/cli/session_menu.py +146 -0
  53. mycode/presentation/cli/subagent_observer.py +124 -0
  54. mycode/presentation/command_format.py +90 -0
  55. mycode/presentation/commands.py +95 -0
  56. mycode/presentation/tui/__init__.py +6 -0
  57. mycode/presentation/tui/app.py +1351 -0
  58. mycode/presentation/tui/interactions.py +253 -0
  59. mycode/presentation/tui/presenter.py +266 -0
  60. mycode/presentation/tui/screens.py +305 -0
  61. mycode/presentation/tui/widgets.py +214 -0
  62. mycode/project.py +22 -0
  63. mycode/prompts.py +181 -0
  64. mycode/reasoning.py +40 -0
  65. mycode/session.py +86 -0
  66. mycode/skills/__init__.py +27 -0
  67. mycode/skills/builtin/database-recovery/SKILL.md +138 -0
  68. mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
  69. mycode/skills/registry.py +295 -0
  70. mycode/skills/state.py +68 -0
  71. mycode/subagents/__init__.py +1 -0
  72. mycode/subagents/audit.py +212 -0
  73. mycode/subagents/concurrency.py +124 -0
  74. mycode/subagents/contracts.py +421 -0
  75. mycode/subagents/delegate.py +80 -0
  76. mycode/subagents/delegation.py +128 -0
  77. mycode/subagents/lifecycle.py +86 -0
  78. mycode/subagents/limits.py +7 -0
  79. mycode/subagents/observability.py +150 -0
  80. mycode/subagents/persistence.py +152 -0
  81. mycode/subagents/profiles.py +184 -0
  82. mycode/subagents/prompts.py +67 -0
  83. mycode/subagents/results.py +178 -0
  84. mycode/subagents/runtime.py +528 -0
  85. mycode/subagents/snapshots.py +211 -0
  86. mycode/subagents/tool_batch.py +260 -0
  87. mycode/tools/__init__.py +81 -0
  88. mycode/tools/base.py +222 -0
  89. mycode/tools/bounds.py +14 -0
  90. mycode/tools/command_executor.py +167 -0
  91. mycode/tools/command_output.py +166 -0
  92. mycode/tools/command_risk.py +596 -0
  93. mycode/tools/defaults.py +59 -0
  94. mycode/tools/edit_file.py +524 -0
  95. mycode/tools/file_mutation.py +30 -0
  96. mycode/tools/glob.py +247 -0
  97. mycode/tools/grep.py +324 -0
  98. mycode/tools/ignore.py +122 -0
  99. mycode/tools/inspect_changes.py +269 -0
  100. mycode/tools/load_skill.py +92 -0
  101. mycode/tools/memory.py +264 -0
  102. mycode/tools/path_permissions.py +78 -0
  103. mycode/tools/patterns.py +48 -0
  104. mycode/tools/permission_metadata.py +27 -0
  105. mycode/tools/process_tree.py +166 -0
  106. mycode/tools/read_file.py +242 -0
  107. mycode/tools/read_skill_resource.py +93 -0
  108. mycode/tools/registry.py +279 -0
  109. mycode/tools/run_command.py +237 -0
  110. mycode/tools/run_skill_script.py +206 -0
  111. mycode/tools/run_validation.py +107 -0
  112. mycode/tools/submit_result.py +93 -0
  113. mycode/tools/text.py +15 -0
  114. mycode/tools/validation_command.py +377 -0
  115. mycode/tools/workspace.py +33 -0
  116. mycode/tools/write_file.py +169 -0
  117. mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
  118. mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
  119. mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  120. mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  121. mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
mycode/tools/base.py ADDED
@@ -0,0 +1,222 @@
1
+ import asyncio
2
+ import copy
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass, field
5
+ from typing import ClassVar, Generic, TypeVar
6
+
7
+ from pydantic import BaseModel, ConfigDict, ValidationError
8
+
9
+ from mycode.permissions import (
10
+ PermissionChecker,
11
+ PermissionDecision,
12
+ PermissionRequest,
13
+ ToolCapability,
14
+ ToolPermissionProfile,
15
+ ToolRisk,
16
+ )
17
+
18
+
19
+ class ToolArgs(BaseModel):
20
+ model_config = ConfigDict(extra="forbid")
21
+
22
+
23
+ ArgsT = TypeVar("ArgsT")
24
+ PydanticArgsT = TypeVar("PydanticArgsT", bound=ToolArgs)
25
+
26
+
27
+ class ToolPermissionProfileError(ValueError):
28
+ pass
29
+
30
+
31
+ class ToolArgumentValidationError(ValueError):
32
+ def __init__(self, errors: list[dict[str, object]]) -> None:
33
+ super().__init__("Invalid tool arguments")
34
+ self.errors = errors
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ToolResult:
39
+ ok: bool
40
+ content: str = ""
41
+ error: str | None = None
42
+ metadata: dict[str, object] = field(default_factory=dict)
43
+
44
+ @classmethod
45
+ def success(
46
+ cls,
47
+ content: str,
48
+ metadata: dict[str, object] | None = None,
49
+ ) -> "ToolResult":
50
+ return cls(
51
+ ok=True,
52
+ content=content,
53
+ metadata={} if metadata is None else dict(metadata),
54
+ )
55
+
56
+ @classmethod
57
+ def failure(
58
+ cls,
59
+ error: str,
60
+ metadata: dict[str, object] | None = None,
61
+ ) -> "ToolResult":
62
+ return cls(
63
+ ok=False,
64
+ error=error,
65
+ metadata={} if metadata is None else dict(metadata),
66
+ )
67
+
68
+
69
+ class BaseTool(ABC, Generic[ArgsT]):
70
+ name: ClassVar[str]
71
+ description: ClassVar[str]
72
+ capability: ClassVar[ToolCapability]
73
+ risk: ClassVar[ToolRisk]
74
+ # Tools must opt in explicitly. The scheduler also checks capability and
75
+ # risk, so a write/command/control tool cannot become concurrent by mistake.
76
+ concurrency_safe: ClassVar[bool] = False
77
+
78
+ @property
79
+ @abstractmethod
80
+ def input_schema(self) -> dict[str, object]:
81
+ """Return the JSON Schema accepted by this tool."""
82
+ raise NotImplementedError
83
+
84
+ def get_schema(self) -> dict[str, object]:
85
+ return {
86
+ "name": self.name,
87
+ "description": self.description,
88
+ "parameters": copy.deepcopy(self.input_schema),
89
+ }
90
+
91
+ def get_permission_profile(self) -> ToolPermissionProfile:
92
+ capability = getattr(self, "capability", None)
93
+ if capability is None:
94
+ raise ToolPermissionProfileError(
95
+ f"Tool must declare capability: {self.name}"
96
+ )
97
+
98
+ risk = getattr(self, "risk", None)
99
+ if risk is None:
100
+ raise ToolPermissionProfileError(
101
+ f"Tool must declare risk: {self.name}"
102
+ )
103
+
104
+ return ToolPermissionProfile(
105
+ capability=capability,
106
+ risk=risk,
107
+ )
108
+
109
+ def build_permission_request(self, args: ArgsT) -> PermissionRequest:
110
+ return PermissionRequest(
111
+ tool_name=self.name,
112
+ capability=self.capability,
113
+ action=self.name,
114
+ arguments=self.arguments_to_dict(args),
115
+ )
116
+
117
+ def check_permission(
118
+ self,
119
+ args: ArgsT,
120
+ permission_checker: PermissionChecker,
121
+ ) -> tuple[PermissionRequest, PermissionDecision]:
122
+ request = self.build_permission_request(args)
123
+ decision = permission_checker.check(
124
+ request,
125
+ self.get_permission_profile(),
126
+ )
127
+
128
+ return request, decision
129
+
130
+ @abstractmethod
131
+ def parse_arguments(self, arguments: dict[str, object]) -> ArgsT:
132
+ """Validate raw arguments and return the tool's execution value."""
133
+ raise NotImplementedError
134
+
135
+ @abstractmethod
136
+ def arguments_to_dict(self, args: ArgsT) -> dict[str, object]:
137
+ """Serialize validated arguments for the permission request."""
138
+ raise NotImplementedError
139
+
140
+ @abstractmethod
141
+ async def run_authorized_async(
142
+ self,
143
+ args: ArgsT,
144
+ decision: PermissionDecision,
145
+ ) -> ToolResult:
146
+ """Execute validated, authorized arguments through the common path."""
147
+ raise NotImplementedError
148
+
149
+
150
+ class SyncTool(BaseTool[ArgsT], Generic[ArgsT]):
151
+ """Adapt a synchronous tool implementation to the common async contract."""
152
+
153
+ def run(self, arguments: dict[str, object]) -> ToolResult:
154
+ try:
155
+ args = self.parse_arguments(arguments)
156
+ except ToolArgumentValidationError as error:
157
+ return ToolResult.failure(
158
+ error="Invalid tool arguments",
159
+ metadata={"validation_errors": error.errors},
160
+ )
161
+
162
+ return self.run_parsed(args)
163
+
164
+ def run_parsed(self, args: ArgsT) -> ToolResult:
165
+ try:
166
+ return self._run(args)
167
+ except Exception as error: # noqa: BLE001 - normalize tool boundary failures
168
+ return ToolResult.failure(
169
+ error=f"Tool execution failed: {error}",
170
+ metadata={"exception_type": type(error).__name__},
171
+ )
172
+
173
+ def run_authorized(
174
+ self,
175
+ args: ArgsT,
176
+ decision: PermissionDecision,
177
+ ) -> ToolResult:
178
+ return self.run_parsed(args)
179
+
180
+ async def run_authorized_async(
181
+ self,
182
+ args: ArgsT,
183
+ decision: PermissionDecision,
184
+ ) -> ToolResult:
185
+ return await asyncio.to_thread(self.run_authorized, args, decision)
186
+
187
+ @abstractmethod
188
+ def _run(self, args: ArgsT) -> ToolResult:
189
+ pass
190
+
191
+
192
+ class PydanticTool(SyncTool[PydanticArgsT], Generic[PydanticArgsT]):
193
+ """Base class for tools whose arguments are defined by a Pydantic model."""
194
+
195
+ args_model: ClassVar[type[PydanticArgsT]]
196
+
197
+ @property
198
+ def input_schema(self) -> dict[str, object]:
199
+ return _remove_schema_titles(self.args_model.model_json_schema())
200
+
201
+ def parse_arguments(self, arguments: dict[str, object]) -> PydanticArgsT:
202
+ try:
203
+ return self.args_model.model_validate(arguments)
204
+ except ValidationError as error:
205
+ raise ToolArgumentValidationError(error.errors()) from error
206
+
207
+ def arguments_to_dict(self, args: PydanticArgsT) -> dict[str, object]:
208
+ return args.model_dump()
209
+
210
+
211
+ def _remove_schema_titles(value: object) -> object:
212
+ if isinstance(value, dict):
213
+ return {
214
+ key: _remove_schema_titles(item)
215
+ for key, item in value.items()
216
+ if key != "title"
217
+ }
218
+
219
+ if isinstance(value, list):
220
+ return [_remove_schema_titles(item) for item in value]
221
+
222
+ return value
mycode/tools/bounds.py ADDED
@@ -0,0 +1,14 @@
1
+ """Narrow normalization for bounded read-count arguments."""
2
+
3
+
4
+ def clamp_positive_int_upper_bound(
5
+ value: object,
6
+ *,
7
+ upper_bound: int,
8
+ ) -> object:
9
+ """Clamp only genuine positive integer overflow; preserve all invalid inputs."""
10
+ if upper_bound < 1:
11
+ raise ValueError("upper_bound must be at least 1")
12
+ if type(value) is int and value > upper_bound:
13
+ return upper_bound
14
+ return value
@@ -0,0 +1,167 @@
1
+ import subprocess
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ import time
5
+
6
+ from mycode.tools.base import ToolResult
7
+ from mycode.tools.command_output import (
8
+ BoundedOutputCapture,
9
+ build_output_metadata,
10
+ finish_output_threads,
11
+ start_output_threads,
12
+ )
13
+ from mycode.tools.process_tree import (
14
+ create_process_tree_cleanup,
15
+ process_tree_popen_kwargs,
16
+ )
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class CommandExecutionArgs:
21
+ command: list[str]
22
+ timeout_seconds: float
23
+ max_output_chars: int
24
+
25
+
26
+ def execute_command(
27
+ *,
28
+ args: CommandExecutionArgs,
29
+ cwd: Path,
30
+ permission_metadata: dict[str, object],
31
+ permission_status: str,
32
+ ) -> ToolResult:
33
+ started_at = time.monotonic()
34
+ try:
35
+ process = subprocess.Popen(
36
+ args.command,
37
+ cwd=cwd,
38
+ stdin=subprocess.DEVNULL,
39
+ stdout=subprocess.PIPE,
40
+ stderr=subprocess.PIPE,
41
+ text=True,
42
+ encoding="utf-8",
43
+ errors="replace",
44
+ shell=False,
45
+ **process_tree_popen_kwargs(),
46
+ )
47
+ except OSError as error:
48
+ return ToolResult.failure(
49
+ error=f"Command failed to start: {error}",
50
+ metadata={
51
+ **permission_metadata,
52
+ "exit_code": None,
53
+ "timed_out": False,
54
+ "duration_ms": _elapsed_milliseconds(started_at),
55
+ "permission_status": permission_status,
56
+ "exception_type": type(error).__name__,
57
+ },
58
+ )
59
+
60
+ process_tree_cleanup = create_process_tree_cleanup(process)
61
+ stdout_capture = BoundedOutputCapture(args.max_output_chars)
62
+ stderr_capture = BoundedOutputCapture(args.max_output_chars)
63
+ output_threads = start_output_threads(
64
+ process=process,
65
+ stdout_capture=stdout_capture,
66
+ stderr_capture=stderr_capture,
67
+ )
68
+
69
+ timed_out = False
70
+ output_capture_complete = True
71
+ process_tree_cleanup_success = True
72
+ returncode: int | None
73
+ try:
74
+ returncode = process.wait(timeout=args.timeout_seconds)
75
+ except subprocess.TimeoutExpired:
76
+ timed_out = True
77
+ returncode = None
78
+ process_tree_cleanup_success = process_tree_cleanup.close()
79
+ if not process_tree_cleanup_success:
80
+ process.kill()
81
+ process.wait()
82
+ finally:
83
+ process_tree_cleanup_success = (
84
+ process_tree_cleanup.close() and process_tree_cleanup_success
85
+ )
86
+ output_capture_complete = finish_output_threads(process, output_threads)
87
+
88
+ stdout = stdout_capture.snapshot()
89
+ stderr = stderr_capture.snapshot()
90
+ output_metadata = build_output_metadata(stdout=stdout, stderr=stderr)
91
+
92
+ metadata = {
93
+ **permission_metadata,
94
+ **output_metadata,
95
+ "exit_code": returncode,
96
+ "timed_out": timed_out,
97
+ "duration_ms": _elapsed_milliseconds(started_at),
98
+ "output_capture_complete": output_capture_complete,
99
+ "process_tree_cleanup_method": process_tree_cleanup.method,
100
+ "process_tree_cleanup_success": process_tree_cleanup_success,
101
+ "permission_status": permission_status,
102
+ }
103
+ if process_tree_cleanup.error is not None:
104
+ metadata["process_tree_cleanup_error"] = process_tree_cleanup.error
105
+
106
+ if timed_out:
107
+ timeout_error = f"Command timed out after {args.timeout_seconds:g} seconds."
108
+ captured_output = _format_captured_output(
109
+ stdout=stdout.content,
110
+ stderr=stderr.content,
111
+ )
112
+ return ToolResult.failure(
113
+ error=(
114
+ timeout_error
115
+ if captured_output == ""
116
+ else f"{timeout_error}\n\n{captured_output}"
117
+ ),
118
+ metadata=metadata,
119
+ )
120
+
121
+ content = _format_command_result(
122
+ exit_code=returncode,
123
+ stdout=stdout.content,
124
+ stderr=stderr.content,
125
+ )
126
+
127
+ if returncode != 0:
128
+ return ToolResult.failure(
129
+ error=content,
130
+ metadata=metadata,
131
+ )
132
+
133
+ return ToolResult.success(content=content, metadata=metadata)
134
+
135
+
136
+ def _elapsed_milliseconds(started_at: float) -> int:
137
+ return max(0, round((time.monotonic() - started_at) * 1000))
138
+
139
+
140
+ def _format_command_result(
141
+ *,
142
+ exit_code: int | None,
143
+ stdout: object,
144
+ stderr: object,
145
+ ) -> str:
146
+ parts = [f"Command exited with code {exit_code}."]
147
+ captured_output = _format_captured_output(stdout=stdout, stderr=stderr)
148
+ if captured_output:
149
+ parts.extend(["", captured_output])
150
+
151
+ return "\n".join(parts)
152
+
153
+
154
+ def _format_captured_output(*, stdout: object, stderr: object) -> str:
155
+ sections: list[str] = []
156
+ final_newline = False
157
+ for label, value in (("STDOUT", stdout), ("STDERR", stderr)):
158
+ if not value:
159
+ continue
160
+ text = str(value)
161
+ final_newline = text.endswith(("\n", "\r"))
162
+ sections.append(f"{label}\n{text.rstrip(chr(10) + chr(13))}")
163
+
164
+ formatted = "\n\n".join(sections)
165
+ if formatted and final_newline:
166
+ return f"{formatted}\n"
167
+ return formatted
@@ -0,0 +1,166 @@
1
+ import subprocess
2
+ import threading
3
+ import time
4
+ from collections import deque
5
+ from dataclasses import dataclass
6
+
7
+
8
+ OUTPUT_THREAD_JOIN_TIMEOUT_SECONDS = 1.0
9
+ OUTPUT_THREAD_CLOSE_JOIN_TIMEOUT_SECONDS = 0.2
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class CapturedOutput:
14
+ content: str
15
+ chars: int
16
+ truncated: bool
17
+
18
+
19
+ class BoundedOutputCapture:
20
+ def __init__(self, max_chars: int) -> None:
21
+ self.max_chars = max_chars
22
+ self._chars = 0
23
+ self._tail_chars = 0
24
+ self._chunks: deque[str] = deque()
25
+ self._lock = threading.Lock()
26
+
27
+ def append(self, chunk: str) -> None:
28
+ if chunk == "":
29
+ return
30
+
31
+ with self._lock:
32
+ self._chars += len(chunk)
33
+ if self.max_chars == 0:
34
+ return
35
+
36
+ if len(chunk) >= self.max_chars:
37
+ self._chunks.clear()
38
+ tail_chunk = chunk[-self.max_chars :]
39
+ self._chunks.append(tail_chunk)
40
+ self._tail_chars = len(tail_chunk)
41
+ return
42
+
43
+ self._chunks.append(chunk)
44
+ self._tail_chars += len(chunk)
45
+ self._trim_to_max_chars()
46
+
47
+ def snapshot(self) -> CapturedOutput:
48
+ with self._lock:
49
+ return CapturedOutput(
50
+ content="".join(self._chunks),
51
+ chars=self._chars,
52
+ truncated=self._chars > self.max_chars,
53
+ )
54
+
55
+ def _trim_to_max_chars(self) -> None:
56
+ overflow = self._tail_chars - self.max_chars
57
+ while overflow > 0 and self._chunks:
58
+ left = self._chunks[0]
59
+ if len(left) <= overflow:
60
+ self._chunks.popleft()
61
+ self._tail_chars -= len(left)
62
+ overflow -= len(left)
63
+ continue
64
+
65
+ self._chunks[0] = left[overflow:]
66
+ self._tail_chars -= overflow
67
+ break
68
+
69
+
70
+ def start_output_threads(
71
+ *,
72
+ process: subprocess.Popen[str],
73
+ stdout_capture: BoundedOutputCapture,
74
+ stderr_capture: BoundedOutputCapture,
75
+ ) -> list[threading.Thread]:
76
+ threads: list[threading.Thread] = []
77
+ if process.stdout is not None:
78
+ threads.append(
79
+ threading.Thread(
80
+ target=_consume_output_stream,
81
+ args=(process.stdout, stdout_capture),
82
+ daemon=True,
83
+ )
84
+ )
85
+ if process.stderr is not None:
86
+ threads.append(
87
+ threading.Thread(
88
+ target=_consume_output_stream,
89
+ args=(process.stderr, stderr_capture),
90
+ daemon=True,
91
+ )
92
+ )
93
+
94
+ for thread in threads:
95
+ thread.start()
96
+
97
+ return threads
98
+
99
+
100
+ def finish_output_threads(
101
+ process: subprocess.Popen[str],
102
+ threads: list[threading.Thread],
103
+ ) -> bool:
104
+ if _join_output_threads(threads, OUTPUT_THREAD_JOIN_TIMEOUT_SECONDS):
105
+ return True
106
+
107
+ _close_process_output_streams(process)
108
+ _join_output_threads(threads, OUTPUT_THREAD_CLOSE_JOIN_TIMEOUT_SECONDS)
109
+ return False
110
+
111
+
112
+ def build_output_metadata(
113
+ *,
114
+ stdout: CapturedOutput,
115
+ stderr: CapturedOutput,
116
+ ) -> dict[str, object]:
117
+ return {
118
+ "stdout_chars": stdout.chars,
119
+ "stderr_chars": stderr.chars,
120
+ "stdout_truncated": stdout.truncated,
121
+ "stderr_truncated": stderr.truncated,
122
+ "output_truncation_strategy": "tail",
123
+ }
124
+
125
+
126
+ def _consume_output_stream(
127
+ stream: object,
128
+ capture: BoundedOutputCapture,
129
+ ) -> None:
130
+ try:
131
+ while True:
132
+ try:
133
+ chunk = stream.read(8192) # type: ignore[attr-defined]
134
+ except (OSError, ValueError):
135
+ break
136
+ if not chunk:
137
+ break
138
+ capture.append(str(chunk))
139
+ finally:
140
+ _close_stream(stream)
141
+
142
+
143
+ def _join_output_threads(
144
+ threads: list[threading.Thread],
145
+ timeout_seconds: float,
146
+ ) -> bool:
147
+ deadline = time.monotonic() + timeout_seconds
148
+ for thread in threads:
149
+ remaining = max(0.0, deadline - time.monotonic())
150
+ thread.join(timeout=remaining)
151
+
152
+ return not any(thread.is_alive() for thread in threads)
153
+
154
+
155
+ def _close_process_output_streams(process: subprocess.Popen[str]) -> None:
156
+ if process.stdout is not None:
157
+ _close_stream(process.stdout)
158
+ if process.stderr is not None:
159
+ _close_stream(process.stderr)
160
+
161
+
162
+ def _close_stream(stream: object) -> None:
163
+ try:
164
+ stream.close() # type: ignore[attr-defined]
165
+ except (OSError, ValueError):
166
+ pass