Skip to content

Commit 61798e6

Browse files
committed
feat(goal): add research goal runtime
Add the session-scoped Research Goal layer across backend, CLI, API/MCP, SSE, and Web UI. Goals now persist claims, acceptance criteria, evidence, budgets, and completion policy; agent tools and /goal can create goals and attach evidence; REST/MCP expose snapshots and evidence writes; SSE keeps chat clients fresh. Also carry the 2026-05-24 transplant cleanup: replay-all/session-id/upload wiring, report-worthy Full Report gating, English-only Web UI copy, and audit fixes for verified evidence, live-trading risk tiers, CLI session linkage, goal ledger cleanup, replay-all hookup, and frontend snapshot races.
1 parent 4faf216 commit 61798e6

46 files changed

Lines changed: 4102 additions & 438 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

agent/api_server.py

Lines changed: 217 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,63 @@ class MessageResponse(BaseModel):
238238
metadata: Optional[Dict[str, Any]] = None
239239

240240

241+
class CreateGoalRequest(BaseModel):
242+
"""Create or replace a finance research goal."""
243+
244+
objective: str = Field(..., min_length=1, max_length=5000)
245+
criteria: List[str] = Field(default_factory=list)
246+
ui_summary: str = ""
247+
protocol: str = "thesis_review"
248+
risk_tier: str = "research_general"
249+
token_budget: Optional[int] = Field(None, ge=1)
250+
turn_budget: Optional[int] = Field(None, ge=1)
251+
time_budget_seconds: Optional[int] = Field(None, ge=1)
252+
253+
254+
class AddGoalEvidenceRequest(BaseModel):
255+
"""Append evidence to a finance research goal."""
256+
257+
goal_id: str = Field(..., min_length=1)
258+
expected_goal_id: str = Field(..., min_length=1)
259+
text: str = Field(..., min_length=1, max_length=10000)
260+
criterion_id: Optional[str] = None
261+
claim_id: Optional[str] = None
262+
evidence_type: str = "evidence"
263+
tool_call_id: Optional[str] = None
264+
run_id: Optional[str] = None
265+
source_provider: Optional[str] = None
266+
source_type: Optional[str] = None
267+
source_uri: Optional[str] = None
268+
symbol_universe: List[str] = Field(default_factory=list)
269+
benchmark: List[str] = Field(default_factory=list)
270+
timeframe: Optional[str] = None
271+
method: Optional[str] = None
272+
assumptions: Dict[str, Any] = Field(default_factory=dict)
273+
artifact_path: Optional[str] = None
274+
artifact_hash: Optional[str] = None
275+
data_as_of: Optional[str] = None
276+
confidence: Optional[str] = None
277+
caveat: Optional[str] = None
278+
contradicts_claim_ids: List[str] = Field(default_factory=list)
279+
280+
281+
class GoalSnapshotResponse(BaseModel):
282+
"""Finance research goal snapshot."""
283+
284+
goal: Dict[str, Any]
285+
claims: List[Dict[str, Any]]
286+
criteria: List[Dict[str, Any]]
287+
evidence: List[Dict[str, Any]]
288+
evidence_count: int = 0
289+
290+
291+
class AddGoalEvidenceResponse(BaseModel):
292+
"""Response after appending goal evidence."""
293+
294+
evidence: Dict[str, Any]
295+
snapshot: GoalSnapshotResponse
296+
297+
241298

242299
# ============================================================================
243300
# FastAPI Application
@@ -1320,6 +1377,7 @@ async def api_info():
13201377
# ============================================================================
13211378

13221379
_session_service = None
1380+
_goal_store = None
13231381

13241382

13251383
def _get_session_service():
@@ -1353,6 +1411,26 @@ def _get_session_service():
13531411
return _session_service
13541412

13551413

