feat(agent): complete EvoAgent integration for all 6 agent roles

Migrate all agent roles from Legacy to EvoAgent architecture:
- fundamentals_analyst, technical_analyst, sentiment_analyst, valuation_analyst
- risk_manager, portfolio_manager

Key changes:
- EvoAgent now supports Portfolio Manager compatibility methods (_make_decision,
  get_decisions, get_portfolio_state, load_portfolio_state, update_portfolio)
- Add UnifiedAgentFactory for centralized agent creation
- ToolGuard with batch approval API and WebSocket broadcast
- Legacy agents marked deprecated (AnalystAgent, RiskAgent, PMAgent)
- Remove backend/agents/compat.py migration shim
- Add run_id alongside workspace_id for semantic clarity
- Complete integration test coverage (13 tests)
- All smoke tests passing for 6 agent roles

Constraint: Must maintain backward compatibility with existing run configs
Constraint: Memory support must work with EvoAgent (no fallback to Legacy)
Rejected: Separate PM implementation for EvoAgent | unified approach cleaner
Confidence: high
Scope-risk: broad
Directive: EVO_AGENT_IDS env var still respected but defaults to all roles
Not-tested: Kubernetes sandbox mode for skill execution
This commit is contained in:
2026-04-02 00:55:08 +08:00
parent 0fa413380c
commit 16b54d5ccc
73 changed files with 9454 additions and 904 deletions

View File

@@ -7,7 +7,7 @@ Provides REST API endpoints for tool guard operations.
from __future__ import annotations
from typing import Any, Dict, List, Optional
from datetime import datetime
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
@@ -29,7 +29,7 @@ class ToolCallRequest(BaseModel):
tool_name: str = Field(..., description="Name of the tool")
tool_input: Dict[str, Any] = Field(default_factory=dict, description="Tool parameters")
agent_id: str = Field(..., description="Agent making the request")
workspace_id: str = Field(..., description="Workspace context")
workspace_id: str = Field(..., description="Run context; historical field name retained for compatibility")
session_id: Optional[str] = Field(None, description="Session identifier")
@@ -46,6 +46,21 @@ class DenyRequest(BaseModel):
reason: Optional[str] = Field(None, description="Reason for denial")
class BatchApprovalRequest(BaseModel):
"""Request to approve multiple tool calls."""
approval_ids: List[str] = Field(..., description="List of approval request IDs")
one_time: bool = Field(True, description="Whether these are one-time approvals")
class BatchApprovalResponse(BaseModel):
"""Response for batch approval operation."""
approved: List[ApprovalResponse] = Field(default_factory=list, description="Successfully approved")
failed: List[Dict[str, Any]] = Field(default_factory=list, description="Failed approvals with errors")
total_requested: int
total_approved: int
total_failed: int
class ToolFinding(BaseModel):
"""Tool guard finding."""
severity: SeverityLevel
@@ -61,11 +76,17 @@ class ApprovalResponse(BaseModel):
tool_input: Dict[str, Any]
agent_id: str
workspace_id: str
run_id: str
session_id: Optional[str] = None
findings: List[ToolFinding] = Field(default_factory=list)
created_at: str
resolved_at: Optional[str] = None
resolved_by: Optional[str] = None
scope_type: str = "runtime_run"
scope_note: str = (
"Approvals are scoped to the active runtime run. `workspace_id` is "
"retained as a compatibility field name; prefer `run_id` for display."
)
class PendingApprovalsResponse(BaseModel):
@@ -91,6 +112,7 @@ def _to_response(record: ApprovalRecord) -> ApprovalResponse:
tool_input=record.tool_input,
agent_id=record.agent_id,
workspace_id=record.workspace_id,
run_id=record.workspace_id,
session_id=record.session_id,
findings=[ToolFinding(**f.to_dict()) for f in record.findings],
created_at=record.created_at.isoformat(),
@@ -124,7 +146,7 @@ async def check_tool_call(
if request.tool_name in SAFE_TOOLS:
record.status = ApprovalStatus.APPROVED
record.resolved_at = datetime.utcnow()
record.resolved_at = datetime.now(UTC)
record.resolved_by = "system"
STORE.set_status(
record.approval_id,
@@ -156,9 +178,12 @@ async def approve_tool_call(
if record.status != ApprovalStatus.PENDING:
raise HTTPException(status_code=400, detail=f"Approval already {record.status}")
record.status = ApprovalStatus.APPROVED
record.resolved_at = datetime.utcnow()
record.resolved_by = "user"
record = STORE.set_status(
request.approval_id,
ApprovalStatus.APPROVED,
resolved_by="user",
notify_request=True,
)
return _to_response(record)
@@ -183,9 +208,12 @@ async def deny_tool_call(
if record.status != ApprovalStatus.PENDING:
raise HTTPException(status_code=400, detail=f"Approval already {record.status}")
record.status = ApprovalStatus.DENIED
record.resolved_at = datetime.utcnow()
record.resolved_by = "user"
record = STORE.set_status(
request.approval_id,
ApprovalStatus.DENIED,
resolved_by="user",
notify_request=True,
)
record.metadata["denial_reason"] = request.reason
return _to_response(record)
@@ -200,7 +228,7 @@ async def list_pending_approvals(
List pending tool approval requests.
Args:
workspace_id: Filter by workspace
workspace_id: Filter by run id (historical query parameter name retained)
agent_id: Filter by agent
Returns:
@@ -255,3 +283,58 @@ async def cancel_approval(
STORE.cancel(approval_id)
return _to_response(record)
@router.post("/approve/batch", response_model=BatchApprovalResponse)
async def batch_approve_tool_calls(
request: BatchApprovalRequest,
):
"""
Approve multiple pending tool calls in a single request.
Args:
request: Batch approval parameters with list of approval IDs
Returns:
Batch approval results with successful and failed approvals
"""
approved: List[ApprovalResponse] = []
failed: List[Dict[str, Any]] = []
for approval_id in request.approval_ids:
record = STORE.get(approval_id)
if not record:
failed.append({
"approval_id": approval_id,
"error": "Approval request not found",
})
continue
if record.status != ApprovalStatus.PENDING:
failed.append({
"approval_id": approval_id,
"error": f"Approval already {record.status}",
})
continue
try:
record = STORE.set_status(
approval_id,
ApprovalStatus.APPROVED,
resolved_by="user",
notify_request=True,
)
approved.append(_to_response(record))
except Exception as e:
failed.append({
"approval_id": approval_id,
"error": str(e),
})
return BatchApprovalResponse(
approved=approved,
failed=failed,
total_requested=len(request.approval_ids),
total_approved=len(approved),
total_failed=len(failed),
)