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:
Claude 2026-02-21 10:53:12 +00:00
parent c6d0c4a636
commit 001649a364
No known key found for this signature in database
31 changed files with 2502 additions and 81 deletions

View file

@ -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)})

View 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()

View file

@ -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: