devagent-ai 0.6.0__tar.gz → 0.7.0__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 (77) hide show
  1. {devagent_ai-0.6.0/devagent_ai.egg-info → devagent_ai-0.7.0}/PKG-INFO +1 -1
  2. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/__init__.py +1 -1
  3. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/discovery.py +125 -14
  4. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/orchestrator.py +82 -4
  5. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/workspace.py +51 -6
  6. {devagent_ai-0.6.0 → devagent_ai-0.7.0/devagent_ai.egg-info}/PKG-INFO +1 -1
  7. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent_ai.egg-info/SOURCES.txt +5 -0
  8. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/pyproject.toml +1 -1
  9. devagent_ai-0.7.0/tests/test_huge_monorepo_v070.py +44 -0
  10. devagent_ai-0.7.0/tests/test_migration_e2e_v070.py +160 -0
  11. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_multistack_qualification.py +62 -0
  12. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_production_v040.py +1 -1
  13. devagent_ai-0.7.0/tests/test_structural_devagent_e2e_v070.py +120 -0
  14. devagent_ai-0.7.0/tests/test_structural_operations.py +111 -0
  15. devagent_ai-0.7.0/tests/test_v070_engineering_breadth.py +119 -0
  16. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/LICENSE +0 -0
  17. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/NOTICE +0 -0
  18. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/README.md +0 -0
  19. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/agent/__init__.py +0 -0
  20. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/agent/llm.py +0 -0
  21. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/agent/loop.py +0 -0
  22. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/agent/memory.py +0 -0
  23. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/agent/prompts.py +0 -0
  24. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/agent/tools.py +0 -0
  25. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/__main__.py +0 -0
  26. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/artifacts.py +0 -0
  27. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/browser.py +0 -0
  28. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/cli.py +0 -0
  29. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/config.py +0 -0
  30. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/evaluation.py +0 -0
  31. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/memory.py +0 -0
  32. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/models.py +0 -0
  33. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/providers.py +0 -0
  34. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/qualification.py +0 -0
  35. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/realworld.py +0 -0
  36. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/report.py +0 -0
  37. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/retrieval.py +0 -0
  38. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/routing.py +0 -0
  39. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/runtime.py +0 -0
  40. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/safety.py +0 -0
  41. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/source_control.py +0 -0
  42. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/state_machine.py +0 -0
  43. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/tasking.py +0 -0
  44. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/technical_review.py +0 -0
  45. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent/worktree.py +0 -0
  46. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent_ai.egg-info/dependency_links.txt +0 -0
  47. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent_ai.egg-info/entry_points.txt +0 -0
  48. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent_ai.egg-info/requires.txt +0 -0
  49. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/devagent_ai.egg-info/top_level.txt +0 -0
  50. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/setup.cfg +0 -0
  51. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_acceptance_contract.py +0 -0
  52. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_benchmark_catalog.py +0 -0
  53. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_browser_verification.py +0 -0
  54. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_capability_discovery.py +0 -0
  55. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_cli.py +0 -0
  56. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_developer_review_report.py +0 -0
  57. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_discovery_memory.py +0 -0
  58. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_e2e_fake_provider.py +0 -0
  59. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_evaluation_harness.py +0 -0
  60. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_evaluation_matrix.py +0 -0
  61. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_evaluation_regression_evidence.py +0 -0
  62. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_functional_qualification.py +0 -0
  63. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_model_routing.py +0 -0
  64. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_multilang_technical_review.py +0 -0
  65. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_multistack_devagent_e2e.py +0 -0
  66. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_packaging_metadata.py +0 -0
  67. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_preservation_contradiction.py +0 -0
  68. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_production_hardening.py +0 -0
  69. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_realworld_benchmark.py +0 -0
  70. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_retrieval.py +0 -0
  71. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_runtime_sandbox.py +0 -0
  72. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_safety_workspace.py +0 -0
  73. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_source_control_publish.py +0 -0
  74. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_structured_provider_contract.py +0 -0
  75. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_tasking_state.py +0 -0
  76. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_workspace_environment.py +0 -0
  77. {devagent_ai-0.6.0 → devagent_ai-0.7.0}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devagent-ai
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Evidence-driven local autonomous software engineering agent
5
5
  Author: Tom Ha
