feat(agent): SSE agent route backed by open-agent-sdk via BoosAPI provider
Adds the Agent Workbench HTTP surface wiring the open-agent-sdk loop into
BoosAPI:
- POST /api/agent/chat (SSE): runs an agentic turn and streams session /
text / thinking / tool_use / tool_result / approval_request / result /
error / done events. The agent's LLM calls are routed back through
BoosAPI's own OpenAI-compatible /v1 using the built-in downstream key
(constructed via the SDK's OpenAIProvider), so any aggregated model works
and usage flows through the normal accounting pipeline.
- POST /api/agent/approve: resolves a pending tool approval by request id.
- GET /api/agent/status: engine info + a session's accounting.
Safety is enforced in the SDK permission callback: every tool first passes
sandbox path validation (no escaping the per-session working dir), then the
risk/mode matrix — writes and command execution require an interactive
approval round-trip (default mode) that blocks the loop until the user
answers or the request times out (300s -> deny). Each session runs in its
own data/agent_sessions/{id}/ sandbox and turn usage is persisted.
Registered in the API router and added open-agent-sdk as a path dependency.
This commit is contained in:
Generated
+36
@@ -390,6 +390,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
"async-trait",
|
||||||
"axum 0.8.9",
|
"axum 0.8.9",
|
||||||
"axum-test",
|
"axum-test",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -407,6 +408,7 @@ dependencies = [
|
|||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"open-agent-sdk",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -1825,6 +1827,31 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "open-agent-sdk"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64",
|
||||||
|
"bytes",
|
||||||
|
"chrono",
|
||||||
|
"futures",
|
||||||
|
"glob",
|
||||||
|
"pin-project-lite",
|
||||||
|
"regex",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.80"
|
version = "0.10.80"
|
||||||
@@ -3811,6 +3838,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ hex = "0.4"
|
|||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
mime_guess = "2"
|
mime_guess = "2"
|
||||||
|
|
||||||
|
# Embedded AI agent engine (MIT, vendored as a path dependency). Provides the
|
||||||
|
# agentic loop, built-in tools, permission callback and cost tracking that the
|
||||||
|
# Agent Workbench (src/routes/agent.rs) drives through BoosAPI's own provider.
|
||||||
|
open-agent-sdk = { path = "../open-agent-sdk-rust" }
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rstest = "0.24"
|
rstest = "0.24"
|
||||||
wiremock = "0.6"
|
wiremock = "0.6"
|
||||||
|
|||||||
@@ -0,0 +1,455 @@
|
|||||||
|
//! Agent Workbench HTTP surface.
|
||||||
|
//!
|
||||||
|
//! A general-purpose, server-side AI agent exposed to the SPA over SSE. The
|
||||||
|
//! agentic loop comes from the `open-agent-sdk` crate; every LLM call is routed
|
||||||
|
//! back through BoosAPI's own OpenAI-compatible `/v1` endpoint using the
|
||||||
|
//! built-in downstream key, so agent usage flows through the same aggregation
|
||||||
|
//! and accounting pipeline as any other consumer (and can use any aggregated
|
||||||
|
//! model).
|
||||||
|
//!
|
||||||
|
//! Routes (all require a Bearer token — admin token, proxy token, or a user JWT):
|
||||||
|
//! - `POST /api/agent/chat` Stream a turn as SSE (text / tool_use /
|
||||||
|
//! tool_result / approval_request / result / error / done).
|
||||||
|
//! - `POST /api/agent/approve` Resolve a pending tool approval.
|
||||||
|
//! - `GET /api/agent/status` Engine status + a session's accounting.
|
||||||
|
//!
|
||||||
|
//! ## Safety
|
||||||
|
//! Each session runs inside an isolated sandbox directory
|
||||||
|
//! (`data/agent_sessions/{id}/`). The SDK permission callback ([`build_can_use_tool`])
|
||||||
|
//! enforces two gates on every tool: (1) path arguments must stay within the
|
||||||
|
//! sandbox, and (2) writes/executes require explicit user approval unless the
|
||||||
|
//! session mode opts out. The default mode requires approval for anything that
|
||||||
|
//! mutates state or runs a command.
|
||||||
|
|
||||||
|
use crate::auth::jwt::validate_jwt;
|
||||||
|
use crate::services::agent_workbench::{
|
||||||
|
approval::{self, ApprovalOutcome},
|
||||||
|
permission::{classify_tool_risk, decide_permission, PermissionAction, PermissionMode},
|
||||||
|
sandbox,
|
||||||
|
session_store::{self, UsageDelta},
|
||||||
|
};
|
||||||
|
use crate::AppState;
|
||||||
|
use axum::{
|
||||||
|
extract::{DefaultBodyLimit, State},
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
response::sse::{Event, Sse},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use futures::stream::Stream;
|
||||||
|
use open_agent_sdk::api::openai::OpenAIProvider;
|
||||||
|
use open_agent_sdk::types::{ContentBlock, PermissionDecision};
|
||||||
|
use open_agent_sdk::{Agent, AgentOptions, LLMProvider, SDKMessage};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
||||||
|
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Max agentic turns per chat request (bounds runaway loops / cost).
|
||||||
|
const MAX_TURNS: u32 = 20;
|
||||||
|
/// How long a tool approval waits for the user before auto-denying.
|
||||||
|
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
|
/// Body limit for a chat request (images/attachments can be large).
|
||||||
|
const AGENT_CHAT_BODY_LIMIT_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT: &str = "You are BoosAPI's built-in engineering agent. You can read and write files \
|
||||||
|
and run shell commands, but only inside the current working directory, which is an \
|
||||||
|
isolated sandbox for this session. Be concise. Explain what you are about to do before \
|
||||||
|
using a tool that writes files or runs commands. Never attempt to access paths outside \
|
||||||
|
the working directory.";
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/agent/chat", post(chat_handler))
|
||||||
|
.route("/api/agent/approve", post(approve_handler))
|
||||||
|
.route("/api/agent/status", get(status_handler))
|
||||||
|
.layer(DefaultBodyLimit::max(AGENT_CHAT_BODY_LIMIT_BYTES))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChatRequest {
|
||||||
|
/// The user's prompt for this turn.
|
||||||
|
message: String,
|
||||||
|
/// Existing session id to continue; a new one is minted when absent.
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
/// Aggregated model id; falls back to the configured agent model.
|
||||||
|
#[serde(default)]
|
||||||
|
model: Option<String>,
|
||||||
|
/// Permission mode: "default" | "accept_edits" | "full_access".
|
||||||
|
#[serde(default)]
|
||||||
|
permission_mode: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ApproveRequest {
|
||||||
|
request_id: String,
|
||||||
|
approved: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved caller identity for an agent request.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct AgentAuth {
|
||||||
|
/// `None` for admin/global-token callers; `Some(id)` for user JWTs.
|
||||||
|
user_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||||
|
headers
|
||||||
|
.get(axum::http::header::AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate the caller. Admin token / proxy token map to an admin
|
||||||
|
/// (user-less) session; a valid user/admin JWT maps to that user's id.
|
||||||
|
async fn resolve_auth(
|
||||||
|
headers: &HeaderMap,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<AgentAuth, (StatusCode, Json<Value>)> {
|
||||||
|
let unauthorized = || {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(json!({ "error": "Unauthorized" })),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = extract_bearer_token(headers).ok_or_else(unauthorized)?;
|
||||||
|
|
||||||
|
if token == state.config.auth.auth_token || token == state.runtime_proxy_token() {
|
||||||
|
return Ok(AgentAuth { user_id: None });
|
||||||
|
}
|
||||||
|
|
||||||
|
let claims = validate_jwt(&state.config.auth.jwt_secret, token).map_err(|_| unauthorized())?;
|
||||||
|
if claims.role != "admin" && claims.role != "user" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": "Forbidden" }))));
|
||||||
|
}
|
||||||
|
let user_id = claims
|
||||||
|
.sub
|
||||||
|
.parse::<i64>()
|
||||||
|
.map_err(|_| (StatusCode::UNAUTHORIZED, Json(json!({ "error": "Invalid user id" }))))?;
|
||||||
|
Ok(AgentAuth { user_id: Some(user_id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send one SSE event; returns false if the receiver (client) has gone away.
|
||||||
|
fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) -> bool {
|
||||||
|
tx.send(Ok(Event::default().data(payload.to_string()))).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve BoosAPI's own base URL (no `/v1` suffix — the OpenAI provider appends
|
||||||
|
/// `/v1/chat/completions`).
|
||||||
|
fn boosapi_base(state: &AppState) -> String {
|
||||||
|
let host = match state.config.server.host.as_str() {
|
||||||
|
"0.0.0.0" | "::" => "127.0.0.1",
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
format!("http://{}:{}", host, state.config.server.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the SDK permission callback that enforces sandbox path safety and the
|
||||||
|
/// approval flow. The callback runs inside the agent loop for every tool call.
|
||||||
|
///
|
||||||
|
/// Order of checks:
|
||||||
|
/// 1. Sandbox: any path argument that escapes `cwd` is hard-denied immediately.
|
||||||
|
/// 2. Risk + mode: read-only (and, per mode, writes) auto-allow; otherwise emit
|
||||||
|
/// an `approval_request` SSE event and block until the user responds (or the
|
||||||
|
/// approval times out, which denies).
|
||||||
|
fn build_can_use_tool(
|
||||||
|
sse_tx: UnboundedSender<Result<Event, Infallible>>,
|
||||||
|
cwd: std::path::PathBuf,
|
||||||
|
mode: PermissionMode,
|
||||||
|
) -> open_agent_sdk::types::CanUseToolFn {
|
||||||
|
Arc::new(move |tool_name: &str, input: &Value| {
|
||||||
|
let sse_tx = sse_tx.clone();
|
||||||
|
let cwd = cwd.clone();
|
||||||
|
let tool_name = tool_name.to_string();
|
||||||
|
let input = input.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
// Gate 1: sandbox path safety (always enforced, even in full access).
|
||||||
|
if let Err(reason) = sandbox::check_tool_paths(&tool_name, &input, &cwd) {
|
||||||
|
send_sse(
|
||||||
|
&sse_tx,
|
||||||
|
json!({
|
||||||
|
"type": "tool_denied",
|
||||||
|
"data": { "name": tool_name, "reason": reason }
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return PermissionDecision::Deny(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate 2: risk + permission mode.
|
||||||
|
let risk = classify_tool_risk(&tool_name);
|
||||||
|
match decide_permission(mode, risk) {
|
||||||
|
PermissionAction::AutoAllow => PermissionDecision::Allow,
|
||||||
|
PermissionAction::RequireApproval => {
|
||||||
|
let request_id = Uuid::new_v4().to_string();
|
||||||
|
let rx = approval::register(&request_id);
|
||||||
|
send_sse(
|
||||||
|
&sse_tx,
|
||||||
|
json!({
|
||||||
|
"type": "approval_request",
|
||||||
|
"data": {
|
||||||
|
"request_id": request_id,
|
||||||
|
"name": tool_name,
|
||||||
|
"input": input,
|
||||||
|
"risk": format!("{risk:?}"),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
match tokio::time::timeout(APPROVAL_TIMEOUT, rx).await {
|
||||||
|
Ok(Ok(ApprovalOutcome::Approve)) => PermissionDecision::Allow,
|
||||||
|
Ok(Ok(ApprovalOutcome::Deny)) => {
|
||||||
|
PermissionDecision::Deny("Denied by user".to_string())
|
||||||
|
}
|
||||||
|
// Sender dropped (session ended) or timed out.
|
||||||
|
_ => {
|
||||||
|
approval::cancel(&request_id);
|
||||||
|
PermissionDecision::Deny("Approval timed out".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
async fn chat_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<ChatRequest>,
|
||||||
|
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (StatusCode, Json<Value>)> {
|
||||||
|
let auth = resolve_auth(&headers, &state).await?;
|
||||||
|
|
||||||
|
if body.message.trim().is_empty() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({ "error": "message is required" })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_id = body
|
||||||
|
.session_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||||
|
let mode = PermissionMode::from_wire(body.permission_mode.as_deref().unwrap_or("default"));
|
||||||
|
let model = body
|
||||||
|
.model
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.unwrap_or_else(|| state.config.comfyui.agent_model.clone());
|
||||||
|
|
||||||
|
// Per-session sandbox working directory.
|
||||||
|
let cwd = match sandbox::ensure_session_dir(&session_id) {
|
||||||
|
Ok(dir) => dir,
|
||||||
|
Err(e) => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": format!("failed to create session sandbox: {e}") })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Persist (idempotently) the session row before streaming.
|
||||||
|
if let Err(e) = session_store::ensure_session(
|
||||||
|
&state.db,
|
||||||
|
&session_id,
|
||||||
|
auth.user_id,
|
||||||
|
&model,
|
||||||
|
&format!("{mode:?}").to_lowercase(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(%e, "failed to ensure agent session row");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route the agent's LLM calls back through BoosAPI's own aggregation using
|
||||||
|
// the built-in downstream key.
|
||||||
|
let base_url = boosapi_base(&state);
|
||||||
|
let internal_key = state.builtin_keys.comfyui_agent.1.clone();
|
||||||
|
|
||||||
|
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
|
||||||
|
|
||||||
|
let db = state.db.clone();
|
||||||
|
let prompt = body.message;
|
||||||
|
let session_for_event = session_id.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// First event: hand the (possibly newly minted) session id to the client.
|
||||||
|
send_sse(
|
||||||
|
&tx,
|
||||||
|
json!({ "type": "session", "data": { "session_id": session_for_event, "model": model } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
let provider: Arc<dyn LLMProvider> = Arc::new(OpenAIProvider::new(
|
||||||
|
reqwest::Client::new(),
|
||||||
|
internal_key,
|
||||||
|
base_url,
|
||||||
|
Default::default(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let can_use_tool = build_can_use_tool(tx.clone(), cwd.clone(), mode);
|
||||||
|
|
||||||
|
let agent = Agent::new(AgentOptions {
|
||||||
|
provider: Some(provider),
|
||||||
|
model: Some(model.clone()),
|
||||||
|
cwd: Some(cwd.to_string_lossy().to_string()),
|
||||||
|
system_prompt: Some(SYSTEM_PROMPT.to_string()),
|
||||||
|
max_turns: Some(MAX_TURNS),
|
||||||
|
can_use_tool: Some(can_use_tool),
|
||||||
|
session_id: Some(session_id.clone()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut agent = match agent {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
send_sse(&tx, json!({ "type": "error", "data": { "message": e } }));
|
||||||
|
send_sse(&tx, json!({ "type": "done" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut rx_msg, handle) = agent.query(&prompt).await;
|
||||||
|
|
||||||
|
while let Some(event) = rx_msg.recv().await {
|
||||||
|
// Stop early if the client disconnected.
|
||||||
|
if tx.is_closed() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
forward_sdk_event(&tx, &db, &session_id, event).await;
|
||||||
|
}
|
||||||
|
let _ = handle.await;
|
||||||
|
agent.close().await;
|
||||||
|
|
||||||
|
send_sse(&tx, json!({ "type": "done" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Sse::new(UnboundedReceiverStream::new(rx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate one SDK message into SSE event(s), and persist usage on Result.
|
||||||
|
async fn forward_sdk_event(
|
||||||
|
tx: &UnboundedSender<Result<Event, Infallible>>,
|
||||||
|
db: &sqlx::SqlitePool,
|
||||||
|
session_id: &str,
|
||||||
|
event: SDKMessage,
|
||||||
|
) {
|
||||||
|
match event {
|
||||||
|
SDKMessage::Assistant { message, .. } => {
|
||||||
|
for block in &message.content {
|
||||||
|
match block {
|
||||||
|
ContentBlock::Text { text } if !text.is_empty() => {
|
||||||
|
send_sse(tx, json!({ "type": "text", "data": { "text": text } }));
|
||||||
|
}
|
||||||
|
ContentBlock::ToolUse { id, name, input } => {
|
||||||
|
send_sse(
|
||||||
|
tx,
|
||||||
|
json!({ "type": "tool_use", "data": { "id": id, "name": name, "input": input } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ContentBlock::Thinking { thinking, .. } if !thinking.is_empty() => {
|
||||||
|
send_sse(tx, json!({ "type": "thinking", "data": { "thinking": thinking } }));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SDKMessage::ToolResult { tool_use_id, tool_name, content, is_error } => {
|
||||||
|
send_sse(
|
||||||
|
tx,
|
||||||
|
json!({
|
||||||
|
"type": "tool_result",
|
||||||
|
"data": { "tool_use_id": tool_use_id, "name": tool_name, "content": content, "is_error": is_error }
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
SDKMessage::Result { text, usage, num_turns, cost_usd, .. } => {
|
||||||
|
// Persist this turn's accounting (user prompt + assistant reply ≈ 2 msgs).
|
||||||
|
let delta = UsageDelta {
|
||||||
|
input_tokens: usage.input_tokens as i64,
|
||||||
|
output_tokens: usage.output_tokens as i64,
|
||||||
|
cost_usd,
|
||||||
|
num_turns: num_turns as i64,
|
||||||
|
};
|
||||||
|
if let Err(e) = session_store::record_turn(db, session_id, delta, 2).await {
|
||||||
|
tracing::warn!(%e, "failed to record agent turn usage");
|
||||||
|
}
|
||||||
|
send_sse(
|
||||||
|
tx,
|
||||||
|
json!({
|
||||||
|
"type": "result",
|
||||||
|
"data": {
|
||||||
|
"text": text,
|
||||||
|
"num_turns": num_turns,
|
||||||
|
"input_tokens": usage.input_tokens,
|
||||||
|
"output_tokens": usage.output_tokens,
|
||||||
|
"total_tokens": usage.input_tokens + usage.output_tokens,
|
||||||
|
"cost_usd": cost_usd,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
SDKMessage::Error { message } => {
|
||||||
|
send_sse(tx, json!({ "type": "error", "data": { "message": message } }));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn approve_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<ApproveRequest>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
// Auth required, but any authenticated caller may answer the prompt for
|
||||||
|
// their own running session (approvals are keyed by an unguessable uuid).
|
||||||
|
resolve_auth(&headers, &state).await?;
|
||||||
|
let outcome = if body.approved { ApprovalOutcome::Approve } else { ApprovalOutcome::Deny };
|
||||||
|
let delivered = approval::resolve(&body.request_id, outcome);
|
||||||
|
Ok(Json(json!({ "ok": delivered })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn status_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
axum::extract::Query(q): axum::extract::Query<StatusQuery>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
let auth = resolve_auth(&headers, &state).await?;
|
||||||
|
let session = match q.session_id.as_deref().filter(|s| !s.is_empty()) {
|
||||||
|
Some(sid) => session_store::get_session(&state.db, sid, auth.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": e.to_string() })),
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"engine": "open-agent-sdk",
|
||||||
|
"default_model": state.config.comfyui.agent_model,
|
||||||
|
"session": session,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct StatusQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ const _: &[u8] = include_bytes!(concat!(
|
|||||||
));
|
));
|
||||||
|
|
||||||
mod admin;
|
mod admin;
|
||||||
|
mod agent;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod checkin;
|
mod checkin;
|
||||||
mod comfy_cloud_adapter;
|
mod comfy_cloud_adapter;
|
||||||
@@ -57,6 +58,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(comfy_cloud_adapter::routes())
|
.merge(comfy_cloud_adapter::routes())
|
||||||
.merge(money_printer::routes())
|
.merge(money_printer::routes())
|
||||||
.merge(comfyui_agent::routes())
|
.merge(comfyui_agent::routes())
|
||||||
|
.merge(agent::routes())
|
||||||
.merge(digital_human::routes())
|
.merge(digital_human::routes())
|
||||||
.merge(comfyui::routes())
|
.merge(comfyui::routes())
|
||||||
.merge(crate::workflow::routes::routes())
|
.merge(crate::workflow::routes::routes())
|
||||||
|
|||||||
Reference in New Issue
Block a user