Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ original issue body:
agent-loop issue 123 --repo OWNER/REPO
```

For larger or ambiguous issues, add `--plan-first` to run a plan review on the
issue before code is written. The coder may inspect the checkout but must not
edit files, push, or open a PR during planning. Reviewers approve or block with
`AGENT_PLAN_STATE` markers. By default the loop posts the approved plan summary
and stops; add `--implement-after-approval` to continue into the normal PR flow:

```bash
agent-loop issue 123 --repo OWNER/REPO --plan-first --implement-after-approval
```

Provide a one-off task directly when there is no issue yet:

```bash
Expand Down
24 changes: 23 additions & 1 deletion docs/local_agent_loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,23 @@ Issue mode includes the issue title, body, and comments in the coder prompt and
issue-origin review prompts. Comments are ordered oldest to newest so later
discussion can refine or supersede the original body.

Run issue mode as plan-first discussion before implementation:

```bash
agent-loop issue 56 --repo OWNER/REPO --plan-first
```

With `--plan-first`, the coder writes an implementation plan without editing
code, pushing a branch, or opening a PR. Reviewers critique that plan on the
issue using `AGENT_PLAN_STATE` markers until every reviewer approves in the
same planning round. By default the loop posts an approved consensus summary to
the issue and stops. Add `--implement-after-approval` to continue into the
normal implementation and PR review loop using the approved plan:

```bash
agent-loop issue 56 --repo OWNER/REPO --plan-first --implement-after-approval
```

Implement a free-form task:

```bash
Expand Down Expand Up @@ -360,10 +377,15 @@ Agent responses are parsed using HTML comment markers:
<!-- AGENT_PR: 123 -->
<!-- AGENT_STATE: approved -->
<!-- AGENT_STATE: blocking -->
<!-- AGENT_PLAN_STATE: approved -->
<!-- AGENT_PLAN_STATE: blocking -->
<!-- AGENT_CLARIFY -->
```

`AGENT_PR` is required after a coder creates a PR. Review/fix responses must include a final `AGENT_STATE` marker. If a response quotes older markers, the final marker is treated as authoritative.
`AGENT_PR` is required after a coder creates a PR. Review/fix responses must
include a final `AGENT_STATE` marker. Plan-first coder/reviewer responses use
`AGENT_PLAN_STATE` instead. If a response quotes older markers, the final
matching marker is treated as authoritative.

When `--approved-followups` is set to `summarize`, `issue`, or a `fix-and-*`
mode, approved reviewer responses may also include optional future-work items
Expand Down
23 changes: 22 additions & 1 deletion src/coding_review_agent_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,19 @@ def add_common(subparser: argparse.ArgumentParser) -> None:

issue = subparsers.add_parser("issue", help="Ask the coder to fix an issue, then review it.")
issue.add_argument("issue_number", type=int)
issue.add_argument(
"--plan-first",
action="store_true",
help=(
"Run an issue planning/review stage before implementation. By default, "
"stop after the plan is approved and post the outcome to the issue."
),
)
issue.add_argument(
"--implement-after-approval",
action="store_true",
help="With --plan-first, continue into implementation after reviewers approve the plan.",
)
add_common(issue)

