475 lines
19 KiB
Python
475 lines
19 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import uvicorn
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from pydantic import BaseModel
|
|
|
|
from duck_core.approvals.service import ApprovalService
|
|
from duck_core.config import get_settings
|
|
from duck_core.events.store import EventStore
|
|
from duck_core.experience.recorder import ExperienceRecorder
|
|
from duck_core.memory.vector_memory import EmbeddingsUnavailableError, VectorMemory
|
|
from duck_core.model_client import ModelClient
|
|
from duck_core.runtime_loop import RuntimeLoop
|
|
from duck_core.skills.registry import SkillRegistry
|
|
from duck_core.tasks.store import TaskStore
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ChatRequest(BaseModel):
|
|
message: str
|
|
workspace: str | None = None
|
|
debug: bool = False
|
|
|
|
|
|
class ContinueRequest(BaseModel):
|
|
approval_id: str
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
settings = get_settings()
|
|
if settings.api_host == "0.0.0.0":
|
|
logger.warning(
|
|
"DuckLM API is listening on 0.0.0.0. This may expose local tool execution endpoints."
|
|
)
|
|
Path(settings.workspace).mkdir(parents=True, exist_ok=True)
|
|
Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
app = FastAPI(title="DuckLM", version="0.1.0")
|
|
templates = Jinja2Templates(directory="duck_core/web/templates")
|
|
app.mount("/static", StaticFiles(directory="duck_core/web/static"), name="static")
|
|
|
|
task_store = TaskStore(settings.db_path)
|
|
event_store = EventStore(settings.db_path)
|
|
model_client = ModelClient()
|
|
approvals = ApprovalService(settings.db_path)
|
|
runtime = RuntimeLoop(task_store, event_store, model_client, approval_service=approvals)
|
|
skills = SkillRegistry("skills")
|
|
experience = ExperienceRecorder(settings.db_path)
|
|
memory = VectorMemory(settings.qdrant_url, embeddings_base_url=None)
|
|
|
|
@app.on_event("startup")
|
|
async def startup() -> None:
|
|
await task_store.init()
|
|
await event_store.init()
|
|
await approvals.init()
|
|
await experience.init()
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def index(request: Request) -> HTMLResponse:
|
|
return templates.TemplateResponse(request, "index.html")
|
|
|
|
@app.get("/approvals", response_class=HTMLResponse)
|
|
async def approvals_page(request: Request) -> HTMLResponse:
|
|
return templates.TemplateResponse(request, "approvals.html")
|
|
|
|
@app.get("/skills", response_class=HTMLResponse)
|
|
async def skills_page(request: Request) -> HTMLResponse:
|
|
return templates.TemplateResponse(request, "skills.html")
|
|
|
|
@app.get("/memory", response_class=HTMLResponse)
|
|
async def memory_page(request: Request) -> HTMLResponse:
|
|
return templates.TemplateResponse(request, "memory.html")
|
|
|
|
@app.get("/experience", response_class=HTMLResponse)
|
|
async def experience_page(request: Request) -> HTMLResponse:
|
|
return templates.TemplateResponse(request, "experience.html")
|
|
|
|
@app.get("/health")
|
|
async def health() -> dict[str, str]:
|
|
return {"status": "ok"}
|
|
|
|
@app.get("/v1/status")
|
|
async def status() -> dict[str, Any]:
|
|
return {
|
|
"name": "DuckLM",
|
|
"version": "0.1.0",
|
|
"api_host": settings.api_host,
|
|
"api_port": settings.api_port,
|
|
"workspace": settings.workspace,
|
|
"db_path": settings.db_path,
|
|
}
|
|
|
|
@app.get("/v1/models/roles")
|
|
async def roles() -> dict[str, Any]:
|
|
return model_client.list_roles()
|
|
|
|
@app.get("/v1/models/ping")
|
|
async def models_ping() -> dict[str, Any]:
|
|
return await model_client.ping()
|
|
|
|
@app.post("/v1/chat")
|
|
async def chat(body: ChatRequest) -> dict[str, Any]:
|
|
result = await runtime.run_chat(body.message, body.workspace or settings.workspace, body.debug)
|
|
return result.__dict__
|
|
|
|
def sse(event: str, payload: dict[str, Any]) -> str:
|
|
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
|
|
|
async def emit_tool_events(task_id: str, after_sequence: int):
|
|
events = await event_store.list_events(task_id)
|
|
visible_types = {
|
|
"tool_call_started",
|
|
"tool_call_finished",
|
|
"tool_approval_requested",
|
|
}
|
|
for event in events:
|
|
if event.sequence > after_sequence and event.event_type in visible_types:
|
|
yield sse(event.event_type, event.model_dump())
|
|
|
|
@app.post("/v1/chat/stream")
|
|
async def chat_stream(body: ChatRequest) -> StreamingResponse:
|
|
async def generator():
|
|
task = await task_store.create_task(
|
|
body.message, body.workspace or settings.workspace, body.debug
|
|
)
|
|
task_event = await event_store.append(
|
|
task.task_id,
|
|
"task_created",
|
|
{
|
|
"message": body.message,
|
|
"workspace": body.workspace or settings.workspace,
|
|
"debug": body.debug,
|
|
},
|
|
)
|
|
yield sse("task_created", task_event.model_dump())
|
|
|
|
reasoning_parts: list[str] = []
|
|
content_parts: list[str] = []
|
|
try:
|
|
messages = runtime.context_builder.build_basic_messages(task)
|
|
tool_observations = await runtime._run_action_loop(
|
|
task.task_id, messages, body.workspace or settings.workspace
|
|
)
|
|
async for tool_event in emit_tool_events(task.task_id, task_event.sequence):
|
|
yield tool_event
|
|
if any(observation.get("requires_approval") for observation in tool_observations):
|
|
await task_store.waiting_for_approval(task.task_id)
|
|
await event_store.append(
|
|
task.task_id,
|
|
"task_waiting_for_approval",
|
|
{"observations": tool_observations},
|
|
)
|
|
yield sse(
|
|
"done",
|
|
{
|
|
"task_id": task.task_id,
|
|
"status": "waiting_for_approval",
|
|
"final_response": "Waiting for approval.",
|
|
"reasoning_content": None,
|
|
},
|
|
)
|
|
return
|
|
if tool_observations:
|
|
messages = [
|
|
*messages,
|
|
{
|
|
"role": "user",
|
|
"content": "tool_observations:\n"
|
|
+ json.dumps(tool_observations, ensure_ascii=False, indent=2),
|
|
},
|
|
]
|
|
await event_store.append(task.task_id, "model_call_started", {"role": "thinker"})
|
|
async for chunk in model_client.stream_chat("thinker", messages):
|
|
delta = str(chunk.get("delta") or "")
|
|
if chunk.get("type") == "reasoning_delta":
|
|
reasoning_parts.append(delta)
|
|
yield sse(
|
|
"reasoning_delta",
|
|
{"task_id": task.task_id, "delta": delta},
|
|
)
|
|
elif chunk.get("type") == "content_delta":
|
|
content_parts.append(delta)
|
|
yield sse(
|
|
"content_delta",
|
|
{"task_id": task.task_id, "delta": delta},
|
|
)
|
|
|
|
content = "".join(content_parts)
|
|
reasoning_content = "".join(reasoning_parts) or None
|
|
await event_store.append(
|
|
task.task_id,
|
|
"cognition_response",
|
|
{
|
|
"role": "thinker",
|
|
"content": content,
|
|
"reasoning_content": reasoning_content,
|
|
},
|
|
)
|
|
await event_store.append(
|
|
task.task_id,
|
|
"model_call_finished",
|
|
{
|
|
"role": "thinker",
|
|
"model": model_client.get_role_config("thinker").model,
|
|
},
|
|
)
|
|
await task_store.complete_task(task.task_id, content)
|
|
await event_store.append(
|
|
task.task_id,
|
|
"task_completed",
|
|
{
|
|
"final_response": content,
|
|
"reasoning_content": reasoning_content,
|
|
},
|
|
)
|
|
yield sse(
|
|
"done",
|
|
{
|
|
"task_id": task.task_id,
|
|
"status": "completed",
|
|
"final_response": content,
|
|
"reasoning_content": reasoning_content,
|
|
},
|
|
)
|
|
except Exception as exc:
|
|
await task_store.fail_task(task.task_id, str(exc))
|
|
await event_store.append(task.task_id, "task_failed", {"error": str(exc)})
|
|
yield sse(
|
|
"error",
|
|
{
|
|
"task_id": task.task_id,
|
|
"status": "failed",
|
|
"error": str(exc),
|
|
},
|
|
)
|
|
|
|
return StreamingResponse(generator(), media_type="text/event-stream")
|
|
|
|
@app.post("/v1/tasks")
|
|
async def create_task(body: ChatRequest) -> dict[str, Any]:
|
|
task = await task_store.create_task(body.message, body.workspace or settings.workspace, body.debug)
|
|
await event_store.append(task.task_id, "task_created", body.model_dump())
|
|
return task.model_dump()
|
|
|
|
@app.get("/v1/tasks")
|
|
async def list_tasks() -> list[dict[str, Any]]:
|
|
return [task.model_dump() for task in await task_store.list_tasks()]
|
|
|
|
@app.get("/v1/tasks/{task_id}")
|
|
async def get_task(task_id: str) -> dict[str, Any]:
|
|
task = await task_store.get_task(task_id)
|
|
if task is None:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
return task.model_dump()
|
|
|
|
@app.get("/v1/tasks/{task_id}/events")
|
|
async def get_events(task_id: str) -> list[dict[str, Any]]:
|
|
return [event.model_dump() for event in await event_store.list_events(task_id)]
|
|
|
|
@app.get("/v1/tasks/{task_id}/stream")
|
|
async def stream_events(task_id: str) -> StreamingResponse:
|
|
async def generator():
|
|
sent = 0
|
|
for _ in range(30):
|
|
events = await event_store.list_events(task_id)
|
|
for event in events[sent:]:
|
|
yield f"data: {json.dumps(event.model_dump())}\n\n"
|
|
sent = len(events)
|
|
await asyncio.sleep(1)
|
|
|
|
return StreamingResponse(generator(), media_type="text/event-stream")
|
|
|
|
@app.post("/v1/tasks/{task_id}/continue")
|
|
async def continue_task(task_id: str) -> dict[str, str]:
|
|
task = await task_store.get_task(task_id)
|
|
if task is None:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
await task_store.update_status(task_id, "running")
|
|
await event_store.append(task_id, "task_continued", {})
|
|
return {"status": "running"}
|
|
|
|
@app.post("/v1/tasks/{task_id}/continue/stream")
|
|
async def continue_task_stream(task_id: str, body: ContinueRequest) -> StreamingResponse:
|
|
task = await task_store.get_task(task_id)
|
|
if task is None:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
approval = await approvals.get(body.approval_id)
|
|
if approval is None or approval.task_id != task_id:
|
|
raise HTTPException(status_code=404, detail="Approval not found for task")
|
|
if approval.decision is None:
|
|
raise HTTPException(status_code=409, detail="Approval is still pending")
|
|
|
|
async def generator():
|
|
reasoning_parts: list[str] = []
|
|
content_parts: list[str] = []
|
|
try:
|
|
await task_store.update_status(task_id, "running")
|
|
continued_event = await event_store.append(
|
|
task_id,
|
|
"task_continued",
|
|
{"approval_id": body.approval_id, "decision": approval.decision},
|
|
)
|
|
tool_observation = await runtime._run_approved_or_denied_action(
|
|
task_id, approval.normalized_action, approval.decision
|
|
)
|
|
messages = runtime.context_builder.build_basic_messages(task)
|
|
tool_observations = [tool_observation]
|
|
if approval.decision != "deny":
|
|
tool_observations = await runtime._run_action_loop(
|
|
task_id,
|
|
messages,
|
|
task.workspace,
|
|
initial_observations=tool_observations,
|
|
)
|
|
async for tool_event in emit_tool_events(task_id, continued_event.sequence):
|
|
yield tool_event
|
|
if any(observation.get("requires_approval") for observation in tool_observations):
|
|
await task_store.waiting_for_approval(task_id)
|
|
await event_store.append(
|
|
task_id,
|
|
"task_waiting_for_approval",
|
|
{"observations": tool_observations},
|
|
)
|
|
yield sse(
|
|
"done",
|
|
{
|
|
"task_id": task_id,
|
|
"status": "waiting_for_approval",
|
|
"final_response": "Waiting for approval.",
|
|
"reasoning_content": None,
|
|
},
|
|
)
|
|
return
|
|
|
|
messages = [
|
|
*messages,
|
|
{
|
|
"role": "user",
|
|
"content": "tool_observations:\n"
|
|
+ json.dumps(tool_observations, ensure_ascii=False, indent=2),
|
|
},
|
|
]
|
|
await event_store.append(task_id, "model_call_started", {"role": "thinker"})
|
|
async for chunk in model_client.stream_chat("thinker", messages):
|
|
delta = str(chunk.get("delta") or "")
|
|
if chunk.get("type") == "reasoning_delta":
|
|
reasoning_parts.append(delta)
|
|
yield sse("reasoning_delta", {"task_id": task_id, "delta": delta})
|
|
elif chunk.get("type") == "content_delta":
|
|
content_parts.append(delta)
|
|
yield sse("content_delta", {"task_id": task_id, "delta": delta})
|
|
|
|
content = "".join(content_parts)
|
|
reasoning_content = "".join(reasoning_parts) or None
|
|
await event_store.append(
|
|
task_id,
|
|
"cognition_response",
|
|
{
|
|
"role": "thinker",
|
|
"content": content,
|
|
"reasoning_content": reasoning_content,
|
|
},
|
|
)
|
|
await event_store.append(
|
|
task_id,
|
|
"model_call_finished",
|
|
{
|
|
"role": "thinker",
|
|
"model": model_client.get_role_config("thinker").model,
|
|
},
|
|
)
|
|
await task_store.complete_task(task_id, content)
|
|
await event_store.append(
|
|
task_id,
|
|
"task_completed",
|
|
{
|
|
"final_response": content,
|
|
"reasoning_content": reasoning_content,
|
|
},
|
|
)
|
|
yield sse(
|
|
"done",
|
|
{
|
|
"task_id": task_id,
|
|
"status": "completed",
|
|
"final_response": content,
|
|
"reasoning_content": reasoning_content,
|
|
},
|
|
)
|
|
except Exception as exc:
|
|
await task_store.fail_task(task_id, str(exc))
|
|
await event_store.append(task_id, "task_failed", {"error": str(exc)})
|
|
yield sse(
|
|
"error",
|
|
{
|
|
"task_id": task_id,
|
|
"status": "failed",
|
|
"error": str(exc),
|
|
},
|
|
)
|
|
|
|
return StreamingResponse(generator(), media_type="text/event-stream")
|
|
|
|
@app.post("/v1/tasks/{task_id}/cancel")
|
|
async def cancel_task(task_id: str) -> dict[str, str]:
|
|
await task_store.cancel_task(task_id)
|
|
await event_store.append(task_id, "task_cancelled", {})
|
|
return {"status": "cancelled"}
|
|
|
|
@app.get("/v1/approvals/pending")
|
|
async def pending_approvals() -> list[dict[str, Any]]:
|
|
return [approval.model_dump() for approval in await approvals.pending()]
|
|
|
|
@app.post("/v1/approvals/{approval_id}/allow_once")
|
|
async def allow_once(approval_id: str) -> dict[str, str]:
|
|
await approvals.allow_once(approval_id)
|
|
return {"status": "allowed_once"}
|
|
|
|
@app.post("/v1/approvals/{approval_id}/allow_forever")
|
|
async def allow_forever(approval_id: str) -> dict[str, str]:
|
|
await approvals.allow_forever(approval_id)
|
|
return {"status": "allowed_forever"}
|
|
|
|
@app.post("/v1/approvals/{approval_id}/deny")
|
|
async def deny(approval_id: str) -> dict[str, str]:
|
|
await approvals.deny(approval_id)
|
|
return {"status": "denied"}
|
|
|
|
@app.get("/v1/skills")
|
|
async def list_skills() -> list[dict[str, Any]]:
|
|
return [skill.model_dump() for skill in skills.load_skills()]
|
|
|
|
@app.get("/v1/skills/{skill_id}")
|
|
async def get_skill(skill_id: str) -> dict[str, Any]:
|
|
skill = skills.get_skill(skill_id)
|
|
if skill is None:
|
|
raise HTTPException(status_code=404, detail="Skill not found")
|
|
return skill.model_dump()
|
|
|
|
@app.get("/v1/experience")
|
|
async def list_experience() -> list[dict[str, Any]]:
|
|
return [record.model_dump() for record in await experience.list_records()]
|
|
|
|
@app.get("/v1/experience/{record_id}")
|
|
async def get_experience(record_id: int) -> dict[str, Any]:
|
|
record = await experience.get_record(record_id)
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail="Experience record not found")
|
|
return record.model_dump()
|
|
|
|
@app.get("/v1/memory/search")
|
|
async def search_memory(q: str) -> dict[str, Any]:
|
|
try:
|
|
return {"results": await memory.search_memory(q)}
|
|
except EmbeddingsUnavailableError as exc:
|
|
return {"results": [], "warning": str(exc)}
|
|
|
|
return app
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
settings = get_settings()
|
|
uvicorn.run("duck_core.api:app", host=settings.api_host, port=settings.api_port, reload=False)
|