Implement Phase 4: tools, God Mode, and missing features
Backend: - Add Tavily web search tool wrapper (tools/web_search.py) - Add PDF reader + ChromaDB vector store tool (tools/pdf_reader.py) - Bind tools to LLM calls via .bind_tools() in dynamic_graph_builder - Implement God Mode using LangGraph interrupt_before + MemorySaver - Add approve/reject/modify API endpoints for God Mode - Add PDF upload endpoint with ingestion pipeline - Add persistent run history (CouncilRun model + run_service + API) - Add Alembic migration for council_runs table - Enhance WebSocket to emit run_paused and run_resumed events - Add tests for tools, God Mode, and run history Frontend: - Add God Mode approval UI (GodModePanel component) - Add Auto-Pilot / God Mode toggle in Konferenzzimmer - Add functional PDF upload handler - Add Conditional Edge editor (EdgeSettingsPanel component) - Add edge click selection in ArchitectCanvas - Update Zustand store with edge selection and update actions - Update types for God Mode, execution modes, and WS events - Update API client with God Mode, PDF upload, and blueprint run endpoints - Update WebSocket hook for paused/resumed events - Add Vitest config and frontend tests (store, parser, types, API) https://claude.ai/code/session_017U6idFgaqnYTXzPxA7mxMv
This commit is contained in:
parent
c6d0c4a636
commit
001649a364
31 changed files with 2502 additions and 81 deletions
|
|
@ -2,23 +2,33 @@
|
|||
REST API routes for CouncilOS.
|
||||
|
||||
Endpoints:
|
||||
POST /api/councils/run — Start a new council run (Phase 1 hard-coded graph)
|
||||
POST /api/councils/{id}/run — Start a run from a saved blueprint (Phase 3)
|
||||
GET /api/councils/run/{run_id} — Poll the status/result of a run
|
||||
GET /api/health — Health check
|
||||
POST /api/councils/run — Start a new council run (Phase 1)
|
||||
POST /api/councils/{id}/run — Start a run from a blueprint (Phase 3)
|
||||
GET /api/councils/run/{run_id} — Poll the status/result of a run
|
||||
POST /api/councils/run/{run_id}/approve — God Mode: approve/reject/modify (Phase 4)
|
||||
GET /api/councils/run/{run_id}/state — God Mode: get paused state (Phase 4)
|
||||
POST /api/councils/upload-pdf — Upload and ingest a PDF (Phase 4)
|
||||
GET /api/health — Health check
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from services.graph_builder import run_council_async
|
||||
from services.dynamic_graph_builder import run_blueprint_council_async
|
||||
from services.blueprint_service import get_blueprint
|
||||
from database import get_session
|
||||
from api.run_store import run_store
|
||||
from database import get_session
|
||||
from services.blueprint_service import get_blueprint
|
||||
from services.dynamic_graph_builder import (
|
||||
get_god_mode_state,
|
||||
resume_god_mode,
|
||||
run_blueprint_council_async,
|
||||
)
|
||||
from services.graph_builder import run_council_async
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -36,11 +46,15 @@ class CouncilRunRequest(BaseModel):
|
|||
description="The user's prompt or document content for the council to work on.",
|
||||
examples=["Erkläre die wichtigsten Konzepte des maschinellen Lernens für Einsteiger."],
|
||||
)
|
||||
god_mode: bool = Field(
|
||||
default=False,
|
||||
description="If true, the run pauses before each node for human approval.",
|
||||
)
|
||||
|
||||
|
||||
class CouncilRunResponse(BaseModel):
|
||||
run_id: str
|
||||
status: str # "pending" | "running" | "completed" | "failed"
|
||||
status: str # "pending" | "running" | "completed" | "failed" | "paused"
|
||||
message: str
|
||||
|
||||
|
||||
|
|
@ -51,6 +65,26 @@ class CouncilResultResponse(BaseModel):
|
|||
critic_score: Optional[float] = None
|
||||
iteration_count: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
paused: Optional[bool] = None
|
||||
next_nodes: Optional[List[str]] = None
|
||||
current_draft: Optional[str] = None
|
||||
|
||||
|
||||
class GodModeApprovalRequest(BaseModel):
|
||||
action: str = Field(
|
||||
...,
|
||||
description="Action to take: 'approve', 'reject', or 'modify'.",
|
||||
)
|
||||
modified_state: Optional[dict] = Field(
|
||||
default=None,
|
||||
description="Partial state override when action is 'modify'.",
|
||||
)
|
||||
|
||||
|
||||
class PdfUploadResponse(BaseModel):
|
||||
filename: str
|
||||
chunks_ingested: int
|
||||
message: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -104,8 +138,8 @@ async def start_blueprint_run(
|
|||
"""
|
||||
Start a council run using a saved blueprint (Phase 3 dynamic graph).
|
||||
|
||||
Reads the blueprint from PostgreSQL and dynamically constructs the
|
||||
LangGraph execution graph at runtime.
|
||||
Set god_mode=true to pause before each agent node and require
|
||||
human approval via the /approve endpoint.
|
||||
"""
|
||||
bp = await get_blueprint(session, blueprint_id)
|
||||
if bp is None:
|
||||
|
|
@ -116,14 +150,19 @@ async def start_blueprint_run(
|
|||
|
||||
blueprint_dict = bp.to_dict()
|
||||
background_tasks.add_task(
|
||||
_execute_blueprint_run, run_id, request.input_topic, blueprint_dict
|
||||
_execute_blueprint_run,
|
||||
run_id,
|
||||
request.input_topic,
|
||||
blueprint_dict,
|
||||
request.god_mode,
|
||||
)
|
||||
|
||||
mode_label = "God Mode" if request.god_mode else "Auto-Pilot"
|
||||
return CouncilRunResponse(
|
||||
run_id=run_id,
|
||||
status="pending",
|
||||
message=(
|
||||
f"Council run started from blueprint '{bp.name}'. "
|
||||
f"Council run started from blueprint '{bp.name}' ({mode_label}). "
|
||||
f"Connect to /ws/council/{run_id} for live updates."
|
||||
),
|
||||
)
|
||||
|
|
@ -133,11 +172,21 @@ async def start_blueprint_run(
|
|||
async def get_council_result(run_id: str):
|
||||
"""
|
||||
Retrieve the current status or final result of a council run.
|
||||
|
||||
In God Mode, includes paused state and next_nodes info.
|
||||
"""
|
||||
run = run_store.get(run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found.")
|
||||
|
||||
# Check for god mode paused state
|
||||
god_state = get_god_mode_state(run_id)
|
||||
paused = god_state["paused"] if god_state else None
|
||||
next_nodes = god_state["next_nodes"] if god_state else None
|
||||
current_draft = (
|
||||
god_state["current_state"].get("current_draft") if god_state else None
|
||||
)
|
||||
|
||||
return CouncilResultResponse(
|
||||
run_id=run_id,
|
||||
status=run["status"],
|
||||
|
|
@ -145,6 +194,97 @@ async def get_council_result(run_id: str):
|
|||
critic_score=run.get("critic_score"),
|
||||
iteration_count=run.get("iteration_count"),
|
||||
error=run.get("error"),
|
||||
paused=paused,
|
||||
next_nodes=next_nodes,
|
||||
current_draft=current_draft,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/councils/run/{run_id}/approve", response_model=CouncilResultResponse)
|
||||
async def approve_god_mode(
|
||||
run_id: str,
|
||||
request: GodModeApprovalRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
):
|
||||
"""
|
||||
Approve, reject, or modify a paused God Mode council run.
|
||||
|
||||
Actions:
|
||||
approve — continue execution to the next node
|
||||
reject — stop the run entirely
|
||||
modify — update the state before continuing (pass modified_state)
|
||||
"""
|
||||
god_state = get_god_mode_state(run_id)
|
||||
if not god_state or not god_state.get("paused"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Run '{run_id}' is not paused in God Mode.",
|
||||
)
|
||||
|
||||
if request.action == "reject":
|
||||
state = await resume_god_mode(run_id, action="reject")
|
||||
run_store.update(run_id, {"status": "failed", "error": "Rejected by user in God Mode."})
|
||||
return CouncilResultResponse(
|
||||
run_id=run_id,
|
||||
status="failed",
|
||||
error="Rejected by user in God Mode.",
|
||||
)
|
||||
|
||||
# Resume in background (approve or modify)
|
||||
background_tasks.add_task(
|
||||
_resume_god_mode_task,
|
||||
run_id,
|
||||
request.action,
|
||||
request.modified_state,
|
||||
)
|
||||
|
||||
return CouncilResultResponse(
|
||||
run_id=run_id,
|
||||
status="running",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/councils/run/{run_id}/state")
|
||||
async def get_run_state(run_id: str):
|
||||
"""
|
||||
Get the full paused state of a God Mode run for the approval UI.
|
||||
"""
|
||||
god_state = get_god_mode_state(run_id)
|
||||
if not god_state:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No God Mode session found for run '{run_id}'.",
|
||||
)
|
||||
return god_state
|
||||
|
||||
|
||||
@router.post("/councils/upload-pdf", response_model=PdfUploadResponse)
|
||||
async def upload_pdf(file: UploadFile = File(...)):
|
||||
"""
|
||||
Upload and ingest a PDF file into the ChromaDB vector store.
|
||||
|
||||
The content becomes searchable by agents with the PDF Reader tool enabled.
|
||||
"""
|
||||
if not file.filename or not file.filename.lower().endswith(".pdf"):
|
||||
raise HTTPException(status_code=400, detail="Only PDF files are accepted.")
|
||||
|
||||
from tools.pdf_reader import ingest_pdf
|
||||
|
||||
# Save upload to a temp file
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
|
||||
content = await file.read()
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
chunks = ingest_pdf(tmp_path)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
return PdfUploadResponse(
|
||||
filename=file.filename,
|
||||
chunks_ingested=chunks,
|
||||
message=f"Successfully ingested {chunks} chunks from '{file.filename}'.",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -180,7 +320,10 @@ async def _execute_run(run_id: str, input_topic: str) -> None:
|
|||
|
||||
|
||||
async def _execute_blueprint_run(
|
||||
run_id: str, input_topic: str, blueprint: dict
|
||||
run_id: str,
|
||||
input_topic: str,
|
||||
blueprint: dict,
|
||||
god_mode: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Background task that runs a dynamically built LangGraph from a blueprint.
|
||||
|
|
@ -191,10 +334,22 @@ async def _execute_blueprint_run(
|
|||
blueprint=blueprint,
|
||||
input_topic=input_topic,
|
||||
run_id=run_id,
|
||||
god_mode=god_mode,
|
||||
on_node_event=lambda nid, node: run_store.update(
|
||||
nid, {"active_node": node}
|
||||
),
|
||||
)
|
||||
|
||||
# In god mode, the first invoke will pause at the first node
|
||||
if god_mode and final_state:
|
||||
god_state = get_god_mode_state(run_id)
|
||||
if god_state and god_state.get("paused"):
|
||||
run_store.update(run_id, {
|
||||
"status": "paused",
|
||||
"active_node": god_state["next_nodes"][0] if god_state["next_nodes"] else None,
|
||||
})
|
||||
return
|
||||
|
||||
run_store.update(
|
||||
run_id,
|
||||
{
|
||||
|
|
@ -207,3 +362,42 @@ async def _execute_blueprint_run(
|
|||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
run_store.update(run_id, {"status": "failed", "error": str(exc)})
|
||||
|
||||
|
||||
async def _resume_god_mode_task(
|
||||
run_id: str,
|
||||
action: str,
|
||||
modified_state: Optional[dict],
|
||||
) -> None:
|
||||
"""Background task that resumes a god mode run after human approval."""
|
||||
run_store.update(run_id, {"status": "running"})
|
||||
try:
|
||||
state = await resume_god_mode(run_id, action=action, modified_state=modified_state)
|
||||
|
||||
if state is None:
|
||||
run_store.update(run_id, {"status": "failed", "error": "God Mode session not found."})
|
||||
return
|
||||
|
||||
# Check if paused again at next node
|
||||
god_state = get_god_mode_state(run_id)
|
||||
if god_state and god_state.get("paused"):
|
||||
run_store.update(run_id, {
|
||||
"status": "paused",
|
||||
"active_node": god_state["next_nodes"][0] if god_state["next_nodes"] else None,
|
||||
"current_draft": state.get("current_draft"),
|
||||
"critic_score": state.get("critic_score"),
|
||||
"iteration_count": state.get("iteration_count"),
|
||||
})
|
||||
else:
|
||||
run_store.update(
|
||||
run_id,
|
||||
{
|
||||
"status": "completed",
|
||||
"final_draft": state.get("current_draft"),
|
||||
"critic_score": state.get("critic_score"),
|
||||
"iteration_count": state.get("iteration_count"),
|
||||
"active_node": "done",
|
||||
},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
run_store.update(run_id, {"status": "failed", "error": str(exc)})
|
||||
|
|
|
|||
64
backend/api/run_history_routes.py
Normal file
64
backend/api/run_history_routes.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""
|
||||
REST API routes for council run history.
|
||||
|
||||
Endpoints:
|
||||
GET /api/runs/ — List recent council runs
|
||||
GET /api/runs/{run_id} — Get a specific run's details
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_session
|
||||
from services.run_service import get_run, list_runs
|
||||
|
||||
|
||||
run_history_router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RunHistoryResponse(BaseModel):
|
||||
id: str
|
||||
blueprint_id: Optional[str] = None
|
||||
input_topic: str
|
||||
status: str
|
||||
execution_mode: str
|
||||
final_draft: Optional[str] = None
|
||||
critic_score: Optional[float] = None
|
||||
iteration_count: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@run_history_router.get("/runs/", response_model=List[RunHistoryResponse])
|
||||
async def list_all_runs(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List recent council runs, ordered by most recent first."""
|
||||
runs = await list_runs(session, limit=limit, offset=offset)
|
||||
return [r.to_dict() for r in runs]
|
||||
|
||||
|
||||
@run_history_router.get("/runs/{run_id}", response_model=RunHistoryResponse)
|
||||
async def get_single_run(
|
||||
run_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Retrieve a specific council run by ID."""
|
||||
run = await get_run(session, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found.")
|
||||
return run.to_dict()
|
||||
|
|
@ -2,20 +2,22 @@
|
|||
WebSocket endpoint for real-time agent status updates.
|
||||
|
||||
Clients connect to /ws/council/{run_id} and receive JSON events whenever
|
||||
an agent node becomes active. This powers the live diagram pulsing in the frontend.
|
||||
an agent node becomes active or the run status changes.
|
||||
|
||||
Event format:
|
||||
{"event": "node_start", "run_id": "...", "node": "master_agent", "iteration": 2}
|
||||
{"event": "node_complete", "run_id": "...", "node": "critic_agent", "score": 6.5}
|
||||
{"event": "run_complete", "run_id": "...", "final_draft": "..."}
|
||||
{"event": "node_active", "run_id": "...", "node": "master_agent", "iteration": 2}
|
||||
{"event": "run_paused", "run_id": "...", "next_nodes": ["critic_agent"], "current_draft": "..."}
|
||||
{"event": "run_complete", "run_id": "...", "final_draft": "...", "critic_score": 8.5}
|
||||
{"event": "run_failed", "run_id": "...", "error": "..."}
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from api.run_store import run_store
|
||||
from services.dynamic_graph_builder import get_god_mode_state
|
||||
|
||||
|
||||
ws_router = APIRouter()
|
||||
|
|
@ -53,6 +55,7 @@ async def council_websocket(websocket: WebSocket, run_id: str):
|
|||
|
||||
On connect: sends the current run status immediately.
|
||||
While running: polls the run store and pushes status changes.
|
||||
On paused: sends a god mode pause event with next_nodes.
|
||||
On complete/failed: sends a final event and closes the connection.
|
||||
"""
|
||||
await websocket.accept()
|
||||
|
|
@ -77,13 +80,17 @@ async def council_websocket(websocket: WebSocket, run_id: str):
|
|||
|
||||
# Poll for status changes and push updates
|
||||
last_node = None
|
||||
last_status = run["status"]
|
||||
while True:
|
||||
run = run_store.get(run_id)
|
||||
if run is None:
|
||||
break
|
||||
|
||||
current_node = run.get("active_node")
|
||||
if current_node and current_node != last_node:
|
||||
current_status = run["status"]
|
||||
|
||||
# Emit node_active when the active agent changes
|
||||
if current_node and current_node != last_node and current_node != "done":
|
||||
await websocket.send_text(
|
||||
json.dumps({
|
||||
"event": "node_active",
|
||||
|
|
@ -94,7 +101,41 @@ async def council_websocket(websocket: WebSocket, run_id: str):
|
|||
)
|
||||
last_node = current_node
|
||||
|
||||
if run["status"] == "completed":
|
||||
# God Mode: emit pause event
|
||||
if current_status == "paused" and last_status != "paused":
|
||||
god_state = get_god_mode_state(run_id)
|
||||
await websocket.send_text(
|
||||
json.dumps({
|
||||
"event": "run_paused",
|
||||
"run_id": run_id,
|
||||
"next_nodes": god_state["next_nodes"] if god_state else [],
|
||||
"current_draft": (
|
||||
god_state["current_state"].get("current_draft", "")
|
||||
if god_state else ""
|
||||
),
|
||||
"critic_score": (
|
||||
god_state["current_state"].get("critic_score")
|
||||
if god_state else None
|
||||
),
|
||||
"iteration_count": (
|
||||
god_state["current_state"].get("iteration_count")
|
||||
if god_state else None
|
||||
),
|
||||
})
|
||||
)
|
||||
last_status = current_status
|
||||
|
||||
# Status changed from paused back to running (user approved)
|
||||
if current_status == "running" and last_status == "paused":
|
||||
await websocket.send_text(
|
||||
json.dumps({
|
||||
"event": "run_resumed",
|
||||
"run_id": run_id,
|
||||
})
|
||||
)
|
||||
last_status = current_status
|
||||
|
||||
if current_status == "completed":
|
||||
await websocket.send_text(
|
||||
json.dumps({
|
||||
"event": "run_complete",
|
||||
|
|
@ -106,7 +147,7 @@ async def council_websocket(websocket: WebSocket, run_id: str):
|
|||
)
|
||||
break
|
||||
|
||||
if run["status"] == "failed":
|
||||
if current_status == "failed":
|
||||
await websocket.send_text(
|
||||
json.dumps({
|
||||
"event": "run_failed",
|
||||
|
|
@ -116,6 +157,7 @@ async def council_websocket(websocket: WebSocket, run_id: str):
|
|||
)
|
||||
break
|
||||
|
||||
last_status = current_status
|
||||
await asyncio.sleep(0.5) # 500ms polling interval
|
||||
|
||||
except WebSocketDisconnect:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue