auto-repair: commit 7 uncommitted file(s) — 2026-03-30
This commit is contained in:
parent
18252e05e6
commit
afd14d1d00
BIN
engram.db.bak.20260330_181823
Normal file
BIN
engram.db.bak.20260330_181823
Normal file
Binary file not shown.
211
public_api.py
Executable file
211
public_api.py
Executable file
@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cortex Public API — lightweight endpoints for external Claude sessions.
|
||||
Runs on port 8112, exposed via Caddy at cortex.hydrascale.net/api/
|
||||
|
||||
Auth: pass API key via X-API-Key header, Authorization: Bearer, or ?key= query param.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Header, Depends, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
API_KEY = "cortex-pub-a7f3e9c1d4b8"
|
||||
|
||||
app = FastAPI(title="Cortex Public API", version="0.2.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
def verify_key(
|
||||
request: Request,
|
||||
x_api_key: Optional[str] = Header(None),
|
||||
authorization: Optional[str] = Header(None),
|
||||
key: Optional[str] = Query(None),
|
||||
):
|
||||
"""Accept key via X-API-Key header, Authorization: Bearer, or ?key= query param."""
|
||||
token = x_api_key or key
|
||||
if not token and authorization and authorization.startswith("Bearer "):
|
||||
token = authorization[7:]
|
||||
if token != API_KEY:
|
||||
raise HTTPException(status_code=401, detail="Invalid API key. Pass via X-API-Key header or ?key= param.")
|
||||
return token
|
||||
|
||||
|
||||
# ──────────── /health (no auth) ────────────
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "timestamp": datetime.utcnow().isoformat() + "Z"}
|
||||
|
||||
|
||||
# ──────────── /info — system specs ────────────
|
||||
@app.get("/info")
|
||||
async def system_info(auth: str = Depends(verify_key)):
|
||||
"""Return full system specs — CPU, RAM, disk, GPU, OS, etc."""
|
||||
specs = {}
|
||||
|
||||
# OS
|
||||
try:
|
||||
out = subprocess.check_output(["lsb_release", "-ds"], text=True).strip()
|
||||
specs["os"] = out
|
||||
except Exception:
|
||||
specs["os"] = "unknown"
|
||||
|
||||
# Kernel
|
||||
specs["kernel"] = subprocess.check_output(["uname", "-r"], text=True).strip()
|
||||
|
||||
# Hostname
|
||||
specs["hostname"] = subprocess.check_output(["hostname"], text=True).strip()
|
||||
|
||||
# CPU
|
||||
try:
|
||||
cpuinfo = Path("/proc/cpuinfo").read_text()
|
||||
model_names = [l.split(":")[1].strip() for l in cpuinfo.splitlines() if "model name" in l]
|
||||
specs["cpu"] = {
|
||||
"model": model_names[0] if model_names else "unknown",
|
||||
"cores": len(model_names),
|
||||
}
|
||||
except Exception:
|
||||
specs["cpu"] = "unknown"
|
||||
|
||||
# RAM
|
||||
try:
|
||||
meminfo = Path("/proc/meminfo").read_text()
|
||||
for line in meminfo.splitlines():
|
||||
if line.startswith("MemTotal:"):
|
||||
kb = int(line.split()[1])
|
||||
specs["ram_gb"] = round(kb / 1024 / 1024, 1)
|
||||
break
|
||||
except Exception:
|
||||
specs["ram_gb"] = "unknown"
|
||||
|
||||
# Disk
|
||||
try:
|
||||
df = subprocess.check_output(["df", "-h", "/"], text=True).strip().split("\n")[1].split()
|
||||
specs["disk"] = {"total": df[1], "used": df[2], "available": df[3], "use_pct": df[4]}
|
||||
except Exception:
|
||||
specs["disk"] = "unknown"
|
||||
|
||||
# GPU
|
||||
try:
|
||||
gpu = subprocess.check_output(["lspci"], text=True)
|
||||
gpu_lines = [l for l in gpu.splitlines() if "VGA" in l or "3D" in l or "Display" in l]
|
||||
specs["gpu"] = gpu_lines if gpu_lines else "none detected"
|
||||
except Exception:
|
||||
specs["gpu"] = "lspci not available"
|
||||
|
||||
# Uptime
|
||||
try:
|
||||
uptime = Path("/proc/uptime").read_text().split()[0]
|
||||
specs["uptime_hours"] = round(float(uptime) / 3600, 1)
|
||||
except Exception:
|
||||
specs["uptime_hours"] = "unknown"
|
||||
|
||||
# Docker containers
|
||||
try:
|
||||
containers = subprocess.check_output(
|
||||
["docker", "ps", "--format", "{{.Names}}: {{.Status}}"], text=True
|
||||
).strip()
|
||||
specs["docker_containers"] = containers.splitlines() if containers else []
|
||||
except Exception:
|
||||
specs["docker_containers"] = "docker not available"
|
||||
|
||||
# Services
|
||||
services = {}
|
||||
for svc in ["caddy", "fail2ban", "docker", "symbiont-api", "cortex-public-api"]:
|
||||
try:
|
||||
status = subprocess.check_output(
|
||||
["systemctl", "is-active", svc], text=True
|
||||
).strip()
|
||||
services[svc] = status
|
||||
except Exception:
|
||||
services[svc] = "unknown"
|
||||
specs["services"] = services
|
||||
|
||||
specs["timestamp"] = datetime.utcnow().isoformat() + "Z"
|
||||
return specs
|
||||
|
||||
|
||||
# ──────────── /engram/sitrep — session awareness ────────────
|
||||
@app.get("/engram/sitrep")
|
||||
async def engram_sitrep(auth: str = Depends(verify_key)):
|
||||
"""Return situation report from engram DB."""
|
||||
db_path = Path("/data/symbiont/engram.db")
|
||||
if not db_path.exists():
|
||||
return {"error": "engram.db not found", "sessions": []}
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get table info first
|
||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = [r["name"] for r in cur.fetchall()]
|
||||
|
||||
if "sessions" not in tables:
|
||||
conn.close()
|
||||
return {"error": "sessions table not found", "tables": tables}
|
||||
|
||||
# Get active sessions
|
||||
cur.execute("SELECT * FROM sessions WHERE status = 'active' ORDER BY started_at DESC")
|
||||
active = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
# Get recent completed (last 24h)
|
||||
cutoff = (datetime.utcnow() - timedelta(hours=24)).isoformat()
|
||||
cur.execute(
|
||||
"SELECT * FROM sessions WHERE status = 'completed' AND started_at > ? ORDER BY started_at DESC",
|
||||
(cutoff,)
|
||||
)
|
||||
recent = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
conn.close()
|
||||
return {"active_sessions": active, "recent_24h": recent}
|
||||
|
||||
|
||||
# ──────────── /engram/register — register a session ────────────
|
||||
@app.post("/engram/register")
|
||||
async def engram_register(
|
||||
session_type: str = Query(default="claude-chat"),
|
||||
description: str = Query(default="External Claude session"),
|
||||
auth: str = Depends(verify_key),
|
||||
):
|
||||
"""Register a new session in engram."""
|
||||
import sys
|
||||
sys.path.insert(0, "/data/symbiont")
|
||||
from symbiont.engram import Engram
|
||||
eng = Engram()
|
||||
sid = eng.register(session_type, description)
|
||||
return {"session_id": sid, "status": "registered"}
|
||||
|
||||
|
||||
# ──────────── /engram/log — log to a session ────────────
|
||||
@app.post("/engram/log")
|
||||
async def engram_log(
|
||||
session_id: str = Query(...),
|
||||
message: str = Query(...),
|
||||
auth: str = Depends(verify_key),
|
||||
):
|
||||
"""Log a message to an existing session."""
|
||||
import sys
|
||||
sys.path.insert(0, "/data/symbiont")
|
||||
from symbiont.engram import Engram
|
||||
eng = Engram()
|
||||
eng.log(session_id, message)
|
||||
return {"status": "logged"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="127.0.0.1", port=8112)
|
||||
189
symbiont/api.py.bak
Normal file
189
symbiont/api.py.bak
Normal file
@ -0,0 +1,189 @@
|
||||
"""
|
||||
FastAPI server for the Symbiont orchestrator.
|
||||
|
||||
Endpoints:
|
||||
POST /task — Run a task through the router
|
||||
POST /queue — Add a task to the queue
|
||||
GET /status — Health check + rate limit status
|
||||
GET /ledger — Recent ledger entries
|
||||
GET /ledger/stats — Aggregate cost/usage stats
|
||||
GET /sessions — Active and recent sessions
|
||||
GET /sitrep — Situation report for new sessions
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .dispatcher import ModelTier, rate_limits
|
||||
from .router import route_task
|
||||
from .scheduler import enqueue_task, get_pending_tasks
|
||||
from .engram import Engram as SessionRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(
|
||||
title="Symbiont",
|
||||
description="Self-sustaining AI orchestrator",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
LEDGER_PATH = Path("/data/symbiont/ledger.jsonl")
|
||||
|
||||
|
||||
class TaskRequest(BaseModel):
|
||||
task: str
|
||||
system_prompt: Optional[str] = None
|
||||
force_tier: Optional[str] = None # "haiku", "sonnet", "opus"
|
||||
|
||||
|
||||
class QueueRequest(BaseModel):
|
||||
task: str
|
||||
priority: int = 5
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
|
||||
@app.post("/task")
|
||||
async def run_task(req: TaskRequest):
|
||||
"""Execute a task immediately through the router. Logs to session registry."""
|
||||
force = None
|
||||
if req.force_tier:
|
||||
try:
|
||||
force = ModelTier(req.force_tier.lower())
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"Invalid tier: {req.force_tier}")
|
||||
|
||||
# Register this dispatch as a micro-session
|
||||
reg = SessionRegistry()
|
||||
sid = reg.register("api-task", req.task[:120])
|
||||
|
||||
result = route_task(
|
||||
req.task,
|
||||
system_prompt=req.system_prompt,
|
||||
force_tier=force,
|
||||
)
|
||||
|
||||
# Complete the micro-session
|
||||
if result["success"]:
|
||||
reg.complete(sid, f"Completed via {result['model_used']} (${result['estimated_cost_usd']:.4f})")
|
||||
else:
|
||||
reg.complete(sid, f"Failed: {result.get('error', 'unknown')}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/queue")
|
||||
async def queue_task(req: QueueRequest):
|
||||
"""Add a task to the queue for later processing."""
|
||||
task_id = enqueue_task(req.task, req.priority, req.metadata)
|
||||
return {"task_id": task_id, "status": "queued"}
|
||||
|
||||
|
||||
@app.get("/status")
|
||||
async def status():
|
||||
"""Health check and current rate limit status."""
|
||||
limits = {}
|
||||
for tier in ModelTier:
|
||||
if rate_limits.is_limited(tier):
|
||||
limits[tier.value] = {
|
||||
"limited": True,
|
||||
"until": rate_limits.limited_until[tier].isoformat(),
|
||||
}
|
||||
else:
|
||||
limits[tier.value] = {"limited": False}
|
||||
|
||||
pending = get_pending_tasks()
|
||||
|
||||
return {
|
||||
"status": "alive",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"rate_limits": limits,
|
||||
"pending_tasks": len(pending),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/ledger")
|
||||
async def get_ledger(limit: int = 50):
|
||||
"""Return the most recent ledger entries."""
|
||||
if not LEDGER_PATH.exists():
|
||||
return {"entries": [], "total": 0}
|
||||
|
||||
lines = LEDGER_PATH.read_text().strip().split("\n")
|
||||
entries = []
|
||||
for line in lines[-limit:]:
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return {"entries": entries, "total": len(lines)}
|
||||
|
||||
|
||||
@app.get("/ledger/stats")
|
||||
async def ledger_stats():
|
||||
"""Aggregate statistics from the ledger."""
|
||||
if not LEDGER_PATH.exists():
|
||||
return {"total_calls": 0}
|
||||
|
||||
lines = LEDGER_PATH.read_text().strip().split("\n")
|
||||
entries = []
|
||||
for line in lines:
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if not entries:
|
||||
return {"total_calls": 0}
|
||||
|
||||
total_cost = sum(e.get("estimated_cost_usd", 0) for e in entries)
|
||||
by_model = {}
|
||||
for e in entries:
|
||||
model = e.get("model", "unknown")
|
||||
if model not in by_model:
|
||||
by_model[model] = {"calls": 0, "cost": 0, "tokens_in": 0, "tokens_out": 0}
|
||||
by_model[model]["calls"] += 1
|
||||
by_model[model]["cost"] += e.get("estimated_cost_usd", 0)
|
||||
by_model[model]["tokens_in"] += e.get("input_tokens", 0)
|
||||
by_model[model]["tokens_out"] += e.get("output_tokens", 0)
|
||||
|
||||
successes = sum(1 for e in entries if e.get("success"))
|
||||
|
||||
return {
|
||||
"total_calls": len(entries),
|
||||
"successful_calls": successes,
|
||||
"total_estimated_cost_usd": round(total_cost, 4),
|
||||
"by_model": by_model,
|
||||
"first_entry": entries[0].get("timestamp") if entries else None,
|
||||
"last_entry": entries[-1].get("timestamp") if entries else None,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/sitrep")
|
||||
async def get_sitrep():
|
||||
"""Situation report — what other sessions are doing. Read this first."""
|
||||
reg = SessionRegistry()
|
||||
return {
|
||||
"report": reg.get_situation_report(),
|
||||
"active": reg.get_active_sessions(),
|
||||
"recent_24h": reg.get_recent_sessions(hours=24),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/sessions")
|
||||
async def get_sessions(status: Optional[str] = None):
|
||||
"""List sessions, optionally filtered by status."""
|
||||
reg = SessionRegistry()
|
||||
if status == "active":
|
||||
return {"sessions": reg.get_active_sessions()}
|
||||
elif status == "completed":
|
||||
return {"sessions": reg.get_recent_sessions(hours=168)} # 1 week
|
||||
else:
|
||||
active = reg.get_active_sessions()
|
||||
recent = reg.get_recent_sessions(hours=24)
|
||||
return {"active": active, "recent_24h": recent}
|
||||
347
symbiont/api_additions.py
Normal file
347
symbiont/api_additions.py
Normal file
@ -0,0 +1,347 @@
|
||||
"""
|
||||
FastAPI Endpoint Additions for Compound Tasks
|
||||
==============================================
|
||||
|
||||
New endpoints to integrate into the existing Symbiont API (/data/symbiont/symbiont/api.py).
|
||||
|
||||
These endpoints expose the compound task system to external callers:
|
||||
1. POST /task/compound - Submit a new compound task for execution
|
||||
2. GET /task/{task_id}/progress - Poll for task progress
|
||||
3. GET /tasks/recent - List recent tasks (dashboard view)
|
||||
|
||||
Authentication:
|
||||
- Task submission requires a bearer token
|
||||
- Progress polling requires no auth (task ID is the "secret")
|
||||
- Recent tasks list is unauthenticated (low-sensitivity data)
|
||||
|
||||
Integration Instructions:
|
||||
1. Import task_manager functions at the top of api.py:
|
||||
from .task_manager import submit_compound_task, get_task_progress, list_recent_tasks
|
||||
|
||||
2. Define authentication token (set to your secret):
|
||||
TASK_AUTH_TOKEN = "cortex-tasks-2026"
|
||||
|
||||
3. Add these classes and functions to api.py
|
||||
|
||||
4. Add the three routes to your FastAPI app instance
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Depends, Header
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from .task_manager import submit_compound_task, get_task_progress, list_recent_tasks
|
||||
|
||||
# These imports should be added to your api.py
|
||||
# from .task_manager import submit_compound_task, get_task_progress, list_recent_tasks
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Simple shared secret for task submission auth
|
||||
# In production, consider using OAuth2, API keys, or other stronger methods
|
||||
TASK_AUTH_TOKEN = "cortex-tasks-2026"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Authentication
|
||||
# ============================================================================
|
||||
|
||||
def verify_task_auth(
|
||||
authorization: Optional[str] = Header(None),
|
||||
token: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Verify task submission authentication.
|
||||
|
||||
Supports two methods:
|
||||
1. Bearer token in Authorization header: "Authorization: Bearer {token}"
|
||||
2. Query parameter: ?token={token}
|
||||
|
||||
Args:
|
||||
authorization: Authorization header value
|
||||
token: Token from query parameter
|
||||
|
||||
Returns:
|
||||
Verified token
|
||||
|
||||
Raises:
|
||||
HTTPException 401: Invalid or missing token
|
||||
"""
|
||||
auth_token = token or (
|
||||
authorization.replace("Bearer ", "") if authorization else None
|
||||
)
|
||||
|
||||
if auth_token != TASK_AUTH_TOKEN:
|
||||
raise HTTPException(status_code=401, detail="Invalid or missing auth token")
|
||||
|
||||
return auth_token
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Request/Response Models
|
||||
# ============================================================================
|
||||
|
||||
class CompoundTaskRequest(BaseModel):
|
||||
"""Request body for compound task submission."""
|
||||
|
||||
prompt: str = Field(
|
||||
...,
|
||||
description="The user's request to decompose and execute",
|
||||
min_length=1,
|
||||
max_length=5000
|
||||
)
|
||||
token: Optional[str] = Field(
|
||||
None,
|
||||
description="Auth token (alternative to Bearer header)"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"prompt": "Search for Python concurrency patterns and summarize the top 3"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CompoundTaskResponse(BaseModel):
|
||||
"""Immediate response from task submission."""
|
||||
|
||||
id: str = Field(
|
||||
...,
|
||||
description="Unique task ID for polling progress"
|
||||
)
|
||||
status: str = Field(
|
||||
...,
|
||||
description="Current task status (planned, executing, completed, partial)"
|
||||
)
|
||||
subtask_count: int = Field(
|
||||
...,
|
||||
description="Total number of subtasks in the plan"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"id": "compound-a1b2c3d4e5f6",
|
||||
"status": "planned",
|
||||
"subtask_count": 3
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SubtaskSnapshot(BaseModel):
|
||||
"""Current state of a single subtask."""
|
||||
|
||||
id: str
|
||||
index: int
|
||||
description: str
|
||||
tier_hint: int
|
||||
tier_assigned: Optional[int]
|
||||
model: Optional[str]
|
||||
depends_on: List[int]
|
||||
status: str
|
||||
result: Optional[str]
|
||||
cost: Optional[float]
|
||||
started_at: Optional[str]
|
||||
completed_at: Optional[str]
|
||||
|
||||
|
||||
class TaskProgressResponse(BaseModel):
|
||||
"""Complete progress snapshot for a compound task."""
|
||||
|
||||
id: str
|
||||
prompt: str
|
||||
status: str
|
||||
reasoning: str
|
||||
subtasks: List[SubtaskSnapshot]
|
||||
created_at: str
|
||||
planned_at: str
|
||||
completed_at: Optional[str]
|
||||
total_cost: float
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"id": "compound-a1b2c3d4e5f6",
|
||||
"prompt": "Search for Python patterns and summarize",
|
||||
"status": "executing",
|
||||
"reasoning": "Breaking into search, summarization, and output formatting",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "compound-a1b2c3d4e5f6-sub-0",
|
||||
"index": 0,
|
||||
"description": "Search for Python concurrency patterns",
|
||||
"tier_hint": 2,
|
||||
"tier_assigned": 2,
|
||||
"model": "sonnet",
|
||||
"depends_on": [],
|
||||
"status": "completed",
|
||||
"result": "Found patterns including...",
|
||||
"cost": 0.04,
|
||||
"started_at": "2026-03-21T15:30:00Z",
|
||||
"completed_at": "2026-03-21T15:30:05Z"
|
||||
}
|
||||
],
|
||||
"created_at": "2026-03-21T15:30:00Z",
|
||||
"planned_at": "2026-03-21T15:30:00Z",
|
||||
"completed_at": None,
|
||||
"total_cost": 0.04
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TaskSummary(BaseModel):
|
||||
"""Summary of a task for list view."""
|
||||
|
||||
id: str
|
||||
prompt: str
|
||||
status: str
|
||||
subtask_count: int
|
||||
completed_count: int
|
||||
total_cost: float
|
||||
created_at: str
|
||||
completed_at: Optional[str]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints
|
||||
# ============================================================================
|
||||
|
||||
def setup_compound_task_endpoints(app: FastAPI) -> None:
|
||||
"""
|
||||
Register all compound task endpoints on the FastAPI app.
|
||||
|
||||
Call this from your main api.py file:
|
||||
from .api_additions import setup_compound_task_endpoints
|
||||
setup_compound_task_endpoints(app)
|
||||
|
||||
Or manually add the @app.post/@app.get routes below.
|
||||
"""
|
||||
|
||||
@app.post(
|
||||
"/task/compound",
|
||||
response_model=CompoundTaskResponse,
|
||||
tags=["Compound Tasks"],
|
||||
summary="Submit a compound task",
|
||||
description="Submit a user prompt to be decomposed into subtasks and executed in parallel"
|
||||
)
|
||||
async def create_compound_task(
|
||||
req: CompoundTaskRequest,
|
||||
auth: str = Depends(verify_task_auth)
|
||||
) -> CompoundTaskResponse:
|
||||
"""
|
||||
Submit a compound task for execution.
|
||||
|
||||
The task is decomposed by Haiku into subtasks that can run in parallel.
|
||||
Returns immediately with a task ID for polling progress.
|
||||
|
||||
Args:
|
||||
req: Task request with prompt and optional token
|
||||
auth: Verified authentication token
|
||||
|
||||
Returns:
|
||||
Task ID and initial status
|
||||
|
||||
Example:
|
||||
curl -X POST http://localhost:8000/task/compound \\
|
||||
-H "Authorization: Bearer cortex-tasks-2026" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"prompt": "Search for patterns and summarize"}'
|
||||
"""
|
||||
result = submit_compound_task(req.prompt, auth_token=auth)
|
||||
return CompoundTaskResponse(**result)
|
||||
|
||||
@app.get(
|
||||
"/task/{task_id}/progress",
|
||||
response_model=TaskProgressResponse,
|
||||
tags=["Compound Tasks"],
|
||||
summary="Get task progress",
|
||||
description="Poll for current execution status of a compound task"
|
||||
)
|
||||
async def task_progress(task_id: str) -> TaskProgressResponse:
|
||||
"""
|
||||
Poll for compound task progress.
|
||||
|
||||
No authentication required - task ID serves as the secret. Clients can poll
|
||||
this endpoint to track execution progress and see individual subtask results.
|
||||
|
||||
Args:
|
||||
task_id: The task ID from create_compound_task
|
||||
|
||||
Returns:
|
||||
Complete task snapshot including all subtasks and their progress
|
||||
|
||||
Raises:
|
||||
404: Task not found (expired from cache or invalid ID)
|
||||
|
||||
Example:
|
||||
curl http://localhost:8000/task/compound-a1b2c3d4e5f6/progress
|
||||
"""
|
||||
progress = get_task_progress(task_id)
|
||||
if progress is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return TaskProgressResponse(**progress)
|
||||
|
||||
@app.get(
|
||||
"/tasks/recent",
|
||||
response_model=List[TaskSummary],
|
||||
tags=["Compound Tasks"],
|
||||
summary="List recent tasks",
|
||||
description="Get summaries of recently submitted compound tasks"
|
||||
)
|
||||
async def recent_tasks(limit: int = 20) -> List[TaskSummary]:
|
||||
"""
|
||||
List recent compound tasks (dashboard view).
|
||||
|
||||
Useful for monitoring system activity and recent executions.
|
||||
No authentication required.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of tasks to return (default 20, max 100)
|
||||
|
||||
Returns:
|
||||
List of task summaries ordered by most recent first
|
||||
|
||||
Example:
|
||||
curl 'http://localhost:8000/tasks/recent?limit=10'
|
||||
"""
|
||||
if limit > 100:
|
||||
limit = 100
|
||||
tasks = list_recent_tasks(limit=limit)
|
||||
return [TaskSummary(**t) for t in tasks]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Manual Integration (if not using setup_compound_task_endpoints)
|
||||
# ============================================================================
|
||||
|
||||
"""
|
||||
If you prefer to manually add routes instead of using setup_compound_task_endpoints(),
|
||||
add these to your api.py after creating the FastAPI app:
|
||||
|
||||
# At the top of api.py, import these:
|
||||
from .task_manager import submit_compound_task, get_task_progress, list_recent_tasks
|
||||
|
||||
# Then add these route definitions:
|
||||
|
||||
@app.post("/task/compound", response_model=CompoundTaskResponse, tags=["Compound Tasks"])
|
||||
async def create_compound_task(req: CompoundTaskRequest, auth: str = Depends(verify_task_auth)):
|
||||
result = submit_compound_task(req.prompt, auth_token=auth)
|
||||
return CompoundTaskResponse(**result)
|
||||
|
||||
@app.get("/task/{task_id}/progress", response_model=TaskProgressResponse, tags=["Compound Tasks"])
|
||||
async def task_progress(task_id: str):
|
||||
progress = get_task_progress(task_id)
|
||||
if progress is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return TaskProgressResponse(**progress)
|
||||
|
||||
@app.get("/tasks/recent", response_model=List[TaskSummary], tags=["Compound Tasks"])
|
||||
async def recent_tasks(limit: int = 20):
|
||||
if limit > 100:
|
||||
limit = 100
|
||||
tasks = list_recent_tasks(limit=limit)
|
||||
return [TaskSummary(**t) for t in tasks]
|
||||
"""
|
||||
167
symbiont/planner.py
Normal file
167
symbiont/planner.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""
|
||||
Compound Task Planner
|
||||
=====================
|
||||
|
||||
Takes a user prompt and uses Haiku (the cheapest tier) to decompose it into
|
||||
independent subtasks that can be executed in parallel where possible.
|
||||
|
||||
The planner handles:
|
||||
- Breaking down complex requests into manageable subtasks
|
||||
- Identifying parallelizable work (tasks with no dependencies)
|
||||
- Suggesting optimal tier assignments (Haiku/Sonnet/Opus)
|
||||
- Graceful fallback to single-task execution if planning fails
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Any
|
||||
|
||||
|
||||
PLANNING_PROMPT_TEMPLATE = """You are a task planner. Given a user's request, break it into independent subtasks that can be executed in parallel where possible.
|
||||
|
||||
Return ONLY valid JSON in this exact format:
|
||||
{{
|
||||
"reasoning": "Brief explanation of your decomposition strategy",
|
||||
"subtasks": [
|
||||
{{
|
||||
"description": "Clear, self-contained task description",
|
||||
"tier_hint": 1,
|
||||
"depends_on": []
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
Rules:
|
||||
- tier_hint: 1=simple/extraction, 2=moderate/writing/code, 3=complex/reasoning
|
||||
- depends_on: array of subtask indices (0-based) that must complete first
|
||||
- Tasks with empty depends_on can run in parallel
|
||||
- Each subtask should be self-contained enough to execute independently
|
||||
- Keep subtasks to 2-6 items (don't over-decompose simple requests)
|
||||
- For simple single-step requests, return just one subtask
|
||||
|
||||
User request: {prompt}"""
|
||||
|
||||
|
||||
def plan_task(prompt: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Use Haiku to decompose a prompt into subtasks.
|
||||
|
||||
Args:
|
||||
prompt: The user's request to decompose
|
||||
|
||||
Returns:
|
||||
A compound task plan with the following structure:
|
||||
{
|
||||
"id": "compound-{uuid}",
|
||||
"prompt": original prompt,
|
||||
"status": "planned",
|
||||
"reasoning": explanation from Haiku,
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "compound-{uuid}-sub-{i}",
|
||||
"index": i,
|
||||
"description": task description,
|
||||
"tier_hint": 1|2|3,
|
||||
"tier_assigned": None (will be set during execution),
|
||||
"model": None (will be set during execution),
|
||||
"depends_on": [indices of predecessor tasks],
|
||||
"status": "pending",
|
||||
"result": None,
|
||||
"cost": None,
|
||||
"started_at": None,
|
||||
"completed_at": None
|
||||
},
|
||||
...
|
||||
],
|
||||
"created_at": ISO8601 timestamp,
|
||||
"planned_at": ISO8601 timestamp,
|
||||
"completed_at": None,
|
||||
"total_cost": 0.0
|
||||
}
|
||||
"""
|
||||
|
||||
task_id = f"compound-{uuid.uuid4().hex[:12]}"
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
planning_prompt = PLANNING_PROMPT_TEMPLATE.format(prompt=prompt)
|
||||
|
||||
try:
|
||||
# Invoke Claude CLI with Haiku for planning (cheapest option)
|
||||
result = subprocess.run(
|
||||
['claude', '-p', '--model', 'haiku', '--output-format', 'json'],
|
||||
input=planning_prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
output = json.loads(result.stdout)
|
||||
response_text = output.get('result', output.get('content', ''))
|
||||
|
||||
# Extract JSON from response (handle markdown code blocks)
|
||||
if '```json' in response_text:
|
||||
response_text = response_text.split('```json')[1].split('```')[0]
|
||||
elif '```' in response_text:
|
||||
response_text = response_text.split('```')[1].split('```')[0]
|
||||
|
||||
plan = json.loads(response_text.strip())
|
||||
|
||||
# Build the compound task structure
|
||||
subtasks = []
|
||||
for i, st in enumerate(plan.get('subtasks', [])):
|
||||
subtasks.append({
|
||||
"id": f"{task_id}-sub-{i}",
|
||||
"index": i,
|
||||
"description": st['description'],
|
||||
"tier_hint": st.get('tier_hint', 2),
|
||||
"tier_assigned": None,
|
||||
"model": None,
|
||||
"depends_on": st.get('depends_on', []),
|
||||
"status": "pending",
|
||||
"result": None,
|
||||
"cost": None,
|
||||
"started_at": None,
|
||||
"completed_at": None
|
||||
})
|
||||
|
||||
return {
|
||||
"id": task_id,
|
||||
"prompt": prompt,
|
||||
"status": "planned",
|
||||
"reasoning": plan.get('reasoning', ''),
|
||||
"subtasks": subtasks,
|
||||
"created_at": now,
|
||||
"planned_at": now,
|
||||
"completed_at": None,
|
||||
"total_cost": 0.0
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# If planning fails, wrap the whole thing as a single task
|
||||
# This ensures the system degrades gracefully
|
||||
return {
|
||||
"id": task_id,
|
||||
"prompt": prompt,
|
||||
"status": "planned",
|
||||
"reasoning": f"Planning failed ({str(e)}), treating as single task",
|
||||
"subtasks": [{
|
||||
"id": f"{task_id}-sub-0",
|
||||
"index": 0,
|
||||
"description": prompt,
|
||||
"tier_hint": 2,
|
||||
"tier_assigned": None,
|
||||
"model": None,
|
||||
"depends_on": [],
|
||||
"status": "pending",
|
||||
"result": None,
|
||||
"cost": None,
|
||||
"started_at": None,
|
||||
"completed_at": None
|
||||
}],
|
||||
"created_at": now,
|
||||
"planned_at": now,
|
||||
"completed_at": None,
|
||||
"total_cost": 0.0
|
||||
}
|
||||
385
symbiont/task_manager.py
Normal file
385
symbiont/task_manager.py
Normal file
@ -0,0 +1,385 @@
|
||||
"""
|
||||
Compound Task Manager
|
||||
=====================
|
||||
|
||||
Manages the complete lifecycle of compound tasks:
|
||||
- Tracks task state through planning and execution phases
|
||||
- Executes subtasks in parallel while respecting dependencies
|
||||
- Updates progress in real-time (via polling)
|
||||
- Logs all executions to the immutable ledger
|
||||
|
||||
Architecture:
|
||||
1. submit_compound_task() - plans task (sync, fast) then spawns background execution
|
||||
2. Background thread executes subtasks in dependency-respecting waves
|
||||
3. get_task_progress() - clients poll for current state
|
||||
4. list_recent_tasks() - dashboard view of recent executions
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Optional, Dict, List, Any
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
from .planner import plan_task
|
||||
|
||||
|
||||
# In-memory store for active/recent tasks
|
||||
# Limited to _MAX_TASKS to prevent unbounded memory growth
|
||||
_tasks = {}
|
||||
_tasks_lock = threading.Lock()
|
||||
_MAX_TASKS = 50
|
||||
|
||||
# Model-to-tier mapping
|
||||
TIER_MODELS = {1: "haiku", 2: "sonnet", 3: "opus"}
|
||||
|
||||
# Approximate costs per execution (USD)
|
||||
# These are rough estimates - actual costs depend on token usage
|
||||
TIER_COSTS = {1: 0.008, 2: 0.04, 3: 0.15}
|
||||
|
||||
# Ledger file path (immutable execution log)
|
||||
LEDGER_PATH = Path("/data/symbiont/ledger.jsonl")
|
||||
|
||||
|
||||
def _log_ledger(entry: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Append an entry to the immutable execution ledger.
|
||||
|
||||
Args:
|
||||
entry: Dictionary with execution details (timestamp, model, tokens, cost, etc.)
|
||||
|
||||
The ledger is a jsonl file (one JSON object per line) used for:
|
||||
- Cost tracking and billing
|
||||
- Audit trails
|
||||
- Performance analysis
|
||||
"""
|
||||
try:
|
||||
with open(LEDGER_PATH, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
except Exception:
|
||||
# Silently fail ledger writes to prevent task execution failures
|
||||
pass
|
||||
|
||||
|
||||
def _execute_subtask(subtask: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute a single subtask via Claude CLI.
|
||||
|
||||
Args:
|
||||
subtask: Subtask dict with description and tier assignment
|
||||
|
||||
Returns:
|
||||
Updated subtask dict with result, status, cost, and timing info
|
||||
|
||||
Process:
|
||||
1. Determine model from tier assignment
|
||||
2. Invoke Claude CLI with subprocess
|
||||
3. Parse JSON output and extract result
|
||||
4. Calculate cost and record in ledger
|
||||
5. Update subtask status and completion time
|
||||
"""
|
||||
|
||||
model = TIER_MODELS.get(subtask.get("tier_assigned") or subtask.get("tier_hint", 2), "sonnet")
|
||||
_update_subtask(subtask, model=model, status="executing",
|
||||
started_at=datetime.now(timezone.utc).isoformat())
|
||||
|
||||
try:
|
||||
# Execute via Claude CLI with JSON output mode
|
||||
result = subprocess.run(
|
||||
['claude', '-p', '--model', model, '--output-format', 'json'],
|
||||
input=subtask["description"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
output = json.loads(result.stdout)
|
||||
response_text = output.get('result', output.get('content', str(output)))
|
||||
|
||||
# Extract token counts for cost calculation
|
||||
tokens_in = output.get('input_tokens', 0)
|
||||
tokens_out = output.get('output_tokens', 0)
|
||||
cost = TIER_COSTS.get(subtask.get("tier_assigned", 2), 0.04)
|
||||
|
||||
truncated = response_text[:2000]
|
||||
if len(response_text) > 2000:
|
||||
truncated += "\n[TRUNCATED...]"
|
||||
_update_subtask(subtask, status="completed", result=truncated,
|
||||
cost=cost, completed_at=datetime.now(timezone.utc).isoformat())
|
||||
|
||||
# Log successful execution to ledger
|
||||
_log_ledger({
|
||||
"timestamp": subtask["completed_at"],
|
||||
"model": model,
|
||||
"success": True,
|
||||
"input_tokens": tokens_in,
|
||||
"output_tokens": tokens_out,
|
||||
"estimated_cost_usd": cost,
|
||||
"prompt_preview": subtask["description"][:100],
|
||||
"compound_task_id": subtask.get("id", "unknown")
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
_update_subtask(subtask, status="failed", result="Execution timed out (120s)", completed_at=now)
|
||||
_log_ledger({
|
||||
"timestamp": now, "model": model, "success": False,
|
||||
"error": "timeout", "compound_task_id": subtask.get("id", "unknown")
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
_update_subtask(subtask, status="failed", result=f"Error: {str(e)}", completed_at=now)
|
||||
_log_ledger({
|
||||
"timestamp": now, "model": model, "success": False,
|
||||
"error": str(e), "compound_task_id": subtask.get("id", "unknown")
|
||||
})
|
||||
|
||||
return subtask
|
||||
|
||||
|
||||
def _validate_dependencies(subtasks: List[Dict[str, Any]]) -> None:
|
||||
"""Validate and clamp dependency indices to valid range."""
|
||||
valid_indices = set(s["index"] for s in subtasks)
|
||||
for st in subtasks:
|
||||
deps = st.get("depends_on", [])
|
||||
# Remove self-references and out-of-range indices
|
||||
st["depends_on"] = [d for d in deps if d in valid_indices and d != st["index"]]
|
||||
|
||||
|
||||
def _update_subtask(subtask: Dict[str, Any], **updates) -> None:
|
||||
"""Thread-safe subtask field update under the global lock."""
|
||||
with _tasks_lock:
|
||||
subtask.update(updates)
|
||||
|
||||
|
||||
def _run_compound_task(task_id: str) -> None:
|
||||
"""
|
||||
Background thread function: execute subtasks respecting dependency order.
|
||||
|
||||
Args:
|
||||
task_id: ID of the compound task to execute
|
||||
|
||||
Execution strategy:
|
||||
1. Validate dependency graph
|
||||
2. Execute subtasks in dependency-respecting waves
|
||||
3. A subtask is ready when all its dependencies have completed
|
||||
4. Ready subtasks are executed in parallel (up to 4 concurrent workers)
|
||||
5. Repeat until all subtasks are complete or stuck
|
||||
6. Calculate total cost and finalize task status
|
||||
"""
|
||||
|
||||
with _tasks_lock:
|
||||
task = _tasks.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
task["status"] = "executing"
|
||||
|
||||
subtasks = task["subtasks"]
|
||||
completed_indices = set()
|
||||
|
||||
# Phase 0: Validate dependency graph
|
||||
_validate_dependencies(subtasks)
|
||||
|
||||
# Phase 1: Routing - assign tier to each subtask
|
||||
for st in subtasks:
|
||||
_update_subtask(st, status="routing", tier_assigned=st.get("tier_hint", 2))
|
||||
|
||||
# Phase 2: Execution - run subtasks in waves respecting dependencies
|
||||
max_stall_cycles = 60 # 30 seconds max stall (0.5s * 60)
|
||||
stall_count = 0
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
while len(completed_indices) < len(subtasks):
|
||||
# Find subtasks that are ready to execute
|
||||
ready = []
|
||||
for st in subtasks:
|
||||
if st["index"] in completed_indices:
|
||||
continue
|
||||
if st["status"] in ("executing", "queued", "failed"):
|
||||
continue
|
||||
deps = set(st.get("depends_on", []))
|
||||
if deps.issubset(completed_indices):
|
||||
ready.append(st)
|
||||
|
||||
if not ready:
|
||||
remaining = [s for s in subtasks if s["index"] not in completed_indices]
|
||||
still_running = any(s["status"] in ("executing", "queued") for s in remaining)
|
||||
if not still_running:
|
||||
# Truly stuck — all remaining are blocked by failed deps
|
||||
for s in remaining:
|
||||
if s["status"] not in ("completed", "failed"):
|
||||
_update_subtask(s, status="failed", result="Blocked by failed dependency")
|
||||
completed_indices.add(s["index"])
|
||||
break
|
||||
stall_count += 1
|
||||
if stall_count > max_stall_cycles:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
|
||||
stall_count = 0 # Reset on progress
|
||||
|
||||
# Launch ready subtasks in parallel
|
||||
futures = {}
|
||||
for st in ready:
|
||||
_update_subtask(st, status="queued")
|
||||
futures[executor.submit(_execute_subtask, st)] = st
|
||||
|
||||
for future in as_completed(futures):
|
||||
st = futures[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
_update_subtask(st, status="failed", result=str(e))
|
||||
|
||||
if st["status"] in ("completed", "failed"):
|
||||
completed_indices.add(st["index"])
|
||||
|
||||
# Phase 3: Finalization
|
||||
total_cost = sum(s.get("cost", 0) or 0 for s in subtasks)
|
||||
all_ok = all(s["status"] == "completed" for s in subtasks)
|
||||
|
||||
with _tasks_lock:
|
||||
task["status"] = "completed" if all_ok else "partial"
|
||||
task["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
task["total_cost"] = total_cost
|
||||
|
||||
|
||||
def submit_compound_task(prompt: str, auth_token: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Plan and begin executing a compound task.
|
||||
|
||||
Args:
|
||||
prompt: The user's request to decompose and execute
|
||||
auth_token: (Optional) authentication token for the submission
|
||||
|
||||
Returns:
|
||||
Immediate response with task ID for polling:
|
||||
{
|
||||
"id": "compound-{uuid}",
|
||||
"status": "planned",
|
||||
"subtask_count": N
|
||||
}
|
||||
|
||||
Process:
|
||||
1. Use Haiku to plan the task (fast, synchronous)
|
||||
2. Store task in memory
|
||||
3. Spawn background thread for async execution
|
||||
4. Return immediately to client for polling
|
||||
|
||||
The client can then poll /task/{task_id}/progress to monitor execution.
|
||||
"""
|
||||
|
||||
# Phase 1: Plan (synchronous - fast, uses Haiku)
|
||||
task = plan_task(prompt)
|
||||
task_id = task["id"]
|
||||
|
||||
with _tasks_lock:
|
||||
# Evict oldest task if we're at capacity
|
||||
if len(_tasks) >= _MAX_TASKS:
|
||||
oldest_key = min(_tasks, key=lambda k: _tasks[k].get("created_at", ""))
|
||||
del _tasks[oldest_key]
|
||||
_tasks[task_id] = task
|
||||
|
||||
# Phase 2: Execute (async - in background thread)
|
||||
# The task will progress from "planned" -> "executing" -> "completed"/"partial"
|
||||
thread = threading.Thread(target=_run_compound_task, args=(task_id,), daemon=True)
|
||||
thread.start()
|
||||
|
||||
return {
|
||||
"id": task_id,
|
||||
"status": task["status"],
|
||||
"subtask_count": len(task["subtasks"])
|
||||
}
|
||||
|
||||
|
||||
def get_task_progress(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get current state of a compound task (for polling/dashboard).
|
||||
|
||||
Args:
|
||||
task_id: The task ID from submit_compound_task()
|
||||
|
||||
Returns:
|
||||
Complete task snapshot including all subtask progress, or None if not found.
|
||||
|
||||
Returned structure:
|
||||
{
|
||||
"id": task_id,
|
||||
"prompt": original prompt,
|
||||
"status": "planned"|"executing"|"completed"|"partial",
|
||||
"reasoning": explanation from planner,
|
||||
"subtasks": [
|
||||
{
|
||||
"id": subtask ID,
|
||||
"index": 0,
|
||||
"description": task description,
|
||||
"tier_hint": 1|2|3,
|
||||
"tier_assigned": 1|2|3,
|
||||
"model": "haiku"|"sonnet"|"opus"|None,
|
||||
"depends_on": [indices],
|
||||
"status": "pending"|"routing"|"queued"|"executing"|"completed"|"failed",
|
||||
"result": str or None,
|
||||
"cost": float or None,
|
||||
"started_at": ISO8601 or None,
|
||||
"completed_at": ISO8601 or None
|
||||
},
|
||||
...
|
||||
],
|
||||
"created_at": ISO8601,
|
||||
"planned_at": ISO8601,
|
||||
"completed_at": ISO8601 or None,
|
||||
"total_cost": float
|
||||
}
|
||||
"""
|
||||
with _tasks_lock:
|
||||
task = _tasks.get(task_id)
|
||||
if not task:
|
||||
return None
|
||||
# Return a deep copy for thread safety
|
||||
return json.loads(json.dumps(task, default=str))
|
||||
|
||||
|
||||
def list_recent_tasks(limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List recent compound tasks (for dashboard view).
|
||||
|
||||
Args:
|
||||
limit: Maximum number of tasks to return
|
||||
|
||||
Returns:
|
||||
List of task summaries (most recent first):
|
||||
[
|
||||
{
|
||||
"id": task ID,
|
||||
"prompt": truncated prompt,
|
||||
"status": current status,
|
||||
"subtask_count": total subtasks,
|
||||
"completed_count": subtasks finished,
|
||||
"total_cost": cumulative USD cost,
|
||||
"created_at": ISO8601,
|
||||
"completed_at": ISO8601 or None
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
with _tasks_lock:
|
||||
tasks = sorted(_tasks.values(), key=lambda t: t.get("created_at", ""), reverse=True)
|
||||
return [
|
||||
{
|
||||
"id": t["id"],
|
||||
"prompt": t["prompt"][:100],
|
||||
"status": t["status"],
|
||||
"subtask_count": len(t["subtasks"]),
|
||||
"completed_count": sum(1 for s in t["subtasks"] if s["status"] == "completed"),
|
||||
"total_cost": t.get("total_cost", 0),
|
||||
"created_at": t.get("created_at"),
|
||||
"completed_at": t.get("completed_at")
|
||||
}
|
||||
for t in tasks[:limit]
|
||||
]
|
||||
Loading…
Reference in New Issue
Block a user