agentic-devtools 0.2.332__py3-none-any.whl → 0.2.334__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.
- agentic_devtools/_version.py +2 -2
- agentic_devtools/adapters/__init__.py +8 -0
- agentic_devtools/adapters/idempotency_query_provider.py +64 -0
- agentic_devtools/adapters/issue_provider.py +65 -0
- agentic_devtools/adapters/jira_provider.py +140 -75
- agentic_devtools/adapters/operation_plan.py +111 -0
- agentic_devtools/adapters/orchestration_key.py +39 -15
- agentic_devtools/adapters/plan_manifest.py +437 -0
- agentic_devtools/orchestration/graph_builder.py +15 -5
- agentic_devtools/orchestration/nodes/__init__.py +31 -0
- agentic_devtools/orchestration/nodes/_helpers.py +261 -0
- agentic_devtools/orchestration/nodes/checklist_creation.py +139 -0
- agentic_devtools/orchestration/nodes/commit.py +169 -0
- agentic_devtools/orchestration/nodes/completion.py +163 -0
- agentic_devtools/orchestration/nodes/implementation.py +395 -0
- agentic_devtools/orchestration/nodes/implementation_review.py +126 -0
- agentic_devtools/orchestration/nodes/initiate.py +136 -0
- agentic_devtools/orchestration/nodes/planning.py +221 -0
- agentic_devtools/orchestration/nodes/pull_request.py +256 -0
- agentic_devtools/orchestration/nodes/retrieve.py +198 -0
- agentic_devtools/orchestration/nodes/setup.py +123 -0
- agentic_devtools/orchestration/nodes/verification.py +96 -0
- agentic_devtools/orchestration/pilot_workflow.py +53 -21
- agentic_devtools/orchestration/runner.py +62 -9
- agentic_devtools/orchestration/state_schema.py +24 -0
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/METADATA +1 -1
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/RECORD +30 -14
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/WHEEL +0 -0
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/entry_points.txt +0 -0
- {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Completion node: post summary comment and finalize workflow.
|
|
2
|
+
|
|
3
|
+
Builds a completion comment with work summary, PR link, completed
|
|
4
|
+
checklist items, and token usage. Posts to the appropriate platform.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from agentic_devtools.cli.github.repo_resolution import resolve_github_repo_safe
|
|
12
|
+
from agentic_devtools.orchestration.nodes._helpers import (
|
|
13
|
+
_to_nonneg_int,
|
|
14
|
+
detect_issue_provider,
|
|
15
|
+
normalize_issue_key,
|
|
16
|
+
run_command,
|
|
17
|
+
utc_now,
|
|
18
|
+
)
|
|
19
|
+
from agentic_devtools.orchestration.state_schema import WorkOnIssueState
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def completion_node(state: WorkOnIssueState) -> dict[str, Any]:
|
|
23
|
+
"""Post completion comment and mark workflow as done.
|
|
24
|
+
|
|
25
|
+
Builds a summary with PR link, checklist results, and token usage.
|
|
26
|
+
Posts to Jira or GitHub depending on the issue provider.
|
|
27
|
+
|
|
28
|
+
Fails fast when ``issue_key`` is missing, blank, or not a string so a
|
|
29
|
+
corrupted/resumed checkpoint cannot raise ``AttributeError`` inside
|
|
30
|
+
``detect_issue_provider()`` or ``normalize_issue_key()``.
|
|
31
|
+
"""
|
|
32
|
+
issue_key = state.get("issue_key", "")
|
|
33
|
+
if not isinstance(issue_key, str) or not issue_key.strip():
|
|
34
|
+
return {
|
|
35
|
+
"step": "completion",
|
|
36
|
+
"status": "failed",
|
|
37
|
+
"error": "issue_key is required and must be a non-empty string",
|
|
38
|
+
"events": [
|
|
39
|
+
{
|
|
40
|
+
"event": "completion_failed",
|
|
41
|
+
"timestamp": utc_now(),
|
|
42
|
+
"signals": {"error": "missing_issue_key"},
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
}
|
|
46
|
+
issue_key = issue_key.strip()
|
|
47
|
+
raw_issue_provider = state.get("issue_provider")
|
|
48
|
+
issue_provider = (
|
|
49
|
+
raw_issue_provider
|
|
50
|
+
if isinstance(raw_issue_provider, str) and raw_issue_provider in {"jira", "github"}
|
|
51
|
+
else detect_issue_provider(issue_key)
|
|
52
|
+
)
|
|
53
|
+
pr_url = state.get("pr_url", "")
|
|
54
|
+
checklist_items = state.get("checklist_items", [])
|
|
55
|
+
token_usage_prompt = state.get("token_usage_prompt", 0)
|
|
56
|
+
token_usage_completion = state.get("token_usage_completion", 0)
|
|
57
|
+
|
|
58
|
+
# Build completion comment
|
|
59
|
+
comment = _build_completion_comment(
|
|
60
|
+
issue_key=issue_key,
|
|
61
|
+
pr_url=pr_url,
|
|
62
|
+
checklist_items=checklist_items if isinstance(checklist_items, list) else [],
|
|
63
|
+
token_usage_prompt=_to_nonneg_int(token_usage_prompt),
|
|
64
|
+
token_usage_completion=_to_nonneg_int(token_usage_completion),
|
|
65
|
+
issue_provider=issue_provider,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Post comment to appropriate platform
|
|
69
|
+
if issue_provider == "jira":
|
|
70
|
+
_post_jira_comment(issue_key, comment)
|
|
71
|
+
else:
|
|
72
|
+
_post_github_comment(issue_key, comment)
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"step": "completion",
|
|
76
|
+
"status": "completed",
|
|
77
|
+
"events": [
|
|
78
|
+
{
|
|
79
|
+
"event": "completion_completed",
|
|
80
|
+
"timestamp": utc_now(),
|
|
81
|
+
"signals": {"pr_url": pr_url},
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _build_completion_comment(
|
|
88
|
+
*,
|
|
89
|
+
issue_key: str,
|
|
90
|
+
pr_url: str,
|
|
91
|
+
checklist_items: list[Any],
|
|
92
|
+
token_usage_prompt: int,
|
|
93
|
+
token_usage_completion: int,
|
|
94
|
+
issue_provider: str = "jira",
|
|
95
|
+
) -> str:
|
|
96
|
+
"""Build the completion comment text."""
|
|
97
|
+
is_github = issue_provider == "github"
|
|
98
|
+
lines = ["#### Autonomous Implementation Complete" if is_github else "h4. Autonomous Implementation Complete"]
|
|
99
|
+
lines.append("")
|
|
100
|
+
|
|
101
|
+
if pr_url:
|
|
102
|
+
lines.append(f"**Pull Request**: {pr_url}" if is_github else f"*Pull Request*: {pr_url}")
|
|
103
|
+
else:
|
|
104
|
+
lines.append("**Pull Request**: Created (see branch)" if is_github else "*Pull Request*: Created (see branch)")
|
|
105
|
+
|
|
106
|
+
lines.append("")
|
|
107
|
+
|
|
108
|
+
# Checklist summary
|
|
109
|
+
completed = sum(1 for item in checklist_items if isinstance(item, dict) and item.get("is_complete"))
|
|
110
|
+
total = len([item for item in checklist_items if isinstance(item, dict)])
|
|
111
|
+
lines.append(
|
|
112
|
+
f"**Checklist**: {completed}/{total} items completed"
|
|
113
|
+
if is_github
|
|
114
|
+
else f"*Checklist*: {completed}/{total} items completed"
|
|
115
|
+
)
|
|
116
|
+
lines.append("")
|
|
117
|
+
|
|
118
|
+
# Token usage
|
|
119
|
+
total_tokens = token_usage_prompt + token_usage_completion
|
|
120
|
+
lines.append("**Token Usage**:" if is_github else "*Token Usage*:")
|
|
121
|
+
lines.append(f"- Prompt tokens: {token_usage_prompt:,}")
|
|
122
|
+
lines.append(f"- Completion tokens: {token_usage_completion:,}")
|
|
123
|
+
lines.append(f"- Total tokens: {total_tokens:,}")
|
|
124
|
+
|
|
125
|
+
lines.append("")
|
|
126
|
+
lines.append("_Generated by LangChain work-on-issue workflow_")
|
|
127
|
+
|
|
128
|
+
return "\n".join(lines)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _post_jira_comment(issue_key: str, comment: str) -> None:
|
|
132
|
+
"""Post completion comment to Jira."""
|
|
133
|
+
issue_result = run_command(["agdt-set", "jira.issue_key", issue_key])
|
|
134
|
+
if issue_result.returncode != 0:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
comment_result = run_command(["agdt-set", "jira.comment", comment])
|
|
138
|
+
if comment_result.returncode != 0:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
add_result = run_command(["agdt-add-jira-comment"])
|
|
142
|
+
if add_result.returncode == 0:
|
|
143
|
+
run_command(["agdt-task-wait"], timeout=60)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _post_github_comment(issue_key: str, comment: str) -> None:
|
|
147
|
+
"""Post completion comment to GitHub."""
|
|
148
|
+
try:
|
|
149
|
+
from agentic_devtools.adapters.github_adapter import GitHubIssuesAdapter
|
|
150
|
+
|
|
151
|
+
repo = resolve_github_repo_safe()
|
|
152
|
+
if not repo:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
normalized_issue_key = normalize_issue_key(issue_key)
|
|
156
|
+
if not normalized_issue_key:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
adapter = GitHubIssuesAdapter(repo=repo)
|
|
160
|
+
adapter.add_comment(normalized_issue_key, comment)
|
|
161
|
+
except Exception:
|
|
162
|
+
# Best-effort: don't fail the workflow if comment posting fails
|
|
163
|
+
pass
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Implementation node: TDD RED/GREEN cycle for each checklist item.
|
|
2
|
+
|
|
3
|
+
Iterates over incomplete checklist items, performing for each:
|
|
4
|
+
1. RED: Generate a failing test via LLM
|
|
5
|
+
2. GREEN: Generate implementation code via LLM
|
|
6
|
+
3. VERIFY: Run tests to confirm they pass
|
|
7
|
+
|
|
8
|
+
Uses repository context discovery to inform LLM prompts.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from agentic_devtools.orchestration.execution.context_factory import _run_async
|
|
18
|
+
from agentic_devtools.orchestration.nodes._helpers import (
|
|
19
|
+
_to_nonneg_int,
|
|
20
|
+
detect_test_conventions,
|
|
21
|
+
read_file_content,
|
|
22
|
+
run_command,
|
|
23
|
+
scan_directory_structure,
|
|
24
|
+
utc_now,
|
|
25
|
+
)
|
|
26
|
+
from agentic_devtools.orchestration.state_schema import WorkOnIssueState
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def implementation_node(state: WorkOnIssueState) -> dict[str, Any]:
|
|
30
|
+
"""Execute TDD cycle for each incomplete checklist item.
|
|
31
|
+
|
|
32
|
+
Iterates over checklist items, generating tests (RED) and implementation
|
|
33
|
+
(GREEN) for each. Verifies with ``agdt-test-pattern``.
|
|
34
|
+
"""
|
|
35
|
+
checklist_items = state.get("checklist_items", [])
|
|
36
|
+
if not isinstance(checklist_items, list):
|
|
37
|
+
checklist_items = []
|
|
38
|
+
|
|
39
|
+
raw_log = state.get("implementation_log", [])
|
|
40
|
+
implementation_log: list[dict[str, Any]] = raw_log if isinstance(raw_log, list) else []
|
|
41
|
+
raw_paths = state.get("affected_paths", [])
|
|
42
|
+
affected_paths: list[str] = (
|
|
43
|
+
[str(p) for p in raw_paths if not isinstance(p, bool)] if isinstance(raw_paths, list) else []
|
|
44
|
+
)
|
|
45
|
+
error_message: str | None = None
|
|
46
|
+
accumulated_prompt = 0
|
|
47
|
+
accumulated_completion = 0
|
|
48
|
+
|
|
49
|
+
# Process each incomplete item
|
|
50
|
+
for i, item in enumerate(checklist_items):
|
|
51
|
+
if not isinstance(item, dict):
|
|
52
|
+
continue
|
|
53
|
+
if item.get("is_complete"):
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
result = _implement_checklist_item(item, i, state)
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
error_message = f"Implementation failed for item {i}: {exc}"
|
|
60
|
+
implementation_log.append(
|
|
61
|
+
{
|
|
62
|
+
"item_index": i,
|
|
63
|
+
"status": "failed",
|
|
64
|
+
"error": str(exc),
|
|
65
|
+
"timestamp": utc_now(),
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
accumulated_prompt += _to_nonneg_int(result.get("token_usage_prompt"))
|
|
71
|
+
accumulated_completion += _to_nonneg_int(result.get("token_usage_completion"))
|
|
72
|
+
|
|
73
|
+
if result.get("error"):
|
|
74
|
+
error_message = result["error"]
|
|
75
|
+
implementation_log.append(
|
|
76
|
+
{
|
|
77
|
+
"item_index": i,
|
|
78
|
+
"status": "failed",
|
|
79
|
+
"error": error_message,
|
|
80
|
+
"timestamp": utc_now(),
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
# Mark item as complete
|
|
86
|
+
checklist_items[i] = {**item, "is_complete": True}
|
|
87
|
+
affected_paths.extend(result.get("affected_paths", []))
|
|
88
|
+
implementation_log.append(
|
|
89
|
+
{
|
|
90
|
+
"item_index": i,
|
|
91
|
+
"status": "completed",
|
|
92
|
+
"affected_paths": result.get("affected_paths", []),
|
|
93
|
+
"timestamp": utc_now(),
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Determine if all items are complete. A checklist with no valid (dict) items —
|
|
98
|
+
# an empty list, a non-list value coerced to [], or only corrupted non-dict
|
|
99
|
+
# entries — must NOT be treated as complete: ``all(...)`` over an empty
|
|
100
|
+
# generator returns True, which would let the workflow skip implementation
|
|
101
|
+
# entirely and proceed as if the work were done.
|
|
102
|
+
dict_items = [item for item in checklist_items if isinstance(item, dict)]
|
|
103
|
+
all_complete = bool(dict_items) and all(item.get("is_complete", False) for item in dict_items)
|
|
104
|
+
if not dict_items:
|
|
105
|
+
error_message = "No valid checklist items to implement; cannot mark implementation complete."
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"step": "implementation",
|
|
109
|
+
"error": error_message if not all_complete and error_message else None,
|
|
110
|
+
"checklist_items": checklist_items,
|
|
111
|
+
"checklist_complete": all_complete,
|
|
112
|
+
"implementation_log": implementation_log,
|
|
113
|
+
"affected_paths": affected_paths,
|
|
114
|
+
"token_usage_prompt": _to_nonneg_int(state.get("token_usage_prompt")) + accumulated_prompt,
|
|
115
|
+
"token_usage_completion": _to_nonneg_int(state.get("token_usage_completion")) + accumulated_completion,
|
|
116
|
+
"events": [
|
|
117
|
+
{
|
|
118
|
+
"event": "implementation_completed" if all_complete else "implementation_partial",
|
|
119
|
+
"timestamp": utc_now(),
|
|
120
|
+
"signals": {"checklist_complete": all_complete, "error": error_message},
|
|
121
|
+
}
|
|
122
|
+
],
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _implement_checklist_item(
|
|
127
|
+
item: dict[str, Any],
|
|
128
|
+
index: int,
|
|
129
|
+
state: WorkOnIssueState,
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""Implement a single checklist item using TDD cycle.
|
|
132
|
+
|
|
133
|
+
Returns dict with affected_paths list, accumulated token usage counts,
|
|
134
|
+
or an error string.
|
|
135
|
+
"""
|
|
136
|
+
issue_key = state.get("issue_key", "")
|
|
137
|
+
plan = state.get("plan", "")
|
|
138
|
+
description = item.get("description", "")
|
|
139
|
+
|
|
140
|
+
# Discover repository context
|
|
141
|
+
repo_root = _get_repo_root()
|
|
142
|
+
if repo_root is None:
|
|
143
|
+
return {"error": "Cannot determine repository root"}
|
|
144
|
+
|
|
145
|
+
context = _build_context(repo_root)
|
|
146
|
+
prompt_tokens = 0
|
|
147
|
+
completion_tokens = 0
|
|
148
|
+
|
|
149
|
+
# RED phase: generate failing test
|
|
150
|
+
test_result = _generate_test(description, plan, context, issue_key, repo_root)
|
|
151
|
+
test_usage = test_result.get("token_usage", {})
|
|
152
|
+
prompt_tokens += _to_nonneg_int(test_usage.get("prompt_tokens"))
|
|
153
|
+
completion_tokens += _to_nonneg_int(test_usage.get("completion_tokens"))
|
|
154
|
+
if test_result.get("error"):
|
|
155
|
+
return {
|
|
156
|
+
"error": f"RED phase failed: {test_result['error']}",
|
|
157
|
+
"token_usage_prompt": prompt_tokens,
|
|
158
|
+
"token_usage_completion": completion_tokens,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
test_path = test_result.get("path", "")
|
|
162
|
+
affected = [test_path] if test_path else []
|
|
163
|
+
|
|
164
|
+
# RED verification: confirm the generated test actually fails before writing implementation.
|
|
165
|
+
# A test that immediately passes either has no real assertions or the behavior already exists.
|
|
166
|
+
# pytest exit code 1 means "tests ran and at least one failed" — the expected outcome for a
|
|
167
|
+
# properly-written RED-phase test. Any other non-zero code (2 = usage/syntax error,
|
|
168
|
+
# 3 = internal error, 4/5 = no tests collected) indicates a broken test file.
|
|
169
|
+
if test_path:
|
|
170
|
+
red_verify = run_command(
|
|
171
|
+
["agdt-test-pattern", test_path, "-v", "-o", "addopts="],
|
|
172
|
+
timeout=120,
|
|
173
|
+
)
|
|
174
|
+
if red_verify.returncode == 0:
|
|
175
|
+
return {
|
|
176
|
+
"error": (
|
|
177
|
+
f"RED phase failed: generated test already passes before implementation; "
|
|
178
|
+
f"the behavior may already exist or the test has no real assertions ({test_path})"
|
|
179
|
+
),
|
|
180
|
+
"token_usage_prompt": prompt_tokens,
|
|
181
|
+
"token_usage_completion": completion_tokens,
|
|
182
|
+
}
|
|
183
|
+
if red_verify.returncode != 1:
|
|
184
|
+
# Not a clean test failure — exit codes 2+ indicate broken generated test
|
|
185
|
+
# (2 = usage/syntax error, 3 = internal error, 4 = pytest usage error, 5 = no tests collected).
|
|
186
|
+
return {
|
|
187
|
+
"error": (
|
|
188
|
+
f"RED phase failed: generated test is invalid "
|
|
189
|
+
f"(pytest exit {red_verify.returncode}); "
|
|
190
|
+
f"expected exit code 1 (test failures) but got a collection or syntax error. "
|
|
191
|
+
f"Output: {red_verify.stderr[:500]}"
|
|
192
|
+
),
|
|
193
|
+
"token_usage_prompt": prompt_tokens,
|
|
194
|
+
"token_usage_completion": completion_tokens,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# GREEN phase: generate implementation
|
|
198
|
+
impl_result = _generate_implementation(description, plan, context, test_path, issue_key, repo_root)
|
|
199
|
+
impl_usage = impl_result.get("token_usage", {})
|
|
200
|
+
prompt_tokens += _to_nonneg_int(impl_usage.get("prompt_tokens"))
|
|
201
|
+
completion_tokens += _to_nonneg_int(impl_usage.get("completion_tokens"))
|
|
202
|
+
if impl_result.get("error"):
|
|
203
|
+
return {
|
|
204
|
+
"error": f"GREEN phase failed: {impl_result['error']}",
|
|
205
|
+
"token_usage_prompt": prompt_tokens,
|
|
206
|
+
"token_usage_completion": completion_tokens,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
impl_path = impl_result.get("path", "")
|
|
210
|
+
if impl_path:
|
|
211
|
+
affected.append(impl_path)
|
|
212
|
+
|
|
213
|
+
# VERIFY: run tests
|
|
214
|
+
if test_path:
|
|
215
|
+
verify_result = run_command(
|
|
216
|
+
["agdt-test-pattern", test_path, "-v", "-o", "addopts="],
|
|
217
|
+
timeout=120,
|
|
218
|
+
)
|
|
219
|
+
if verify_result.returncode != 0:
|
|
220
|
+
return {
|
|
221
|
+
"error": f"VERIFY phase failed: tests did not pass\n{verify_result.stdout}\n{verify_result.stderr}",
|
|
222
|
+
"token_usage_prompt": prompt_tokens,
|
|
223
|
+
"token_usage_completion": completion_tokens,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
"affected_paths": affected,
|
|
228
|
+
"token_usage_prompt": prompt_tokens,
|
|
229
|
+
"token_usage_completion": completion_tokens,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _get_repo_root() -> Path | None:
|
|
234
|
+
"""Get the repository root path."""
|
|
235
|
+
result = run_command(["git", "rev-parse", "--show-toplevel"])
|
|
236
|
+
if result.returncode != 0:
|
|
237
|
+
return None
|
|
238
|
+
return Path(result.stdout.strip())
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _build_context(repo_root: Path) -> dict[str, Any]:
|
|
242
|
+
"""Build repository context for LLM prompts."""
|
|
243
|
+
structure = scan_directory_structure(repo_root, max_depth=2)
|
|
244
|
+
conventions = detect_test_conventions(repo_root)
|
|
245
|
+
return {
|
|
246
|
+
"structure": structure[:100], # Limit to avoid token overflow
|
|
247
|
+
"conventions": conventions,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _resolve_output_path(repo_root: Path, file_path: str) -> Path:
|
|
252
|
+
"""Resolve an LLM-provided output path within repository bounds."""
|
|
253
|
+
candidate = Path(file_path)
|
|
254
|
+
if candidate.is_absolute():
|
|
255
|
+
raise ValueError(f"Absolute paths are not allowed: {file_path}")
|
|
256
|
+
|
|
257
|
+
resolved = (repo_root / candidate).resolve()
|
|
258
|
+
repo_root_resolved = repo_root.resolve()
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
resolved.relative_to(repo_root_resolved)
|
|
262
|
+
except ValueError as exc:
|
|
263
|
+
raise ValueError(f"Path traversal is not allowed: {file_path}") from exc
|
|
264
|
+
|
|
265
|
+
return resolved
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _generate_test(
|
|
269
|
+
description: str,
|
|
270
|
+
plan: str,
|
|
271
|
+
context: dict[str, Any],
|
|
272
|
+
issue_key: str,
|
|
273
|
+
repo_root: Path,
|
|
274
|
+
) -> dict[str, Any]:
|
|
275
|
+
"""Generate a failing test file via LLM (RED phase)."""
|
|
276
|
+
from agentic_devtools.orchestration.llm.factory import ProviderFactory
|
|
277
|
+
|
|
278
|
+
factory = ProviderFactory()
|
|
279
|
+
provider = factory.get_provider("implementation", "work_on_issue")
|
|
280
|
+
|
|
281
|
+
conventions = context.get("conventions", {})
|
|
282
|
+
test_layout = conventions.get("test_layout", "1:1:1")
|
|
283
|
+
|
|
284
|
+
system_prompt = (
|
|
285
|
+
"You are a test-driven development assistant. Generate a Python test file "
|
|
286
|
+
"that will initially FAIL (RED phase of TDD). The test should verify the "
|
|
287
|
+
"expected behavior described in the checklist item.\n\n"
|
|
288
|
+
f"Test layout convention: {test_layout}\n"
|
|
289
|
+
"Follow pytest conventions. Include only the test code, no explanations.\n"
|
|
290
|
+
'Respond with JSON: {"file_path": "relative/path.py", "content": "..."}'
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
user_prompt = f"Issue: {issue_key}\nChecklist item: {description}\nPlan context: {plan[:2000]}"
|
|
294
|
+
|
|
295
|
+
async def _call():
|
|
296
|
+
from agentic_devtools.orchestration.llm.types import LLMMessage
|
|
297
|
+
|
|
298
|
+
messages = [
|
|
299
|
+
LLMMessage(role="system", content=system_prompt),
|
|
300
|
+
LLMMessage(role="user", content=user_prompt),
|
|
301
|
+
]
|
|
302
|
+
return await provider.complete(messages)
|
|
303
|
+
|
|
304
|
+
token_usage: dict[str, int] = {}
|
|
305
|
+
try:
|
|
306
|
+
response = _run_async(_call())
|
|
307
|
+
if response.usage:
|
|
308
|
+
token_usage = {
|
|
309
|
+
"prompt_tokens": response.usage.input_tokens,
|
|
310
|
+
"completion_tokens": response.usage.output_tokens,
|
|
311
|
+
}
|
|
312
|
+
parsed = json.loads(response.text)
|
|
313
|
+
if not isinstance(parsed, dict):
|
|
314
|
+
return {"error": "LLM did not produce valid test file output", "token_usage": token_usage}
|
|
315
|
+
file_path = parsed.get("file_path", "")
|
|
316
|
+
content = parsed.get("content", "")
|
|
317
|
+
|
|
318
|
+
if file_path and content:
|
|
319
|
+
full_path = _resolve_output_path(repo_root, file_path)
|
|
320
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
321
|
+
full_path.write_text(content, encoding="utf-8")
|
|
322
|
+
return {"path": file_path, "token_usage": token_usage}
|
|
323
|
+
|
|
324
|
+
return {"error": "LLM did not produce valid test file output", "token_usage": token_usage}
|
|
325
|
+
except Exception as exc:
|
|
326
|
+
return {"error": str(exc), "token_usage": token_usage}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _generate_implementation(
|
|
330
|
+
description: str,
|
|
331
|
+
plan: str,
|
|
332
|
+
context: dict[str, Any],
|
|
333
|
+
test_path: str,
|
|
334
|
+
issue_key: str,
|
|
335
|
+
repo_root: Path,
|
|
336
|
+
) -> dict[str, Any]:
|
|
337
|
+
"""Generate implementation code via LLM (GREEN phase)."""
|
|
338
|
+
from agentic_devtools.orchestration.llm.factory import ProviderFactory
|
|
339
|
+
|
|
340
|
+
factory = ProviderFactory()
|
|
341
|
+
provider = factory.get_provider("implementation", "work_on_issue")
|
|
342
|
+
|
|
343
|
+
# Read the test file for context
|
|
344
|
+
test_content = ""
|
|
345
|
+
if test_path:
|
|
346
|
+
test_full_path = repo_root / test_path
|
|
347
|
+
if test_full_path.exists():
|
|
348
|
+
test_content = read_file_content(test_full_path)
|
|
349
|
+
|
|
350
|
+
system_prompt = (
|
|
351
|
+
"You are an implementation assistant. Generate Python source code that makes "
|
|
352
|
+
"the provided failing test pass (GREEN phase of TDD). Write minimal code "
|
|
353
|
+
"to satisfy the test assertions.\n\n"
|
|
354
|
+
'Respond with JSON: {"file_path": "relative/path.py", "content": "..."}'
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
user_prompt = (
|
|
358
|
+
f"Issue: {issue_key}\n"
|
|
359
|
+
f"Checklist item: {description}\n"
|
|
360
|
+
f"Test file ({test_path}):\n{test_content}\n"
|
|
361
|
+
f"Plan context: {plan[:1000]}"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
async def _call():
|
|
365
|
+
from agentic_devtools.orchestration.llm.types import LLMMessage
|
|
366
|
+
|
|
367
|
+
messages = [
|
|
368
|
+
LLMMessage(role="system", content=system_prompt),
|
|
369
|
+
LLMMessage(role="user", content=user_prompt),
|
|
370
|
+
]
|
|
371
|
+
return await provider.complete(messages)
|
|
372
|
+
|
|
373
|
+
token_usage: dict[str, int] = {}
|
|
374
|
+
try:
|
|
375
|
+
response = _run_async(_call())
|
|
376
|
+
if response.usage:
|
|
377
|
+
token_usage = {
|
|
378
|
+
"prompt_tokens": response.usage.input_tokens,
|
|
379
|
+
"completion_tokens": response.usage.output_tokens,
|
|
380
|
+
}
|
|
381
|
+
parsed = json.loads(response.text)
|
|
382
|
+
if not isinstance(parsed, dict):
|
|
383
|
+
return {"error": "LLM did not produce valid implementation output", "token_usage": token_usage}
|
|
384
|
+
file_path = parsed.get("file_path", "")
|
|
385
|
+
content = parsed.get("content", "")
|
|
386
|
+
|
|
387
|
+
if file_path and content:
|
|
388
|
+
full_path = _resolve_output_path(repo_root, file_path)
|
|
389
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
390
|
+
full_path.write_text(content, encoding="utf-8")
|
|
391
|
+
return {"path": file_path, "token_usage": token_usage}
|
|
392
|
+
|
|
393
|
+
return {"error": "LLM did not produce valid implementation output", "token_usage": token_usage}
|
|
394
|
+
except Exception as exc:
|
|
395
|
+
return {"error": str(exc), "token_usage": token_usage}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Implementation review node: self-review generated code for quality.
|
|
2
|
+
|
|
3
|
+
Scans generated code for debug statements (breakpoint, pdb, debug prints)
|
|
4
|
+
and TODO/FIXME/HACK/XXX markers. Routes back to implementation
|
|
5
|
+
if issues are found.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agentic_devtools.orchestration.nodes._helpers import run_command, utc_now
|
|
15
|
+
from agentic_devtools.orchestration.state_schema import WorkOnIssueState
|
|
16
|
+
|
|
17
|
+
# Patterns that indicate leftover debug code
|
|
18
|
+
_DEBUG_PATTERNS = [
|
|
19
|
+
re.compile(r"\bbreakpoint\(\)"),
|
|
20
|
+
re.compile(r"\bpdb\.set_trace\(\)"),
|
|
21
|
+
re.compile(r"\bprint\(.*(debug|DEBUG)"),
|
|
22
|
+
re.compile(r"\bimport pdb\b"),
|
|
23
|
+
re.compile(r"\bimport ipdb\b"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
_TODO_PATTERN = re.compile(r"\b(TODO|FIXME|HACK|XXX)\b", re.IGNORECASE)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def implementation_review_node(state: WorkOnIssueState) -> dict[str, Any]:
|
|
30
|
+
"""Review generated code for quality issues.
|
|
31
|
+
|
|
32
|
+
Checks for debug statements, TODO/FIXME markers, and other
|
|
33
|
+
quality concerns. Sets error to route back to implementation
|
|
34
|
+
if issues are found.
|
|
35
|
+
"""
|
|
36
|
+
raw_paths = state.get("affected_paths", [])
|
|
37
|
+
affected_paths: list[str] = (
|
|
38
|
+
[str(p) for p in raw_paths if not isinstance(p, bool)] if isinstance(raw_paths, list) else []
|
|
39
|
+
)
|
|
40
|
+
issues: list[str] = []
|
|
41
|
+
|
|
42
|
+
# Get repo root
|
|
43
|
+
result = run_command(["git", "rev-parse", "--show-toplevel"])
|
|
44
|
+
if result.returncode != 0:
|
|
45
|
+
return {
|
|
46
|
+
"step": "implementation_review",
|
|
47
|
+
"error": None,
|
|
48
|
+
"verification_ready": True,
|
|
49
|
+
"events": [
|
|
50
|
+
{
|
|
51
|
+
"event": "implementation_review_completed",
|
|
52
|
+
"timestamp": utc_now(),
|
|
53
|
+
"signals": {"verification_ready": True, "skipped": "no_repo_root"},
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
repo_root = Path(result.stdout.strip()).resolve()
|
|
59
|
+
|
|
60
|
+
# Scan each affected file
|
|
61
|
+
for path_str in affected_paths:
|
|
62
|
+
# Resolve the joined path and verify it stays inside the repository.
|
|
63
|
+
# A corrupted path containing ".." segments would otherwise escape
|
|
64
|
+
# repo_root and could expose arbitrary files outside the repository.
|
|
65
|
+
# Path.resolve() uses strict=False by default so it never raises OSError;
|
|
66
|
+
# relative_to() raises ValueError when the path escapes repo_root.
|
|
67
|
+
try:
|
|
68
|
+
file_path = (repo_root / path_str).resolve()
|
|
69
|
+
file_path.relative_to(repo_root)
|
|
70
|
+
except ValueError:
|
|
71
|
+
continue
|
|
72
|
+
if not file_path.exists() or not file_path.is_file():
|
|
73
|
+
continue
|
|
74
|
+
if not path_str.endswith(".py"):
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
content = file_path.read_text(encoding="utf-8")
|
|
79
|
+
except (OSError, UnicodeDecodeError):
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
file_issues = _scan_file(path_str, content)
|
|
83
|
+
issues.extend(file_issues)
|
|
84
|
+
|
|
85
|
+
if issues:
|
|
86
|
+
error_msg = "Implementation review found issues:\n" + "\n".join(f"- {i}" for i in issues)
|
|
87
|
+
return {
|
|
88
|
+
"step": "implementation_review",
|
|
89
|
+
"error": error_msg,
|
|
90
|
+
"verification_ready": False,
|
|
91
|
+
"events": [
|
|
92
|
+
{
|
|
93
|
+
"event": "implementation_review_issues_found",
|
|
94
|
+
"timestamp": utc_now(),
|
|
95
|
+
"signals": {"issue_count": len(issues)},
|
|
96
|
+
}
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"step": "implementation_review",
|
|
102
|
+
"error": None,
|
|
103
|
+
"verification_ready": True,
|
|
104
|
+
"events": [
|
|
105
|
+
{
|
|
106
|
+
"event": "implementation_review_completed",
|
|
107
|
+
"timestamp": utc_now(),
|
|
108
|
+
"signals": {"verification_ready": True},
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _scan_file(path: str, content: str) -> list[str]:
|
|
115
|
+
"""Scan a single file for quality issues."""
|
|
116
|
+
issues: list[str] = []
|
|
117
|
+
|
|
118
|
+
for line_num, line in enumerate(content.splitlines(), 1):
|
|
119
|
+
for pattern in _DEBUG_PATTERNS:
|
|
120
|
+
if pattern.search(line):
|
|
121
|
+
issues.append(f"{path}:{line_num}: Debug statement found: {line.strip()}")
|
|
122
|
+
|
|
123
|
+
if _TODO_PATTERN.search(line):
|
|
124
|
+
issues.append(f"{path}:{line_num}: TODO/FIXME marker: {line.strip()}")
|
|
125
|
+
|
|
126
|
+
return issues
|