Files
Auto-Trader/agent/src/api/sessions_routes.py
T
Haozhe Wu 035673b065 revert: back out IRR-AGL reliability/governance stack (#405, #416)
Reverts merge commits 0b867a7 (#405) and 474f5e4 (#416), and removes the
now-orphaned #406 artifact metadata store (src/reliability + tests, zero
references left outside its own tests).

Why: the stack wrapped the tool registry in every session path (even
governance mode=off) without exposing the registry surface AgentContext
reads, breaking all session-runtime chats within a day (#433, #431). It
also modified protected core files (src/agent/loop.py, src/session/
service.py), shipped 12 internal-convention docs/ files into the public
tree, and its enforcement layer already has reported bypass paths (#420).
Trading safety stays on the single mandate-gate design (#31, #144).

README news entries for 2026-07-07/07-06 updated accordingly.
2026-07-08 14:15:56 +08:00

717 lines
28 KiB
Python

"""Session and goal HTTP routes.
Mounted by ``agent/api_server.py`` via ``register_sessions_routes(app)``.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from fastapi import Depends, FastAPI, HTTPException, Query, Request, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
# ============================================================================
# Pydantic Models
# ============================================================================
class CreateSessionRequest(BaseModel):
"""Create session request body."""
title: str = Field("", description="Session title")
config: Optional[Dict[str, Any]] = Field(None, description="Session config")
class SessionResponse(BaseModel):
"""Session record."""
session_id: str
title: str
status: str
created_at: str
updated_at: str
last_attempt_id: Optional[str] = None
class SendMessageRequest(BaseModel):
"""Send chat message: natural-language strategy description."""
content: str = Field(..., description="Natural language strategy description", min_length=1, max_length=5000)
class MessageResponse(BaseModel):
"""Stored chat message."""
message_id: str
session_id: str
role: str
content: str
created_at: str
linked_attempt_id: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
class CreateGoalRequest(BaseModel):
"""Create or replace a finance research goal."""
objective: str = Field(..., min_length=1, max_length=5000)
criteria: List[str] = Field(default_factory=list)
ui_summary: str = ""
protocol: str = "thesis_review"
risk_tier: str = "research_general"
token_budget: Optional[int] = Field(None, ge=1)
turn_budget: Optional[int] = Field(None, ge=1)
time_budget_seconds: Optional[int] = Field(None, ge=1)
class UpdateGoalRequest(BaseModel):
"""Edit mutable finance research goal fields."""
goal_id: str = Field(..., min_length=1)
expected_goal_id: str = Field(..., min_length=1)
objective: Optional[str] = Field(None, min_length=1, max_length=5000)
ui_summary: Optional[str] = Field(None, max_length=500)
class AddGoalEvidenceRequest(BaseModel):
"""Append evidence to a finance research goal."""
goal_id: str = Field(..., min_length=1)
expected_goal_id: str = Field(..., min_length=1)
text: str = Field(..., min_length=1, max_length=10000)
criterion_id: Optional[str] = None
claim_id: Optional[str] = None
evidence_type: str = "evidence"
tool_call_id: Optional[str] = None
run_id: Optional[str] = None
source_provider: Optional[str] = None
source_type: Optional[str] = None
source_uri: Optional[str] = None
symbol_universe: List[str] = Field(default_factory=list)
benchmark: List[str] = Field(default_factory=list)
timeframe: Optional[str] = None
method: Optional[str] = None
assumptions: Dict[str, Any] = Field(default_factory=dict)
artifact_path: Optional[str] = None
artifact_hash: Optional[str] = None
data_as_of: Optional[str] = None
confidence: Optional[str] = None
caveat: Optional[str] = None
contradicts_claim_ids: List[str] = Field(default_factory=list)
class GoalSnapshotResponse(BaseModel):
"""Finance research goal snapshot."""
goal: Dict[str, Any]
claims: List[Dict[str, Any]]
criteria: List[Dict[str, Any]]
evidence: List[Dict[str, Any]]
evidence_count: int = 0
class AddGoalEvidenceResponse(BaseModel):
"""Response after appending goal evidence."""
evidence: Dict[str, Any]
snapshot: GoalSnapshotResponse
class GoalAuditRowRequest(BaseModel):
"""One criterion row for goal status audits."""
criterion_id: str = Field(..., min_length=1)
result: str = Field(..., min_length=1)
evidence_ids: List[str] = Field(default_factory=list)
notes: str = ""
class UpdateGoalStatusRequest(BaseModel):
"""Update a finance research goal status."""
goal_id: str = Field(..., min_length=1)
expected_goal_id: str = Field(..., min_length=1)
status: str = Field(..., min_length=1)
audit: List[GoalAuditRowRequest] = Field(default_factory=list)
recap: Optional[str] = None
class UpdateGoalStatusResponse(BaseModel):
"""Response after changing a goal status."""
goal: Dict[str, Any]
snapshot: GoalSnapshotResponse
class UpdateGoalResponse(BaseModel):
"""Response after editing a goal."""
goal: Dict[str, Any]
snapshot: GoalSnapshotResponse
class UpdateSessionRequest(BaseModel):
"""Session update fields."""
title: Optional[str] = None
# ============================================================================
# State variables
# ============================================================================
_goal_store = None
# ============================================================================
# Helper Functions
# ============================================================================
def _get_goal_store():
"""Return the shared finance goal store."""
global _goal_store
if _goal_store is None:
from src.goal import GoalStore
_goal_store = GoalStore()
return _goal_store
# ============================================================================
# SSE frame helpers for session events (module-level for re-export)
# ============================================================================
_PROPOSAL_TOOL_NAME = "propose_mandate_profiles"
_PROPOSAL_ID_RE = re.compile(r'"proposal_id"\s*:\s*"(mp_[0-9a-f]{32})"')
def _load_full_proposal(proposal_id: str) -> Optional[Dict[str, Any]]:
"""Reload a persisted mandate proposal by id, broker-agnostic."""
try:
from src.live.paths import live_root
for proposal_path in live_root().glob(f"*/proposals/{proposal_id}.json"):
try:
data = json.loads(proposal_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if isinstance(data, dict) and data.get("type") == "mandate.proposal":
return data
except Exception: # pragma: no cover - relay must never break the stream
logger.debug("mandate.proposal reload failed for %s", proposal_id, exc_info=True)
return None
def _mandate_proposal_frame_from_tool_result(event: Any) -> Optional[str]:
"""Build a mandate.proposal SSE frame from a propose-tool tool_result."""
data = getattr(event, "data", None)
if getattr(event, "event_type", None) != "tool_result" or not isinstance(data, dict):
return None
if data.get("tool") != _PROPOSAL_TOOL_NAME or data.get("status") != "ok":
return None
match = _PROPOSAL_ID_RE.search(str(data.get("preview") or ""))
if not match:
return None
proposal = _load_full_proposal(match.group(1))
if proposal is None:
return None
from src.session.events import SSEEvent
frame = SSEEvent(
event_type="mandate.proposal",
data=proposal,
session_id=getattr(event, "session_id", "") or "",
)
return frame.to_sse()
_LIVE_ACTION_ID_RE = re.compile(r'"audit_id"\s*:\s*"(la_[0-9a-zA-Z]+)"')
def _load_live_action_record(audit_id: str) -> Optional[Dict[str, Any]]:
"""Reload a redacted live-action record from the ledger by audit_id."""
try:
from src.live.paths import live_root
ledger = live_root() / "audit.jsonl"
if not ledger.exists():
return None
for line in reversed(ledger.read_text(encoding="utf-8").splitlines()):
if audit_id not in line:
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(record, dict) and record.get("audit_id") == audit_id:
return record
except Exception: # pragma: no cover - relay must never break the stream
logger.debug("live.action reload failed for %s", audit_id, exc_info=True)
return None
def _live_action_frame_from_tool_result(event: Any) -> Optional[str]:
"""Build a live.action SSE frame from an order-guard tool_result."""
data = getattr(event, "data", None)
if getattr(event, "event_type", None) != "tool_result" or not isinstance(data, dict):
return None
preview = str(data.get("preview") or "")
if '"live_action"' not in preview:
return None
match = _LIVE_ACTION_ID_RE.search(preview)
if not match:
return None
record = _load_live_action_record(match.group(1))
if record is None:
return None
from src.session.events import SSEEvent
frame = SSEEvent(
event_type="live.action",
data=record,
session_id=getattr(event, "session_id", "") or "",
)
return frame.to_sse()
# ============================================================================
# Registration
# ============================================================================
def register_sessions_routes(app: FastAPI) -> None:
"""Mount the session/goal routes onto ``app``.
Resolves shared dependencies from the host ``api_server`` module via
``sys.modules``.
"""
import sys as _sys
host = _sys.modules.get("api_server") or _sys.modules.get("agent.api_server")
if host is None:
raise RuntimeError(
"register_sessions_routes: api_server module not in sys.modules; "
"ensure api_server is imported before calling this function"
)
# Auth dependencies
require_auth = host.require_auth
require_event_stream_auth = host.require_event_stream_auth
# Late-access closures for shared host symbols (monkeypatch-safe)
def _host_get_session_service():
h = _sys.modules.get("api_server") or _sys.modules.get("agent.api_server")
return h._get_session_service()
def _host_validate_path_param(value: str, kind: str) -> None:
h = _sys.modules.get("api_server") or _sys.modules.get("agent.api_server")
return h._validate_path_param(value, kind)
def _host_shell_tools_enabled_for_request(request: Request) -> bool:
h = _sys.modules.get("api_server") or _sys.modules.get("agent.api_server")
return h._shell_tools_enabled_for_request(request)
def _get_existing_session_or_404(session_id: str):
"""Return (service, session) or raise 404."""
svc = _host_get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
session = svc.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
return svc, session
# -----------------------------------------------------------------------
# Session CRUD routes
# -----------------------------------------------------------------------
@app.post("/sessions", response_model=SessionResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_auth)])
async def create_session(request: CreateSessionRequest):
"""Create a chat session."""
svc = _host_get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
session = svc.create_session(title=request.title, config=request.config)
return SessionResponse(
session_id=session.session_id,
title=session.title,
status=session.status.value,
created_at=session.created_at,
updated_at=session.updated_at,
last_attempt_id=session.last_attempt_id,
)
@app.get("/sessions", response_model=List[SessionResponse], dependencies=[Depends(require_auth)])
async def list_sessions(limit: int = Query(50, ge=1, le=200)):
"""List sessions."""
svc = _host_get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
sessions = svc.list_sessions(limit=limit)
return [
SessionResponse(
session_id=s.session_id,
title=s.title,
status=s.status.value,
created_at=s.created_at,
updated_at=s.updated_at,
last_attempt_id=s.last_attempt_id,
)
for s in sessions
]
@app.get("/sessions/{session_id}", response_model=SessionResponse, dependencies=[Depends(require_auth)])
async def get_session(session_id: str):
"""Get one session by id."""
_host_validate_path_param(session_id, "session_id")
svc = _host_get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
session = svc.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
return SessionResponse(
session_id=session.session_id,
title=session.title,
status=session.status.value,
created_at=session.created_at,
updated_at=session.updated_at,
last_attempt_id=session.last_attempt_id,
)
# -----------------------------------------------------------------------
# Goal sub-group routes
# -----------------------------------------------------------------------
@app.post(
"/sessions/{session_id}/goal",
response_model=GoalSnapshotResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_auth)],
)
async def create_session_goal(session_id: str, req: CreateGoalRequest):
"""Create or replace the current finance research goal for a session."""
_host_validate_path_param(session_id, "session_id")
svc, _session = _get_existing_session_or_404(session_id)
from src.goal import RiskTier
from src.goal.context import default_goal_criteria
criteria = [item.strip() for item in req.criteria if item.strip()]
if not criteria:
criteria = default_goal_criteria()
try:
risk_tier = RiskTier(req.risk_tier)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"invalid risk_tier: {req.risk_tier}") from exc
if risk_tier is RiskTier.LIVE_TRADING_OR_EXECUTION:
raise HTTPException(status_code=400, detail="live trading or execution goals are not supported")
goal_store = _get_goal_store()
try:
goal = goal_store.replace_goal(
session_id=session_id,
objective=req.objective,
criteria=criteria,
ui_summary=req.ui_summary,
source="api",
protocol=req.protocol,
risk_tier=risk_tier,
token_budget=req.token_budget,
turn_budget=req.turn_budget,
time_budget_seconds=req.time_budget_seconds,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
snapshot = goal_store.get_goal_snapshot(goal.goal_id)
if snapshot is None:
raise HTTPException(status_code=500, detail="Goal created but could not be reloaded")
svc.event_bus.emit(session_id, "goal.created", {"goal": snapshot["goal"]})
return snapshot
@app.get(
"/sessions/{session_id}/goal",
response_model=GoalSnapshotResponse,
dependencies=[Depends(require_auth)],
)
async def get_session_goal(session_id: str):
"""Return the current finance research goal snapshot for a session."""
_host_validate_path_param(session_id, "session_id")
_get_existing_session_or_404(session_id)
snapshot = _get_goal_store().get_current_snapshot(session_id)
if snapshot is None:
raise HTTPException(status_code=404, detail="No current goal")
return snapshot
@app.patch(
"/sessions/{session_id}/goal",
response_model=UpdateGoalResponse,
dependencies=[Depends(require_auth)],
)
async def update_session_goal(session_id: str, req: UpdateGoalRequest):
"""Edit the current finance research goal without replacing the session."""
_host_validate_path_param(session_id, "session_id")
svc, _session = _get_existing_session_or_404(session_id)
from src.goal import StaleGoalError
if req.objective is None and req.ui_summary is None:
raise HTTPException(status_code=400, detail="objective or ui_summary is required")
goal_store = _get_goal_store()
try:
goal = goal_store.update_goal(
session_id=session_id,
goal_id=req.goal_id,
expected_goal_id=req.expected_goal_id,
objective=req.objective,
ui_summary=req.ui_summary,
)
except StaleGoalError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
snapshot = goal_store.get_goal_snapshot(goal.goal_id)
if snapshot is None:
raise HTTPException(status_code=500, detail="Goal snapshot could not be reloaded")
svc.event_bus.emit(session_id, "goal.updated", {"goal": snapshot["goal"], "snapshot": snapshot})
return {"goal": snapshot["goal"], "snapshot": snapshot}
@app.post(
"/sessions/{session_id}/goal/evidence",
response_model=AddGoalEvidenceResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_auth)],
)
async def add_session_goal_evidence(session_id: str, req: AddGoalEvidenceRequest):
"""Append traceable evidence to the current finance research goal."""
_host_validate_path_param(session_id, "session_id")
svc, _session = _get_existing_session_or_404(session_id)
from dataclasses import asdict
from src.goal import EvidenceInput, StaleGoalError
goal_store = _get_goal_store()
try:
evidence = goal_store.append_evidence(
session_id=session_id,
goal_id=req.goal_id,
expected_goal_id=req.expected_goal_id,
evidence=EvidenceInput(
criterion_id=req.criterion_id,
claim_id=req.claim_id,
evidence_type=req.evidence_type,
text=req.text,
tool_call_id=req.tool_call_id,
run_id=req.run_id,
source_provider=req.source_provider,
source_type=req.source_type,
source_uri=req.source_uri,
symbol_universe=req.symbol_universe,
benchmark=req.benchmark,
timeframe=req.timeframe,
method=req.method,
assumptions=req.assumptions,
artifact_path=req.artifact_path,
artifact_hash=req.artifact_hash,
data_as_of=req.data_as_of,
confidence=req.confidence,
caveat=req.caveat,
contradicts_claim_ids=req.contradicts_claim_ids,
),
)
except StaleGoalError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
snapshot = goal_store.get_goal_snapshot(req.goal_id)
if snapshot is None:
raise HTTPException(status_code=500, detail="Goal snapshot could not be reloaded")
svc.event_bus.emit(
session_id,
"goal.evidence",
{"evidence": asdict(evidence), "goal_id": req.goal_id},
)
return {"evidence": asdict(evidence), "snapshot": snapshot}
@app.patch(
"/sessions/{session_id}/goal/status",
response_model=UpdateGoalStatusResponse,
dependencies=[Depends(require_auth)],
)
async def update_session_goal_status(session_id: str, req: UpdateGoalStatusRequest):
"""Update the current finance research goal status."""
_host_validate_path_param(session_id, "session_id")
svc, _session = _get_existing_session_or_404(session_id)
from src.goal import AuditRow, GoalStatus, StaleGoalError
try:
next_status = GoalStatus(req.status)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"invalid goal status: {req.status}") from exc
goal_store = _get_goal_store()
try:
goal = goal_store.update_status(
session_id=session_id,
goal_id=req.goal_id,
expected_goal_id=req.expected_goal_id,
status=next_status,
audit=[
AuditRow(
criterion_id=row.criterion_id,
result=row.result,
evidence_ids=row.evidence_ids,
notes=row.notes,
)
for row in req.audit
],
recap=req.recap,
)
except StaleGoalError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
snapshot = goal_store.get_goal_snapshot(goal.goal_id)
if snapshot is None:
raise HTTPException(status_code=500, detail="Goal snapshot could not be reloaded")
svc.event_bus.emit(session_id, "goal.updated", {"goal": snapshot["goal"], "snapshot": snapshot})
return {"goal": snapshot["goal"], "snapshot": snapshot}
# -----------------------------------------------------------------------
# Session action routes
# -----------------------------------------------------------------------
@app.delete("/sessions/{session_id}", dependencies=[Depends(require_auth)])
async def delete_session(session_id: str):
"""Delete a session."""
_host_validate_path_param(session_id, "session_id")
svc = _host_get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
deleted = svc.delete_session(session_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
_get_goal_store().delete_session_goals(session_id)
return {"status": "deleted", "session_id": session_id}
@app.patch("/sessions/{session_id}", dependencies=[Depends(require_auth)])
async def update_session(session_id: str, req: UpdateSessionRequest):
"""Update session fields (e.g. title)."""
_host_validate_path_param(session_id, "session_id")
svc = _host_get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
session = svc.store.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
if req.title is not None:
session.title = req.title
session.updated_at = datetime.now(timezone.utc).isoformat()
svc.store.update_session(session)
return {"status": "updated", "session_id": session_id}
@app.post("/sessions/{session_id}/messages", dependencies=[Depends(require_auth)])
async def send_message(session_id: str, payload: SendMessageRequest, http_request: Request):
"""Send a user message and start the agent loop (natural language strategy)."""
_host_validate_path_param(session_id, "session_id")
svc = _host_get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
try:
result = await svc.send_message(
session_id=session_id,
content=payload.content,
include_shell_tools=_host_shell_tools_enabled_for_request(http_request),
)
return result
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
@app.post("/sessions/{session_id}/cancel", dependencies=[Depends(require_auth)])
async def cancel_session(session_id: str):
"""Cancel the in-flight agent loop for this session."""
_host_validate_path_param(session_id, "session_id")
svc = _host_get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
cancelled = svc.cancel_current(session_id)
if not cancelled:
return {"status": "no_active_loop"}
return {"status": "cancelled"}
@app.get("/sessions/{session_id}/messages", response_model=List[MessageResponse], dependencies=[Depends(require_auth)])
async def get_messages(session_id: str, limit: int = Query(100, ge=1, le=1000)):
"""List messages for a session."""
_host_validate_path_param(session_id, "session_id")
svc = _host_get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
messages = svc.get_messages(session_id, limit=limit)
return [
MessageResponse(
message_id=m.message_id,
session_id=m.session_id,
role=m.role,
content=m.content,
created_at=m.created_at,
linked_attempt_id=m.linked_attempt_id,
metadata=m.metadata if m.metadata else None,
)
for m in messages
]
@app.get("/sessions/{session_id}/events", dependencies=[Depends(require_event_stream_auth)])
async def session_events(
session_id: str,
request: Request,
last_event_id: Optional[str] = Query(None, alias="Last-Event-ID"),
replay: Optional[str] = Query(None),
):
"""SSE stream for agent events."""
_host_validate_path_param(session_id, "session_id")
svc = _host_get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
session = svc.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
header_id = request.headers.get("Last-Event-ID")
event_id = header_id or last_event_id
replay_active = (replay or "").lower() == "active"
replay_all = False
if replay_active and not event_id and session.last_attempt_id:
attempt = svc.store.get_attempt(session_id, session.last_attempt_id)
attempt_status = getattr(attempt.status, "value", attempt.status) if attempt else None
replay_all = attempt_status == "running"
async def event_generator():
async for event in svc.event_bus.subscribe(
session_id,
last_event_id=event_id,
replay_all=replay_all,
):
if await request.is_disconnected():
break
yield event.to_sse()
relayed = _mandate_proposal_frame_from_tool_result(event)
if relayed is not None:
yield relayed
live_action = _live_action_frame_from_tool_result(event)
if live_action is not None:
yield live_action
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)