Skip to content

Commit 2ec4296

Browse files
committed
fix(goal): close research goal lifecycle
1 parent 7045d25 commit 2ec4296

19 files changed

Lines changed: 1607 additions & 42 deletions

agent/api_server.py

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from fastapi.middleware.cors import CORSMiddleware
2828
from rich.console import Console
2929

30+
from src.goal.context import default_goal_criteria
3031
from src.ui_services import build_run_analysis, load_run_context
3132

3233
# UTF-8 on Windows
@@ -251,6 +252,15 @@ class CreateGoalRequest(BaseModel):
251252
time_budget_seconds: Optional[int] = Field(None, ge=1)
252253

253254

255+
class UpdateGoalRequest(BaseModel):
256+
"""Edit mutable finance research goal fields."""
257+
258+
goal_id: str = Field(..., min_length=1)
259+
expected_goal_id: str = Field(..., min_length=1)
260+
objective: Optional[str] = Field(None, min_length=1, max_length=5000)
261+
ui_summary: Optional[str] = Field(None, max_length=500)
262+
263+
254264
class AddGoalEvidenceRequest(BaseModel):
255265
"""Append evidence to a finance research goal."""
256266

@@ -295,6 +305,39 @@ class AddGoalEvidenceResponse(BaseModel):
295305
snapshot: GoalSnapshotResponse
296306

297307

308+
class GoalAuditRowRequest(BaseModel):
309+
"""One criterion row for goal status audits."""
310+
311+
criterion_id: str = Field(..., min_length=1)
312+
result: str = Field(..., min_length=1)
313+
evidence_ids: List[str] = Field(default_factory=list)
314+
notes: str = ""
315+
316+
317+
class UpdateGoalStatusRequest(BaseModel):
318+
"""Update a finance research goal status."""
319+
320+
goal_id: str = Field(..., min_length=1)
321+
expected_goal_id: str = Field(..., min_length=1)
322+
status: str = Field(..., min_length=1)
323+
audit: List[GoalAuditRowRequest] = Field(default_factory=list)
324+
recap: Optional[str] = None
325+
326+
327+
class UpdateGoalStatusResponse(BaseModel):
328+
"""Response after changing a goal status."""
329+
330+
goal: Dict[str, Any]
331+
snapshot: GoalSnapshotResponse
332+
333+
334+
class UpdateGoalResponse(BaseModel):
335+
"""Response after editing a goal."""
336+
337+
goal: Dict[str, Any]
338+
snapshot: GoalSnapshotResponse
339+
340+
298341

299342
# ============================================================================
300343
# FastAPI Application
@@ -1502,7 +1545,7 @@ async def create_session_goal(session_id: str, req: CreateGoalRequest):
15021545

15031546
criteria = [item.strip() for item in req.criteria if item.strip()]
15041547
if not criteria:
1505-
criteria = ["Define research thesis", "Record at least one supporting or contradicting evidence row"]
1548+
criteria = default_goal_criteria()
15061549
try:
15071550
risk_tier = RiskTier(req.risk_tier)
15081551
except ValueError as exc:
@@ -1548,6 +1591,41 @@ async def get_session_goal(session_id: str):
15481591
return snapshot
15491592

15501593

