devagent-ai 0.3.1__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.
devagent/tasking.py ADDED
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from devagent.models import (
6
+ AcceptanceCriterion,
7
+ RiskLevel,
8
+ TaskSpec,
9
+ TaskType,
10
+ )
11
+
12
+
13
+ _CLASSIFIERS: tuple[tuple[TaskType, tuple[str, ...]], ...] = (
14
+ (TaskType.BUILD_FAILURE, ("build fail", "compile error", "won't build", "does not build")),
15
+ (TaskType.TEST_FAILURE, ("test fail", "failing test", "pytest error")),
16
+ (TaskType.RUNTIME_ERROR, ("traceback", "exception", "runtime error", "crash")),
17
+ (TaskType.PERFORMANCE, ("performance", "optimize", "slow", "latency", "n+1")),
18
+ (TaskType.REFACTOR, ("refactor", "restructure", "cleanup")),
19
+ (TaskType.UNIT_TEST, ("add unit test", "write tests", "test coverage")),
20
+ (TaskType.BUG_FIX, ("fix", "bug", "incorrect", "broken", "regression")),
21
+ (TaskType.FEATURE, ("add ", "implement", "support ", "feature")),
22
+ )
23
+
24
+ _HIGH_RISK = {
25
+ "auth",
26
+ "authentication",
27
+ "authorization",
28
+ "permission",
29
+ "payment",
30
+ "migration",
31
+ "crypto",
32
+ "credential",
33
+ "public api",
34
+ "concurrency",
35
+ }
36
+
37
+
38
+ def _classify(text: str) -> TaskType:
39
+ lowered = text.lower()
40
+ for task_type, needles in _CLASSIFIERS:
41
+ if any(needle in lowered for needle in needles):
42
+ return task_type
43
+ return TaskType.GENERAL_ENGINEERING_TASK
44
+
45
+
46
+ def _risk(text: str, task_type: TaskType) -> RiskLevel:
47
+ lowered = text.lower()
48
+ if any(term in lowered for term in _HIGH_RISK):
49
+ return RiskLevel.HIGH
50
+ if task_type in {TaskType.FEATURE, TaskType.PERFORMANCE, TaskType.REFACTOR}:
51
+ return RiskLevel.MEDIUM
52
+ return RiskLevel.LOW
53
+
54
+
55
+ def compile_task(requirement: str) -> TaskSpec:
56
+ goal = re.sub(r"\s+", " ", requirement).strip()
57
+ if not goal:
58
+ raise ValueError("Engineering requirement cannot be empty")
59
+ task_type = _classify(goal)
60
+ code_change = task_type is not TaskType.UNIT_TEST or "only" not in goal.lower()
61
+ requires_tests = task_type not in {TaskType.BUILD_FAILURE, TaskType.REFACTOR}
62
+
63
+ criteria = [AcceptanceCriterion(goal), AcceptanceCriterion("No unrelated behavior changes")]
64
+ if task_type in {TaskType.BUG_FIX, TaskType.RUNTIME_ERROR, TaskType.TEST_FAILURE}:
65
+ criteria.insert(1, AcceptanceCriterion("The root cause is addressed, not merely masked"))
66
+ criteria.insert(2, AcceptanceCriterion("Regression coverage exercises the failing case"))
67
+ elif task_type is TaskType.FEATURE:
68
+ criteria.insert(1, AcceptanceCriterion("The feature follows existing repository conventions"))
69
+ criteria.insert(2, AcceptanceCriterion("Normal and relevant edge-case behavior are covered"))
70
+ elif task_type is TaskType.UNIT_TEST:
71
+ criteria.insert(1, AcceptanceCriterion("Tests assert externally meaningful behavior"))
72
+ if requires_tests:
73
+ criteria.append(AcceptanceCriterion("Relevant automated tests pass"))
74
+ criteria.append(AcceptanceCriterion("Final diff and independent review pass"))
75
+
76
+ return TaskSpec(
77
+ task_type=task_type,
78
+ goal=goal,
79
+ requires_code_change=code_change,
80
+ requires_tests=requires_tests,
81
+ acceptance_criteria=criteria,
82
+ risk=_risk(goal, task_type),
83
+ )
devagent/workspace.py ADDED
@@ -0,0 +1,199 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Sequence
10
+
11
+ from devagent.artifacts import RunArtifacts
12
+ from devagent.models import FailureClass, VerificationResult
13
+ from devagent.safety import CommandPolicy, PathPolicy, SKIP_DIRECTORIES, SafetyError, is_secret_path
14
+
15
+
16
+ class Workspace:
17
+ def __init__(self, root: Path | str, artifacts: RunArtifacts, dirty_files: Sequence[str] = ()) -> None:
18
+ self.root = Path(root).resolve()
19
+ self.paths = PathPolicy(self.root)
20
+ self.artifacts = artifacts
21
+ self.dirty_files = frozenset(dirty_files)
22
+ self.revision = 0
23
+ self.modified_paths: set[str] = set()
24
+
25
+ def _relative(self, target: Path) -> str:
26
+ return target.relative_to(self.root).as_posix()
27
+
28
+ def _writable(self, relative: str) -> Path:
29
+ target = self.paths.resolve(relative)
30
+ normalized = self._relative(target)
31
+ if normalized in self.dirty_files:
32
+ raise SafetyError(f"Refusing to overwrite pre-existing developer modification: {normalized}")
33
+ return target
34
+
35
+ def list_files(self, path: str = ".", pattern: str = "*", limit: int = 250) -> list[str]:
36
+ base = self.paths.resolve(path, allow_missing=False)
37
+ candidates = [base] if base.is_file() else base.rglob("*")
38
+ results: list[str] = []
39
+ for candidate in candidates:
40
+ if len(results) >= min(limit, 12_000):
41
+ break
42
+ relative = candidate.relative_to(self.root)
43
+ try:
44
+ resolved = candidate.resolve()
45
+ resolved_relative = resolved.relative_to(self.root)
46
+ except (OSError, ValueError):
47
+ continue
48
+ if (
49
+ not resolved.is_file()
50
+ or any(part in SKIP_DIRECTORIES for part in relative.parts)
51
+ or is_secret_path(relative)
52
+ or is_secret_path(resolved_relative)
53
+ ):
54
+ continue
55
+ rendered = relative.as_posix()
56
+ if fnmatch.fnmatch(candidate.name, pattern) or fnmatch.fnmatch(rendered, pattern):
57
+ results.append(rendered)
58
+ return results
59
+
60
+ def read_file(self, path: str, max_chars: int = 60_000) -> str:
61
+ target = self.paths.resolve(path, allow_missing=False)
62
+ if not target.is_file() or target.stat().st_size > 2_000_000:
63
+ raise SafetyError(f"File is not safe bounded text: {path}")
64
+ try:
65
+ text = target.read_text(encoding="utf-8")
66
+ except UnicodeDecodeError as exc:
67
+ raise SafetyError(f"File is not UTF-8 text: {path}") from exc
68
+ return text[:max_chars] + (f"\n... truncated at {max_chars} characters" if len(text) > max_chars else "")
69
+
70
+ def search_text(self, query: str, path: str = ".", limit: int = 100) -> list[str]:
71
+ if not query.strip():
72
+ raise SafetyError("Search query cannot be empty")
73
+ matches: list[str] = []
74
+ for relative in self.list_files(path, "*", limit=1000):
75
+ try:
76
+ target = self.paths.resolve(relative, allow_missing=False)
77
+ except SafetyError:
78
+ continue
79
+ if target.stat().st_size > 2_000_000:
80
+ continue
81
+ try:
82
+ lines = target.read_text(encoding="utf-8").splitlines()
83
+ except (OSError, UnicodeDecodeError):
84
+ continue
85
+ for line_number, line in enumerate(lines, 1):
86
+ if query.casefold() in line.casefold():
87
+ matches.append(f"{relative}:{line_number}: {line[:500]}")
88
+ if len(matches) >= limit:
89
+ return matches
90
+ return matches
91
+
92
+ def write_file(self, path: str, content: str) -> None:
93
+ target = self._writable(path)
94
+ if target.exists() and not target.is_file():
95
+ raise SafetyError(f"Write target is not a file: {path}")
96
+ self.artifacts.backup(target, relative_to=self.root)
97
+ target.parent.mkdir(parents=True, exist_ok=True)
98
+ target.write_text(content, encoding="utf-8")
99
+ self.revision += 1
100
+ self.modified_paths.add(self._relative(target))
101
+ self.artifacts.record("file_written", path=path, revision=self.revision)
102
+
103
+ def replace_text(self, path: str, old: str, new: str, count: int = 1) -> None:
104
+ if not old or count < 1:
105
+ raise SafetyError("replace_text requires non-empty old text and count >= 1")
106
+ target = self._writable(path)
107
+ if not target.is_file():
108
+ raise SafetyError(f"Replace target is not a file: {path}")
109
+ source = target.read_text(encoding="utf-8")
110
+ occurrences = source.count(old)
111
+ if occurrences < count:
112
+ raise SafetyError(f"Expected {count} exact occurrence(s), found {occurrences}: {path}")
113
+ self.artifacts.backup(target, relative_to=self.root)
114
+ target.write_text(source.replace(old, new, count), encoding="utf-8")
115
+ self.revision += 1
116
+ self.modified_paths.add(self._relative(target))
117
+ self.artifacts.record("text_replaced", path=path, count=count, revision=self.revision)
118
+
119
+ def run(self, command: str | Sequence[str], *, timeout: int = 300, phase: str, baseline: bool = False) -> VerificationResult:
120
+ argv = CommandPolicy.validate(command)
121
+ timeout = max(1, min(int(timeout), 1800))
122
+ started = time.monotonic()
123
+ timed_out = False
124
+ sandbox_home = self.artifacts.root / "command-home"
125
+ sandbox_home.mkdir(exist_ok=True)
126
+ environment = {
127
+ key: value
128
+ for key, value in os.environ.items()
129
+ if key in {"PATH", "LANG", "LC_ALL", "TERM", "TMPDIR", "VIRTUAL_ENV", "PYTHONPATH", "SYSTEMROOT", "WINDIR"}
130
+ }
131
+ environment.update({"HOME": str(sandbox_home), "CI": "true", "DEVAGENT_RUN_ID": self.artifacts.run_id})
132
+ try:
133
+ completed = subprocess.run(
134
+ argv, cwd=self.root, env=environment, capture_output=True, text=True, timeout=timeout, check=False
135
+ )
136
+ exit_code: int | None = completed.returncode
137
+ stdout, stderr = completed.stdout[-24_000:], completed.stderr[-24_000:]
138
+ except subprocess.TimeoutExpired as exc:
139
+ timed_out = True
140
+ exit_code = None
141
+ stdout = (exc.stdout or "")[-24_000:] if isinstance(exc.stdout, str) else ""
142
+ stderr = (exc.stderr or "")[-24_000:] if isinstance(exc.stderr, str) else ""
143
+ duration = time.monotonic() - started
144
+ classification = None if exit_code == 0 else classify_failure(stdout, stderr, timed_out)
145
+ tests_run, tests_passed = _test_counts(stdout, stderr, exit_code)
146
+ result = VerificationResult(
147
+ command=argv,
148
+ exit_code=exit_code,
149
+ duration_seconds=duration,
150
+ stdout=stdout,
151
+ stderr=stderr,
152
+ classification=classification,
153
+ revision=self.revision,
154
+ phase=phase,
155
+ timed_out=timed_out,
156
+ baseline=baseline,
157
+ tests_run=tests_run,
158
+ tests_passed=tests_passed,
159
+ )
160
+ self.artifacts.record("command_finished", result=result)
161
+ return result
162
+
163
+
164
+ def _test_counts(stdout: str, stderr: str, exit_code: int | None) -> tuple[int | None, int | None]:
165
+ text = f"{stdout}\n{stderr}"
166
+ summary = "\n".join(text.splitlines()[-8:])
167
+ counts: dict[str, int] = {}
168
+ for value, status in re.findall(
169
+ r"(\d+)\s+(passed|failed|errors?|skipped|xfailed|xpassed)(?:\b|$)", summary, re.IGNORECASE
170
+ ):
171
+ normalized = "error" if status.casefold().startswith("error") else status.casefold()
172
+ counts[normalized] = max(counts.get(normalized, 0), int(value))
173
+ if counts:
174
+ tests_run = sum(counts.get(name, 0) for name in ("passed", "failed", "error", "skipped", "xfailed", "xpassed"))
175
+ return tests_run, counts.get("passed", 0)
176
+ unittest_match = re.search(r"Ran\s+(\d+)\s+tests?", text)
177
+ if unittest_match:
178
+ tests_run = int(unittest_match.group(1))
179
+ return tests_run, tests_run if exit_code == 0 else None
180
+ return None, None
181
+
182
+
183
+ def classify_failure(stdout: str, stderr: str, timed_out: bool = False) -> FailureClass:
184
+ if timed_out:
185
+ return FailureClass.TIMEOUT
186
+ text = f"{stdout}\n{stderr}".lower()
187
+ patterns = (
188
+ (FailureClass.SYNTAX_ERROR, ("syntaxerror", "syntax error")),
189
+ (FailureClass.IMPORT_ERROR, ("importerror", "modulenotfounderror", "cannot find module", "no module named")),
190
+ (FailureClass.TYPE_ERROR, ("typeerror", "type error", "mypy")),
191
+ (FailureClass.ASSERTION_FAILURE, ("assertionerror", "assertion failed", " failed")),
192
+ (FailureClass.DEPENDENCY_ERROR, ("command not found", "could not resolve", "package is not installed")),
193
+ (FailureClass.BUILD_ERROR, ("build failed", "compilation failed", "linker")),
194
+ (FailureClass.ENVIRONMENT_ERROR, ("permission denied", "connection refused", "credentials", "not installed")),
195
+ )
196
+ for classification, needles in patterns:
197
+ if any(needle in text for needle in needles):
198
+ return classification
199
+ return FailureClass.UNKNOWN
devagent/worktree.py ADDED
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import re
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class WorktreeSelection:
13
+ root: Path
14
+ isolated: bool
15
+ reason: str
16
+ creation_failed: bool = False
17
+
18
+
19
+ def _repository_identifier(source_root: Path) -> str:
20
+ identity = str(source_root.resolve()).encode("utf-8", errors="surrogatepass")
21
+ return "repo-" + hashlib.sha256(identity).hexdigest()[:20]
22
+
23
+
24
+ def _state_root() -> Path:
25
+ configured = os.environ.get("XDG_STATE_HOME")
26
+ if configured:
27
+ return Path(configured).expanduser().resolve()
28
+ return (Path.home() / ".local" / "state").resolve()
29
+
30
+
31
+ def _active_worktrees(source_root: Path) -> tuple[list[Path], str | None]:
32
+ try:
33
+ completed = subprocess.run(
34
+ ["git", "worktree", "list", "--porcelain"],
35
+ cwd=source_root,
36
+ capture_output=True,
37
+ text=True,
38
+ timeout=15,
39
+ check=False,
40
+ )
41
+ except (OSError, subprocess.TimeoutExpired) as exc:
42
+ return [], str(exc)
43
+ if completed.returncode != 0:
44
+ reason = completed.stderr.strip() or completed.stdout.strip() or "git worktree list failed"
45
+ return [], reason
46
+ return (
47
+ [
48
+ Path(line.removeprefix("worktree ")).resolve()
49
+ for line in completed.stdout.splitlines()
50
+ if line.startswith("worktree ")
51
+ ],
52
+ None,
53
+ )
54
+
55
+
56
+ def select_worktree(
57
+ source_root: Path,
58
+ run_id: str,
59
+ *,
60
+ enabled: bool,
61
+ git_head: str | None,
62
+ dirty_files: list[str],
63
+ state_root: Path | None = None,
64
+ ) -> WorktreeSelection:
65
+ """Create a retained detached worktree outside every active Git worktree."""
66
+ source_root = source_root.expanduser().resolve()
67
+ if not enabled:
68
+ return WorktreeSelection(source_root, False, "isolation disabled by caller")
69
+ if not git_head:
70
+ return WorktreeSelection(source_root, False, "repository is not a Git worktree")
71
+ if dirty_files:
72
+ return WorktreeSelection(
73
+ source_root,
74
+ False,
75
+ "repository has pre-existing changes; dirty files remain protected",
76
+ )
77
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", run_id):
78
+ return WorktreeSelection(
79
+ source_root,
80
+ False,
81
+ "isolation unavailable: run identifier is not a safe path segment",
82
+ True,
83
+ )
84
+
85
+ base = (state_root or _state_root()).expanduser().resolve()
86
+ destination = (
87
+ base / "devagent" / "worktrees" / _repository_identifier(source_root) / run_id
88
+ ).resolve()
89
+ active_worktrees, enumeration_error = _active_worktrees(source_root)
90
+ if enumeration_error is not None:
91
+ return WorktreeSelection(
92
+ source_root,
93
+ False,
94
+ f"isolation unavailable: cannot enumerate active worktrees: {enumeration_error}",
95
+ True,
96
+ )
97
+ if any(
98
+ destination == active or destination.is_relative_to(active)
99
+ for active in active_worktrees
100
+ ):
101
+ return WorktreeSelection(
102
+ source_root,
103
+ False,
104
+ "isolation unavailable: retained worktree destination is inside an active worktree",
105
+ True,
106
+ )
107
+ if destination.exists():
108
+ return WorktreeSelection(
109
+ source_root,
110
+ False,
111
+ "isolation unavailable: retained worktree destination already exists",
112
+ True,
113
+ )
114
+ try:
115
+ destination.parent.mkdir(parents=True, exist_ok=True)
116
+ except OSError as exc:
117
+ return WorktreeSelection(
118
+ source_root,
119
+ False,
120
+ f"isolation unavailable: cannot create retained worktree parent: {exc}",
121
+ True,
122
+ )
123
+ try:
124
+ completed = subprocess.run(
125
+ ["git", "worktree", "add", "--detach", str(destination), git_head],
126
+ cwd=source_root,
127
+ capture_output=True,
128
+ text=True,
129
+ timeout=30,
130
+ check=False,
131
+ )
132
+ except (OSError, subprocess.TimeoutExpired) as exc:
133
+ return WorktreeSelection(
134
+ source_root,
135
+ False,
136
+ f"isolation unavailable: git worktree add failed safely: {exc}",
137
+ True,
138
+ )
139
+ if completed.returncode != 0:
140
+ reason = completed.stderr.strip() or completed.stdout.strip() or "git worktree add failed"
141
+ return WorktreeSelection(
142
+ source_root,
143
+ False,
144
+ f"isolation unavailable: {reason}",
145
+ True,
146
+ )
147
+ return WorktreeSelection(
148
+ destination,
149
+ True,
150
+ "clean Git HEAD isolated in a retained external detached worktree",
151
+ )