Files
Aperant/apps/backend/agents/tools_pkg/models.py
T
Andy d1fbccde39 fix(pr-review): add three-tier recovery for structured output validation failure (#1797)
* fix(pr-review): add three-tier recovery for structured output validation failure

When structured output validation fails after SDK max retries, the followup
reviewer crashed with RuntimeError instead of recovering. This wastes all
multi-agent analysis work (often 100+ messages across 3 specialist agents).

Changes:
- sdk_utils: add error_recoverable flag and last_assistant_text to stream result
- followup reviewer: attempt extraction call with minimal schema before text fallback
- pydantic_models: add FollowupExtractionResponse (~6 flat fields, near-100% success)
- orchestrator reviewer: add structured_output to FindingValidator retryable errors

Recovery cascade: structured output → extraction call → text parsing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(pr-review): address review findings from PR #1797

- Register pr_followup_extraction agent type in AGENT_CONFIGS (fixes Tier 2 dead code)
- Move RECOVERABLE_ERRORS to module-level constant in sdk_utils for importability
- Update docstring to document new return fields (last_assistant_text, error_recoverable)
- Use self.config.fast_mode instead of hardcoded True for consistency
- Rewrite tests to import actual production constants instead of reimplementing logic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tests): fix import paths for CI environment

CI runs pytest from apps/backend/ so runners/github/ must be on sys.path
for services.sdk_utils and services.pydantic_models imports to resolve.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tests): use bare module imports to avoid services/ package collision

There are two services/ directories (apps/backend/services/ and
runners/github/services/). Adding github services dir to sys.path and
importing via `from services.sdk_utils` fails because Python finds the
wrong services/ package first. Fix: add the services dir directly and
use bare imports (from sdk_utils import ...).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(pr-review): fix extraction call type error and control flow issues

- Use self.project_dir instead of str(Path.cwd()) for create_client (fixes
  AttributeError making Tier 2 always crash, and uses correct project path)
- Force structured_output = None on recoverable errors to skip redundant
  parse-then-fail cycle and go directly to Tier 2 extraction
- Include dismissed_finding_count in extraction return dict for symmetry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(pr-review): address follow-up review findings

- Read dismissed_finding_count fallback in consumer (fixes silent data loss)
- Consolidate recoverable error handling into single control flow block
- Default text fallback verdict to NEEDS_REVISION (consistent with _create_empty_result)
- Add missing keys to _parse_text_output and _create_empty_result for consistent
  return dict contracts across all three recovery tiers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: ruff format parallel_followup_reviewer.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 19:43:44 +01:00

539 lines
22 KiB
Python

"""
Tool Models and Constants
==========================
Defines tool name constants and configuration for auto-claude MCP tools.
This module is the single source of truth for all tool definitions used by
the Claude Agent SDK client. Tool lists are organized by category:
- Base tools: Core file operations (Read, Write, Edit, etc.)
- Web tools: Documentation and research (WebFetch, WebSearch)
- MCP tools: External integrations (Context7, Linear, Graphiti, etc.)
- Auto-Claude tools: Custom build management tools
"""
import os
# =============================================================================
# Base Tools (Built-in Claude Code tools)
# =============================================================================
# Core file operation tools
BASE_READ_TOOLS = ["Read", "Glob", "Grep"]
BASE_WRITE_TOOLS = ["Write", "Edit", "Bash"]
# Web tools for documentation lookup and research
# Always available to all agents for accessing external information
WEB_TOOLS = ["WebFetch", "WebSearch"]
# =============================================================================
# Auto-Claude MCP Tools (Custom build management)
# =============================================================================
# Auto-Claude MCP tool names (prefixed with mcp__auto-claude__)
TOOL_UPDATE_SUBTASK_STATUS = "mcp__auto-claude__update_subtask_status"
TOOL_GET_BUILD_PROGRESS = "mcp__auto-claude__get_build_progress"
TOOL_RECORD_DISCOVERY = "mcp__auto-claude__record_discovery"
TOOL_RECORD_GOTCHA = "mcp__auto-claude__record_gotcha"
TOOL_GET_SESSION_CONTEXT = "mcp__auto-claude__get_session_context"
TOOL_UPDATE_QA_STATUS = "mcp__auto-claude__update_qa_status"
# =============================================================================
# External MCP Tools
# =============================================================================
# Context7 MCP tools for documentation lookup (always enabled)
CONTEXT7_TOOLS = [
"mcp__context7__resolve-library-id",
"mcp__context7__query-docs",
]
# Linear MCP tools for project management (when LINEAR_API_KEY is set)
LINEAR_TOOLS = [
"mcp__linear-server__list_teams",
"mcp__linear-server__get_team",
"mcp__linear-server__list_projects",
"mcp__linear-server__get_project",
"mcp__linear-server__create_project",
"mcp__linear-server__update_project",
"mcp__linear-server__list_issues",
"mcp__linear-server__get_issue",
"mcp__linear-server__create_issue",
"mcp__linear-server__update_issue",
"mcp__linear-server__list_comments",
"mcp__linear-server__create_comment",
"mcp__linear-server__list_issue_statuses",
"mcp__linear-server__list_issue_labels",
"mcp__linear-server__list_users",
"mcp__linear-server__get_user",
]
# Graphiti MCP tools for knowledge graph memory (when GRAPHITI_MCP_URL is set)
# See: https://github.com/getzep/graphiti
GRAPHITI_MCP_TOOLS = [
"mcp__graphiti-memory__search_nodes", # Search entity summaries
"mcp__graphiti-memory__search_facts", # Search relationships between entities
"mcp__graphiti-memory__add_episode", # Add data to knowledge graph
"mcp__graphiti-memory__get_episodes", # Retrieve recent episodes
"mcp__graphiti-memory__get_entity_edge", # Get specific entity/relationship
]
# =============================================================================
# Browser Automation MCP Tools (QA agents only)
# =============================================================================
# Puppeteer MCP tools for web browser automation
# Used for web frontend validation (non-Electron web apps)
# NOTE: Screenshots must be compressed (1280x720, quality 60, JPEG) to stay under
# Claude SDK's 1MB JSON message buffer limit. See GitHub issue #74.
PUPPETEER_TOOLS = [
"mcp__puppeteer__puppeteer_connect_active_tab",
"mcp__puppeteer__puppeteer_navigate",
"mcp__puppeteer__puppeteer_screenshot",
"mcp__puppeteer__puppeteer_click",
"mcp__puppeteer__puppeteer_fill",
"mcp__puppeteer__puppeteer_select",
"mcp__puppeteer__puppeteer_hover",
"mcp__puppeteer__puppeteer_evaluate",
]
# Electron MCP tools for desktop app automation (when ELECTRON_MCP_ENABLED is set)
# Uses electron-mcp-server to connect to Electron apps via Chrome DevTools Protocol.
# Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT).
# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner.
# NOTE: Screenshots must be compressed to stay under Claude SDK's 1MB JSON message buffer limit.
ELECTRON_TOOLS = [
"mcp__electron__get_electron_window_info", # Get info about running Electron windows
"mcp__electron__take_screenshot", # Capture screenshot of Electron window
"mcp__electron__send_command_to_electron", # Send commands (click, fill, evaluate JS)
"mcp__electron__read_electron_logs", # Read console logs from Electron app
]
# =============================================================================
# Configuration
# =============================================================================
def is_electron_mcp_enabled() -> bool:
"""
Check if Electron MCP server integration is enabled.
Requires ELECTRON_MCP_ENABLED to be set to 'true'.
When enabled, QA agents can use Electron MCP tools to connect to Electron apps
via Chrome DevTools Protocol on the configured debug port.
"""
return os.environ.get("ELECTRON_MCP_ENABLED", "").lower() == "true"
# =============================================================================
# Agent Configuration Registry
# =============================================================================
# Single source of truth for phase → tools → MCP servers mapping.
# This enables phase-aware tool control and context window optimization.
AGENT_CONFIGS = {
# ═══════════════════════════════════════════════════════════════════════
# SPEC CREATION PHASES (Minimal tools, fast startup)
# ═══════════════════════════════════════════════════════════════════════
"spec_gatherer": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": [], # No MCP needed - just reads project
"auto_claude_tools": [],
"thinking_default": "medium",
},
"spec_researcher": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7"], # Needs docs lookup
"auto_claude_tools": [],
"thinking_default": "medium",
},
"spec_writer": {
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
"mcp_servers": [], # Just writes spec.md
"auto_claude_tools": [],
"thinking_default": "high",
},
"spec_critic": {
"tools": BASE_READ_TOOLS,
"mcp_servers": [], # Self-critique, no external tools
"auto_claude_tools": [],
"thinking_default": "high",
},
"spec_discovery": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "medium",
},
"spec_context": {
"tools": BASE_READ_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "medium",
},
"spec_validation": {
"tools": BASE_READ_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "high",
},
"spec_compaction": {
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "medium",
},
# ═══════════════════════════════════════════════════════════════════════
# BUILD PHASES (Full tools + Graphiti memory)
# Note: "linear" is conditional on project setting "update_linear_with_tasks"
# ═══════════════════════════════════════════════════════════════════════
"planner": {
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7", "graphiti", "auto-claude"],
"mcp_servers_optional": ["linear"], # Only if project setting enabled
"auto_claude_tools": [
TOOL_GET_BUILD_PROGRESS,
TOOL_GET_SESSION_CONTEXT,
TOOL_RECORD_DISCOVERY,
],
"thinking_default": "high",
},
"coder": {
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7", "graphiti", "auto-claude"],
"mcp_servers_optional": ["linear"],
"auto_claude_tools": [
TOOL_UPDATE_SUBTASK_STATUS,
TOOL_GET_BUILD_PROGRESS,
TOOL_RECORD_DISCOVERY,
TOOL_RECORD_GOTCHA,
TOOL_GET_SESSION_CONTEXT,
],
"thinking_default": "low", # Coding uses minimal thinking (effort: low for Opus, 1024 tokens for Sonnet/Haiku)
},
# ═══════════════════════════════════════════════════════════════════════
# QA PHASES (Read + test + browser + Graphiti memory)
# ═══════════════════════════════════════════════════════════════════════
"qa_reviewer": {
# Read + Write/Edit (for QA reports and plan updates) + Bash (for tests)
# Note: Reviewer writes to spec directory only (qa_report.md, implementation_plan.json)
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
"mcp_servers_optional": ["linear"], # For updating issue status
"auto_claude_tools": [
TOOL_GET_BUILD_PROGRESS,
TOOL_UPDATE_QA_STATUS,
TOOL_GET_SESSION_CONTEXT,
],
"thinking_default": "high",
},
"qa_fixer": {
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
"mcp_servers_optional": ["linear"],
"auto_claude_tools": [
TOOL_UPDATE_SUBTASK_STATUS,
TOOL_GET_BUILD_PROGRESS,
TOOL_UPDATE_QA_STATUS,
TOOL_RECORD_GOTCHA,
],
"thinking_default": "medium",
},
# ═══════════════════════════════════════════════════════════════════════
# UTILITY PHASES (Minimal, no MCP)
# ═══════════════════════════════════════════════════════════════════════
"insights": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
# Note: Default to "low" for minimal thinking overhead
# Haiku doesn't support thinking; create_simple_client() handles this
"thinking_default": "low",
},
"merge_resolver": {
"tools": [], # Text-only analysis
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "low",
},
"commit_message": {
"tools": [],
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "low",
},
"pr_template_filler": {
"tools": BASE_READ_TOOLS, # Read-only — reads diff, template, spec
"mcp_servers": [], # No MCP needed, context passed via prompt
"auto_claude_tools": [],
"thinking_default": "low", # Fast utility task for structured fill-in
},
"pr_reviewer": {
"tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only
"mcp_servers": ["context7"],
"auto_claude_tools": [],
"thinking_default": "high",
},
"pr_orchestrator_parallel": {
# Read-only for parallel PR orchestrator
# NOTE: Do NOT add "Task" here - the SDK auto-allows Task when agents are defined
# via the --agents flag. Explicitly adding it interferes with agent registration.
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7"],
"auto_claude_tools": [],
"thinking_default": "high",
},
"pr_followup_parallel": {
# Read-only for parallel followup reviewer
# NOTE: Do NOT add "Task" here - same reason as pr_orchestrator_parallel
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7"],
"auto_claude_tools": [],
"thinking_default": "high",
},
"pr_followup_extraction": {
# Lightweight extraction call for recovering data when structured output fails
# Pure structured output extraction, no tools needed
"tools": [],
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "low",
},
"pr_finding_validator": {
# Standalone validator for re-checking findings against actual code
# Called separately from orchestrator to validate findings with fresh context
"tools": BASE_READ_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "medium",
},
# ═══════════════════════════════════════════════════════════════════════
# ANALYSIS PHASES
# ═══════════════════════════════════════════════════════════════════════
"analysis": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7"],
"auto_claude_tools": [],
"thinking_default": "medium",
},
"batch_analysis": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "low",
},
"batch_validation": {
"tools": BASE_READ_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "low",
},
# ═══════════════════════════════════════════════════════════════════════
# ROADMAP & IDEATION
# ═══════════════════════════════════════════════════════════════════════
"roadmap_discovery": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7"],
"auto_claude_tools": [],
"thinking_default": "high",
},
"competitor_analysis": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": ["context7"], # WebSearch for competitor research
"auto_claude_tools": [],
"thinking_default": "high",
},
"ideation": {
"tools": BASE_READ_TOOLS + WEB_TOOLS,
"mcp_servers": [],
"auto_claude_tools": [],
"thinking_default": "high",
},
}
# =============================================================================
# Agent Config Helper Functions
# =============================================================================
def get_agent_config(agent_type: str) -> dict:
"""
Get full configuration for an agent type.
Args:
agent_type: The agent type identifier (e.g., 'coder', 'planner', 'qa_reviewer')
Returns:
Configuration dict containing tools, mcp_servers, auto_claude_tools, thinking_default
Raises:
ValueError: If agent_type is not found in AGENT_CONFIGS (strict mode)
"""
if agent_type not in AGENT_CONFIGS:
raise ValueError(
f"Unknown agent type: '{agent_type}'. "
f"Valid types: {sorted(AGENT_CONFIGS.keys())}"
)
return AGENT_CONFIGS[agent_type]
def _map_mcp_server_name(
name: str, custom_server_ids: list[str] | None = None
) -> str | None:
"""
Map user-friendly MCP server names to internal identifiers.
Also accepts custom server IDs directly.
Args:
name: User-provided MCP server name
custom_server_ids: List of custom server IDs to accept as-is
Returns:
Internal server identifier or None if not recognized
"""
if not name:
return None
mappings = {
"context7": "context7",
"graphiti-memory": "graphiti",
"graphiti": "graphiti",
"linear": "linear",
"electron": "electron",
"puppeteer": "puppeteer",
"auto-claude": "auto-claude",
}
# Check if it's a known mapping
mapped = mappings.get(name.lower().strip())
if mapped:
return mapped
# Check if it's a custom server ID (accept as-is)
if custom_server_ids and name in custom_server_ids:
return name
return None
def get_required_mcp_servers(
agent_type: str,
project_capabilities: dict | None = None,
linear_enabled: bool = False,
mcp_config: dict | None = None,
) -> list[str]:
"""
Get MCP servers required for this agent type.
Handles dynamic server selection:
- "browser" → electron (if is_electron) or puppeteer (if is_web_frontend)
- "linear" → only if in mcp_servers_optional AND linear_enabled is True
- "graphiti" → only if GRAPHITI_MCP_URL is set
- Respects per-project MCP config overrides from .auto-claude/.env
- Applies per-agent ADD/REMOVE overrides from AGENT_MCP_<agent>_ADD/REMOVE
Args:
agent_type: The agent type identifier
project_capabilities: Dict from detect_project_capabilities() or None
linear_enabled: Whether Linear integration is enabled for this project
mcp_config: Per-project MCP server toggles from .auto-claude/.env
Keys: CONTEXT7_ENABLED, LINEAR_MCP_ENABLED, ELECTRON_MCP_ENABLED,
PUPPETEER_MCP_ENABLED, AGENT_MCP_<agent>_ADD/REMOVE
Returns:
List of MCP server names to start
"""
config = get_agent_config(agent_type)
servers = list(config.get("mcp_servers", []))
# Load per-project config (or use defaults)
if mcp_config is None:
mcp_config = {}
# Filter context7 if explicitly disabled by project config
if "context7" in servers:
context7_enabled = mcp_config.get("CONTEXT7_ENABLED", "true")
if str(context7_enabled).lower() == "false":
servers = [s for s in servers if s != "context7"]
# Handle optional servers (e.g., Linear if project setting enabled)
optional = config.get("mcp_servers_optional", [])
if "linear" in optional and linear_enabled:
# Also check per-project LINEAR_MCP_ENABLED override
linear_mcp_enabled = mcp_config.get("LINEAR_MCP_ENABLED", "true")
if str(linear_mcp_enabled).lower() != "false":
servers.append("linear")
# Handle dynamic "browser" → electron/puppeteer based on project type and config
if "browser" in servers:
servers = [s for s in servers if s != "browser"]
if project_capabilities:
is_electron = project_capabilities.get("is_electron", False)
is_web_frontend = project_capabilities.get("is_web_frontend", False)
# Check per-project overrides (default false for both)
electron_enabled = mcp_config.get("ELECTRON_MCP_ENABLED", "false")
puppeteer_enabled = mcp_config.get("PUPPETEER_MCP_ENABLED", "false")
# Electron: enabled by project config OR global env var
if is_electron and (
str(electron_enabled).lower() == "true" or is_electron_mcp_enabled()
):
servers.append("electron")
# Puppeteer: enabled by project config (no global env var)
elif is_web_frontend and not is_electron:
if str(puppeteer_enabled).lower() == "true":
servers.append("puppeteer")
# Filter graphiti if not enabled
if "graphiti" in servers:
if not os.environ.get("GRAPHITI_MCP_URL"):
servers = [s for s in servers if s != "graphiti"]
# ========== Apply per-agent MCP overrides ==========
# Format: AGENT_MCP_<agent_type>_ADD=server1,server2
# AGENT_MCP_<agent_type>_REMOVE=server1,server2
add_key = f"AGENT_MCP_{agent_type}_ADD"
remove_key = f"AGENT_MCP_{agent_type}_REMOVE"
# Extract custom server IDs for mapping (allows custom servers to be recognized)
custom_servers = mcp_config.get("CUSTOM_MCP_SERVERS", [])
custom_server_ids = [s.get("id") for s in custom_servers if s.get("id")]
# Process additions
if add_key in mcp_config:
additions = [
s.strip() for s in str(mcp_config[add_key]).split(",") if s.strip()
]
for server in additions:
mapped = _map_mcp_server_name(server, custom_server_ids)
if mapped and mapped not in servers:
servers.append(mapped)
# Process removals (but never remove auto-claude)
if remove_key in mcp_config:
removals = [
s.strip() for s in str(mcp_config[remove_key]).split(",") if s.strip()
]
for server in removals:
mapped = _map_mcp_server_name(server, custom_server_ids)
if mapped and mapped != "auto-claude": # auto-claude cannot be removed
servers = [s for s in servers if s != mapped]
return servers
def get_default_thinking_level(agent_type: str) -> str:
"""
Get default thinking level string for agent type.
This returns the thinking level name (e.g., 'medium', 'high'), not the token budget.
To convert to tokens, use phase_config.get_thinking_budget(level).
Args:
agent_type: The agent type identifier
Returns:
Thinking level string (low, medium, high)
"""
config = get_agent_config(agent_type)
return config.get("thinking_default", "medium")