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.
- mycode/__init__.py +0 -0
- mycode/adapters/__init__.py +21 -0
- mycode/adapters/jsonl.py +692 -0
- mycode/agent/__init__.py +25 -0
- mycode/agent/events.py +111 -0
- mycode/agent/outcome.py +103 -0
- mycode/agent/progress.py +373 -0
- mycode/agent/runner.py +1481 -0
- mycode/application/__init__.py +38 -0
- mycode/application/agent_session.py +367 -0
- mycode/application/events.py +59 -0
- mycode/application/runtime.py +211 -0
- mycode/application/sessions.py +180 -0
- mycode/cli.py +840 -0
- mycode/config.py +355 -0
- mycode/context/__init__.py +1 -0
- mycode/context/artifacts.py +672 -0
- mycode/context/budget.py +752 -0
- mycode/context/builder.py +112 -0
- mycode/context/compact.py +795 -0
- mycode/context/tool_result_format.py +199 -0
- mycode/context/tool_result_retention.py +261 -0
- mycode/conversation.py +78 -0
- mycode/error_handling.py +481 -0
- mycode/event_format.py +147 -0
- mycode/instructions.py +285 -0
- mycode/llm.py +771 -0
- mycode/mcp/__init__.py +41 -0
- mycode/mcp/client.py +44 -0
- mycode/mcp/config.py +207 -0
- mycode/mcp/errors.py +302 -0
- mycode/mcp/manager.py +339 -0
- mycode/mcp/models.py +20 -0
- mycode/mcp/result_adapter.py +58 -0
- mycode/mcp/tool_adapter.py +145 -0
- mycode/mcp/trust.py +313 -0
- mycode/memory.py +570 -0
- mycode/memory_context.py +245 -0
- mycode/messages.py +63 -0
- mycode/observability.py +28 -0
- mycode/permissions.py +262 -0
- mycode/persistence/__init__.py +1 -0
- mycode/persistence/filesystem.py +291 -0
- mycode/persistence/project_storage.py +208 -0
- mycode/persistence/session_lock.py +138 -0
- mycode/persistence/session_store.py +503 -0
- mycode/presentation/__init__.py +1 -0
- mycode/presentation/cli/__init__.py +14 -0
- mycode/presentation/cli/confirmer.py +116 -0
- mycode/presentation/cli/mcp_trust.py +61 -0
- mycode/presentation/cli/presenter.py +320 -0
- mycode/presentation/cli/session_menu.py +146 -0
- mycode/presentation/cli/subagent_observer.py +124 -0
- mycode/presentation/command_format.py +90 -0
- mycode/presentation/commands.py +95 -0
- mycode/presentation/tui/__init__.py +6 -0
- mycode/presentation/tui/app.py +1351 -0
- mycode/presentation/tui/interactions.py +253 -0
- mycode/presentation/tui/presenter.py +266 -0
- mycode/presentation/tui/screens.py +305 -0
- mycode/presentation/tui/widgets.py +214 -0
- mycode/project.py +22 -0
- mycode/prompts.py +181 -0
- mycode/reasoning.py +40 -0
- mycode/session.py +86 -0
- mycode/skills/__init__.py +27 -0
- mycode/skills/builtin/database-recovery/SKILL.md +138 -0
- mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
- mycode/skills/registry.py +295 -0
- mycode/skills/state.py +68 -0
- mycode/subagents/__init__.py +1 -0
- mycode/subagents/audit.py +212 -0
- mycode/subagents/concurrency.py +124 -0
- mycode/subagents/contracts.py +421 -0
- mycode/subagents/delegate.py +80 -0
- mycode/subagents/delegation.py +128 -0
- mycode/subagents/lifecycle.py +86 -0
- mycode/subagents/limits.py +7 -0
- mycode/subagents/observability.py +150 -0
- mycode/subagents/persistence.py +152 -0
- mycode/subagents/profiles.py +184 -0
- mycode/subagents/prompts.py +67 -0
- mycode/subagents/results.py +178 -0
- mycode/subagents/runtime.py +528 -0
- mycode/subagents/snapshots.py +211 -0
- mycode/subagents/tool_batch.py +260 -0
- mycode/tools/__init__.py +81 -0
- mycode/tools/base.py +222 -0
- mycode/tools/bounds.py +14 -0
- mycode/tools/command_executor.py +167 -0
- mycode/tools/command_output.py +166 -0
- mycode/tools/command_risk.py +596 -0
- mycode/tools/defaults.py +59 -0
- mycode/tools/edit_file.py +524 -0
- mycode/tools/file_mutation.py +30 -0
- mycode/tools/glob.py +247 -0
- mycode/tools/grep.py +324 -0
- mycode/tools/ignore.py +122 -0
- mycode/tools/inspect_changes.py +269 -0
- mycode/tools/load_skill.py +92 -0
- mycode/tools/memory.py +264 -0
- mycode/tools/path_permissions.py +78 -0
- mycode/tools/patterns.py +48 -0
- mycode/tools/permission_metadata.py +27 -0
- mycode/tools/process_tree.py +166 -0
- mycode/tools/read_file.py +242 -0
- mycode/tools/read_skill_resource.py +93 -0
- mycode/tools/registry.py +279 -0
- mycode/tools/run_command.py +237 -0
- mycode/tools/run_skill_script.py +206 -0
- mycode/tools/run_validation.py +107 -0
- mycode/tools/submit_result.py +93 -0
- mycode/tools/text.py +15 -0
- mycode/tools/validation_command.py +377 -0
- mycode/tools/workspace.py +33 -0
- mycode/tools/write_file.py +169 -0
- mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
- mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
- mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|