coding-agents 0.0.1.dev0__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.
- coding_agents/__init__.py +37 -0
- coding_agents/cli.py +87 -0
- coding_agents/core/__init__.py +26 -0
- coding_agents/core/agent.py +164 -0
- coding_agents/core/budget.py +75 -0
- coding_agents/core/loop.py +76 -0
- coding_agents/core/models.py +55 -0
- coding_agents/evolution/__init__.py +0 -0
- coding_agents/evolution/parallel_planner.py +67 -0
- coding_agents/evolution/skill_miner.py +106 -0
- coding_agents/py.typed +2 -0
- coding_agents/sandbox/__init__.py +7 -0
- coding_agents/sandbox/policy.py +61 -0
- coding_agents/sandbox/worktree.py +61 -0
- coding_agents/tools/__init__.py +8 -0
- coding_agents/tools/base.py +55 -0
- coding_agents/tools/bash_tool.py +103 -0
- coding_agents/tools/patch_editor.py +111 -0
- coding_agents/verification/__init__.py +15 -0
- coding_agents/verification/fault_localizer.py +51 -0
- coding_agents/verification/oscillation.py +46 -0
- coding_agents/verification/pipeline.py +96 -0
- coding_agents-0.0.1.dev0.dist-info/METADATA +267 -0
- coding_agents-0.0.1.dev0.dist-info/RECORD +27 -0
- coding_agents-0.0.1.dev0.dist-info/WHEEL +5 -0
- coding_agents-0.0.1.dev0.dist-info/entry_points.txt +2 -0
- coding_agents-0.0.1.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Autonomous Skill Synthesis and Pattern Mining from Verified Trajectories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MinedToolStep(BaseModel):
|
|
12
|
+
tool_name: str
|
|
13
|
+
arguments: Dict[str, Any]
|
|
14
|
+
output_summary: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CandidateSkill(BaseModel):
|
|
18
|
+
name: str
|
|
19
|
+
description: str
|
|
20
|
+
trigger_pattern: str
|
|
21
|
+
steps: List[MinedToolStep] = Field(default_factory=list)
|
|
22
|
+
parameter_names: List[str] = Field(default_factory=list)
|
|
23
|
+
skill_md_content: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SkillMiner:
|
|
27
|
+
"""Extracts repeatable procedures from verified successful execution trajectories."""
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def mine_trajectory(
|
|
31
|
+
cls,
|
|
32
|
+
task_description: str,
|
|
33
|
+
turn_records: List[Dict[str, Any]],
|
|
34
|
+
skill_name_hint: Optional[str] = None,
|
|
35
|
+
) -> CandidateSkill:
|
|
36
|
+
steps: List[MinedToolStep] = []
|
|
37
|
+
param_candidates: set[str] = set()
|
|
38
|
+
|
|
39
|
+
for turn in turn_records:
|
|
40
|
+
calls = turn.get("tool_calls", [])
|
|
41
|
+
results = turn.get("tool_results", [])
|
|
42
|
+
for i, call in enumerate(calls):
|
|
43
|
+
tool_name = call.get("name", "")
|
|
44
|
+
args = call.get("arguments", {})
|
|
45
|
+
res = results[i] if i < len(results) else {}
|
|
46
|
+
output = res.get("output", "")[:200]
|
|
47
|
+
|
|
48
|
+
# Identify potential file path parameters
|
|
49
|
+
for k, v in args.items():
|
|
50
|
+
if isinstance(v, str) and ("/" in v or "." in v):
|
|
51
|
+
param_candidates.add(v)
|
|
52
|
+
|
|
53
|
+
steps.append(MinedToolStep(
|
|
54
|
+
tool_name=tool_name,
|
|
55
|
+
arguments=args,
|
|
56
|
+
output_summary=output,
|
|
57
|
+
))
|
|
58
|
+
|
|
59
|
+
clean_name = skill_name_hint or re.sub(r"[^a-z0-9_-]", "-", task_description.lower())[:24].strip("-")
|
|
60
|
+
clean_name = clean_name or "extracted-skill"
|
|
61
|
+
|
|
62
|
+
# Synthesize SKILL.md markdown
|
|
63
|
+
skill_md = [
|
|
64
|
+
"---",
|
|
65
|
+
f"name: {clean_name}",
|
|
66
|
+
f'description: "Procedural skill mined from successful task: {task_description}"',
|
|
67
|
+
"---",
|
|
68
|
+
f"# Procedure: {clean_name}",
|
|
69
|
+
"",
|
|
70
|
+
"## When to Use",
|
|
71
|
+
f"- Trigger on tasks matching: `{task_description}`",
|
|
72
|
+
"",
|
|
73
|
+
"## Ordered Steps",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
for idx, step in enumerate(steps, start=1):
|
|
77
|
+
skill_md.append(f"{idx}. Execute `{step.tool_name}` with arguments: `{step.arguments}`")
|
|
78
|
+
|
|
79
|
+
skill_md.append("")
|
|
80
|
+
skill_md.append("## Verification Checklist")
|
|
81
|
+
skill_md.append("- [ ] Run verification pipeline to confirm all assertions pass.")
|
|
82
|
+
|
|
83
|
+
candidate = CandidateSkill(
|
|
84
|
+
name=clean_name,
|
|
85
|
+
description=f"Auto-mined skill for {task_description}",
|
|
86
|
+
trigger_pattern=task_description,
|
|
87
|
+
steps=steps,
|
|
88
|
+
parameter_names=sorted(list(param_candidates)),
|
|
89
|
+
skill_md_content="\n".join(skill_md),
|
|
90
|
+
)
|
|
91
|
+
return candidate
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def export_skill_package(cls, candidate: CandidateSkill, base_output_dir: str) -> str:
|
|
95
|
+
"""Write candidate skill into standard .agents/skills/<name>/ package."""
|
|
96
|
+
skill_dir = os.path.join(base_output_dir, candidate.name)
|
|
97
|
+
os.makedirs(skill_dir, exist_ok=True)
|
|
98
|
+
os.makedirs(os.path.join(skill_dir, "scripts"), exist_ok=True)
|
|
99
|
+
os.makedirs(os.path.join(skill_dir, "references"), exist_ok=True)
|
|
100
|
+
|
|
101
|
+
skill_md_path = os.path.join(skill_dir, "SKILL.md")
|
|
102
|
+
with open(skill_md_path, "w", encoding="utf-8") as f:
|
|
103
|
+
f.write(candidate.skill_md_content)
|
|
104
|
+
|
|
105
|
+
return skill_dir
|
|
106
|
+
|
coding_agents/py.typed
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Policy governance gates and immutable test defenses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import os
|
|
7
|
+
from typing import List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PolicyGate:
|
|
11
|
+
"""Enforces execution boundaries, command deny-lists, and test file immutability."""
|
|
12
|
+
|
|
13
|
+
DEFAULT_DENY_PATTERNS: List[str] = [
|
|
14
|
+
"rm -rf /*",
|
|
15
|
+
"rm -rf ~*",
|
|
16
|
+
":(){ :|:& };:",
|
|
17
|
+
"mkfs*",
|
|
18
|
+
"dd if=*",
|
|
19
|
+
"chmod -R 777 /*",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
DEFAULT_IMMUTABLE_PATHS: List[str] = [
|
|
23
|
+
"tests/*",
|
|
24
|
+
"test/*",
|
|
25
|
+
"spec/*",
|
|
26
|
+
".github/workflows/*",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
deny_patterns: Optional[List[str]] = None,
|
|
32
|
+
immutable_paths: Optional[List[str]] = None,
|
|
33
|
+
lock_tests_during_repair: bool = True,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.deny_patterns = deny_patterns or self.DEFAULT_DENY_PATTERNS
|
|
36
|
+
self.immutable_paths = immutable_paths or self.DEFAULT_IMMUTABLE_PATHS
|
|
37
|
+
self.lock_tests_during_repair = lock_tests_during_repair
|
|
38
|
+
|
|
39
|
+
def check_command(self, cmd: str) -> Tuple[bool, Optional[str]]:
|
|
40
|
+
"""Verify if a shell command violates security policies."""
|
|
41
|
+
cleaned = cmd.strip()
|
|
42
|
+
for pattern in self.deny_patterns:
|
|
43
|
+
if fnmatch.fnmatch(cleaned, pattern) or pattern in cleaned:
|
|
44
|
+
return False, f"Policy violation: Command '{cmd}' matches forbidden pattern '{pattern}'"
|
|
45
|
+
return True, None
|
|
46
|
+
|
|
47
|
+
def check_file_mutation(self, file_path: str) -> Tuple[bool, Optional[str]]:
|
|
48
|
+
"""Verify if a file path is protected by test immutability rules."""
|
|
49
|
+
if not self.lock_tests_during_repair:
|
|
50
|
+
return True, None
|
|
51
|
+
|
|
52
|
+
normalized = file_path.replace("\\", "/").lstrip("./")
|
|
53
|
+
for pattern in self.immutable_paths:
|
|
54
|
+
if fnmatch.fnmatch(normalized, pattern) or fnmatch.fnmatch(os.path.basename(normalized), pattern):
|
|
55
|
+
return (
|
|
56
|
+
False,
|
|
57
|
+
f"Policy violation: File '{file_path}' is protected by immutable test boundary. "
|
|
58
|
+
"You cannot edit test assertions during a bug-fix repair loop.",
|
|
59
|
+
)
|
|
60
|
+
return True, None
|
|
61
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Ephemeral Git worktree sandboxing for safe workspace isolation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WorktreeSandbox:
|
|
13
|
+
"""Manages ephemeral git worktree checkouts for isolated agent execution."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, repo_dir: str = ".", base_ref: str = "HEAD") -> None:
|
|
16
|
+
self.repo_dir = os.path.abspath(repo_dir)
|
|
17
|
+
self.base_ref = base_ref
|
|
18
|
+
self.worktree_dir: Optional[str] = None
|
|
19
|
+
self.branch_name: Optional[str] = None
|
|
20
|
+
|
|
21
|
+
def __enter__(self) -> WorktreeSandbox:
|
|
22
|
+
self.setup()
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
26
|
+
self.cleanup()
|
|
27
|
+
|
|
28
|
+
def setup(self) -> str:
|
|
29
|
+
"""Create an ephemeral worktree in a temporary directory."""
|
|
30
|
+
unique_id = uuid.uuid4().hex[:8]
|
|
31
|
+
self.branch_name = f"agent-sandbox-{unique_id}"
|
|
32
|
+
self.worktree_dir = os.path.join(self.repo_dir, ".worktrees", self.branch_name)
|
|
33
|
+
|
|
34
|
+
os.makedirs(os.path.dirname(self.worktree_dir), exist_ok=True)
|
|
35
|
+
|
|
36
|
+
cmd = [
|
|
37
|
+
"git", "worktree", "add", "-b", self.branch_name,
|
|
38
|
+
self.worktree_dir, self.base_ref
|
|
39
|
+
]
|
|
40
|
+
subprocess.run(cmd, cwd=self.repo_dir, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
41
|
+
return self.worktree_dir
|
|
42
|
+
|
|
43
|
+
def cleanup(self) -> None:
|
|
44
|
+
"""Remove worktree and delete the ephemeral branch."""
|
|
45
|
+
if self.worktree_dir and os.path.exists(self.worktree_dir):
|
|
46
|
+
subprocess.run(
|
|
47
|
+
["git", "worktree", "remove", "--force", self.worktree_dir],
|
|
48
|
+
cwd=self.repo_dir,
|
|
49
|
+
stdout=subprocess.PIPE,
|
|
50
|
+
stderr=subprocess.PIPE,
|
|
51
|
+
)
|
|
52
|
+
shutil.rmtree(self.worktree_dir, ignore_errors=True)
|
|
53
|
+
|
|
54
|
+
if self.branch_name:
|
|
55
|
+
subprocess.run(
|
|
56
|
+
["git", "branch", "-D", self.branch_name],
|
|
57
|
+
cwd=self.repo_dir,
|
|
58
|
+
stdout=subprocess.PIPE,
|
|
59
|
+
stderr=subprocess.PIPE,
|
|
60
|
+
)
|
|
61
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Tool interfaces and reference implementations."""
|
|
2
|
+
|
|
3
|
+
from coding_agents.tools.base import BaseTool
|
|
4
|
+
from coding_agents.tools.patch_editor import PatchEditor
|
|
5
|
+
from coding_agents.tools.bash_tool import BashTool
|
|
6
|
+
|
|
7
|
+
__all__ = ["BaseTool", "PatchEditor", "BashTool"]
|
|
8
|
+
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Base protocol and interfaces for tools and skills."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from coding_agents.core.models import ToolResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseTool(ABC):
|
|
12
|
+
"""Abstract base class for all agent tools."""
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def name(self) -> str:
|
|
17
|
+
"""Tool name presented to the model."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def description(self) -> str:
|
|
23
|
+
"""Instructional schema docstring for the model."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def parameters_schema(self) -> Dict[str, Any]:
|
|
29
|
+
"""JSON schema defining accepted parameters."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def execute(self, **kwargs: Any) -> ToolResult:
|
|
34
|
+
"""Execute the tool in the environment and return structured result."""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
def to_openai_schema(self) -> Dict[str, Any]:
|
|
38
|
+
"""Format as standard OpenAI function definition."""
|
|
39
|
+
return {
|
|
40
|
+
"type": "function",
|
|
41
|
+
"function": {
|
|
42
|
+
"name": self.name,
|
|
43
|
+
"description": self.description,
|
|
44
|
+
"parameters": self.parameters_schema,
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def to_anthropic_schema(self) -> Dict[str, Any]:
|
|
49
|
+
"""Format as Anthropic tool definition."""
|
|
50
|
+
return {
|
|
51
|
+
"name": self.name,
|
|
52
|
+
"description": self.description,
|
|
53
|
+
"input_schema": self.parameters_schema,
|
|
54
|
+
}
|
|
55
|
+
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Safe subprocess execution tool with output truncation and timeouts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
from coding_agents.tools.base import BaseTool
|
|
9
|
+
from coding_agents.core.models import ToolResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BashTool(BaseTool):
|
|
13
|
+
"""Subprocess execution tool bounded by timeout and token/byte caps."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
working_dir: str = ".",
|
|
18
|
+
timeout_seconds: int = 60,
|
|
19
|
+
max_output_bytes: int = 32_000,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.working_dir = os.path.abspath(working_dir)
|
|
22
|
+
self.timeout_seconds = timeout_seconds
|
|
23
|
+
self.max_output_bytes = max_output_bytes
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def name(self) -> str:
|
|
27
|
+
return "bash"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def description(self) -> str:
|
|
31
|
+
return (
|
|
32
|
+
"Run a command in the bash shell. "
|
|
33
|
+
"Returns stdout, stderr, and the process exit code. "
|
|
34
|
+
"Output is automatically truncated if it exceeds maximum byte limits."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def parameters_schema(self) -> Dict[str, Any]:
|
|
39
|
+
return {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"properties": {
|
|
42
|
+
"command": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "The exact shell command line to run",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
"required": ["command"],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def execute(self, command: str, **kwargs: Any) -> ToolResult:
|
|
51
|
+
try:
|
|
52
|
+
proc = subprocess.run(
|
|
53
|
+
command,
|
|
54
|
+
shell=True,
|
|
55
|
+
cwd=self.working_dir,
|
|
56
|
+
stdout=subprocess.PIPE,
|
|
57
|
+
stderr=subprocess.PIPE,
|
|
58
|
+
text=True,
|
|
59
|
+
timeout=self.timeout_seconds,
|
|
60
|
+
)
|
|
61
|
+
stdout = proc.stdout
|
|
62
|
+
stderr = proc.stderr
|
|
63
|
+
exit_code = proc.returncode
|
|
64
|
+
except subprocess.TimeoutExpired:
|
|
65
|
+
return ToolResult(
|
|
66
|
+
name=self.name,
|
|
67
|
+
output="",
|
|
68
|
+
error=f"Error: Command timed out after {self.timeout_seconds} seconds.",
|
|
69
|
+
exit_code=124,
|
|
70
|
+
)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
return ToolResult(
|
|
73
|
+
name=self.name,
|
|
74
|
+
output="",
|
|
75
|
+
error=f"Error executing command: {str(e)}",
|
|
76
|
+
exit_code=1,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
combined = stdout
|
|
80
|
+
if stderr:
|
|
81
|
+
combined += f"\n[stderr]\n{stderr}"
|
|
82
|
+
|
|
83
|
+
truncated = False
|
|
84
|
+
if len(combined.encode("utf-8")) > self.max_output_bytes:
|
|
85
|
+
# Keep first 20% and last 80% of allowed budget
|
|
86
|
+
head_bytes = int(self.max_output_bytes * 0.2)
|
|
87
|
+
tail_bytes = int(self.max_output_bytes * 0.8)
|
|
88
|
+
combined_bytes = combined.encode("utf-8", errors="replace")
|
|
89
|
+
combined = (
|
|
90
|
+
combined_bytes[:head_bytes].decode("utf-8", errors="replace")
|
|
91
|
+
+ f"\n\n... [Truncated {len(combined_bytes) - self.max_output_bytes} bytes] ...\n\n"
|
|
92
|
+
+ combined_bytes[-tail_bytes:].decode("utf-8", errors="replace")
|
|
93
|
+
)
|
|
94
|
+
truncated = True
|
|
95
|
+
|
|
96
|
+
return ToolResult(
|
|
97
|
+
name=self.name,
|
|
98
|
+
output=combined,
|
|
99
|
+
error=stderr if exit_code != 0 else None,
|
|
100
|
+
exit_code=exit_code,
|
|
101
|
+
truncated=truncated,
|
|
102
|
+
)
|
|
103
|
+
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Exact-match string replacement tool with uniqueness collision protection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
from coding_agents.tools.base import BaseTool
|
|
8
|
+
from coding_agents.core.models import ToolResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PatchEditor(BaseTool):
|
|
12
|
+
"""File editing tool using strict exact-match substring replacement."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, working_dir: str = ".") -> None:
|
|
15
|
+
self.working_dir = os.path.abspath(working_dir)
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def name(self) -> str:
|
|
19
|
+
return "edit_file_exact"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def description(self) -> str:
|
|
23
|
+
return (
|
|
24
|
+
"Replace an exact substring in a file with new content. "
|
|
25
|
+
"The target string must be unique in the file to prevent ambiguous edits. "
|
|
26
|
+
"Include sufficient surrounding context lines to ensure uniqueness."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def parameters_schema(self) -> Dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"properties": {
|
|
34
|
+
"path": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"description": "Relative or absolute path to the file to edit",
|
|
37
|
+
},
|
|
38
|
+
"old_str": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"description": "Exact substring to replace. Must occur exactly once in the file.",
|
|
41
|
+
},
|
|
42
|
+
"new_str": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "Replacement string.",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
"required": ["path", "old_str", "new_str"],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def execute(self, path: str, old_str: str, new_str: str, **kwargs: Any) -> ToolResult:
|
|
51
|
+
full_path = os.path.join(self.working_dir, path) if not os.path.isabs(path) else path
|
|
52
|
+
|
|
53
|
+
if not os.path.exists(full_path):
|
|
54
|
+
return ToolResult(
|
|
55
|
+
name=self.name,
|
|
56
|
+
output="",
|
|
57
|
+
error=f"Error: File '{path}' does not exist.",
|
|
58
|
+
exit_code=1,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
with open(full_path, "r", encoding="utf-8") as f:
|
|
63
|
+
content = f.read()
|
|
64
|
+
except Exception as e:
|
|
65
|
+
return ToolResult(
|
|
66
|
+
name=self.name,
|
|
67
|
+
output="",
|
|
68
|
+
error=f"Error reading '{path}': {str(e)}",
|
|
69
|
+
exit_code=1,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
occurrences = content.count(old_str)
|
|
73
|
+
if occurrences == 0:
|
|
74
|
+
return ToolResult(
|
|
75
|
+
name=self.name,
|
|
76
|
+
output="",
|
|
77
|
+
error=(
|
|
78
|
+
f"Error: 'old_str' not found in '{path}'. "
|
|
79
|
+
"Make sure whitespace and indentation match exactly."
|
|
80
|
+
),
|
|
81
|
+
exit_code=1,
|
|
82
|
+
)
|
|
83
|
+
elif occurrences > 1:
|
|
84
|
+
return ToolResult(
|
|
85
|
+
name=self.name,
|
|
86
|
+
output="",
|
|
87
|
+
error=(
|
|
88
|
+
f"Error: 'old_str' found {occurrences} times in '{path}'. "
|
|
89
|
+
"Ambiguous edit rejected. Add more surrounding context to disambiguate."
|
|
90
|
+
),
|
|
91
|
+
exit_code=1,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
new_content = content.replace(old_str, new_str, 1)
|
|
95
|
+
try:
|
|
96
|
+
with open(full_path, "w", encoding="utf-8") as f:
|
|
97
|
+
f.write(new_content)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
return ToolResult(
|
|
100
|
+
name=self.name,
|
|
101
|
+
output="",
|
|
102
|
+
error=f"Error writing '{path}': {str(e)}",
|
|
103
|
+
exit_code=1,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return ToolResult(
|
|
107
|
+
name=self.name,
|
|
108
|
+
output=f"Successfully edited '{path}'. Replaced 1 occurrence.",
|
|
109
|
+
exit_code=0,
|
|
110
|
+
)
|
|
111
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Verification pipelines, fault localization, and cycle detection."""
|
|
2
|
+
|
|
3
|
+
from coding_agents.verification.pipeline import VerificationPipeline, VerificationManifest, StageResult
|
|
4
|
+
from coding_agents.verification.fault_localizer import FaultLocalizer, DiagnosticFrame
|
|
5
|
+
from coding_agents.verification.oscillation import OscillationDetector
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"VerificationPipeline",
|
|
9
|
+
"VerificationManifest",
|
|
10
|
+
"StageResult",
|
|
11
|
+
"FaultLocalizer",
|
|
12
|
+
"DiagnosticFrame",
|
|
13
|
+
"OscillationDetector",
|
|
14
|
+
]
|
|
15
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Structured diagnostic fault extraction from test and compiler outputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DiagnosticFrame(BaseModel):
|
|
11
|
+
"""A localized error frame."""
|
|
12
|
+
file_path: str
|
|
13
|
+
line_number: Optional[int] = None
|
|
14
|
+
error_type: str
|
|
15
|
+
message: str
|
|
16
|
+
raw_snippet: Optional[str] = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FaultLocalizer:
|
|
20
|
+
"""Extracts compact, actionable error records from raw compiler and test runner output."""
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def parse_pytest_output(cls, output: str) -> List[DiagnosticFrame]:
|
|
24
|
+
frames: List[DiagnosticFrame] = []
|
|
25
|
+
|
|
26
|
+
# Match pytest failure headers: FAILURES / ERRORS
|
|
27
|
+
failure_pattern = re.compile(r"_{3,}\s*(.*?)\s*_{3,}\n(.*?)(?=\n_{3,}|\n={3,}|\Z)", re.DOTALL)
|
|
28
|
+
for match in failure_pattern.finditer(output):
|
|
29
|
+
test_name = match.group(1).strip()
|
|
30
|
+
trace_body = match.group(2).strip()
|
|
31
|
+
|
|
32
|
+
# Locate file and line: File "...", line 123, in ...
|
|
33
|
+
file_match = re.search(r'File "([^"]+)", line (\d+), in (\w+)', trace_body)
|
|
34
|
+
# Locate exception line
|
|
35
|
+
exc_match = re.search(r"(\w+Error|\w+Exception|AssertionError):\s*(.*)", trace_body)
|
|
36
|
+
|
|
37
|
+
file_path = file_match.group(1) if file_match else test_name
|
|
38
|
+
line_no = int(file_match.group(2)) if file_match else None
|
|
39
|
+
error_type = exc_match.group(1) if exc_match else "AssertionFailure"
|
|
40
|
+
msg = exc_match.group(2).strip() if exc_match else "Test assertion failed"
|
|
41
|
+
|
|
42
|
+
frames.append(DiagnosticFrame(
|
|
43
|
+
file_path=file_path,
|
|
44
|
+
line_number=line_no,
|
|
45
|
+
error_type=error_type,
|
|
46
|
+
message=f"[{test_name}] {error_type}: {msg}",
|
|
47
|
+
raw_snippet=trace_body[-300:] if len(trace_body) > 300 else trace_body,
|
|
48
|
+
))
|
|
49
|
+
|
|
50
|
+
return frames
|
|
51
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Cryptographic AST and diff state hashing for cycle detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import hashlib
|
|
7
|
+
from typing import List, Optional, Set
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OscillationDetector:
|
|
11
|
+
"""Detects cyclic repair states where an agent alternates between identical code mutations."""
|
|
12
|
+
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
self.seen_state_hashes: Set[str] = set()
|
|
15
|
+
self.hash_history: List[str] = []
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def hash_diff(diff_text: str) -> str:
|
|
19
|
+
"""Compute normalized cryptographic hash of candidate diff."""
|
|
20
|
+
normalized = "\n".join(line.rstrip() for line in diff_text.strip().splitlines() if line.strip())
|
|
21
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def hash_python_ast(source_code: str) -> Optional[str]:
|
|
25
|
+
"""Compute structural AST hash invariant to whitespace and formatting changes."""
|
|
26
|
+
try:
|
|
27
|
+
tree = ast.parse(source_code)
|
|
28
|
+
dumped = ast.dump(tree, annotate_fields=False, include_attributes=False)
|
|
29
|
+
return hashlib.sha256(dumped.encode("utf-8")).hexdigest()
|
|
30
|
+
except SyntaxError:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
def record_and_check(self, diff_or_code: str, is_ast: bool = False) -> bool:
|
|
34
|
+
"""Return True if the current state was already visited in this session."""
|
|
35
|
+
if is_ast:
|
|
36
|
+
state_hash = self.hash_python_ast(diff_or_code) or self.hash_diff(diff_or_code)
|
|
37
|
+
else:
|
|
38
|
+
state_hash = self.hash_diff(diff_or_code)
|
|
39
|
+
|
|
40
|
+
if state_hash in self.seen_state_hashes:
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
self.seen_state_hashes.add(state_hash)
|
|
44
|
+
self.hash_history.append(state_hash)
|
|
45
|
+
return False
|
|
46
|
+
|