1594+
@app.patch(
1595+
"/sessions/{session_id}/goal",
1596+
response_model=UpdateGoalResponse,
1597+
dependencies=[Depends(require_auth)],
1598+
)
1599+
async def update_session_goal(session_id: str, req: UpdateGoalRequest):
1600+
"""Edit the current finance research goal without replacing the session."""
1601+
_validate_path_param(session_id, "session_id")
1602+
svc, _session = _get_existing_session_or_404(session_id)
1603+
from src.goal import StaleGoalError
1604+
1605+
if req.objective is None and req.ui_summary is None:
1606+
raise HTTPException(status_code=400, detail="objective or ui_summary is required")
1607+
1608+
goal_store = _get_goal_store()
1609+
try:
1610+
goal = goal_store.update_goal(
1611+
session_id=session_id,
1612+
goal_id=req.goal_id,
1613+
expected_goal_id=req.expected_goal_id,
1614+
objective=req.objective,
1615+
ui_summary=req.ui_summary,
1616+
)
1617+
except StaleGoalError as exc:
1618+
raise HTTPException(status_code=409, detail=str(exc)) from exc
1619+
except ValueError as exc:
1620+
raise HTTPException(status_code=400, detail=str(exc)) from exc
1621+
1622+
snapshot = goal_store.get_goal_snapshot(goal.goal_id)
1623+
if snapshot is None:
1624+
raise HTTPException(status_code=500, detail="Goal snapshot could not be reloaded")
1625+
svc.event_bus.emit(session_id, "goal.updated", {"goal": snapshot["goal"], "snapshot": snapshot})
1626+
return {"goal": snapshot["goal"], "snapshot": snapshot}
1627+
1628+
15511629
@app.post(
15521630
"/sessions/{session_id}/goal/evidence",
15531631
response_model=AddGoalEvidenceResponse,
@@ -1606,6 +1684,52 @@ async def add_session_goal_evidence(session_id: str, req: AddGoalEvidenceRequest
16061684
return {"evidence": asdict(evidence), "snapshot": snapshot}
16071685

16081686

1687+
@app.patch(
1688+
"/sessions/{session_id}/goal/status",
1689+
response_model=UpdateGoalStatusResponse,
1690+
dependencies=[Depends(require_auth)],
1691+
)
1692+
async def update_session_goal_status(session_id: str, req: UpdateGoalStatusRequest):
1693+
"""Update the current finance research goal status."""
1694+
_validate_path_param(session_id, "session_id")
1695+
svc, _session = _get_existing_session_or_404(session_id)
1696+
from src.goal import AuditRow, GoalStatus, StaleGoalError
1697+
1698+
try:
1699+
next_status = GoalStatus(req.status)
1700+
except ValueError as exc:
1701+
raise HTTPException(status_code=400, detail=f"invalid goal status: {req.status}") from exc
1702+
1703+
goal_store = _get_goal_store()
1704+
try:
1705+
goal = goal_store.update_status(
1706+
session_id=session_id,
1707+
goal_id=req.goal_id,
1708+
expected_goal_id=req.expected_goal_id,
1709+
status=next_status,
1710+
audit=[
1711+
AuditRow(
1712+
criterion_id=row.criterion_id,
1713+
result=row.result,
1714+
evidence_ids=row.evidence_ids,
1715+
notes=row.notes,
1716+
)
1717+
for row in req.audit
1718+
],
1719+
recap=req.recap,
1720+
)
1721+
except StaleGoalError as exc:
1722+
raise HTTPException(status_code=409, detail=str(exc)) from exc
1723+
except ValueError as exc:
1724+
raise HTTPException(status_code=400, detail=str(exc)) from exc
1725+
1726+
snapshot = goal_store.get_goal_snapshot(goal.goal_id)
1727+
if snapshot is None:
1728+
raise HTTPException(status_code=500, detail="Goal snapshot could not be reloaded")
1729+
svc.event_bus.emit(session_id, "goal.updated", {"goal": snapshot["goal"], "snapshot": snapshot})
1730+
return {"goal": snapshot["goal"], "snapshot": snapshot}
1731+
1732+
16091733
@app.delete("/sessions/{session_id}", dependencies=[Depends(require_auth)])
16101734
async def delete_session(session_id: str):
16111735
"""Delete a session."""

agent/cli/commands/goal.py

Lines changed: 104 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from rich.text import Text
1414

1515
from cli.theme import get_console
16+
from src.goal.context import default_goal_criteria
1617

1718
_goal_store = None
1819

@@ -34,11 +35,7 @@ def _get_goal_store():
3435

3536
def _default_criteria() -> list[str]:
3637
"""Return the MVP finance protocol checklist."""
37-
return [
38-
"Define the research-only thesis and symbol universe",
39-
"Collect fresh market or benchmark evidence",
40-
"Record caveats, contradictions, and non-advice boundary",
41-
]
38+
return default_goal_criteria()
4239

4340

4441
def _criterion_is_covered(criterion: dict, evidence: list[dict]) -> bool:
@@ -263,6 +260,98 @@ def cmd_evidence(ctx: Any = None, *args: str) -> int:
263260
return 0
264261

265262

263+
def cmd_cancel(ctx: Any = None, *args: str) -> int:
264+
"""Cancel the current research goal."""
265+
session_id = _session_id(ctx, create=False)
266+
if session_id is None:
267+
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="dim"))
268+
return 0
269+
snapshot = _get_goal_store().get_current_snapshot(session_id)
270+
if snapshot is None:
271+
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="dim"))
272+
return 0
273+
274+
recap = " ".join(args).strip() or "Cancelled from CLI."
275+
try:
276+
from src.goal import GoalStatus
277+
278+
updated = _get_goal_store().update_status(
279+
session_id=session_id,
280+
goal_id=snapshot["goal"]["goal_id"],
281+
expected_goal_id=snapshot["goal"]["goal_id"],
282+
status=GoalStatus.CANCELLED,
283+
recap=recap,
284+
)
285+
except Exception as exc: # noqa: BLE001
286+
_resolve_console().print(Text(f"/goal cancel failed: {exc}", style="bold red"))
287+
return 1
288+
289+
terminal_snapshot = _get_goal_store().get_goal_snapshot(updated.goal_id)
290+
if terminal_snapshot is not None:
291+
_render_snapshot(terminal_snapshot, title="/goal cancelled")
292+
return 0
293+
294+
295+
def cmd_complete(ctx: Any = None, *args: str) -> int: # noqa: ARG001
296+
"""Complete the current research goal after auditing verified evidence."""
297+
session_id = _session_id(ctx, create=False)
298+
if session_id is None:
299+
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="dim"))
300+
return 0
301+
snapshot = _get_goal_store().get_current_snapshot(session_id)
302+
if snapshot is None:
303+
_resolve_console().print(Text("No current goal. Use /goal <objective> first.", style="dim"))
304+
return 0
305+
306+
evidence = snapshot.get("evidence") or []
307+
audit_rows = []
308+
for criterion in snapshot.get("criteria") or []:
309+
criterion_evidence = [
310+
item
311+
for item in evidence
312+
if item.get("criterion_id") == criterion["criterion_id"]
313+
and item.get("verification_status") == "verified"
314+
]
315+
if not criterion_evidence:
316+
_resolve_console().print(
317+
Text(
318+
"Cannot complete: every criterion needs verified run/artifact evidence.",
319+
style="bold red",
320+
)
321+
)
322+
return 1
323+
from src.goal import AuditRow
324+
325+
audit_rows.append(
326+
AuditRow(
327+
criterion_id=criterion["criterion_id"],
328+
result="satisfied",
329+
evidence_ids=[item["evidence_id"] for item in criterion_evidence],
330+
notes="Verified evidence attached from CLI audit.",
331+
)
332+
)
333+
334+
try:
335+
from src.goal import GoalStatus
336+
337+
updated = _get_goal_store().update_status(
338+
session_id=session_id,
339+
goal_id=snapshot["goal"]["goal_id"],
340+
expected_goal_id=snapshot["goal"]["goal_id"],
341+
status=GoalStatus.COMPLETE,
342+
audit=audit_rows,
343+
recap=" ".join(args).strip() or "Completed from CLI audit.",
344+
)
345+
except Exception as exc: # noqa: BLE001
346+
_resolve_console().print(Text(f"/goal complete failed: {exc}", style="bold red"))
347+
return 1
348+
349+
terminal_snapshot = _get_goal_store().get_goal_snapshot(updated.goal_id)
350+
if terminal_snapshot is not None:
351+
_render_snapshot(terminal_snapshot, title="/goal completed")
352+
return 0
353+
354+
266355
def cmd_help() -> int:
267356
"""Render /goal usage."""
268357
body = Text()
@@ -272,6 +361,10 @@ def cmd_help() -> int:
272361
body.append(" show the current goal snapshot\n", style="dim")
273362
body.append("/goal evidence <criterion-index-or-id> <note>", style="bold")
274363
body.append(" append manual evidence\n", style="dim")
364+
body.append("/goal complete [recap]", style="bold")
365+
body.append(" complete after verified evidence audit\n", style="dim")
366+
body.append("/goal cancel [recap]", style="bold")
367+
body.append(" cancel the current goal\n", style="dim")
275368
_resolve_console().print(Panel(body, title="/goal", border_style="dim", padding=(1, 2)))
276369
return 0
277370

@@ -290,6 +383,10 @@ def run(ctx: Any = None, *args: str) -> int:
290383
return cmd_start(ctx, *args[1:])
291384
if command == "evidence":
292385
return cmd_evidence(ctx, *args[1:])
386+
if command == "complete":
387+
return cmd_complete(ctx, *args[1:])
388+
if command in {"cancel", "cancelled"}:
389+
return cmd_cancel(ctx, *args[1:])
293390
return cmd_start(ctx, *args)
294391

295392

@@ -298,4 +395,6 @@ def run(ctx: Any = None, *args: str) -> int:
298395
"cmd_start",
299396
"cmd_status",
300397
"cmd_evidence",
398+
"cmd_complete",
399+
"cmd_cancel",
301400
]

0 commit comments

Comments
 (0)