mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
roboco/agents/base.py - Added proper workflow methods: - _qa_pass(task_id, qa_notes) - Calls /pass-qa endpoint - _qa_fail(task_id, qa_notes, issues) - Calls /fail-qa endpoint - _docs_complete(task_id, doc_notes?) - Calls /docs-complete endpoint - Marked old _mark_needs_revision, _mark_awaiting_documentation, _mark_awaiting_pm_review as DEPRECATED roboco/agents/qa.py - Fixed verdict phase: - Now uses _qa_pass() instead of _mark_awaiting_documentation() - Now uses _qa_fail() instead of _mark_needs_revision() - Properly passes QA notes and issue lists roboco/agents/documenter.py - Fixed submit phase: - Now uses _docs_complete() instead of _mark_awaiting_pm_review() - Properly passes documentation summary as notes
617 lines
21 KiB
Python
617 lines
21 KiB
Python
"""
|
|
Developer Agent
|
|
|
|
Implementation of the Developer workflow from the blueprint.
|
|
Handles task lifecycle:
|
|
SCAN → CLAIM → UNDERSTAND → PLAN → EXECUTE → VERIFY → NOTES → CLOSE
|
|
"""
|
|
|
|
from datetime import UTC, datetime
|
|
from uuid import UUID
|
|
|
|
import structlog
|
|
|
|
from roboco.agents.base import Agent, AgentConfig
|
|
from roboco.agents.mixins import PhaseConfig, PhaseEngine
|
|
from roboco.models import AgentStatus, TaskStatus
|
|
from roboco.models.agents import DevTaskPhase, TaskContext
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
class DeveloperAgent(Agent, PhaseEngine[DevTaskPhase, TaskContext]):
|
|
"""
|
|
Developer agent that follows the Dev Lifecycle.
|
|
|
|
Workflow:
|
|
1. SCAN - Check for assigned/paused tasks
|
|
2. CLAIM - Lock and announce task
|
|
3. UNDERSTAND - Read requirements, ask if unclear
|
|
4. PLAN - Break into subtasks, create plan
|
|
5. EXECUTE - Work through subtasks, commit frequently
|
|
6. VERIFY - Self-test, run quality checks
|
|
7. NOTES - Document journey, create handoff, submit for QA
|
|
8. DONE - Return to SCAN (QA → Documenter → PM complete the task)
|
|
"""
|
|
|
|
def __init__(self, config: AgentConfig) -> None:
|
|
"""Initialize developer agent."""
|
|
super().__init__(config)
|
|
self._task_context: TaskContext | None = None
|
|
self._cell_channel_id: UUID | None = None
|
|
|
|
async def _initialize(self) -> None:
|
|
"""Initialize developer-specific resources."""
|
|
self.log.debug("Developer agent initialized", agent_id=str(self.id))
|
|
|
|
async def _cleanup(self) -> None:
|
|
"""Cleanup developer-specific resources."""
|
|
self._task_context = None
|
|
self.log.debug("Developer agent cleanup complete", agent_id=str(self.id))
|
|
|
|
# =========================================================================
|
|
# PHASE ENGINE IMPLEMENTATION
|
|
# =========================================================================
|
|
|
|
def _get_phase_configs(self) -> list[PhaseConfig[DevTaskPhase]]:
|
|
"""Define the developer workflow phases."""
|
|
return [
|
|
PhaseConfig(
|
|
DevTaskPhase.CLAIM,
|
|
self._phase_claim,
|
|
next_phase=DevTaskPhase.UNDERSTAND,
|
|
),
|
|
PhaseConfig(
|
|
DevTaskPhase.UNDERSTAND,
|
|
self._phase_understand,
|
|
next_phase=DevTaskPhase.PLAN,
|
|
requires_completion=True,
|
|
),
|
|
PhaseConfig(
|
|
DevTaskPhase.PLAN,
|
|
self._phase_plan,
|
|
next_phase=DevTaskPhase.EXECUTE,
|
|
),
|
|
PhaseConfig(
|
|
DevTaskPhase.EXECUTE,
|
|
self._phase_execute,
|
|
next_phase=DevTaskPhase.VERIFY,
|
|
requires_completion=True,
|
|
),
|
|
PhaseConfig(
|
|
DevTaskPhase.VERIFY,
|
|
self._phase_verify,
|
|
next_phase=DevTaskPhase.NOTES,
|
|
fail_phase=DevTaskPhase.EXECUTE, # Back to execute on failure
|
|
requires_completion=True,
|
|
),
|
|
PhaseConfig(
|
|
DevTaskPhase.NOTES,
|
|
self._phase_notes,
|
|
next_phase=None, # Terminal - developer done
|
|
),
|
|
PhaseConfig(
|
|
DevTaskPhase.BLOCKED,
|
|
self._phase_blocked,
|
|
next_phase=DevTaskPhase.EXECUTE, # Resume execution when unblocked
|
|
requires_completion=True,
|
|
),
|
|
]
|
|
|
|
def _get_current_phase(self, ctx: TaskContext) -> DevTaskPhase:
|
|
"""Get the current phase from context."""
|
|
return ctx.phase
|
|
|
|
def _set_current_phase(self, ctx: TaskContext, phase: DevTaskPhase) -> None:
|
|
"""Set the current phase in context."""
|
|
ctx.phase = phase
|
|
|
|
# =========================================================================
|
|
# LIFECYCLE IMPLEMENTATION
|
|
# =========================================================================
|
|
|
|
async def find_work(self) -> UUID | None:
|
|
"""
|
|
SCAN phase: Find available work.
|
|
|
|
Priority order:
|
|
1. Own paused/interrupted tasks
|
|
2. Assigned tasks
|
|
3. If none, signal availability to PM
|
|
"""
|
|
self.log.info("Scanning for work")
|
|
|
|
# Check for paused tasks first (highest priority)
|
|
paused_task = await self._find_paused_task()
|
|
if paused_task:
|
|
self.log.info("Found paused task", task_id=str(paused_task))
|
|
return paused_task
|
|
|
|
# Check for assigned tasks
|
|
assigned_task = await self._find_assigned_task()
|
|
if assigned_task:
|
|
self.log.info("Found assigned task", task_id=str(assigned_task))
|
|
return assigned_task
|
|
|
|
# Signal availability to PM
|
|
await self._signal_availability()
|
|
return None
|
|
|
|
async def execute_task(self, task_id: UUID) -> bool:
|
|
"""
|
|
Execute task through the developer lifecycle phases.
|
|
|
|
Returns True when developer's work is complete (submitted for QA).
|
|
QA, Documenter, and PM handle the rest of the lifecycle.
|
|
"""
|
|
# Initialize or restore task context
|
|
if self._task_context is None or self._task_context.task_id != task_id:
|
|
title, session_id = await self._get_task_info(task_id)
|
|
self._task_context = TaskContext(
|
|
task_id=task_id,
|
|
title=title,
|
|
session_id=session_id,
|
|
)
|
|
|
|
ctx = self._task_context
|
|
|
|
try:
|
|
result = await self._run_phase_engine(ctx)
|
|
|
|
if result.error:
|
|
self.log.error("Phase error", error=result.error)
|
|
ctx.blockers.append(result.error)
|
|
ctx.phase = DevTaskPhase.BLOCKED
|
|
return False
|
|
|
|
if result.completed:
|
|
self._task_context = None
|
|
return True
|
|
|
|
return False
|
|
|
|
except Exception as e:
|
|
self.log.error("Error in task phase", phase=ctx.phase.value, error=str(e))
|
|
ctx.blockers.append(str(e))
|
|
ctx.phase = DevTaskPhase.BLOCKED
|
|
return False
|
|
|
|
# =========================================================================
|
|
# PHASE IMPLEMENTATIONS
|
|
# =========================================================================
|
|
|
|
async def _phase_claim(self, ctx: TaskContext) -> None:
|
|
"""
|
|
CLAIM phase: Lock the task and announce.
|
|
|
|
- Claim task via /claim endpoint (validates status)
|
|
- Announce in cell channel
|
|
"""
|
|
self.log.info("CLAIM phase", task_id=str(ctx.task_id))
|
|
|
|
# Claim via proper endpoint (validates task is claimable)
|
|
await self._mark_claimed(ctx.task_id)
|
|
|
|
# Announce in session
|
|
await self.send_message(
|
|
ctx.session_id,
|
|
f"Claiming TASK-{str(ctx.task_id)[:8]}: {ctx.title}",
|
|
message_type="action",
|
|
task_id=ctx.task_id,
|
|
)
|
|
|
|
# Journal entry
|
|
ctx.journal_entries.append(
|
|
f"[{datetime.now(UTC).isoformat()}] Claimed task. Beginning work."
|
|
)
|
|
|
|
async def _phase_understand(self, ctx: TaskContext) -> bool:
|
|
"""
|
|
UNDERSTAND phase: Read and comprehend requirements.
|
|
|
|
- Read task record
|
|
- Read related code/docs
|
|
- Ask if unclear (GATE: must understand before proceeding)
|
|
|
|
Returns True if understood, False if still clarifying.
|
|
"""
|
|
self.log.info("UNDERSTAND phase", task_id=str(ctx.task_id))
|
|
|
|
# Read task requirements
|
|
requirements = await self._read_task_requirements(ctx.task_id)
|
|
|
|
# Format context using TOON for token efficiency
|
|
task_context = self.format_context_labeled(
|
|
"Task Context",
|
|
{"title": ctx.title, "requirements": requirements},
|
|
)
|
|
|
|
# Use LLM to understand and identify gaps
|
|
prompt = f"""You are analyzing a task before beginning work.
|
|
|
|
{task_context}
|
|
|
|
Analyze:
|
|
1. What exactly needs to be done?
|
|
2. What are the acceptance criteria?
|
|
3. Is anything unclear that requires clarification?
|
|
|
|
If everything is clear, respond with: "UNDERSTOOD: [your understanding summary]"
|
|
If clarification needed, respond with: "QUESTION: [your question]"
|
|
"""
|
|
response = await self.think(prompt)
|
|
|
|
if response.startswith("UNDERSTOOD:"):
|
|
# Add understanding to journal
|
|
ctx.journal_entries.append(
|
|
f"[{datetime.now(UTC).isoformat()}] Understanding: {response}"
|
|
)
|
|
return True
|
|
else:
|
|
# Ask question in session
|
|
question = response.replace("QUESTION:", "").strip()
|
|
await self.send_message(
|
|
ctx.session_id,
|
|
f"Question about TASK-{str(ctx.task_id)[:8]}: {question}",
|
|
message_type="dialogue",
|
|
task_id=ctx.task_id,
|
|
)
|
|
return False
|
|
|
|
async def _phase_plan(self, ctx: TaskContext) -> None:
|
|
"""
|
|
PLAN phase: Break task into subtasks.
|
|
|
|
- Create implementation plan
|
|
- Save plan to task via API (REQUIRED before start)
|
|
- Identify dependencies and risks
|
|
- Journal the approach
|
|
"""
|
|
self.log.info("PLAN phase", task_id=str(ctx.task_id))
|
|
|
|
# Format context using TOON
|
|
plan_context = self.format_context_labeled(
|
|
"Task",
|
|
{
|
|
"title": ctx.title,
|
|
"understanding": ctx.journal_entries[-1]
|
|
if ctx.journal_entries
|
|
else "No context",
|
|
},
|
|
)
|
|
|
|
# Use LLM to create plan - request TOON tabular response
|
|
prompt = f"""Create an implementation plan for this task:
|
|
|
|
{plan_context}
|
|
|
|
Break this into ordered subtasks. For each subtask provide:
|
|
- Clear description
|
|
- Files to modify
|
|
- Estimated complexity (small/medium/large)
|
|
|
|
Format response as TOON tabular:
|
|
[N,]{{description,files,complexity}}:
|
|
Implement the main logic,src/main.py|src/utils.py,medium
|
|
Add unit tests,tests/test_main.py,small
|
|
"""
|
|
response = await self.think(prompt)
|
|
|
|
# Parse subtasks using TOON (falls back to JSON)
|
|
try:
|
|
subtasks = self.parse_llm_response(response)
|
|
if isinstance(subtasks, list):
|
|
ctx.subtasks = subtasks
|
|
else:
|
|
ctx.subtasks = [
|
|
{"description": response, "files": [], "complexity": "medium"}
|
|
]
|
|
except ValueError:
|
|
# Fallback if parsing fails
|
|
ctx.subtasks = [
|
|
{"description": response, "files": [], "complexity": "medium"}
|
|
]
|
|
|
|
# Analyze risks based on subtask complexity
|
|
risks = []
|
|
complex_subtasks = [
|
|
s for s in ctx.subtasks if s.get("complexity") == "high"
|
|
]
|
|
if complex_subtasks:
|
|
risks.append(
|
|
f"{len(complex_subtasks)} high-complexity subtasks may need extra time"
|
|
)
|
|
max_subtasks_per_phase = 5
|
|
if len(ctx.subtasks) > max_subtasks_per_phase:
|
|
risks.append("Large number of subtasks - may need to split into phases")
|
|
|
|
# Estimate sessions based on subtask count and complexity
|
|
estimated_sessions = max(1, len(ctx.subtasks) // 3 + len(complex_subtasks))
|
|
|
|
# Save plan to task via API (REQUIRED before start can be called)
|
|
plan_data = {
|
|
"approach": f"Implement {ctx.title}",
|
|
"steps": [s.get("description", str(s)) for s in ctx.subtasks],
|
|
"risks": risks,
|
|
"estimated_sessions": estimated_sessions,
|
|
}
|
|
await self._api_call("PATCH", f"/tasks/{ctx.task_id}", json={"plan": plan_data})
|
|
|
|
# Journal entry
|
|
ts = datetime.now(UTC).isoformat()
|
|
ctx.journal_entries.append(f"[{ts}] Plan: {len(ctx.subtasks)} subtasks created")
|
|
|
|
# Announce plan
|
|
await self.send_message(
|
|
ctx.session_id,
|
|
f"TASK-{str(ctx.task_id)[:8]} plan ready: {len(ctx.subtasks)} subtasks",
|
|
message_type="action",
|
|
task_id=ctx.task_id,
|
|
)
|
|
|
|
async def _phase_execute(self, ctx: TaskContext) -> bool:
|
|
"""
|
|
EXECUTE phase: Work through subtasks.
|
|
|
|
- START: Transition to in_progress on first execution
|
|
- Execute current subtask
|
|
- Commit with meaningful messages
|
|
- Update progress
|
|
|
|
Returns True when all subtasks complete.
|
|
"""
|
|
self.log.info(
|
|
"EXECUTE phase",
|
|
task_id=str(ctx.task_id),
|
|
subtask=ctx.current_subtask,
|
|
total=len(ctx.subtasks),
|
|
)
|
|
|
|
# START: Transition to in_progress on first subtask
|
|
if ctx.current_subtask == 0:
|
|
await self._mark_in_progress(ctx.task_id)
|
|
self.log.info("Task started (in_progress)", task_id=str(ctx.task_id))
|
|
|
|
if ctx.current_subtask >= len(ctx.subtasks):
|
|
return True
|
|
|
|
subtask = ctx.subtasks[ctx.current_subtask]
|
|
|
|
# Format context using TOON
|
|
execute_context = self.format_context_labeled(
|
|
"Execution Context",
|
|
{
|
|
"task": ctx.title,
|
|
"subtask_number": ctx.current_subtask + 1,
|
|
"total_subtasks": len(ctx.subtasks),
|
|
"description": subtask.get("description", ""),
|
|
"files": subtask.get("files", []),
|
|
},
|
|
)
|
|
|
|
# Use LLM to work on subtask
|
|
prompt = f"""Execute this subtask:
|
|
|
|
{execute_context}
|
|
|
|
Provide:
|
|
1. Code changes needed
|
|
2. Commands to run
|
|
3. Commit message in format: type(scope): description
|
|
|
|
Respond with the implementation.
|
|
"""
|
|
response = await self.think_and_stream(prompt)
|
|
|
|
# Record work done
|
|
ts = datetime.now(UTC).isoformat()
|
|
subtask_num = ctx.current_subtask + 1
|
|
ctx.journal_entries.append(f"[{ts}] Subtask {subtask_num}: {response[:100]}...")
|
|
|
|
# Simulate commit (in real implementation would execute git)
|
|
commit_hash = f"commit_{ctx.current_subtask}"
|
|
ctx.commits.append(commit_hash)
|
|
|
|
# Progress update - save to task AND send message
|
|
completed = ctx.current_subtask + 1
|
|
total = len(ctx.subtasks)
|
|
percentage = int((completed / total) * 100) if total > 0 else 0
|
|
progress_msg = f"Completed subtask {completed}/{total}: {subtask['title']}"
|
|
|
|
# Save progress to task (QA will see this!)
|
|
await self._add_progress(ctx.task_id, progress_msg, percentage)
|
|
|
|
await self.send_message(
|
|
ctx.session_id,
|
|
f"TASK-{str(ctx.task_id)[:8]} ({percentage}%) {progress_msg}",
|
|
message_type="action",
|
|
task_id=ctx.task_id,
|
|
)
|
|
|
|
ctx.current_subtask += 1
|
|
return ctx.current_subtask >= len(ctx.subtasks)
|
|
|
|
async def _phase_verify(self, ctx: TaskContext) -> bool:
|
|
"""
|
|
VERIFY phase: Self-test against acceptance criteria.
|
|
|
|
- Run quality checks (ruff, mypy, pytest)
|
|
- Self-review against acceptance criteria
|
|
- Flag for QA if passing
|
|
|
|
Returns True if verified, False if issues found.
|
|
"""
|
|
self.log.info("VERIFY phase", task_id=str(ctx.task_id))
|
|
|
|
# Run quality checks (simulated)
|
|
checks = [
|
|
("ruff format", True),
|
|
("ruff check", True),
|
|
("mypy", True),
|
|
("pytest", True),
|
|
]
|
|
|
|
all_passed = True
|
|
for check_name, passed in checks:
|
|
if not passed:
|
|
all_passed = False
|
|
ctx.journal_entries.append(
|
|
f"[{datetime.now(UTC).isoformat()}] VERIFY FAILED: {check_name}"
|
|
)
|
|
|
|
if all_passed:
|
|
# Flag for QA
|
|
await self.send_message(
|
|
ctx.session_id,
|
|
f"TASK-{str(ctx.task_id)[:8]} ready for QA review. "
|
|
f"Commits: {', '.join(ctx.commits)}",
|
|
message_type="action",
|
|
task_id=ctx.task_id,
|
|
)
|
|
ctx.journal_entries.append(
|
|
f"[{datetime.now(UTC).isoformat()}] VERIFY PASSED. Flagged for QA."
|
|
)
|
|
|
|
return all_passed
|
|
|
|
async def _phase_notes(self, ctx: TaskContext) -> None:
|
|
"""
|
|
NOTES phase: Document journey and create handoff.
|
|
|
|
- Complete journey notes (stored in task dev_notes for QA)
|
|
- Link commits
|
|
- Create documenter handoff summary
|
|
"""
|
|
self.log.info("NOTES phase", task_id=str(ctx.task_id))
|
|
|
|
# Generate dev_notes for QA verification
|
|
dev_notes_prompt = f"""
|
|
Summarize the work done for QA verification:
|
|
|
|
Task: {ctx.title}
|
|
Commits: {", ".join(ctx.commits)}
|
|
Work log:
|
|
{chr(10).join(ctx.journal_entries)}
|
|
|
|
Create a brief summary for QA including:
|
|
1. What was built and where (files/modules)
|
|
2. Key implementation decisions
|
|
3. Tests added
|
|
4. Any gotchas or important context
|
|
"""
|
|
dev_notes = await self.think(dev_notes_prompt)
|
|
|
|
# Generate handoff summary for documenter
|
|
handoff_prompt = f"""
|
|
Create a handoff summary for the documenter:
|
|
|
|
Task: {ctx.title}
|
|
What was built: {dev_notes[:500]}
|
|
|
|
Summarize in 2-3 sentences what documentation is needed.
|
|
"""
|
|
handoff_summary = await self.think(handoff_prompt)
|
|
|
|
# Store notes in task via API (this is what QA will see!)
|
|
await self._submit_for_qa(ctx.task_id, dev_notes, handoff_summary)
|
|
|
|
ctx.journal_entries.append(
|
|
f"[{datetime.now(UTC).isoformat()}] Submitted for QA with dev_notes"
|
|
)
|
|
|
|
async def _phase_blocked(self, ctx: TaskContext) -> bool:
|
|
"""
|
|
BLOCKED phase: Handle blocked state.
|
|
|
|
- Document blocker
|
|
- Notify PM
|
|
- Wait for resolution
|
|
|
|
Returns True if resolved.
|
|
"""
|
|
self.log.info("BLOCKED", task_id=str(ctx.task_id), blockers=ctx.blockers)
|
|
|
|
if ctx.blockers:
|
|
blocker = ctx.blockers[-1]
|
|
await self.send_message(
|
|
ctx.session_id,
|
|
f"BLOCKED on TASK-{str(ctx.task_id)[:8]}: {blocker}",
|
|
message_type="blocker",
|
|
task_id=ctx.task_id,
|
|
)
|
|
await self._update_task_status(ctx.task_id, TaskStatus.BLOCKED)
|
|
|
|
# Check if blocker resolved (simulated)
|
|
resolved = False
|
|
if resolved:
|
|
ctx.blockers.clear()
|
|
|
|
return resolved
|
|
|
|
# =========================================================================
|
|
# HELPER METHODS
|
|
# =========================================================================
|
|
|
|
async def _find_paused_task(self) -> UUID | None:
|
|
"""Find own paused/interrupted tasks."""
|
|
try:
|
|
result = await self._api_call(
|
|
"GET",
|
|
"/tasks",
|
|
params={"status": "paused", "assigned_to": str(self.id)},
|
|
)
|
|
tasks = result.get("items", [])
|
|
return UUID(tasks[0]["id"]) if tasks else None
|
|
except Exception as e:
|
|
self.log.warning("Failed to find paused task", error=str(e))
|
|
return None
|
|
|
|
async def _find_assigned_task(self) -> UUID | None:
|
|
"""Find tasks assigned to this agent."""
|
|
try:
|
|
result = await self._api_call(
|
|
"GET",
|
|
"/tasks",
|
|
params={"status": "pending", "assigned_to": str(self.id)},
|
|
)
|
|
tasks = result.get("items", [])
|
|
return UUID(tasks[0]["id"]) if tasks else None
|
|
except Exception as e:
|
|
self.log.warning("Failed to find assigned task", error=str(e))
|
|
return None
|
|
|
|
async def _signal_availability(self) -> None:
|
|
"""Signal availability to orchestrator (no task context, so use API)."""
|
|
self.log.info("Signaling availability", agent_name=self.name)
|
|
# No task/session context - signal via state update instead of message
|
|
self.state.status = AgentStatus.IDLE
|
|
self.state.current_task_id = None
|
|
self.state.current_session_id = None
|
|
|
|
async def _submit_for_qa(
|
|
self, task_id: UUID, dev_notes: str, handoff_summary: str
|
|
) -> None:
|
|
"""
|
|
Submit task for QA review with notes.
|
|
|
|
This stores dev_notes in the task (visible to QA) and transitions
|
|
the task to awaiting_qa status.
|
|
"""
|
|
try:
|
|
# First store dev_notes (this is what QA will see!)
|
|
combined_notes = f"{dev_notes}\n\n---\nHandoff Summary:\n{handoff_summary}"
|
|
await self._api_call(
|
|
"PATCH",
|
|
f"/tasks/{task_id}",
|
|
json={"dev_notes": combined_notes},
|
|
)
|
|
self.log.info("Dev notes saved to task", task_id=str(task_id))
|
|
|
|
# Then transition to awaiting_qa
|
|
await self._api_call("POST", f"/tasks/{task_id}/submit-qa")
|
|
self.log.info("Task submitted for QA", task_id=str(task_id))
|
|
|
|
except Exception as e:
|
|
self.log.error("Failed to submit for QA", error=str(e))
|
|
raise
|