Import ducklm runtime

This commit is contained in:
2026-05-10 23:37:56 +08:00
parent fd1a045488
commit dc8267880a
90 changed files with 9171 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""API layer."""
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from pydantic import BaseModel
class CriticFeedbackRequest(BaseModel):
feedback: str
task_id: str | None = None
session_id: str | None = None
correctness_override: float | None = None
usefulness_override: float | None = None
safety_override: float | None = None
from app.core.permission_resolution import PermissionResolutionRequest, SecretResolutionRequest, PasswordResolutionRequest
from app.core.contracts import UserTask
from app.runtime.runtime_controller import RuntimeController
from app.streaming.manager import StreamingManager
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Load models on startup."""
print("Lifespan: Starting model loading...")
loop = asyncio.get_event_loop()
def load_models():
try:
print("Lifespan: Loading models...")
runtime.load_models_at_startup()
print("Lifespan: Models loaded")
except Exception as e:
print(f"Lifespan: Failed to load models: {e}")
import traceback
traceback.print_exc()
await loop.run_in_executor(None, load_models)
yield # Server runs here
print("Lifespan: Shutting down...")
app = FastAPI(title="ducklm", lifespan=lifespan)
runtime = RuntimeController(base_dir=Path(__file__).resolve().parents[2])
streaming = StreamingManager(runtime.event_bus)
@app.get("/")
def index() -> FileResponse:
return FileResponse(Path(__file__).resolve().parent / "static" / "index.html")
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/chat")
def chat(task: UserTask) -> dict[str, object]:
return runtime.handle_task(task)
@app.post("/permissions/resolve")
def resolve_permission(request: PermissionResolutionRequest) -> dict[str, object]:
return runtime.resolve_permission(task_id=request.task_id, decision=request.decision)
@app.post("/secrets/resolve")
def resolve_secret(request: SecretResolutionRequest) -> dict[str, object]:
return runtime.resolve_secret(task_id=request.task_id, secret=request.secret)
@app.post("/password/resolve")
def resolve_password(request: PasswordResolutionRequest) -> dict[str, object]:
return runtime.resolve_password(task_id=request.task_id, password=request.password)
@app.post("/critic/feedback")
def critic_feedback(request: CriticFeedbackRequest) -> dict[str, object]:
feedback = runtime.handle_critic_feedback(
feedback=request.feedback,
task_id=request.task_id,
session_id=request.session_id,
correctness_override=request.correctness_override,
usefulness_override=request.usefulness_override,
safety_override=request.safety_override,
)
return feedback
@app.websocket("/stream/{task_id}")
async def stream_task(websocket: WebSocket, task_id: str) -> None:
await websocket.accept()
replayed_events = streaming.replay_events(task_id)
for event in replayed_events:
await websocket.send_json(event.model_dump(mode="json"))
if replayed_events and replayed_events[-1].type in {"task_completed", "task_failed"}:
await websocket.close()
return
queue = streaming.subscribe(task_id)
try:
while True:
event = await asyncio.wait_for(queue.get(), timeout=15)
await websocket.send_json(event.model_dump(mode="json"))
if event.type in {"task_completed", "task_failed", "task_awaiting_permission", "task_awaiting_input"}:
break
except (asyncio.TimeoutError, WebSocketDisconnect):
pass
finally:
streaming.unsubscribe(task_id, queue)
await websocket.close()
+510
View File
@@ -0,0 +1,510 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ducklm runtime test chat</title>
<style>
:root {
--bg: #f5f1e8;
--panel: #fffdf7;
--border: #c9bba4;
--text: #1d1a16;
--accent: #195c4b;
--muted: #6f6659;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Georgia, "Iowan Old Style", serif;
background: radial-gradient(circle at top, #fff7df 0%, var(--bg) 60%);
color: var(--text);
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 32px 20px 48px;
}
h1 {
margin: 0 0 8px;
font-size: 2.2rem;
}
p {
color: var(--muted);
margin: 0 0 24px;
}
.layout {
display: grid;
grid-template-columns: 1fr 350px;
gap: 20px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 18px;
padding: 18px;
box-shadow: 0 14px 30px rgba(35, 28, 17, 0.08);
max-width: 100%;
}
textarea, input {
width: 100%;
border: 1px solid var(--border);
background: #fff;
border-radius: 12px;
padding: 12px;
font: inherit;
}
textarea {
min-height: 120px;
resize: vertical;
}
button {
margin-top: 12px;
background: var(--accent);
color: white;
border: 0;
border-radius: 999px;
padding: 10px 18px;
font: inherit;
cursor: pointer;
}
.messages, .events {
display: grid;
gap: 12px;
max-height: 520px;
overflow: auto;
width: 100%;
}
.messages {
width: 100%;
}
.events {
width: 100%;
}
.bubble, .event {
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px;
background: white;
}
.event code {
color: var(--accent);
}
.row {
display: grid;
gap: 10px;
margin-top: 12px;
}
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<h1>ducklm runtime test chat</h1>
<p>Thin browser client for checking task submission, tool execution and event replay.</p>
<div class="layout">
<section class="panel">
<div class="messages" id="messages"></div>
<div class="row">
<textarea id="prompt" placeholder="Опиши задачу..." style="min-height: 60px;"></textarea>
<button id="sendBtn">Отправить</button>
</div>
</section>
<aside class="panel">
<strong>Events</strong>
<div class="events" id="events"></div>
</aside>
</div>
</main>
<script>
const messages = document.getElementById("messages");
const events = document.getElementById("events");
const promptEl = document.getElementById("prompt");
const sendBtn = document.getElementById("sendBtn");
let lastPermissionRequest = null;
let lastSecretRequest = null;
let lastPasswordRequest = null;
const seenEvents = new Set();
let activePermissionBubble = null;
let activeSecretBubble = null;
let activePasswordBubble = null;
function addBubble(title, body) {
const el = document.createElement("div");
el.className = "bubble";
el.innerHTML = `<strong>${title}</strong><div>${body}</div>`;
messages.appendChild(el);
messages.scrollTop = messages.scrollHeight;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function addSystemMessage(title, text) {
addBubble(title, `<div>${escapeHtml(text)}</div>`);
}
function addJsonBubble(title, data) {
addBubble(title, `<pre>${escapeHtml(JSON.stringify(data, null, 2))}</pre>`);
}
function renderRuntimeResult(result, status) {
if (!result) {
addSystemMessage("Runtime", "No result returned.");
return;
}
if (status === "awaiting_permission" && result.permission_request) {
lastPermissionRequest = result.permission_request;
renderPermissionControls(result.permission_request);
return;
}
if (status === "awaiting_input" && result.secret_request) {
addSystemMessage("System", result.secret_request.prompt || "Secret input required.");
lastSecretRequest = result.secret_request;
renderSecretControls(result.secret_request);
return;
}
if (status === "awaiting_password" && result.needs_sudo) {
lastPasswordRequest = result;
renderPasswordControls(result);
return;
}
if (status === "awaiting_permission" && result.error) {
addSystemMessage("System", result.error);
return;
}
if (result.message && !result.step_results) {
addSystemMessage("Runtime", result.message);
}
if (result.step_results && Array.isArray(result.step_results)) {
for (const step of result.step_results) {
const toolResult = step.result?.result || step.result;
if (toolResult && toolResult.output) {
addBubble("💻", escapeHtml(toolResult.output));
} else if (toolResult && toolResult.error) {
addSystemMessage("❌", toolResult.error);
}
}
return;
}
if (typeof result.output === "string") {
addBubble("Runtime", `<pre>${escapeHtml(result.output)}</pre>`);
return;
}
addJsonBubble("Runtime", result);
}
function addEvent(event) {
const eventKey = `${event.task_id || "na"}:${event.sequence || JSON.stringify(event)}`;
if (seenEvents.has(eventKey)) {
return;
}
seenEvents.add(eventKey);
const el = document.createElement("div");
el.className = "event";
el.innerHTML = `<div><code>${event.type}</code></div><pre>${JSON.stringify(event.payload ?? event, null, 2)}</pre>`;
events.appendChild(el);
events.scrollTop = events.scrollHeight;
if (event.type === "permission_requested") {
lastPermissionRequest = event.payload;
renderPermissionControls(event.payload);
}
if (event.type === "permission_resolved" || event.type === "task_failed" || event.type === "task_completed") {
clearPermissionControls();
}
if (event.type === "orchestrator_result") {
const p = event.payload || {};
const directive = p.directive || {};
if (directive.type === "respond") {
const text = directive.payload?.text || directive.payload?.message || "";
if (text) {
addBubble("🤖", escapeHtml(text));
}
}
}
if (event.type === "task_completed") {
const p = event.payload || {};
const execResult = p.execution_result || {};
if (execResult.step_results && Array.isArray(execResult.step_results)) {
for (const step of execResult.step_results) {
const toolResult = step.result?.result || step.result;
if (toolResult && toolResult.output) {
addBubble("💻", escapeHtml(toolResult.output));
}
}
}
}
if (event.type === "secret_requested") {
lastSecretRequest = event.payload;
renderSecretControls(event.payload);
}
if (event.type === "task_completed" || event.type === "task_failed") {
clearSecretControls();
clearPasswordControls();
}
}
function renderPermissionControls(request) {
clearPermissionControls();
const command = request.command || JSON.stringify(request);
const el = document.createElement("div");
el.className = "bubble";
el.innerHTML = `
<strong>⚠️ Требуется разрешение</strong>
<div style="margin: 8px 0; padding: 8px; background: #fff3cd; border-radius: 8px; font-family: monospace; font-size: 12px;">${escapeHtml(command)}</div>
<div>
<button data-decision="allow_once">Разрешить</button>
${request.allow_always !== false ? '<button data-decision="allow_always">Разрешить навсегда</button>' : ''}
<button data-decision="deny" style="background: #dc3545;">Запретить</button>
</div>
`;
el.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", async () => {
if (button.disabled) return;
await resolvePermission(button.dataset.decision);
});
});
activePermissionBubble = el;
messages.appendChild(el);
messages.scrollTop = messages.scrollHeight;
}
function clearPermissionControls() {
if (activePermissionBubble) {
activePermissionBubble.remove();
activePermissionBubble = null;
}
}
function renderSecretControls(request) {
clearSecretControls();
const el = document.createElement("div");
el.className = "bubble";
el.innerHTML = `
<strong>Secret required</strong>
<div>${escapeHtml(request.prompt)}</div>
<div style="margin-top:8px;color:var(--muted)">Command: <code>${escapeHtml(request.command || "")}</code></div>
<input type="password" placeholder="Enter secret" />
<button>Submit secret</button>
`;
const input = el.querySelector("input");
const submitBtn = el.querySelector("button");
input.addEventListener("keydown", async (event) => {
if (event.key === "Enter") {
event.preventDefault();
await resolveSecret(input.value);
}
});
submitBtn.addEventListener("click", async () => {
await resolveSecret(input.value);
});
activeSecretBubble = el;
messages.appendChild(el);
messages.scrollTop = messages.scrollHeight;
input.focus();
}
function clearSecretControls() {
if (activeSecretBubble) {
activeSecretBubble.remove();
activeSecretBubble = null;
}
}
function renderPasswordControls(request) {
clearPasswordControls();
const command = request.command || "unknown command";
const el = document.createElement("div");
el.className = "bubble";
el.innerHTML = `
<strong>🔐 Требуется пароль sudo</strong>
<div style="margin: 8px 0; padding: 8px; background: #fff3cd; border-radius: 8px; font-family: monospace; font-size: 12px;">sudo ${escapeHtml(command)}</div>
<div style="margin-top:8px;">
<input type="password" placeholder="Введите пароль sudo" />
<button>Выполнить с sudo</button>
</div>
`;
const input = el.querySelector("input");
const button = el.querySelector("button");
input.addEventListener("keydown", async (event) => {
if (event.key === "Enter") {
event.preventDefault();
await resolvePassword(input.value);
}
});
button.addEventListener("click", async () => {
await resolvePassword(input.value);
});
activePasswordBubble = el;
messages.appendChild(el);
messages.scrollTop = messages.scrollHeight;
input.focus();
}
function clearPasswordControls() {
if (activePasswordBubble) {
activePasswordBubble.remove();
activePasswordBubble = null;
}
}
async function resolvePermission(decision) {
if (!lastPermissionRequest) {
addBubble("Client error", "No pending permission request.");
return;
}
if (activePermissionBubble) {
activePermissionBubble.querySelectorAll("button").forEach((button) => {
button.disabled = true;
});
}
const response = await fetch("/permissions/resolve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
task_id: lastPermissionRequest.task_id,
decision
})
});
const data = await response.json();
clearPermissionControls();
if (!data || !data.status) {
addSystemMessage("Error", "Failed to resolve permission");
return;
}
if (decision === "deny") {
addSystemMessage("Permission", "Permission denied.");
} else if (decision === "allow_always") {
addSystemMessage("Permission", "Permission granted permanently for this pattern.");
} else {
addSystemMessage("Permission", "Permission granted once.");
}
events.innerHTML = "";
seenEvents.clear();
if (data.events && Array.isArray(data.events)) {
data.events.forEach(addEvent);
}
renderRuntimeResult(data.result, data.status);
}
async function resolveSecret(secret) {
if (!lastSecretRequest) {
addBubble("Client error", "No pending secret request.");
return;
}
if (!secret) {
addSystemMessage("System", "Secret cannot be empty.");
return;
}
if (activeSecretBubble) {
const input = activeSecretBubble.querySelector("input");
const button = activeSecretBubble.querySelector("button");
if (input) input.disabled = true;
if (button) button.disabled = true;
}
clearSecretControls();
addSystemMessage("System", "Secret submitted. Waiting for command result.");
const response = await fetch("/secrets/resolve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
task_id: lastSecretRequest.task_id,
secret
})
});
const data = await response.json();
events.innerHTML = "";
seenEvents.clear();
data.events.forEach(addEvent);
renderRuntimeResult(data.result, data.status);
}
async function resolvePassword(password) {
if (!lastPasswordRequest) {
addBubble("Client error", "No pending password request.");
return;
}
if (!password) {
addSystemMessage("System", "Пароль не может быть пустым.");
return;
}
if (activePasswordBubble) {
const input = activePasswordBubble.querySelector("input");
const button = activePasswordBubble.querySelector("button");
if (input) input.disabled = true;
if (button) button.disabled = true;
}
clearPasswordControls();
addSystemMessage("System", "Выполняю команду с sudo...");
const response = await fetch("/password/resolve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
task_id: lastPasswordRequest.task_id,
password
})
});
const data = await response.json();
events.innerHTML = "";
seenEvents.clear();
data.events.forEach(addEvent);
renderRuntimeResult(data.result, data.status);
}
async function sendTask() {
const body = {
input: promptEl.value || "browser task",
task_id: "web-" + Date.now(),
session_id: "web-session",
context: {}
};
if (!promptEl.value.trim()) return;
addBubble("User", promptEl.value);
promptEl.value = "";
const response = await fetch("/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const data = await response.json();
if (!data || !data.events) {
addSystemMessage("Error", "Invalid response from server");
return;
}
lastPermissionRequest = null;
lastSecretRequest = null;
lastPasswordRequest = null;
clearPermissionControls();
clearSecretControls();
clearPasswordControls();
events.innerHTML = "";
seenEvents.clear();
data.events.forEach(addEvent);
renderRuntimeResult(data.result, data.status);
if (data.task_id) {
attachStream(data.task_id);
}
}
function attachStream(taskId) {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${location.host}/stream/${taskId}`);
ws.onmessage = (message) => {
const data = JSON.parse(message.data);
addEvent(data);
};
}
sendBtn.addEventListener("click", sendTask);
</script>
</body>
</html>