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.
- agent/__init__.py +5 -0
- agent/llm.py +6 -0
- agent/loop.py +16 -0
- agent/memory.py +5 -0
- agent/prompts.py +3 -0
- agent/tools.py +6 -0
- devagent/__init__.py +3 -0
- devagent/__main__.py +4 -0
- devagent/artifacts.py +46 -0
- devagent/cli.py +164 -0
- devagent/config.py +72 -0
- devagent/discovery.py +504 -0
- devagent/evaluation.py +388 -0
- devagent/memory.py +51 -0
- devagent/models.py +251 -0
- devagent/orchestrator.py +887 -0
- devagent/providers.py +344 -0
- devagent/report.py +52 -0
- devagent/retrieval.py +478 -0
- devagent/safety.py +131 -0
- devagent/state_machine.py +45 -0
- devagent/tasking.py +83 -0
- devagent/workspace.py +199 -0
- devagent/worktree.py +151 -0
- devagent_ai-0.3.1.dist-info/METADATA +415 -0
- devagent_ai-0.3.1.dist-info/RECORD +31 -0
- devagent_ai-0.3.1.dist-info/WHEEL +5 -0
- devagent_ai-0.3.1.dist-info/entry_points.txt +2 -0
- devagent_ai-0.3.1.dist-info/licenses/LICENSE +21 -0
- devagent_ai-0.3.1.dist-info/licenses/NOTICE +10 -0
- devagent_ai-0.3.1.dist-info/top_level.txt +2 -0
agent/__init__.py
ADDED
agent/llm.py
ADDED
agent/loop.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Compatibility wrapper around the deterministic DevAgent orchestrator."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from devagent.config import ProviderConfig, load_config
|
|
7
|
+
from devagent.orchestrator import run_devagent
|
|
8
|
+
from devagent.providers import create_provider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_bugfix_loop(repo_path: str, task: str, max_steps: int = 8, provider: Optional[str] = None) -> str:
|
|
12
|
+
del max_steps # The deterministic lifecycle owns bounded correction budgets.
|
|
13
|
+
configured = load_config()
|
|
14
|
+
selected = ProviderConfig(provider or configured.provider, configured.model, configured.base_url, configured.api_key_env, configured.timeout_seconds)
|
|
15
|
+
_, report = run_devagent(Path(repo_path), task, create_provider(selected))
|
|
16
|
+
return report
|
agent/memory.py
ADDED
agent/prompts.py
ADDED
agent/tools.py
ADDED
devagent/__init__.py
ADDED
devagent/__main__.py
ADDED
devagent/artifacts.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from secrets import token_hex
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from devagent.models import jsonable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def new_run_id() -> str:
|
|
14
|
+
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ-") + token_hex(3)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RunArtifacts:
|
|
18
|
+
def __init__(self, repository_root: Path | str, run_id: str | None = None) -> None:
|
|
19
|
+
self.repository_root = Path(repository_root).resolve()
|
|
20
|
+
self.run_id = run_id or new_run_id()
|
|
21
|
+
self.root = self.repository_root / ".devagent" / "runs" / self.run_id
|
|
22
|
+
self.backups = self.root / "backups"
|
|
23
|
+
self.backups.mkdir(parents=True, exist_ok=False)
|
|
24
|
+
self.observations = self.root / "observations.jsonl"
|
|
25
|
+
self.verification = self.root / "verification.json"
|
|
26
|
+
self.report = self.root / "report.json"
|
|
27
|
+
|
|
28
|
+
def write_json(self, name: str, value: Any) -> None:
|
|
29
|
+
target = self.root / name
|
|
30
|
+
target.write_text(json.dumps(jsonable(value), indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
31
|
+
|
|
32
|
+
def record(self, event: str, **data: Any) -> None:
|
|
33
|
+
entry = {"timestamp": datetime.now(timezone.utc).isoformat(), "event": event, **jsonable(data)}
|
|
34
|
+
with self.observations.open("a", encoding="utf-8") as handle:
|
|
35
|
+
handle.write(json.dumps(entry, sort_keys=True) + "\n")
|
|
36
|
+
|
|
37
|
+
def backup(self, target: Path, *, relative_to: Path | None = None) -> Path | None:
|
|
38
|
+
if not target.exists():
|
|
39
|
+
return None
|
|
40
|
+
relative = target.resolve().relative_to((relative_to or self.repository_root).resolve())
|
|
41
|
+
destination = self.backups / relative
|
|
42
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
if not destination.exists():
|
|
44
|
+
shutil.copy2(target, destination)
|
|
45
|
+
self.record("backup_created", path=relative.as_posix(), backup=str(destination))
|
|
46
|
+
return destination
|
devagent/cli.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Sequence
|
|
11
|
+
|
|
12
|
+
from devagent import __version__
|
|
13
|
+
from devagent.config import ProviderConfig, config_path, load_config, save_config
|
|
14
|
+
from devagent.models import Outcome
|
|
15
|
+
from devagent.orchestrator import run_devagent
|
|
16
|
+
from devagent.providers import ProviderError, create_provider
|
|
17
|
+
from devagent.safety import is_secret_path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _top_parser() -> argparse.ArgumentParser:
|
|
21
|
+
parser = argparse.ArgumentParser(prog="devagent", description="Evidence-driven local software engineering agent")
|
|
22
|
+
parser.add_argument("--version", action="version", version=f"DevAgent {__version__}")
|
|
23
|
+
parser.add_argument("task", nargs="?", help="Engineering requirement; reads stdin or prompts when omitted")
|
|
24
|
+
parser.add_argument("--repo", "-r", type=Path, default=Path.cwd(), help="Target repository (default: current directory)")
|
|
25
|
+
parser.add_argument("--input", "-i", type=Path, help="Read the requirement/error context from a file")
|
|
26
|
+
parser.add_argument("--provider", choices=("openai", "anthropic", "claude", "xai", "grok", "compatible"))
|
|
27
|
+
parser.add_argument("--model")
|
|
28
|
+
parser.add_argument("--base-url")
|
|
29
|
+
parser.add_argument("--verbose", action="store_true", help="Show state transitions")
|
|
30
|
+
parser.add_argument("--no-isolation", action="store_true", help="Work in place instead of creating a local detached worktree")
|
|
31
|
+
return parser
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _setup_parser() -> argparse.ArgumentParser:
|
|
35
|
+
parser = argparse.ArgumentParser(prog="devagent setup", description="Configure the default model provider")
|
|
36
|
+
parser.add_argument("--provider", choices=("openai", "anthropic", "xai", "compatible"), default="openai")
|
|
37
|
+
parser.add_argument("--model")
|
|
38
|
+
parser.add_argument("--base-url")
|
|
39
|
+
parser.add_argument("--api-key-env")
|
|
40
|
+
return parser
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _defaults(provider: str) -> tuple[str, str]:
|
|
44
|
+
provider = {"claude": "anthropic", "grok": "xai"}.get(provider, provider)
|
|
45
|
+
return {
|
|
46
|
+
"openai": ("gpt-5", "OPENAI_API_KEY"),
|
|
47
|
+
"anthropic": ("claude-sonnet-4-20250514", "ANTHROPIC_API_KEY"),
|
|
48
|
+
"xai": ("grok-4", "XAI_API_KEY"),
|
|
49
|
+
"compatible": ("local-model", "DEVAGENT_API_KEY"),
|
|
50
|
+
}[provider]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _setup(argv: Sequence[str]) -> int:
|
|
54
|
+
args = _setup_parser().parse_args(argv)
|
|
55
|
+
model, key_env = _defaults(args.provider)
|
|
56
|
+
target = save_config(ProviderConfig(args.provider, args.model or model, args.base_url, args.api_key_env or key_env))
|
|
57
|
+
print(f"Configured {args.provider} in {target}")
|
|
58
|
+
print(f"API keys are not stored; set {args.api_key_env or key_env} in your environment.")
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _doctor() -> int:
|
|
63
|
+
config = load_config()
|
|
64
|
+
checks = {
|
|
65
|
+
"git": shutil.which("git") is not None,
|
|
66
|
+
"configuration": config_path().is_file(),
|
|
67
|
+
"provider_sdk": importlib.util.find_spec("anthropic" if config.provider in {"anthropic", "claude"} else "openai") is not None,
|
|
68
|
+
"api_key": config.provider == "compatible" or not config.api_key_env or bool(os.getenv(config.api_key_env)),
|
|
69
|
+
}
|
|
70
|
+
print("DEVAGENT DOCTOR")
|
|
71
|
+
for name, okay in checks.items():
|
|
72
|
+
print(f"{'OK' if okay else 'WARN'} {name}")
|
|
73
|
+
if not checks["configuration"]:
|
|
74
|
+
print("Run `devagent setup` before a cloud-provider engineering run.")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _status(repo: Path) -> int:
|
|
79
|
+
runs = repo.resolve() / ".devagent" / "runs"
|
|
80
|
+
reports = sorted(runs.glob("*/report.json")) if runs.is_dir() else []
|
|
81
|
+
if not reports:
|
|
82
|
+
print("No DevAgent runs found for this repository.")
|
|
83
|
+
return 0
|
|
84
|
+
latest = reports[-1]
|
|
85
|
+
try:
|
|
86
|
+
data = json.loads(latest.read_text(encoding="utf-8"))
|
|
87
|
+
except (OSError, json.JSONDecodeError):
|
|
88
|
+
print(f"Latest run report is unreadable: {latest}")
|
|
89
|
+
return 1
|
|
90
|
+
print(f"Run: {data.get('run_id', latest.parent.name)}")
|
|
91
|
+
print(f"Status: {data.get('outcome', 'UNKNOWN')}")
|
|
92
|
+
print(f"Report: {latest}")
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _requirement(args: argparse.Namespace) -> str:
|
|
97
|
+
if args.input:
|
|
98
|
+
if is_secret_path(args.input):
|
|
99
|
+
raise ValueError("Refusing to read a sensitive input file")
|
|
100
|
+
if not args.input.is_file() or args.input.stat().st_size > 2_000_000:
|
|
101
|
+
raise ValueError("Input must be a bounded local text file")
|
|
102
|
+
return args.input.read_text(encoding="utf-8").strip()
|
|
103
|
+
if args.task:
|
|
104
|
+
return args.task.strip()
|
|
105
|
+
if not sys.stdin.isatty():
|
|
106
|
+
return sys.stdin.read(2_000_001).strip()
|
|
107
|
+
return input("Engineering requirement: ").strip()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _run(argv: Sequence[str]) -> int:
|
|
111
|
+
args = _top_parser().parse_args(argv)
|
|
112
|
+
try:
|
|
113
|
+
requirement = _requirement(args)
|
|
114
|
+
if not requirement:
|
|
115
|
+
raise ValueError("Engineering requirement cannot be empty")
|
|
116
|
+
if not args.repo.resolve().is_dir():
|
|
117
|
+
raise ValueError(f"Repository does not exist: {args.repo}")
|
|
118
|
+
configured = load_config()
|
|
119
|
+
selected_provider = args.provider or configured.provider
|
|
120
|
+
if args.provider and args.provider != configured.provider:
|
|
121
|
+
default_model, default_key_env = _defaults(args.provider)
|
|
122
|
+
else:
|
|
123
|
+
default_model, default_key_env = configured.model, configured.api_key_env
|
|
124
|
+
config = ProviderConfig(
|
|
125
|
+
provider=selected_provider,
|
|
126
|
+
model=args.model or default_model,
|
|
127
|
+
base_url=args.base_url or configured.base_url,
|
|
128
|
+
api_key_env=default_key_env,
|
|
129
|
+
timeout_seconds=configured.timeout_seconds,
|
|
130
|
+
)
|
|
131
|
+
print("DevAgent is working...")
|
|
132
|
+
from devagent.orchestrator import DevAgent
|
|
133
|
+
from devagent.report import render_report
|
|
134
|
+
|
|
135
|
+
result = DevAgent(
|
|
136
|
+
create_provider(config),
|
|
137
|
+
isolate=not args.no_isolation,
|
|
138
|
+
verbose=args.verbose,
|
|
139
|
+
status=print,
|
|
140
|
+
).run(args.repo, requirement)
|
|
141
|
+
report = render_report(result)
|
|
142
|
+
print(report)
|
|
143
|
+
return {Outcome.VERIFIED: 0, Outcome.PARTIALLY_VERIFIED: 2, Outcome.BLOCKED: 1}[result.outcome]
|
|
144
|
+
except (ValueError, OSError, ProviderError) as exc:
|
|
145
|
+
print(f"DevAgent failed: {exc}", file=sys.stderr)
|
|
146
|
+
return 1
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
150
|
+
arguments = list(sys.argv[1:] if argv is None else argv)
|
|
151
|
+
if arguments and arguments[0] == "setup":
|
|
152
|
+
return _setup(arguments[1:])
|
|
153
|
+
if arguments and arguments[0] == "doctor":
|
|
154
|
+
argparse.ArgumentParser(prog="devagent doctor", description="Check local DevAgent readiness").parse_args(arguments[1:])
|
|
155
|
+
return _doctor()
|
|
156
|
+
if arguments and arguments[0] == "status":
|
|
157
|
+
parser = argparse.ArgumentParser(prog="devagent status", description="Show the latest local run")
|
|
158
|
+
parser.add_argument("--repo", "-r", type=Path, default=Path.cwd())
|
|
159
|
+
return _status(parser.parse_args(arguments[1:]).repo)
|
|
160
|
+
return _run(arguments)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__": # pragma: no cover
|
|
164
|
+
raise SystemExit(main())
|
devagent/config.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import tomllib
|
|
11
|
+
except ModuleNotFoundError: # pragma: no cover - Python 3.10 only
|
|
12
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ProviderConfig:
|
|
17
|
+
provider: str = "openai"
|
|
18
|
+
model: str = "gpt-5"
|
|
19
|
+
base_url: str | None = None
|
|
20
|
+
api_key_env: str = "OPENAI_API_KEY"
|
|
21
|
+
timeout_seconds: int = 120
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def config_path() -> Path:
|
|
25
|
+
override = os.getenv("DEVAGENT_CONFIG")
|
|
26
|
+
if override:
|
|
27
|
+
return Path(override).expanduser()
|
|
28
|
+
base = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
29
|
+
return base / "devagent" / "config.toml"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_config(path: Path | None = None) -> ProviderConfig:
|
|
33
|
+
target = path or config_path()
|
|
34
|
+
data: dict[str, Any] = {}
|
|
35
|
+
if target.is_file():
|
|
36
|
+
with target.open("rb") as handle:
|
|
37
|
+
loaded = tomllib.load(handle)
|
|
38
|
+
data = loaded.get("provider", loaded)
|
|
39
|
+
provider = os.getenv("DEVAGENT_PROVIDER", str(data.get("name", "openai"))).lower()
|
|
40
|
+
defaults = {
|
|
41
|
+
"openai": ("gpt-5", "OPENAI_API_KEY"),
|
|
42
|
+
"anthropic": ("claude-sonnet-4-20250514", "ANTHROPIC_API_KEY"),
|
|
43
|
+
"claude": ("claude-sonnet-4-20250514", "ANTHROPIC_API_KEY"),
|
|
44
|
+
"xai": ("grok-4", "XAI_API_KEY"),
|
|
45
|
+
"grok": ("grok-4", "XAI_API_KEY"),
|
|
46
|
+
"compatible": ("local-model", "DEVAGENT_API_KEY"),
|
|
47
|
+
"fake": ("fake", ""),
|
|
48
|
+
}
|
|
49
|
+
default_model, default_env = defaults.get(provider, defaults["compatible"])
|
|
50
|
+
return ProviderConfig(
|
|
51
|
+
provider=provider,
|
|
52
|
+
model=os.getenv("DEVAGENT_MODEL", str(data.get("model", default_model))),
|
|
53
|
+
base_url=os.getenv("DEVAGENT_BASE_URL") or data.get("base_url"),
|
|
54
|
+
api_key_env=str(data.get("api_key_env", default_env)),
|
|
55
|
+
timeout_seconds=int(data.get("timeout_seconds", 120)),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_config(config: ProviderConfig, path: Path | None = None) -> Path:
|
|
60
|
+
target = path or config_path()
|
|
61
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
lines = [
|
|
63
|
+
"[provider]",
|
|
64
|
+
f"name = {json.dumps(config.provider)}",
|
|
65
|
+
f"model = {json.dumps(config.model)}",
|
|
66
|
+
f"api_key_env = {json.dumps(config.api_key_env)}",
|
|
67
|
+
f"timeout_seconds = {config.timeout_seconds}",
|
|
68
|
+
]
|
|
69
|
+
if config.base_url:
|
|
70
|
+
lines.append(f"base_url = {json.dumps(config.base_url)}")
|
|
71
|
+
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
72
|
+
return target
|