1414+
def _get_goal_store():
1415+
"""Return the shared finance goal store."""
1416+
global _goal_store
1417+
if _goal_store is None:
1418+
from src.goal import GoalStore
1419+
1420+
_goal_store = GoalStore()
1421+
return _goal_store
1422+
1423+
1424+
def _get_existing_session_or_404(session_id: str):
1425+
svc = _get_session_service()
1426+
if not svc:
1427+
raise HTTPException(status_code=501, detail="Session runtime not enabled")
1428+
session = svc.get_session(session_id)
1429+
if not session:
1430+
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
1431+
return svc, session
1432+
1433+
13561434
@app.post("/sessions", response_model=SessionResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_auth)])
13571435
async def create_session(request: CreateSessionRequest):
13581436
"""Create a chat session."""
@@ -1410,6 +1488,124 @@ async def get_session(session_id: str):
14101488
)
14111489

14121490

1491+
@app.post(
1492+
"/sessions/{session_id}/goal",
1493+
response_model=GoalSnapshotResponse,
1494+
status_code=status.HTTP_201_CREATED,
1495+
dependencies=[Depends(require_auth)],
1496+
)
1497+
async def create_session_goal(session_id: str, req: CreateGoalRequest):
1498+
"""Create or replace the current finance research goal for a session."""
1499+
_validate_path_param(session_id, "session_id")
1500+
svc, _session = _get_existing_session_or_404(session_id)
1501+
from src.goal import RiskTier
1502+
1503+
criteria = [item.strip() for item in req.criteria if item.strip()]
1504+
if not criteria:
1505+
criteria = ["Define research thesis", "Record at least one supporting or contradicting evidence row"]
1506+
try:
1507+
risk_tier = RiskTier(req.risk_tier)
1508+
except ValueError as exc:
1509+
raise HTTPException(status_code=400, detail=f"invalid risk_tier: {req.risk_tier}") from exc
1510+
if risk_tier is RiskTier.LIVE_TRADING_OR_EXECUTION:
1511+
raise HTTPException(status_code=400, detail="live trading or execution goals are not supported")
1512+
1513+
goal_store = _get_goal_store()
1514+
try:
1515+
goal = goal_store.replace_goal(
1516+
session_id=session_id,
1517+
objective=req.objective,
1518+
criteria=criteria,
1519+
ui_summary=req.ui_summary,
1520+
source="api",
1521+
protocol=req.protocol,
1522+
risk_tier=risk_tier,
1523+
token_budget=req.token_budget,
1524+
turn_budget=req.turn_budget,
1525+
time_budget_seconds=req.time_budget_seconds,
1526+
)
1527+
except ValueError as exc:
1528+
raise HTTPException(status_code=400, detail=str(exc)) from exc
1529+
snapshot = goal_store.get_goal_snapshot(goal.goal_id)
1530+
if snapshot is None:
1531+
raise HTTPException(status_code=500, detail="Goal created but could not be reloaded")
1532+
svc.event_bus.emit(session_id, "goal.created", {"goal": snapshot["goal"]})
1533+
return snapshot
1534+
1535+
1536+
@app.get(
1537+
"/sessions/{session_id}/goal",
1538+
response_model=GoalSnapshotResponse,
1539+
dependencies=[Depends(require_auth)],
1540+
)
1541+
async def get_session_goal(session_id: str):
1542+
"""Return the current finance research goal snapshot for a session."""
1543+
_validate_path_param(session_id, "session_id")
1544+
_get_existing_session_or_404(session_id)
1545+
snapshot = _get_goal_store().get_current_snapshot(session_id)
1546+
if snapshot is None:
1547+
raise HTTPException(status_code=404, detail="No current goal")
1548+
return snapshot
1549+
1550+
1551+
@app.post(
1552+
"/sessions/{session_id}/goal/evidence",
1553+
response_model=AddGoalEvidenceResponse,
1554+
status_code=status.HTTP_201_CREATED,
1555+
dependencies=[Depends(require_auth)],
1556+
)
1557+
async def add_session_goal_evidence(session_id: str, req: AddGoalEvidenceRequest):
1558+
"""Append traceable evidence to the current finance research goal."""
1559+
_validate_path_param(session_id, "session_id")
1560+
svc, _session = _get_existing_session_or_404(session_id)
1561+
from dataclasses import asdict
1562+
from src.goal import EvidenceInput, StaleGoalError
1563+
1564+
goal_store = _get_goal_store()
1565+
try:
1566+
evidence = goal_store.append_evidence(
1567+
session_id=session_id,
1568+
goal_id=req.goal_id,
1569+
expected_goal_id=req.expected_goal_id,
1570+
evidence=EvidenceInput(
1571+
criterion_id=req.criterion_id,
1572+
claim_id=req.claim_id,
1573+
evidence_type=req.evidence_type,
1574+
text=req.text,
1575+
tool_call_id=req.tool_call_id,
1576+
run_id=req.run_id,
1577+
source_provider=req.source_provider,
1578+
source_type=req.source_type,
1579+
source_uri=req.source_uri,
1580+
symbol_universe=req.symbol_universe,
1581+
benchmark=req.benchmark,
1582+
timeframe=req.timeframe,
1583+
method=req.method,
1584+
assumptions=req.assumptions,
1585+
artifact_path=req.artifact_path,
1586+
artifact_hash=req.artifact_hash,
1587+
data_as_of=req.data_as_of,
1588+
confidence=req.confidence,
1589+
caveat=req.caveat,
1590+
contradicts_claim_ids=req.contradicts_claim_ids,
1591+
),
1592+
)
1593+
except StaleGoalError as exc:
1594+
raise HTTPException(status_code=409, detail=str(exc)) from exc
1595+
except ValueError as exc:
1596+
raise HTTPException(status_code=400, detail=str(exc)) from exc
1597+
1598+
snapshot = goal_store.get_goal_snapshot(req.goal_id)
1599+
if snapshot is None:
1600+
raise HTTPException(status_code=500, detail="Goal snapshot could not be reloaded")
1601+
svc.event_bus.emit(
1602+
session_id,
1603+
"goal.evidence",
1604+
{"evidence": asdict(evidence), "goal_id": req.goal_id},
1605+
)
1606+
return {"evidence": asdict(evidence), "snapshot": snapshot}
1607+
1608+
14131609
@app.delete("/sessions/{session_id}", dependencies=[Depends(require_auth)])
14141610
async def delete_session(session_id: str):
14151611
"""Delete a session."""
@@ -1420,6 +1616,7 @@ async def delete_session(session_id: str):
14201616
deleted = svc.delete_session(session_id)
14211617
if not deleted:
14221618
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
1619+
_get_goal_store().delete_session_goals(session_id)
14231620
return {"status": "deleted", "session_id": session_id}
14241621

14251622

@@ -1504,6 +1701,7 @@ async def session_events(
15041701
session_id: str,
15051702
request: Request,
15061703
last_event_id: Optional[str] = Query(None, alias="Last-Event-ID"),
1704+
replay: Optional[str] = Query(None),
15071705
):
15081706
"""SSE stream for agent events."""
15091707
_validate_path_param(session_id, "session_id")
@@ -1516,9 +1714,19 @@ async def session_events(
15161714

15171715
header_id = request.headers.get("Last-Event-ID")
15181716
event_id = header_id or last_event_id
1717+
replay_active = (replay or "").lower() == "active"
1718+
replay_all = False
1719+
if replay_active and not event_id and session.last_attempt_id:
1720+
attempt = svc.store.get_attempt(session_id, session.last_attempt_id)
1721+
attempt_status = getattr(attempt.status, "value", attempt.status) if attempt else None
1722+
replay_all = attempt_status == "running"
15191723

15201724
async def event_generator():
1521-
async for event in svc.event_bus.subscribe(session_id, last_event_id=event_id):
1725+
async for event in svc.event_bus.subscribe(
1726+
session_id,
1727+
last_event_id=event_id,
1728+
replay_all=replay_all,
1729+
):
15221730
if await request.is_disconnected():
15231731
break
15241732
yield event.to_sse()
@@ -1594,20 +1802,19 @@ async def upload_file(file: UploadFile):
15941802
if not file.filename:
15951803
raise HTTPException(status_code=400, detail="Missing filename")
15961804
filename = Path(file.filename).name
1597-
ext = Path(file.filename).suffix.lower()
1805+
ext = Path(filename).suffix.lower()
15981806
if ext in _BLOCKED_UPLOAD_EXT or filename.lower() in _BLOCKED_UPLOAD_NAMES:
15991807
raise HTTPException(
16001808
status_code=400,
16011809
detail="This file type is not allowed for upload.",
16021810
)
16031811

1604-
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
1605-
16061812
safe_name = f"{uuid.uuid4().hex}{ext}"
16071813
dest = UPLOADS_DIR / safe_name
16081814
total_size = 0
16091815

16101816
try:
1817+
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
16111818
with dest.open("wb") as handle:
16121819
while True:
16131820
chunk = await file.read(_UPLOAD_CHUNK_SIZE)
@@ -1628,14 +1835,17 @@ async def upload_file(file: UploadFile):
16281835
except OSError as exc:
16291836
if dest.exists():
16301837
dest.unlink()
1631-
raise HTTPException(status_code=500, detail=f"Failed to store upload: {exc}") from exc
1838+
raise HTTPException(
1839+
status_code=500,
1840+
detail="Upload failed while storing the file. Please retry or choose a different file.",
1841+
) from exc
16321842
finally:
16331843
await file.close()
16341844

16351845
return {
16361846
"status": "ok",
1637-
"file_path": str(dest.resolve()),
1638-
"filename": file.filename,
1847+
"file_path": f"uploads/{safe_name}",
1848+
"filename": filename,
16391849
}
16401850

16411851

agent/cli/_legacy.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,7 @@ def _run_agent(
850850
no_rich: bool = False,
851851
stream_output: bool = True,
852852
dashboard: Optional[_RunDashboard] = None,
853+
session_id: str = "",
853854
) -> dict:
854855
"""Build AgentLoop and execute, return result dict."""
855856
from src.tools import build_registry
@@ -975,6 +976,7 @@ def _mcp_warn(msg: str) -> None:
975976
persistent_memory=pm,
976977
include_shell_tools=True,
977978
agent_config=agent_config,
979+
session_id=session_id or None,
978980
warn_callback=_mcp_warn,
979981
),
980982
llm=ChatLLM(),
@@ -985,7 +987,13 @@ def _mcp_warn(msg: str) -> None:
985987
if run_dir_override:
986988
agent.memory.run_dir = run_dir_override
987989

