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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ ai-agent -c config/agent_config.yaml
| Mode | Command | Notes |
|---|---|---|
| Console | `uv run ai-agent -c config/agent_config.yaml` | Single agent |
| Evolve status | `uv run ai-agent evolve status` | Organism queue, budgets, last run/PR |
| Research operator | `uv run ai-agent -c config/agents/operator.yaml` | Interactive Research Desk |
| One-shot brief | `uv run ai-agent brief "agent harness"` | Writes `briefs/YYYYMMDD_slug.md` |
| Brief + approve | `uv run ai-agent brief "topic" --approve` | Console Y/n before write |
Expand Down
8 changes: 5 additions & 3 deletions config/agents/engineer.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ system_prompt: >
Workflow for each intent:
1. Survey with workspace_search (search, or read with start_line/end_line).
Do not open `.ai_agent/**` for doc intents.
2. Edit ONCE with replace_in_file (preferred for README bullets). After it
succeeds, NEVER call replace_in_file/write_file/apply_patch again.
2. Implement the FULL intent with up to 3 related edits (helper + CLI + test
when asked). A stub helper alone is NOT done — keep editing until usable.
After the feature is complete, NEVER call edit tools again.
3. VERIFY immediately: git_diff (must be NON-EMPTY) then run_checks preset=pytest.
4. PUBLISH in SEPARATE rounds: git_commit alone, then open_pull_request alone.
Commit/PR are auto-approved unless the operator enabled HITL (--approve).
Expand All @@ -17,7 +18,8 @@ system_prompt: >
Allowlist only: src/, tests/, config/, docs/, README/DECISIONS/AGENTS.
Never edit path_policy, merge policy kernel, STOP, or .env.
Never invent tool results. Never narrate-and-stop mid-pipeline.
max_tool_rounds: 16
Survey with at most 2-3 searches, then edit — do not burn rounds on search.
max_tool_rounds: 24
personality:
tone: pragmatic
style: concise
Expand Down
29 changes: 22 additions & 7 deletions config/evolve_backlog.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
# Rotating intents used when the organism goal queue is empty.
# Keep items small, allowlisted (src/tests/config/docs/README*), never kernel edits.
# Rotating intents when the organism goal queue is empty.
# Prefer concrete harness features over one-line doc tweaks.
goals:
- "Add or improve one unit test under tests/ for MergePolicy day-budget behavior."
- "Clarify Phase 2 evolve-worker usage in README.md with one short paragraph."
- "Add a brief AGENTS.md note about evolve-goal add/list and the STOP kill switch."
- "Improve DECISIONS.md with one sentence on goal-queue vs free-form invent intents."
- "Add or tighten one docstring in src/ai_agent/features/evolve/organism.py without behavior changes."
- >-
Add `ai-agent evolve status` CLI: print organism id, stopped flag, goal queue,
evolves_today/max_evolves_per_day, merges_today, next_wake_at, last_run_id,
and if last run exists print its status/pr_url/intent from run.json.
Implement in src/ai_agent/entrypoints/cli.py (+ thin helper under
features/evolve if needed). Add a focused unit test. Update the Modes table
in README.md with one row for evolve status. Do not edit PathPolicy/STOP/.env.
- >-
Add `ai-agent ops summary` that reads OpsEvent JSONL and prints counts by
event name plus success rate and average latency_ms when present. Keep it
under src/ai_agent/harness/ops_metrics.py + CLI wiring + one test. Update
README Modes/ops row briefly.
- >-
Add an engineer tool `evolve_inspect` (read-only) that returns JSON for the
current organism + last EvolveRun summary under .ai_agent/evolve/. Register
it for the engineer agent YAML. Include a unit test. No writes, no git.
- >-
Fix the broken Agent harness capabilities markdown table in README.md
(the Evolve CLI prose currently splits the table) so all capability rows
render as one table again, without changing meaning.
29 changes: 20 additions & 9 deletions src/ai_agent/entrypoints/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from ai_agent.features.evolve.goals import enqueue_goal, list_goals
from ai_agent.features.evolve.organism import ensure_organism, worker_loop, worker_tick
from ai_agent.features.evolve.service import run_evolve, save_organism
from ai_agent.features.evolve.status import build_status_state, render_status
from ai_agent.features.harness_bank.bank import (
admit_if_screened,
list_cells,
Expand Down Expand Up @@ -382,6 +383,10 @@ def _run_evolve_goal(args: argparse.Namespace) -> None:
raise ValueError("evolve-goal requires: add <text> | list")


def _run_evolve_status() -> None:
print(render_status(build_status_state()))


def _run_harness_bank(args: argparse.Namespace) -> None:
action = args.command_arg
if action == "list":
Expand Down Expand Up @@ -629,16 +634,22 @@ def main(argv: list[str] | None = None) -> None:
args.message = args.command_arg2 or args.topic or ""
_run_harness_command(args)
elif args.command == "evolve":
intent = args.topic or args.command_arg
if not intent:
raise ValueError('evolve requires an intent: ai-agent evolve "…"')
asyncio.run(
_run_evolve(
intent,
config_path=args.config,
require_approval=args.approve,
if args.command_arg == "status" and not args.topic:
_run_evolve_status()
else:
intent = args.topic or args.command_arg
if not intent:
raise ValueError(
'evolve requires an intent: ai-agent evolve "…" '
"(or: ai-agent evolve status)"
)
asyncio.run(
_run_evolve(
intent,
config_path=args.config,
require_approval=args.approve,
)
)
)
elif args.command == "evolve-worker":
_run_evolve_worker(
auto_merge=args.auto_merge,
Expand Down
164 changes: 138 additions & 26 deletions src/ai_agent/features/evolve/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,21 @@

Interpret the intent carefully:
- Doc/README intents → EDIT README.md (allowlisted). Never open `.ai_agent/**`.
- Make ONE meaningful edit that matches the intent (not a tiny noop).
- Feature intents must be COMPLETE before publish — not a stub helper alone.

Strict pipeline — CALL TOOLS in order; do not narrate:
1. Survey: workspace_search action=search (or read with start_line/end_line).
2. Edit ONCE with replace_in_file (preferred). Do not call replace_in_file again
after it succeeds.
3. VERIFY: git_diff (must be NON-EMPTY) then run_checks preset=pytest.
1. Survey briefly: at most 2 workspace_search/workspace_list calls, then STOP surveying.
2. Implement the FULL intent with up to 3 related edits (write_file/replace_in_file).
Example for a new CLI: helper module + CLI wiring + one test (and README row if asked).
A helper file with no CLI/test is NOT done — keep editing until the feature is usable.
3. VERIFY: git_diff (must be NON-EMPTY and cover the real change) then run_checks preset=pytest.
4. PUBLISH: git_commit alone (feature branch), then open_pull_request alone.
5. Respond with the PR URL only after open_pull_request succeeds.

If the requested text is already present and git_status is clean, respond that
the intent is already satisfied (no PR).
Never push to main. Never edit PathPolicy / MergePolicy / STOP / .env.
Never treat the repo homepage URL as a PR URL.
"""


Expand Down Expand Up @@ -140,10 +142,16 @@ def is_stopped(*, root: Path = DEFAULT_EVOLVE_ROOT) -> bool:
return (root / "STOP").is_file()


def pipeline_progress(tool_results: list[dict[str, object]]) -> PipelineProgress:
edited = any(
item.get("tool_name") in EDIT_TOOLS and item.get("success") for item in tool_results
def pipeline_progress(
tool_results: list[dict[str, object]],
*,
min_edits: int = 1,
) -> PipelineProgress:
edit_count = sum(
1 for item in tool_results if item.get("tool_name") in EDIT_TOOLS and item.get("success")
)
# Feature intents may need several related files before verify/publish.
edited = edit_count >= max(1, min_edits)
diff_seen = False
for item in tool_results:
if item.get("tool_name") != "git_diff" or not item.get("success"):
Expand All @@ -161,6 +169,9 @@ def pipeline_progress(tool_results: list[dict[str, object]]) -> PipelineProgress
item.get("tool_name") == "open_pull_request" and item.get("success")
for item in tool_results
)
# Once verify/publish starts, treat edit phase as complete.
if diff_seen or checks_ok or committed or pr_opened:
edited = True
return PipelineProgress(
edited=edited,
diff_seen=diff_seen or committed or pr_opened,
Expand All @@ -170,8 +181,74 @@ def pipeline_progress(tool_results: list[dict[str, object]]) -> PipelineProgress
)


def continue_prompt_for(progress: PipelineProgress) -> str:
def intent_min_edits(intent: str) -> int:
"""Feature-style intents need more than a stub helper before publish."""
text = intent.lower()
markers = (
"cli",
"test",
"readme",
"wire",
"status",
"helper",
"unit test",
"modes table",
)
if sum(1 for m in markers if m in text) >= 2:
return 2
return 1


def publish_block_reason(
intent: str,
*,
workspace: Path = Path("."),
) -> str | None:
"""
Block verify/publish when a CLI feature is still a stub.

Detects unused ``render_status`` imports and missing ``evolve status`` wiring.
"""
text = intent.lower()
wants_cli = "cli" in text or ("evolve" in text and "status" in text)
if not wants_cli:
return None

cli_path = workspace / "src" / "ai_agent" / "entrypoints" / "cli.py"
if not cli_path.is_file():
return "cli.py missing — implement the command before publish"
body = cli_path.read_text(encoding="utf-8")

if "render_status" in body and "render_status(" not in body:
return (
"cli.py imports render_status but never calls it — "
"wire `ai-agent evolve status` before publish"
)
if "status" in text and "_run_evolve_status" not in body and '== "status"' not in body:
return (
"cli.py has no evolve status handler — "
"add `evolve status` command wiring before publish"
)
if "test" in text:
tests_dir = workspace / "tests" / "features" / "evolve"
has_status_test = (tests_dir / "test_status.py").is_file()
if not has_status_test:
return "missing tests/features/evolve/test_status.py — add a test before publish"
return None


def continue_prompt_for(
progress: PipelineProgress,
*,
block_reason: str | None = None,
) -> str:
"""State-aware nudge so evolve does not re-edit forever."""
if block_reason:
return (
f"CONTINUE: publish blocked — {block_reason}. "
"Edit the missing wiring/tests with replace_in_file or write_file NOW. "
"Do not git_commit or open_pull_request yet."
)
action = progress.next_action
if action == "done":
return "PR already opened. Respond with the PR URL only."
Expand All @@ -192,12 +269,14 @@ def continue_prompt_for(progress: PipelineProgress) -> str:
)
if action == "git_diff":
return (
"CONTINUE: edit already succeeded. Call git_diff then run_checks. "
"Do NOT call replace_in_file/write_file/apply_patch again."
"CONTINUE: if the intent still needs CLI wiring or a test, edit those "
"files NOW (up to 3 total edits). Otherwise call git_diff then run_checks. "
"Do not open a PR for a stub helper alone."
)
return (
"CONTINUE: make the intent edit with replace_in_file ONCE, then git_diff, "
"run_checks, git_commit, open_pull_request. Do not narrate."
"CONTINUE: STOP surveying. Implement the FULL intent with write_file/"
"replace_in_file (helper + CLI + test if the intent asks). Then git_diff, "
"run_checks, git_commit, open_pull_request. Do not publish stubs."
)


Expand Down Expand Up @@ -254,6 +333,7 @@ async def run_evolve(
collected: list[dict[str, object]] = []
last_result: StepResult | None = None
turns = max(1, max_continue_turns)
min_edits = intent_min_edits(cleaned)

try:
for turn in range(turns):
Expand All @@ -265,8 +345,20 @@ async def run_evolve(
emit_ops_event(name="evolve.stopped", run_id=rid, success=False)
return run

progress = pipeline_progress(collected)
user_input = prompt if turn == 0 else continue_prompt_for(progress)
progress = pipeline_progress(collected, min_edits=min_edits)
block = None if progress.pr_opened else publish_block_reason(cleaned)
if block and progress.next_action != "edit":
# Force another edit cycle instead of verify/publish on stubs.
progress = PipelineProgress(
edited=False,
diff_seen=False,
checks_ok=False,
committed=False,
pr_opened=False,
)
user_input = (
prompt if turn == 0 else continue_prompt_for(progress, block_reason=block)
)
if turn > 0:
status_by_next: dict[
str,
Expand All @@ -291,7 +383,23 @@ async def run_evolve(
result = await agent.step(session=session, user_input=user_input)
last_result = result
collected.extend(result.tool_results or [])
progress = pipeline_progress(collected)
progress = pipeline_progress(collected, min_edits=min_edits)
block_after = None if progress.pr_opened else publish_block_reason(cleaned)
if block_after and progress.next_action in {
"git_diff",
"run_checks",
"git_commit",
"open_pull_request",
"done",
}:
# Stub still incomplete — keep evolving even if model raced to verify.
progress = PipelineProgress(
edited=False,
diff_seen=False,
checks_ok=False,
committed=False,
pr_opened=False,
)
logger.info(
"evolve_turn run_id=%s turn=%s kind=%s next=%s msg=%s",
rid,
Expand All @@ -316,9 +424,12 @@ async def run_evolve(
raise RuntimeError(result.message)

pr_url = _extract_pr_url(result.message, collected)
if pr_url or progress.pr_opened:
# Only finish when open_pull_request actually succeeded — never
# treat a random github.com link from README/survey text as a PR.
stub = publish_block_reason(cleaned)
if progress.pr_opened and pr_url and not stub:
latency_ms = int((datetime.now(UTC) - started).total_seconds() * 1000)
run.pr_url = pr_url or _extract_pr_url("", collected)
run.pr_url = pr_url
run.status = "done"
run.error = None
run.last_check_log = _last_check_log(collected)
Expand Down Expand Up @@ -355,7 +466,7 @@ async def run_evolve(
assert last_result is not None
latency_ms = int((datetime.now(UTC) - started).total_seconds() * 1000)
_write_result(artifact_dir, last_result, collected)
progress = pipeline_progress(collected)
progress = pipeline_progress(collected, min_edits=min_edits)
if progress.committed:
run.status = "awaiting_approval"
run.error = "commit succeeded but open_pull_request did not run or failed"
Expand Down Expand Up @@ -448,14 +559,15 @@ def _tool_succeeded(tool_results: list[dict[str, object]] | None, name: str) ->


def _extract_pr_url(message: str, tool_results: list[dict[str, object]] | None) -> str | None:
"""Accept only URLs from a successful open_pull_request tool result."""
_ = message # never scrape free-form assistant text (README links false-positive)
for item in tool_results or []:
if item.get("tool_name") == "open_pull_request" and item.get("success"):
out = str(item.get("output") or "")
if out.startswith("http"):
return out.strip()
match = re.search(r"https://github\.com/[^\s)]+", message)
if match:
return match.group(0)
if item.get("tool_name") != "open_pull_request" or not item.get("success"):
continue
out = str(item.get("output") or "").strip()
if "/pull/" in out and out.startswith("http"):
# First URL token only
return out.split()[0].rstrip("\"')].>")
return None


Expand Down
Loading
Loading