Import ducklm runtime
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""API layer."""
|
||||
|
||||
@@ -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()
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user