6
6
  Maintainer: Tom Ha
@@ -1,3 +1,3 @@
1
1
  """DevAgent: evidence-driven local software engineering automation."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.7.0"
@@ -32,6 +32,8 @@ _LANGUAGE_EXTENSIONS = {
32
32
  ".cpp": "c++",
33
33
  ".h": "c/c++",
34
34
  ".cs": "c#",
35
+ ".fs": "f#",
36
+ ".vb": "visual-basic",
35
37
  ".rb": "ruby",
36
38
  ".php": "php",
37
39
  }
@@ -46,7 +48,11 @@ _MANIFESTS = {
46
48
  "cargo.toml",
47
49
  "go.mod",
48
50
  "pom.xml",
51
+ "mvnw",
49
52
  "build.gradle",
53
+ "build.gradle.kts",
54
+ "settings.gradle",
55
+ "settings.gradle.kts",
50
56
  "gradlew",
51
57
  "cmakelists.txt",
52
58
  "makefile",
@@ -56,6 +62,7 @@ _MANIFESTS = {
56
62
  ".gitlab-ci.yml",
57
63
  "azure-pipelines.yml",
58
64
  }
65
+ _PROJECT_SUFFIXES = {".sln", ".slnx", ".csproj", ".fsproj", ".vbproj"}
59
66
 
60
67
 
61
68
  def _fingerprint(path: Path) -> str:
@@ -121,6 +128,65 @@ def _is_safe_generated_untracked_path(path: str) -> bool:
121
128
  )
122
129
 
123
130
 
131
+ def _is_manifest(path: Path) -> bool:
132
+ return path.name.lower() in _MANIFESTS or path.suffix.lower() in _PROJECT_SUFFIXES
133
+
134
+
135
+ def _is_ci_file(path: Path, root: Path) -> bool:
136
+ relative = path.relative_to(root).as_posix()
137
+ return relative.startswith(".github/workflows/") or path.name.lower() in {
138
+ "jenkinsfile",
139
+ ".gitlab-ci.yml",
140
+ "azure-pipelines.yml",
141
+ }
142
+
143
+
144
+ def _priority_repository_files(root: Path, limit: int = 20_000) -> list[Path]:
145
+ """Recover high-value tracked manifests/CI files even after the bounded walk is full.
146
+
147
+ A huge monorepo can contain more than the normal 12k inventory budget before a deep
148
+ component is reached. Git's index is a cheap authoritative directory of tracked paths;
149
+ we inspect only manifest/project/CI candidates and keep the result bounded.
150
+ """
151
+ listed = _git(root, "ls-files")
152
+ if listed is None:
153
+ return []
154
+ priority: list[Path] = []
155
+ for raw in listed.splitlines():
156
+ if len(priority) >= limit:
157
+ break
158
+ if not raw or "\x00" in raw:
159
+ continue
160
+ relative = Path(raw)
161
+ if any(part in SKIP_DIRECTORIES for part in relative.parts) or is_secret_path(relative):
162
+ continue
163
+ candidate = root / relative
164
+ try:
165
+ resolved = candidate.resolve(strict=True)
166
+ resolved_relative = resolved.relative_to(root)
167
+ except (OSError, ValueError):
168
+ continue
169
+ if not resolved.is_file() or is_secret_path(resolved_relative):
170
+ continue
171
+ if _is_manifest(candidate) or _is_ci_file(candidate, root):
172
+ priority.append(candidate)
173
+ return priority
174
+
175
+
176
+ def _manifest_languages(path: Path) -> set[str]:
177
+ name = path.name.lower()
178
+ suffix = path.suffix.lower()
179
+ if name in {"pom.xml", "mvnw", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "gradlew"}:
180
+ return {"java"}
181
+ if suffix == ".csproj":
182
+ return {"c#"}
183
+ if suffix == ".fsproj":
184
+ return {"f#"}
185
+ if suffix == ".vbproj":
186
+ return {"visual-basic"}
187
+ return set()
188
+
189
+
124
190
  def _package_capabilities(path: Path, relative: str) -> tuple[list[str], list[Capability]]:
125
191
  frameworks: list[str] = []
126
192
  capabilities: list[Capability] = []
@@ -149,9 +215,42 @@ def _package_capabilities(path: Path, relative: str) -> tuple[list[str], list[Ca
149
215
  return sorted(set(frameworks)), capabilities
150
216
 
151
217
 
218
+ def _java_maven_command(path: Path, root: Path, component: str) -> tuple[str, ...]:
219
+ wrapper = path.parent / "mvnw"
220
+ if wrapper.is_file():
221
+ executable = "./mvnw" if component == "." else f"./{component}/mvnw"
222
+ else:
223
+ executable = "mvn"
224
+ return (executable, "test") if component == "." else (executable, "-f", path.relative_to(root).as_posix(), "test")
225
+
226
+
227
+ def _dotnet_capabilities(path: Path, root: Path, component: str) -> list[Capability]:
228
+ relative = path.relative_to(root).as_posix()
229
+ suffix = path.suffix.lower()
230
+ capabilities: list[Capability] = []
231
+ if suffix in {".sln", ".slnx"}:
232
+ capabilities.append(Capability("build", ("dotnet", "build", relative), relative, component, True))
233
+ return capabilities
234
+ if suffix not in {".csproj", ".fsproj", ".vbproj"}:
235
+ return capabilities
236
+
237
+ text = path.read_text(encoding="utf-8", errors="ignore").lower()
238
+ assets = path.parent / "obj" / "project.assets.json"
239
+ no_restore = ("--no-restore",) if assets.is_file() else ()
240
+ capabilities.append(
241
+ Capability("build", ("dotnet", "build", relative, *no_restore), relative, component, True)
242
+ )
243
+ if "<istestproject>true" in text or "microsoft.net.test.sdk" in text:
244
+ capabilities.append(
245
+ Capability("test", ("dotnet", "test", relative, *no_restore), relative, component, False)
246
+ )
247
+ return capabilities
248
+
249
+
152
250
  def _manifest_capabilities(path: Path, root: Path) -> tuple[list[str], list[Capability]]:
153
251
  relative = path.relative_to(root).as_posix()
154
252
  name = path.name.lower()
253
+ suffix = path.suffix.lower()
155
254
  frameworks: list[str] = []
156
255
  capabilities: list[Capability] = []
157
256
  component = path.parent.relative_to(root).as_posix() or "."
@@ -180,12 +279,16 @@ def _manifest_capabilities(path: Path, root: Path) -> tuple[list[str], list[Capa
180
279
  command = ("go", "test", "./...") if component == "." else ("go", "-C", component, "test", "./...")
181
280
  capabilities.append(Capability("test", command, relative, component))
182
281
  elif name == "pom.xml":
183
- command = ("mvn", "test") if component == "." else ("mvn", "-f", relative, "test")
184
- capabilities.append(Capability("test", command, relative, component))
185
- elif name in {"build.gradle", "gradlew"}:
282
+ frameworks.append("maven")
283
+ capabilities.append(Capability("test", _java_maven_command(path, root, component), relative, component))
284
+ elif name in {"build.gradle", "build.gradle.kts", "gradlew"}:
285
+ frameworks.append("gradle")
186
286
  executable = "./gradlew" if component == "." and (path.parent / "gradlew").exists() else (f"./{component}/gradlew" if (path.parent / "gradlew").exists() else "gradle")
187
287
  command = (executable, "test") if component == "." else (executable, "-p", component, "test")
188
288
  capabilities.append(Capability("test", command, relative, component))
289
+ elif suffix in _PROJECT_SUFFIXES:
290
+ frameworks.append("dotnet")
291
+ capabilities.extend(_dotnet_capabilities(path, root, component))
189
292
  elif name == "cmakelists.txt":
190
293
  build_dir = "build" if component == "." else f"{component}/build"
191
294
  capabilities.append(Capability("test", ("ctest", "--test-dir", build_dir), relative, component))
@@ -201,7 +304,7 @@ def _ci_capabilities(path: Path, root: Path) -> list[Capability]:
201
304
  """Extract simple argv commands from CI run/command lines as high-value evidence."""
202
305
  relative = path.relative_to(root).as_posix()
203
306
  commands: list[Capability] = []
204
- allowed = {"python", "python3", "pytest", "npm", "pnpm", "yarn", "go", "cargo", "mvn", "gradle", "./gradlew", "make", "ctest", "dotnet", "ruff", "mypy"}
307
+ allowed = {"python", "python3", "pytest", "npm", "pnpm", "yarn", "go", "cargo", "mvn", "gradle", "./gradlew", "./mvnw", "make", "ctest", "dotnet", "ruff", "mypy"}
205
308
  for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
206
309
  stripped = raw_line.strip()
207
310
  value = ""
@@ -414,14 +517,17 @@ def _probe_python_test_capability(
414
517
 
415
518
  def discover_repository(root: Path | str, *, probe_capabilities: bool = True) -> RepositoryModel:
416
519
  root_path = Path(root).expanduser().resolve()
417
- files = _walk(root_path)
418
- ci_files = [
419
- path
420
- for path in files
421
- if path.relative_to(root_path).as_posix().startswith(".github/workflows/")
422
- or path.name.lower() in {"jenkinsfile", ".gitlab-ci.yml", "azure-pipelines.yml"}
423
- ]
424
- manifests = [path for path in files if path.name.lower() in _MANIFESTS or path.suffix.lower() in {".sln", ".csproj"}]
520
+ walked_files = _walk(root_path)
521
+ files = list(walked_files)
522
+ seen = {path.relative_to(root_path).as_posix() for path in files}
523
+ for path in _priority_repository_files(root_path):
524
+ relative = path.relative_to(root_path).as_posix()
525
+ if relative not in seen:
526
+ files.append(path)
527
+ seen.add(relative)
528
+
529
+ ci_files = [path for path in files if _is_ci_file(path, root_path)]
530
+ manifests = [path for path in files if _is_manifest(path)]
425
531
  component_paths = {root_path}
426
532
  for manifest in manifests:
427
533
  if manifest.parent != root_path and manifest not in ci_files:
@@ -434,6 +540,7 @@ def discover_repository(root: Path | str, *, probe_capabilities: bool = True) ->
434
540
  owned_files = [path for path in files if path == component_path or component_path in path.parents]
435
541
  language_counts = Counter(_LANGUAGE_EXTENSIONS[path.suffix.lower()] for path in owned_files if path.suffix.lower() in _LANGUAGE_EXTENSIONS)
436
542
  component_manifests = [path for path in manifests if path.parent == component_path]
543
+ inferred_languages = set().union(*(_manifest_languages(path) for path in component_manifests)) if component_manifests else set()
437
544
  frameworks: list[str] = []
438
545
  capabilities: list[Capability] = []
439
546
  for manifest in component_manifests:
@@ -456,9 +563,13 @@ def discover_repository(root: Path | str, *, probe_capabilities: bool = True) ->
456
563
  if probed is not None:
457
564
  capabilities.append(probed)
458
565
  frameworks.append("pytest" if "pytest" in probed.command else "unittest")
566
+ ordered_languages = [name for name, _ in language_counts.most_common()]
567
+ for language in sorted(inferred_languages):
568
+ if language not in ordered_languages:
569
+ ordered_languages.append(language)
459
570
  component = Component(
460
571
  path=relative_component,
461
- languages=[name for name, _ in language_counts.most_common()],
572
+ languages=ordered_languages,
462
573
  frameworks=sorted(set(frameworks)),
463
574
  manifests=[path.relative_to(root_path).as_posix() for path in component_manifests],
464
575
  test_locations=_test_locations(owned_files, root_path, component_path),
@@ -501,7 +612,7 @@ def discover_repository(root: Path | str, *, probe_capabilities: bool = True) ->
501
612
  git_branch=_git(root_path, "branch", "--show-current"),
502
613
  git_head=_git(root_path, "rev-parse", "HEAD"),
503
614
  dirty_files=sorted(tracked_dirty | untracked_dirty),
504
- inventory_file_count=len(files),
615
+ inventory_file_count=len(walked_files),
505
616
  capability_diagnostics=capability_diagnostics,
506
617
  )
507
618
 
@@ -99,7 +99,58 @@ _WRITE_ACTION = {
99
99
  "required": ["tool", "arguments"],
100
100
  "additionalProperties": False,
101
101
  }
102
- _ACTION = {"anyOf": [_REPLACE_ACTION, _COUNTED_REPLACE_ACTION, _WRITE_ACTION]}
102
+ _DELETE_ACTION = {
103
+ "type": "object",
104
+ "properties": {
105
+ "tool": {"type": "string", "const": "delete_file"},
106
+ "arguments": {
107
+ "type": "object",
108
+ "properties": {"path": _NON_EMPTY_STRING},
109
+ "required": ["path"],
110
+ "additionalProperties": False,
111
+ },
112
+ },
113
+ "required": ["tool", "arguments"],
114
+ "additionalProperties": False,
115
+ }
116
+ _MOVE_ACTION = {
117
+ "type": "object",
118
+ "properties": {
119
+ "tool": {"type": "string", "const": "move_file"},
120
+ "arguments": {
121
+ "type": "object",
122
+ "properties": {"source": _NON_EMPTY_STRING, "destination": _NON_EMPTY_STRING},
123
+ "required": ["source", "destination"],
124
+ "additionalProperties": False,
125
+ },
126
+ },
127
+ "required": ["tool", "arguments"],
128
+ "additionalProperties": False,
129
+ }
130
+ _RENAME_ACTION = {
131
+ "type": "object",
132
+ "properties": {
133
+ "tool": {"type": "string", "const": "rename_file"},
134
+ "arguments": {
135
+ "type": "object",
136
+ "properties": {"source": _NON_EMPTY_STRING, "destination": _NON_EMPTY_STRING},
137
+ "required": ["source", "destination"],
138
+ "additionalProperties": False,
139
+ },
140
+ },
141
+ "required": ["tool", "arguments"],
142
+ "additionalProperties": False,
143
+ }
144
+ _ACTION = {
145
+ "anyOf": [
146
+ _REPLACE_ACTION,
147
+ _COUNTED_REPLACE_ACTION,
148
+ _WRITE_ACTION,
149
+ _DELETE_ACTION,
150
+ _MOVE_ACTION,
151
+ _RENAME_ACTION,
152
+ ]
153
+ }
103
154
 
104
155
  UNDERSTANDING_SCHEMA = {
105
156
  "type": "object",
@@ -310,12 +361,35 @@ def _execute_actions(workspace: Workspace, response: dict[str, Any], allowed_pat
310
361
  if not isinstance(actions, list) or not actions:
311
362
  raise OrchestrationError("Implementation requires at least one structured action")
312
363
  changed: list[str] = []
364
+ allowed_tools = {"replace_text", "write_file", "delete_file", "move_file", "rename_file"}
313
365
  for action in actions:
314
366
  if not isinstance(action, dict) or set(action) != {"tool", "arguments"} or not isinstance(action["arguments"], dict):
315
367
  raise OrchestrationError("Every action must contain only tool and arguments")
316
368
  tool, arguments = action["tool"], action["arguments"]
317
- if tool not in {"replace_text", "write_file"}:
369
+ if tool not in allowed_tools:
318
370
  raise OrchestrationError(f"Implementation tool is not allowed: {tool}")
371
+
372
+ if tool in {"move_file", "rename_file"}:
373
+ if set(arguments) != {"source", "destination"}:
374
+ raise OrchestrationError(f"{tool} requires only string source and destination")
375
+ source = arguments.get("source")
376
+ destination = arguments.get("destination")
377
+ if (
378
+ not isinstance(source, str)
379
+ or not isinstance(destination, str)
380
+ or source not in allowed_paths
381
+ or destination not in allowed_paths
382
+ ):
383
+ raise OrchestrationError(
384
+ f"Structural source/destination was not inspected/planned: {source} -> {destination}"
385
+ )
386
+ if tool == "move_file":
387
+ workspace.move_file(source, destination)
388
+ else:
389
+ workspace.rename_file(source, destination)
390
+ changed.extend((source, destination))
391
+ continue
392
+
319
393
  path = arguments.get("path")
320
394
  if not isinstance(path, str) or path not in allowed_paths:
321
395
  raise OrchestrationError(f"Action path was not inspected/planned: {path}")
@@ -329,12 +403,16 @@ def _execute_actions(workspace: Workspace, response: dict[str, Any], allowed_pat
329
403
  if isinstance(count, bool) or not isinstance(count, int) or count < 1:
330
404
  raise OrchestrationError("replace_text count must be an integer greater than zero")
331
405
  workspace.replace_text(path, arguments["old"], arguments["new"], count)
332
- else:
406
+ elif tool == "write_file":
333
407
  if set(arguments) != {"path", "content"} or not isinstance(arguments.get("content"), str):
334
408
  raise OrchestrationError("write_file requires only string path and content")
335
409
  workspace.write_file(path, arguments["content"])
410
+ else:
411
+ if set(arguments) != {"path"}:
412
+ raise OrchestrationError("delete_file requires only string path")
413
+ workspace.delete_file(path)
336
414
  changed.append(path)
337
- return changed
415
+ return list(dict.fromkeys(changed))
338
416
 
339
417
 
340
418
  def _diff(root: Path, workspace: Workspace, max_chars: int = 40_000) -> str:
@@ -146,6 +146,57 @@ class Workspace:
146
146
  self.modified_paths.add(self._relative(target))
147
147
  self.artifacts.record("text_replaced", path=path, count=count, revision=self.revision)
148
148
 
149
+ def _reject_structural_symlinks(self, path: str) -> None:
150
+ lexical = self.root
151
+ for part in Path(path).parts:
152
+ lexical = lexical / part
153
+ if lexical.is_symlink():
154
+ raise SafetyError(f"Structural file operations do not follow symlinks: {path}")
155
+
156
+ def _structural_file(self, path: str) -> Path:
157
+ self._reject_structural_symlinks(path)
158
+ target = self._writable(path)
159
+ if not target.exists() or not target.is_file():
160
+ raise SafetyError(f"Structural operation requires an existing regular file: {path}")
161
+ return target
162
+
163
+ def delete_file(self, path: str) -> None:
164
+ """Delete one planned regular file after preserving a run-local backup."""
165
+ target = self._structural_file(path)
166
+ relative = self._relative(target)
167
+ self.artifacts.backup(target, relative_to=self.root)
168
+ target.unlink()
169
+ self.revision += 1
170
+ self.modified_paths.add(relative)
171
+ self.artifacts.record("file_deleted", path=relative, revision=self.revision)
172
+
173
+ def move_file(self, source: str, destination: str, *, operation: str = "move") -> None:
174
+ """Move/rename one planned regular file without overwriting existing content."""
175
+ source_target = self._structural_file(source)
176
+ self._reject_structural_symlinks(destination)
177
+ destination_target = self._writable(destination)
178
+ source_relative = self._relative(source_target)
179
+ destination_relative = self._relative(destination_target)
180
+ if source_relative == destination_relative:
181
+ raise SafetyError("Structural source and destination must be different")
182
+ if destination_target.exists():
183
+ raise SafetyError(f"Refusing to overwrite structural destination: {destination_relative}")
184
+ self.artifacts.backup(source_target, relative_to=self.root)
185
+ destination_target.parent.mkdir(parents=True, exist_ok=True)
186
+ source_target.replace(destination_target)
187
+ self.revision += 1
188
+ self.modified_paths.update({source_relative, destination_relative})
189
+ self.artifacts.record(
190
+ "file_moved",
191
+ source=source_relative,
192
+ destination=destination_relative,
193
+ operation=operation,
194
+ revision=self.revision,
195
+ )
196
+
197
+ def rename_file(self, source: str, destination: str) -> None:
198
+ self.move_file(source, destination, operation="rename")
199
+
149
200
  def _validate_python_requirement_file(self, target: Path) -> None:
150
201
  if target.stat().st_size > 1_000_000:
151
202
  raise SafetyError(f"Dependency requirement file is too large: {self._relative(target)}")
@@ -258,9 +309,6 @@ class Workspace:
258
309
  for key, value in os.environ.items()
259
310
  if key in {"PATH", "LANG", "LC_ALL", "TERM", "VIRTUAL_ENV", "PYTHONPATH", "SYSTEMROOT", "WINDIR"}
260
311
  }
261
- # Keep HOME sandboxed so package-manager and cloud credentials are not inherited.
262
- # Rustup-managed cargo/rustc binaries still need the installed toolchain directory,
263
- # which is safe to expose separately from CARGO_HOME and registry credentials.
264
312
  rustup_home = os.environ.get("RUSTUP_HOME")
265
313
  if rustup_home:
266
314
  environment["RUSTUP_HOME"] = rustup_home
@@ -284,9 +332,6 @@ class Workspace:
284
332
  }
285
333
  )
286
334
  if dependency_install and Path(argv[0]).name.lower() == "yarn":
287
- # Yarn Classic understands ignore-scripts; Yarn Modern uses enableScripts
288
- # and rejects the old --ignore-scripts CLI flag. Supplying both environment
289
- # settings is safe: each generation consumes the setting it understands.
290
335
  environment["YARN_IGNORE_SCRIPTS"] = "true"
291
336
  environment["YARN_ENABLE_SCRIPTS"] = "false"
292
337
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devagent-ai
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Evidence-driven local autonomous software engineering agent
5
5
  Author: Tom Ha
6
6
  Maintainer: Tom Ha
@@ -51,6 +51,8 @@ tests/test_evaluation_harness.py
51
51
  tests/test_evaluation_matrix.py
52
52
  tests/test_evaluation_regression_evidence.py
53
53
  tests/test_functional_qualification.py
54
+ tests/test_huge_monorepo_v070.py
55
+ tests/test_migration_e2e_v070.py
54
56
  tests/test_model_routing.py
55
57
  tests/test_multilang_technical_review.py
56
58
  tests/test_multistack_devagent_e2e.py
@@ -64,7 +66,10 @@ tests/test_retrieval.py
64
66
  tests/test_runtime_sandbox.py
65
67
  tests/test_safety_workspace.py
66
68
  tests/test_source_control_publish.py
69
+ tests/test_structural_devagent_e2e_v070.py
70
+ tests/test_structural_operations.py
67
71
  tests/test_structured_provider_contract.py
68
72
  tests/test_tasking_state.py
73
+ tests/test_v070_engineering_breadth.py
69
74
  tests/test_workspace_environment.py
70
75
  tests/test_worktree.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devagent-ai"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "Evidence-driven local autonomous software engineering agent"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from devagent.discovery import discover_repository
10
+
11
+
12
+ def _production_only() -> None:
13
+ if os.getenv("DEVAGENT_PRODUCTION_QUALIFICATION") != "1":
14
+ pytest.skip("huge-monorepo stress runs only through the production qualification gate")
15
+
16
+
17
+ def test_deep_dotnet_manifest_is_recovered_beyond_12000_file_walk_frontier(tmp_path: Path) -> None:
18
+ _production_only()
19
+ filler = tmp_path / "aaa"
20
+ filler.mkdir()
21
+ for index in range(12_025):
22
+ (filler / f"f{index:05d}.txt").write_text("", encoding="utf-8")
23
+
24
+ deep = tmp_path / "zzz" / "service"
25
+ deep.mkdir(parents=True)
26
+ (deep / "Service.csproj").write_text(
27
+ '<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0</TargetFramework>'
28
+ '</PropertyGroup></Project>\n',
29
+ encoding="utf-8",
30
+ )
31
+ (deep / "Service.cs").write_text(
32
+ "namespace Deep; public static class Service { public static int Value => 1; }\n",
33
+ encoding="utf-8",
34
+ )
35
+ subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
36
+ subprocess.run(["git", "add", "."], cwd=tmp_path, check=True)
37
+
38
+ repository = discover_repository(tmp_path, probe_capabilities=False)
39
+
40
+ assert repository.inventory_file_count == 12_000
41
+ component = next(item for item in repository.components if item.path == "zzz/service")
42
+ assert "zzz/service/Service.csproj" in component.manifests
43
+ assert "c#" in component.languages
44
+ assert any(item.command == ("dotnet", "build", "zzz/service/Service.csproj") for item in component.capabilities)