2026-03-18 23:05:14 -04:00
|
|
|
// agents/api.rs — Direct API backend for agent execution
|
|
|
|
|
//
|
|
|
|
|
// Uses poc-agent's OpenAI-compatible API client to call models directly
|
|
|
|
|
// (vllm, llama.cpp, OpenRouter, etc.) instead of shelling out to claude CLI.
|
|
|
|
|
// Implements the tool loop: send prompt → if tool_calls, execute them →
|
|
|
|
|
// send results back → repeat until text response.
|
|
|
|
|
//
|
|
|
|
|
// Activated when config has api_base_url set.
|
|
|
|
|
|
|
|
|
|
use poc_agent::api::ApiClient;
|
|
|
|
|
use poc_agent::types::*;
|
|
|
|
|
use poc_agent::tools::{self, ProcessTracker};
|
|
|
|
|
use poc_agent::ui_channel::StreamTarget;
|
|
|
|
|
|
2026-03-18 23:09:11 -04:00
|
|
|
use std::sync::OnceLock;
|
|
|
|
|
|
|
|
|
|
static API_CLIENT: OnceLock<ApiClient> = OnceLock::new();
|
|
|
|
|
|
|
|
|
|
fn get_client() -> Result<&'static ApiClient, String> {
|
|
|
|
|
Ok(API_CLIENT.get_or_init(|| {
|
|
|
|
|
let config = crate::config::get();
|
|
|
|
|
let base_url = config.api_base_url.as_deref().unwrap_or("");
|
|
|
|
|
let api_key = config.api_key.as_deref().unwrap_or("");
|
|
|
|
|
let model = config.api_model.as_deref().unwrap_or("qwen-2.5-27b");
|
|
|
|
|
ApiClient::new(base_url, api_key, model)
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 23:05:14 -04:00
|
|
|
/// Run an agent prompt through the direct API with tool support.
|
|
|
|
|
/// Returns the final text response after all tool calls are resolved.
|
|
|
|
|
pub async fn call_api_with_tools(
|
|
|
|
|
agent: &str,
|
|
|
|
|
prompt: &str,
|
|
|
|
|
log: &dyn Fn(&str),
|
|
|
|
|
) -> Result<String, String> {
|
2026-03-18 23:09:11 -04:00
|
|
|
let client = get_client()?;
|
2026-03-18 23:05:14 -04:00
|
|
|
|
2026-03-21 11:33:36 -04:00
|
|
|
// Set up a UI channel — we drain reasoning tokens into the log
|
|
|
|
|
let (ui_tx, mut ui_rx) = poc_agent::ui_channel::channel();
|
2026-03-18 23:05:14 -04:00
|
|
|
|
2026-03-19 22:58:54 -04:00
|
|
|
// Build tool definitions — memory tools for graph operations
|
2026-03-18 23:05:14 -04:00
|
|
|
let all_defs = tools::definitions();
|
|
|
|
|
let tool_defs: Vec<ToolDef> = all_defs.into_iter()
|
2026-03-19 22:58:54 -04:00
|
|
|
.filter(|d| d.function.name.starts_with("memory_"))
|
2026-03-18 23:05:14 -04:00
|
|
|
.collect();
|
|
|
|
|
let tracker = ProcessTracker::new();
|
|
|
|
|
|
|
|
|
|
// Start with the prompt as a user message
|
|
|
|
|
let mut messages = vec![Message::user(prompt)];
|
2026-03-19 22:58:54 -04:00
|
|
|
let reasoning = crate::config::get().api_reasoning.clone();
|
2026-03-18 23:05:14 -04:00
|
|
|
|
|
|
|
|
let max_turns = 50;
|
|
|
|
|
for turn in 0..max_turns {
|
2026-03-22 01:57:47 -04:00
|
|
|
log(&format!("\n=== TURN {} ({} messages) ===\n", turn, messages.len()));
|
2026-03-18 23:05:14 -04:00
|
|
|
|
|
|
|
|
let (msg, usage) = client.chat_completion_stream(
|
|
|
|
|
&messages,
|
|
|
|
|
Some(&tool_defs),
|
|
|
|
|
&ui_tx,
|
|
|
|
|
StreamTarget::Autonomous,
|
2026-03-19 22:58:54 -04:00
|
|
|
&reasoning,
|
2026-03-22 02:16:51 -04:00
|
|
|
).await.map_err(|e| {
|
|
|
|
|
let msg_bytes: usize = messages.iter()
|
|
|
|
|
.map(|m| m.content_text().len())
|
|
|
|
|
.sum();
|
|
|
|
|
format!("API error on turn {} (~{}KB payload, {} messages): {}",
|
|
|
|
|
turn, msg_bytes / 1024, messages.len(), e)
|
|
|
|
|
})?;
|
2026-03-18 23:05:14 -04:00
|
|
|
|
|
|
|
|
if let Some(u) = &usage {
|
|
|
|
|
log(&format!("tokens: {} prompt + {} completion",
|
|
|
|
|
u.prompt_tokens, u.completion_tokens));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 11:33:36 -04:00
|
|
|
// Drain reasoning tokens from the UI channel into the log
|
|
|
|
|
{
|
|
|
|
|
let mut reasoning_buf = String::new();
|
|
|
|
|
while let Ok(ui_msg) = ui_rx.try_recv() {
|
|
|
|
|
if let poc_agent::ui_channel::UiMessage::Reasoning(r) = ui_msg {
|
|
|
|
|
reasoning_buf.push_str(&r);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !reasoning_buf.is_empty() {
|
|
|
|
|
log(&format!("<think>\n{}\n</think>", reasoning_buf.trim()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 23:05:14 -04:00
|
|
|
let has_content = msg.content.is_some();
|
|
|
|
|
let has_tools = msg.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
|
|
|
|
|
|
|
|
|
|
if has_tools {
|
2026-03-20 14:33:36 -04:00
|
|
|
// Push the assistant message with tool calls.
|
|
|
|
|
// Sanitize arguments: vllm re-parses them as JSON when
|
|
|
|
|
// preprocessing the conversation, so invalid JSON from the
|
|
|
|
|
// model crashes the next request.
|
|
|
|
|
let mut sanitized = msg.clone();
|
|
|
|
|
if let Some(ref mut calls) = sanitized.tool_calls {
|
|
|
|
|
for call in calls {
|
|
|
|
|
if serde_json::from_str::<serde_json::Value>(&call.function.arguments).is_err() {
|
|
|
|
|
log(&format!("sanitizing malformed args for {}: {}",
|
|
|
|
|
call.function.name, &call.function.arguments));
|
|
|
|
|
call.function.arguments = "{}".to_string();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
messages.push(sanitized);
|
2026-03-18 23:05:14 -04:00
|
|
|
|
|
|
|
|
// Execute each tool call
|
|
|
|
|
for call in msg.tool_calls.as_ref().unwrap() {
|
2026-03-22 01:57:47 -04:00
|
|
|
log(&format!("\nTOOL CALL: {}({})",
|
2026-03-18 23:05:14 -04:00
|
|
|
call.function.name,
|
2026-03-19 22:58:54 -04:00
|
|
|
&call.function.arguments));
|
2026-03-18 23:05:14 -04:00
|
|
|
|
2026-03-20 14:33:36 -04:00
|
|
|
let args: serde_json::Value = match serde_json::from_str(&call.function.arguments) {
|
|
|
|
|
Ok(v) => v,
|
|
|
|
|
Err(_) => {
|
|
|
|
|
log(&format!("malformed tool call args: {}", &call.function.arguments));
|
|
|
|
|
messages.push(Message::tool_result(
|
|
|
|
|
&call.id,
|
|
|
|
|
"Error: your tool call had malformed JSON arguments. Please retry with valid JSON.",
|
|
|
|
|
));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-03-18 23:05:14 -04:00
|
|
|
|
2026-03-20 12:16:45 -04:00
|
|
|
let output = if call.function.name.starts_with("memory_") {
|
|
|
|
|
let prov = format!("agent:{}", agent);
|
|
|
|
|
match poc_agent::tools::memory::dispatch(
|
|
|
|
|
&call.function.name, &args, Some(&prov),
|
|
|
|
|
) {
|
|
|
|
|
Ok(text) => poc_agent::tools::ToolOutput {
|
|
|
|
|
text, is_yield: false, images: Vec::new(),
|
|
|
|
|
model_switch: None, dmn_pause: false,
|
|
|
|
|
},
|
|
|
|
|
Err(e) => poc_agent::tools::ToolOutput {
|
|
|
|
|
text: format!("Error: {}", e),
|
|
|
|
|
is_yield: false, images: Vec::new(),
|
|
|
|
|
model_switch: None, dmn_pause: false,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
tools::dispatch(&call.function.name, &args, &tracker).await
|
|
|
|
|
};
|
2026-03-18 23:05:14 -04:00
|
|
|
|
2026-03-22 01:57:47 -04:00
|
|
|
log(&format!("TOOL RESULT ({} chars):\n{}", output.text.len(), output.text));
|
2026-03-18 23:05:14 -04:00
|
|
|
|
|
|
|
|
messages.push(Message::tool_result(&call.id, &output.text));
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Text-only response — we're done
|
|
|
|
|
let text = msg.content_text().to_string();
|
|
|
|
|
if text.is_empty() && !has_content {
|
|
|
|
|
log("empty response, retrying");
|
|
|
|
|
messages.push(Message::user(
|
|
|
|
|
"[system] Your previous response was empty. Please respond with text or use a tool."
|
|
|
|
|
));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 01:57:47 -04:00
|
|
|
log(&format!("\n=== RESPONSE ===\n\n{}", text));
|
2026-03-18 23:05:14 -04:00
|
|
|
return Ok(text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Err(format!("agent exceeded {} tool turns", max_turns))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 23:07:49 -04:00
|
|
|
/// Synchronous wrapper — runs the async function on a dedicated thread
|
|
|
|
|
/// with its own tokio runtime. Safe to call from any context.
|
2026-03-18 23:05:14 -04:00
|
|
|
pub fn call_api_with_tools_sync(
|
|
|
|
|
agent: &str,
|
|
|
|
|
prompt: &str,
|
2026-03-18 23:09:11 -04:00
|
|
|
log: &(dyn Fn(&str) + Sync),
|
2026-03-18 23:05:14 -04:00
|
|
|
) -> Result<String, String> {
|
2026-03-18 23:07:49 -04:00
|
|
|
std::thread::scope(|s| {
|
|
|
|
|
s.spawn(|| {
|
|
|
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
|
|
|
.enable_all()
|
|
|
|
|
.build()
|
|
|
|
|
.map_err(|e| format!("tokio runtime: {}", e))?;
|
2026-03-24 01:53:28 -04:00
|
|
|
let prov = format!("agent:{}", agent);
|
|
|
|
|
rt.block_on(
|
|
|
|
|
crate::store::TASK_PROVENANCE.scope(prov,
|
|
|
|
|
call_api_with_tools(agent, prompt, log))
|
|
|
|
|
)
|
2026-03-18 23:07:49 -04:00
|
|
|
}).join().unwrap()
|
|
|
|
|
})
|
2026-03-18 23:05:14 -04:00
|
|
|
}
|