988-
return _run_with_graceful_cancel(agent, prompt, history, no_rich=no_rich)
990+
return _run_with_graceful_cancel(
991+
agent,
992+
prompt,
993+
history,
994+
no_rich=no_rich,
995+
session_id=session_id,
996+
)
989997

990998

991999
def _run_with_graceful_cancel(
@@ -994,6 +1002,7 @@ def _run_with_graceful_cancel(
9941002
history: Optional[List[Dict]],
9951003
*,
9961004
no_rich: bool,
1005+
session_id: str = "",
9971006
) -> dict:
9981007
"""Run an agent loop with first-Ctrl+C = graceful cancel.
9991008
@@ -1020,7 +1029,7 @@ def _run_with_graceful_cancel(
10201029
original = _signal.getsignal(_signal.SIGINT)
10211030
except (ValueError, AttributeError):
10221031
# Not on a thread that can receive signals — skip the handler swap.
1023-
return agent.run(user_message=prompt, history=history)
1032+
return agent.run(user_message=prompt, history=history, session_id=session_id)
10241033

10251034
def _on_sigint(_signum, _frame) -> None:
10261035
now = time.time()
@@ -1041,10 +1050,10 @@ def _on_sigint(_signum, _frame) -> None:
10411050
_signal.signal(_signal.SIGINT, _on_sigint)
10421051
except (ValueError, OSError):
10431052
# signal.signal only works on the main thread of the main interpreter.
1044-
return agent.run(user_message=prompt, history=history)
1053+
return agent.run(user_message=prompt, history=history, session_id=session_id)
10451054

10461055
try:
1047-
return agent.run(user_message=prompt, history=history)
1056+
return agent.run(user_message=prompt, history=history, session_id=session_id)
10481057
finally:
10491058
try:
10501059
_signal.signal(_signal.SIGINT, original)

0 commit comments

Comments
 (0)