pr = subparsers.add_parser("pr", help="Run the reviewer/coder loop on an existing PR.")
Expand Down Expand Up @@ -288,7 +301,15 @@ def main(argv: Sequence[str] | None = None) -> int:
try:
config = config_from_args(args, runner)
if args.command == "issue":
return run_issue_loop(runner, issue_number=args.issue_number, config=config)
if args.implement_after_approval and not args.plan_first:
raise AgentLoopError("--implement-after-approval requires --plan-first.")
return run_issue_loop(
runner,
issue_number=args.issue_number,
config=config,
plan_first=args.plan_first,
implement_after_approval=args.implement_after_approval,
)
if args.command == "pr":
return run_pr_loop(runner, pr_number=args.pr_number, config=config)
if args.command == "task":
Expand Down
48 changes: 48 additions & 0 deletions src/coding_review_agent_loop/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,54 @@ def post_pr_comment(
pass


def post_issue_comment(
runner: Runner,
*,
config: AgentLoopConfig,
issue_number: int,
body: str,
) -> None:
log(config, f"Posting agent output to issue #{issue_number}")
if config.dry_run:
runner.run(
[
config.gh_cmd,
"issue",
"comment",
str(issue_number),
"--repo",
config.repo,
"--body",
body,
],
cwd=active_workdir(config),
)
return

with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle:
handle.write(body)
path = handle.name
try:
runner.run(
[
config.gh_cmd,
"issue",
"comment",
str(issue_number),
"--repo",
config.repo,
"--body-file",
path,
],
cwd=active_workdir(config),
)
finally:
try:
os.unlink(path)
except FileNotFoundError:
pass


def create_issue(
runner: Runner,
*,
Expand Down
182 changes: 180 additions & 2 deletions src/coding_review_agent_loop/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
get_issue_context,
get_pr_metadata,
merge_pr,
post_issue_comment,
post_pr_comment,
validate_open_issue,
validate_open_pr,
Expand All @@ -26,14 +27,18 @@
from .memory import prepare_agent_memory
from .prompts import (
build_followup_prompt,
build_issue_implementation_prompt,
build_issue_plan_prompt,
build_issue_prompt,
build_plan_review_prompt,
build_plan_revision_prompt,
build_review_prompt,
build_same_pr_followup_prompt,
build_task_clarification_prompt,
build_task_prompt,
format_agent_list,
)
from .protocol import is_clarification_request, parse_agent_state, parse_pr_number
from .protocol import is_clarification_request, parse_agent_state, parse_plan_state, parse_pr_number
from .protocol import ApprovedFollowup, parse_approved_followups
from .runner import Runner
from .workdirs import active_workdir
Expand Down Expand Up @@ -223,12 +228,185 @@ def _format_same_pr_followups(followups: Sequence[ApprovedFollowup]) -> str:
return "\n".join(lines).strip()


def run_issue_loop(runner: Runner, *, issue_number: int, config: AgentLoopConfig) -> int:
def _format_plan_approval_summary(issue_number: int, approved_plan: str) -> str:
return "\n".join(
[
f"Planning complete for issue #{issue_number}.",
"",
"Outcome: implement",
"",
"Approved plan:",
"",
approved_plan,
"",
"-- coding-review-agent-loop",
]
)


def _run_plan_first_loop(
runner: Runner,
*,
issue_number: int,
config: AgentLoopConfig,
memory,
issue_context: IssueContext,
implement_after_approval: bool,
) -> int:
coder_name = agent_display_name(config.coder)
configured_reviewers = reviewers(config)
coder_session_id: str | None = None
reviewer_session_ids: dict[AgentName, str | None] = {}

log(config, f"Planning issue #{issue_number}: invoking {coder_name}")
plan_output, coder_session_id = run_agent(
runner,
agent=config.coder,
config=config,
prompt=build_issue_plan_prompt(issue_number, config, memory, issue_context=issue_context),
)
if not plan_output.strip():
raise AgentLoopError(f"{coder_name} produced an empty response.")
if is_clarification_request(plan_output):
raise AgentLoopError(
f"{coder_name} requested clarification during planning; human intervention required.\n\n"
f"{coder_name}'s questions:\n{plan_output}"
)
parse_plan_state(plan_output)
post_issue_comment(runner, config=config, issue_number=issue_number, body=plan_output)
current_plan = plan_output

for round_number in range(1, config.max_rounds + 1):
blocking_reviews: list[tuple[str, str]] = []
for reviewer in configured_reviewers:
reviewer_name = agent_display_name(reviewer)
log(config, f"Planning round {round_number}: {reviewer_name} reviewing issue #{issue_number}")
review_output, new_session_id = run_agent(
runner,
agent=reviewer,
config=config,
prompt=build_plan_review_prompt(
issue_number,
round_number,
current_plan,
config,
reviewer=reviewer,
memory=memory,
issue_context=issue_context,
),
session_id=reviewer_session_ids.get(reviewer),
)
reviewer_session_ids[reviewer] = new_session_id
if not review_output.strip():
raise AgentLoopError(f"{reviewer_name} produced an empty response.")
post_issue_comment(runner, config=config, issue_number=issue_number, body=review_output)
review_state = parse_plan_state(review_output)
log(config, f"Planning round {round_number}: {reviewer_name} state is {review_state}")
if review_state == "blocking":
blocking_reviews.append((reviewer_name, review_output))

if not blocking_reviews:
post_issue_comment(
runner,
config=config,
issue_number=issue_number,
body=_format_plan_approval_summary(issue_number, current_plan),
)
if not implement_after_approval:
print(
f"Issue #{issue_number} plan approved by {format_agent_list(configured_reviewers)}."
)
return 0

log(config, f"Planning approved; invoking {coder_name} to implement issue #{issue_number}")
coder_output, coder_session_id = run_agent(
runner,
agent=config.coder,
config=config,
prompt=build_issue_implementation_prompt(
issue_number,
current_plan,
config,
memory,
issue_context=issue_context,
),
session_id=coder_session_id,
)
pr_number = parse_pr_number(coder_output)
if pr_number is None:
raise AgentLoopError(
f"{coder_name} output did not include a PR marker or PR URL."
)
log(config, f"{coder_name} reported PR #{pr_number}; validating it is open")
validate_open_pr(runner, config=config, pr_number=pr_number)
post_pr_comment(runner, config=config, pr_number=pr_number, body=coder_output)
return run_pr_loop(
runner,
pr_number=pr_number,
config=config,
coder_session_id=coder_session_id,
issue_context=issue_context,
workdirs_ready=True,
)

if round_number == config.max_rounds:
raise AgentLoopError(
f"One or more reviewers still reported blocking plan issues after "
f"round {round_number}; human review required."
)

combined_review = "\n\n".join(
f"{name} plan review:\n\n{review}" for name, review in blocking_reviews
)
log(config, f"Planning round {round_number}: {coder_name} revising the plan")
current_plan, coder_session_id = run_agent(
runner,
agent=config.coder,
config=config,
prompt=build_plan_revision_prompt(
issue_number,
round_number,
current_plan,
combined_review,
config,
memory,
issue_context=issue_context,
),
session_id=coder_session_id,
)
if not current_plan.strip():
raise AgentLoopError(f"{coder_name} produced an empty response.")
parse_plan_state(current_plan)
post_issue_comment(runner, config=config, issue_number=issue_number, body=current_plan)

raise AgentLoopError(
f"Reached max planning rounds ({config.max_rounds}) for issue #{issue_number}; "
"human review required."
)


def run_issue_loop(
runner: Runner,
*,
issue_number: int,
config: AgentLoopConfig,
plan_first: bool = False,
implement_after_approval: bool = False,
) -> int:
ensure_agent_workdirs(config, runner)
log(config, f"Validating issue #{issue_number}")
validate_open_issue(runner, config=config, issue_number=issue_number)
issue_context = get_issue_context(runner, config=config, issue_number=issue_number)
memory = prepare_agent_memory(runner, config)
if plan_first:
return _run_plan_first_loop(
runner,
issue_number=issue_number,
config=config,
memory=memory,
issue_context=issue_context,
implement_after_approval=implement_after_approval,
)

coder_output, coder_session_id = run_agent(
runner,
Expand Down
Loading
Loading