mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-02-01 15:03:36 +00:00
Enable the orchestrator to assign 1-3 features per coding agent subprocess, selected via dependency chain extension + same-category fill. This reduces cold-start overhead and leverages shared context across related features. Orchestrator (parallel_orchestrator.py): - Add batch tracking: _batch_features and _feature_to_primary data structures - Add build_feature_batches() with dependency chain + category fill algorithm - Add start_feature_batch() and _spawn_coding_agent_batch() methods - Update _on_agent_complete() for batch cleanup across all features - Update stop_feature() with _feature_to_primary lookup - Update get_ready_features() to exclude all batch feature IDs - Update main loop to build batches then spawn per available slot CLI and agent layer: - Add --feature-ids (comma-separated) and --batch-size CLI args - Add feature_ids parameter to run_autonomous_agent() with batch prompt selection - Add get_batch_feature_prompt() with sequential workflow instructions WebSocket layer (server/websocket.py): - Add BATCH_CODING_AGENT_START_PATTERN and BATCH_FEATURES_COMPLETE_PATTERN - Add _handle_batch_agent_start() and _handle_batch_agent_complete() methods - Add featureIds field to all agent_update messages - Track current_feature_id updates as agent moves through batch Frontend (React UI): - Add featureIds to ActiveAgent and WSAgentUpdateMessage types - Update KanbanColumn and DependencyGraph agent-feature maps for batch - Update AgentCard to show "Batch: #X, #Y, #Z" with active feature highlight - Add "Features per Agent" segmented control (1-3) in SettingsModal Settings integration (full stack): - Add batch_size to schemas, settings router, agent router, process manager - Default batch_size=3, user-configurable 1-3 via settings UI - batch_size=1 is functionally identical to pre-batching behavior Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
445 lines
18 KiB
Python
445 lines
18 KiB
Python
"""
|
|
Agent Session Logic
|
|
===================
|
|
|
|
Core agent interaction functions for running autonomous coding sessions.
|
|
"""
|
|
|
|
import asyncio
|
|
import io
|
|
import re
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from claude_agent_sdk import ClaudeSDKClient
|
|
|
|
# Fix Windows console encoding for Unicode characters (emoji, etc.)
|
|
# Without this, print() crashes when Claude outputs emoji like ✅
|
|
if sys.platform == "win32":
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace", line_buffering=True)
|
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace", line_buffering=True)
|
|
|
|
from client import create_client
|
|
from progress import (
|
|
count_passing_tests,
|
|
has_features,
|
|
print_progress_summary,
|
|
print_session_header,
|
|
)
|
|
from prompts import (
|
|
copy_spec_to_project,
|
|
get_batch_feature_prompt,
|
|
get_coding_prompt,
|
|
get_initializer_prompt,
|
|
get_single_feature_prompt,
|
|
get_testing_prompt,
|
|
)
|
|
from rate_limit_utils import (
|
|
calculate_error_backoff,
|
|
calculate_rate_limit_backoff,
|
|
clamp_retry_delay,
|
|
is_rate_limit_error,
|
|
parse_retry_after,
|
|
)
|
|
|
|
# Configuration
|
|
AUTO_CONTINUE_DELAY_SECONDS = 3
|
|
|
|
|
|
async def run_agent_session(
|
|
client: ClaudeSDKClient,
|
|
message: str,
|
|
project_dir: Path,
|
|
) -> tuple[str, str]:
|
|
"""
|
|
Run a single agent session using Claude Agent SDK.
|
|
|
|
Args:
|
|
client: Claude SDK client
|
|
message: The prompt to send
|
|
project_dir: Project directory path
|
|
|
|
Returns:
|
|
(status, response_text) where status is:
|
|
- "continue" if agent should continue working
|
|
- "error" if an error occurred
|
|
"""
|
|
print("Sending prompt to Claude Agent SDK...\n")
|
|
|
|
try:
|
|
# Send the query
|
|
await client.query(message)
|
|
|
|
# Collect response text and show tool use
|
|
response_text = ""
|
|
async for msg in client.receive_response():
|
|
msg_type = type(msg).__name__
|
|
|
|
# Handle AssistantMessage (text and tool use)
|
|
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
|
for block in msg.content:
|
|
block_type = type(block).__name__
|
|
|
|
if block_type == "TextBlock" and hasattr(block, "text"):
|
|
response_text += block.text
|
|
print(block.text, end="", flush=True)
|
|
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
|
print(f"\n[Tool: {block.name}]", flush=True)
|
|
if hasattr(block, "input"):
|
|
input_str = str(block.input)
|
|
if len(input_str) > 200:
|
|
print(f" Input: {input_str[:200]}...", flush=True)
|
|
else:
|
|
print(f" Input: {input_str}", flush=True)
|
|
|
|
# Handle UserMessage (tool results)
|
|
elif msg_type == "UserMessage" and hasattr(msg, "content"):
|
|
for block in msg.content:
|
|
block_type = type(block).__name__
|
|
|
|
if block_type == "ToolResultBlock":
|
|
result_content = getattr(block, "content", "")
|
|
is_error = getattr(block, "is_error", False)
|
|
|
|
# Check if command was blocked by security hook
|
|
if "blocked" in str(result_content).lower():
|
|
print(f" [BLOCKED] {result_content}", flush=True)
|
|
elif is_error:
|
|
# Show errors (truncated)
|
|
error_str = str(result_content)[:500]
|
|
print(f" [Error] {error_str}", flush=True)
|
|
else:
|
|
# Tool succeeded - just show brief confirmation
|
|
print(" [Done]", flush=True)
|
|
|
|
print("\n" + "-" * 70 + "\n")
|
|
return "continue", response_text
|
|
|
|
except Exception as e:
|
|
error_str = str(e)
|
|
print(f"Error during agent session: {error_str}")
|
|
|
|
# Detect rate limit errors from exception message
|
|
if is_rate_limit_error(error_str):
|
|
# Try to extract retry-after time from error
|
|
retry_seconds = parse_retry_after(error_str)
|
|
if retry_seconds is not None:
|
|
return "rate_limit", str(retry_seconds)
|
|
else:
|
|
return "rate_limit", "unknown"
|
|
|
|
return "error", error_str
|
|
|
|
|
|
async def run_autonomous_agent(
|
|
project_dir: Path,
|
|
model: str,
|
|
max_iterations: Optional[int] = None,
|
|
yolo_mode: bool = False,
|
|
feature_id: Optional[int] = None,
|
|
feature_ids: Optional[list[int]] = None,
|
|
agent_type: Optional[str] = None,
|
|
testing_feature_id: Optional[int] = None,
|
|
testing_feature_ids: Optional[list[int]] = None,
|
|
) -> None:
|
|
"""
|
|
Run the autonomous agent loop.
|
|
|
|
Args:
|
|
project_dir: Directory for the project
|
|
model: Claude model to use
|
|
max_iterations: Maximum number of iterations (None for unlimited)
|
|
yolo_mode: If True, skip browser testing in coding agent prompts
|
|
feature_id: If set, work only on this specific feature (used by orchestrator for coding agents)
|
|
feature_ids: If set, work on these features in batch (used by orchestrator for batch mode)
|
|
agent_type: Type of agent: "initializer", "coding", "testing", or None (auto-detect)
|
|
testing_feature_id: For testing agents, the pre-claimed feature ID to test (legacy single mode)
|
|
testing_feature_ids: For testing agents, list of feature IDs to batch test
|
|
"""
|
|
print("\n" + "=" * 70)
|
|
print(" AUTONOMOUS CODING AGENT")
|
|
print("=" * 70)
|
|
print(f"\nProject directory: {project_dir}")
|
|
print(f"Model: {model}")
|
|
if agent_type:
|
|
print(f"Agent type: {agent_type}")
|
|
if yolo_mode:
|
|
print("Mode: YOLO (testing agents disabled)")
|
|
if feature_ids and len(feature_ids) > 1:
|
|
print(f"Feature batch: {', '.join(f'#{fid}' for fid in feature_ids)}")
|
|
elif feature_id:
|
|
print(f"Feature assignment: #{feature_id}")
|
|
if max_iterations:
|
|
print(f"Max iterations: {max_iterations}")
|
|
else:
|
|
print("Max iterations: Unlimited (will run until completion)")
|
|
print()
|
|
|
|
# Create project directory
|
|
project_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Determine agent type if not explicitly set
|
|
if agent_type is None:
|
|
# Auto-detect based on whether we have features
|
|
# (This path is for legacy compatibility - orchestrator should always set agent_type)
|
|
is_first_run = not has_features(project_dir)
|
|
if is_first_run:
|
|
agent_type = "initializer"
|
|
else:
|
|
agent_type = "coding"
|
|
|
|
is_initializer = agent_type == "initializer"
|
|
|
|
if is_initializer:
|
|
print("Running as INITIALIZER agent")
|
|
print()
|
|
print("=" * 70)
|
|
print(" NOTE: Initialization takes 10-20+ minutes!")
|
|
print(" The agent is generating detailed test cases.")
|
|
print(" This may appear to hang - it's working. Watch for [Tool: ...] output.")
|
|
print("=" * 70)
|
|
print()
|
|
# Copy the app spec into the project directory for the agent to read
|
|
copy_spec_to_project(project_dir)
|
|
elif agent_type == "testing":
|
|
print("Running as TESTING agent (regression testing)")
|
|
print_progress_summary(project_dir)
|
|
else:
|
|
print("Running as CODING agent")
|
|
print_progress_summary(project_dir)
|
|
|
|
# Main loop
|
|
iteration = 0
|
|
rate_limit_retries = 0 # Track consecutive rate limit errors for exponential backoff
|
|
error_retries = 0 # Track consecutive non-rate-limit errors
|
|
|
|
while True:
|
|
iteration += 1
|
|
|
|
# Check if all features are already complete (before starting a new session)
|
|
# Skip this check if running as initializer (needs to create features first)
|
|
if not is_initializer and iteration == 1:
|
|
passing, in_progress, total = count_passing_tests(project_dir)
|
|
if total > 0 and passing == total:
|
|
print("\n" + "=" * 70)
|
|
print(" ALL FEATURES ALREADY COMPLETE!")
|
|
print("=" * 70)
|
|
print(f"\nAll {total} features are passing. Nothing left to do.")
|
|
break
|
|
|
|
# Check max iterations
|
|
if max_iterations and iteration > max_iterations:
|
|
print(f"\nReached max iterations ({max_iterations})")
|
|
print("To continue, run the script again without --max-iterations")
|
|
break
|
|
|
|
# Print session header
|
|
print_session_header(iteration, is_initializer)
|
|
|
|
# Create client (fresh context)
|
|
# Pass agent_id for browser isolation in multi-agent scenarios
|
|
import os
|
|
if agent_type == "testing":
|
|
agent_id = f"testing-{os.getpid()}" # Unique ID for testing agents
|
|
elif feature_ids and len(feature_ids) > 1:
|
|
agent_id = f"batch-{feature_ids[0]}"
|
|
elif feature_id:
|
|
agent_id = f"feature-{feature_id}"
|
|
else:
|
|
agent_id = None
|
|
client = create_client(project_dir, model, yolo_mode=yolo_mode, agent_id=agent_id, agent_type=agent_type)
|
|
|
|
# Choose prompt based on agent type
|
|
if agent_type == "initializer":
|
|
prompt = get_initializer_prompt(project_dir)
|
|
elif agent_type == "testing":
|
|
prompt = get_testing_prompt(project_dir, testing_feature_id, testing_feature_ids)
|
|
elif feature_ids and len(feature_ids) > 1:
|
|
# Batch mode (used by orchestrator for multi-feature coding agents)
|
|
prompt = get_batch_feature_prompt(feature_ids, project_dir, yolo_mode)
|
|
elif feature_id or (feature_ids is not None and len(feature_ids) == 1):
|
|
# Single-feature mode (used by orchestrator for coding agents)
|
|
fid = feature_id if feature_id is not None else feature_ids[0] # type: ignore[index]
|
|
prompt = get_single_feature_prompt(fid, project_dir, yolo_mode)
|
|
else:
|
|
# General coding prompt (legacy path)
|
|
prompt = get_coding_prompt(project_dir, yolo_mode=yolo_mode)
|
|
|
|
# Run session with async context manager
|
|
# Wrap in try/except to handle MCP server startup failures gracefully
|
|
try:
|
|
async with client:
|
|
status, response = await run_agent_session(client, prompt, project_dir)
|
|
except Exception as e:
|
|
print(f"Client/MCP server error: {e}")
|
|
# Don't crash - return error status so the loop can retry
|
|
status, response = "error", str(e)
|
|
|
|
# Check for project completion - EXIT when all features pass
|
|
if "all features are passing" in response.lower() or "no more work to do" in response.lower():
|
|
print("\n" + "=" * 70)
|
|
print(" 🎉 PROJECT COMPLETE - ALL FEATURES PASSING!")
|
|
print("=" * 70)
|
|
print_progress_summary(project_dir)
|
|
break
|
|
|
|
# Handle status
|
|
if status == "continue":
|
|
# Reset error retries on success; rate-limit retries reset only if no signal
|
|
error_retries = 0
|
|
reset_rate_limit_retries = True
|
|
|
|
delay_seconds = AUTO_CONTINUE_DELAY_SECONDS
|
|
target_time_str = None
|
|
|
|
# Check for rate limit indicators in response text
|
|
if is_rate_limit_error(response):
|
|
print("Claude Agent SDK indicated rate limit reached.")
|
|
reset_rate_limit_retries = False
|
|
|
|
# Try to extract retry-after from response text first
|
|
retry_seconds = parse_retry_after(response)
|
|
if retry_seconds is not None:
|
|
delay_seconds = clamp_retry_delay(retry_seconds)
|
|
else:
|
|
# Use exponential backoff when retry-after unknown
|
|
delay_seconds = calculate_rate_limit_backoff(rate_limit_retries)
|
|
rate_limit_retries += 1
|
|
|
|
# Try to parse reset time from response (more specific format)
|
|
match = re.search(
|
|
r"(?i)\bresets(?:\s+at)?\s+(\d+)(?::(\d+))?\s*(am|pm)\s*\(([^)]+)\)",
|
|
response,
|
|
)
|
|
if match:
|
|
hour = int(match.group(1))
|
|
minute = int(match.group(2)) if match.group(2) else 0
|
|
period = match.group(3).lower()
|
|
tz_name = match.group(4).strip()
|
|
|
|
# Convert to 24-hour format
|
|
if period == "pm" and hour != 12:
|
|
hour += 12
|
|
elif period == "am" and hour == 12:
|
|
hour = 0
|
|
|
|
try:
|
|
tz = ZoneInfo(tz_name)
|
|
now = datetime.now(tz)
|
|
target = now.replace(
|
|
hour=hour, minute=minute, second=0, microsecond=0
|
|
)
|
|
|
|
# If target time has already passed today, wait until tomorrow
|
|
if target <= now:
|
|
target += timedelta(days=1)
|
|
|
|
delta = target - now
|
|
delay_seconds = min(max(int(delta.total_seconds()), 1), 24 * 60 * 60)
|
|
target_time_str = target.strftime("%B %d, %Y at %I:%M %p %Z")
|
|
|
|
except Exception as e:
|
|
print(f"Error parsing reset time: {e}, using default delay")
|
|
|
|
if target_time_str:
|
|
print(
|
|
f"\nClaude Code Limit Reached. Agent will auto-continue in {delay_seconds:.0f}s ({target_time_str})...",
|
|
flush=True,
|
|
)
|
|
else:
|
|
print(
|
|
f"\nAgent will auto-continue in {delay_seconds:.0f}s...", flush=True
|
|
)
|
|
|
|
sys.stdout.flush() # this should allow the pause to be displayed before sleeping
|
|
print_progress_summary(project_dir)
|
|
|
|
# Check if all features are complete - exit gracefully if done
|
|
passing, in_progress, total = count_passing_tests(project_dir)
|
|
if total > 0 and passing == total:
|
|
print("\n" + "=" * 70)
|
|
print(" ALL FEATURES COMPLETE!")
|
|
print("=" * 70)
|
|
print(f"\nCongratulations! All {total} features are passing.")
|
|
print("The autonomous agent has finished its work.")
|
|
break
|
|
|
|
# Single-feature mode, batch mode, or testing agent: exit after one session
|
|
if feature_ids and len(feature_ids) > 1:
|
|
print(f"\nBatch mode: Features {', '.join(f'#{fid}' for fid in feature_ids)} session complete.")
|
|
break
|
|
elif feature_id is not None or (feature_ids is not None and len(feature_ids) == 1):
|
|
fid = feature_id if feature_id is not None else feature_ids[0] # type: ignore[index]
|
|
if agent_type == "testing":
|
|
print("\nTesting agent complete. Terminating session.")
|
|
else:
|
|
print(f"\nSingle-feature mode: Feature #{fid} session complete.")
|
|
break
|
|
elif agent_type == "testing":
|
|
print("\nTesting agent complete. Terminating session.")
|
|
break
|
|
|
|
# Reset rate limit retries only if no rate limit signal was detected
|
|
if reset_rate_limit_retries:
|
|
rate_limit_retries = 0
|
|
|
|
await asyncio.sleep(delay_seconds)
|
|
|
|
elif status == "rate_limit":
|
|
# Smart rate limit handling with exponential backoff
|
|
# Reset error counter so mixed events don't inflate delays
|
|
error_retries = 0
|
|
if response != "unknown":
|
|
try:
|
|
delay_seconds = clamp_retry_delay(int(response))
|
|
except (ValueError, TypeError):
|
|
# Malformed value - fall through to exponential backoff
|
|
response = "unknown"
|
|
if response == "unknown":
|
|
# Use exponential backoff when retry-after unknown or malformed
|
|
delay_seconds = calculate_rate_limit_backoff(rate_limit_retries)
|
|
rate_limit_retries += 1
|
|
print(f"\nRate limit hit. Backoff wait: {delay_seconds} seconds (attempt #{rate_limit_retries})...")
|
|
else:
|
|
print(f"\nRate limit hit. Waiting {delay_seconds} seconds before retry...")
|
|
|
|
await asyncio.sleep(delay_seconds)
|
|
|
|
elif status == "error":
|
|
# Non-rate-limit errors: linear backoff capped at 5 minutes
|
|
# Reset rate limit counter so mixed events don't inflate delays
|
|
rate_limit_retries = 0
|
|
error_retries += 1
|
|
delay_seconds = calculate_error_backoff(error_retries)
|
|
print("\nSession encountered an error")
|
|
print(f"Will retry in {delay_seconds}s (attempt #{error_retries})...")
|
|
await asyncio.sleep(delay_seconds)
|
|
|
|
# Small delay between sessions
|
|
if max_iterations is None or iteration < max_iterations:
|
|
print("\nPreparing next session...\n")
|
|
await asyncio.sleep(1)
|
|
|
|
# Final summary
|
|
print("\n" + "=" * 70)
|
|
print(" SESSION COMPLETE")
|
|
print("=" * 70)
|
|
print(f"\nProject directory: {project_dir}")
|
|
print_progress_summary(project_dir)
|
|
|
|
# Print instructions for running the generated application
|
|
print("\n" + "-" * 70)
|
|
print(" TO RUN THE GENERATED APPLICATION:")
|
|
print("-" * 70)
|
|
print(f"\n cd {project_dir.resolve()}")
|
|
print(" ./init.sh # Run the setup script")
|
|
print(" # Or manually:")
|
|
print(" npm install && npm run dev")
|
|
print("\n Then open http://localhost:3000 (or check init.sh for the URL)")
|
|
print("-" * 70)
|
|
|
|
print("\nDone!")
|