huskies: merge 855

This commit is contained in:
dave
2026-04-29 21:41:03 +00:00
parent a7b1572693
commit 4d24b5b661
17 changed files with 204 additions and 973 deletions
+23 -85
View File
@@ -7,6 +7,8 @@ use serde_json::{Value, json};
use tokio::sync::broadcast;
use crate::agent_log::AgentLogWriter;
use crate::http::mcp::dispatch::dispatch_tool_call;
use crate::http::mcp::tools_list::list_tools;
use crate::slog;
use super::super::{AgentEvent, TokenUsage};
@@ -65,14 +67,16 @@ impl AgentRuntime for OpenAiRuntime {
.unwrap_or_else(|| "gpt-4o".to_string())
};
let mcp_port = ctx.mcp_port;
let mcp_base = format!("http://localhost:{mcp_port}/mcp");
let app_ctx = ctx
.app_ctx
.clone()
.ok_or_else(|| "OpenAI runtime requires app_ctx to be set".to_string())?;
let client = Client::new();
let cancelled = Arc::clone(&self.cancelled);
// Step 1: Fetch MCP tool definitions and convert to OpenAI format.
let openai_tools = fetch_and_convert_mcp_tools(&client, &mcp_base).await?;
let openai_tools = convert_mcp_tools_to_openai();
// Step 2: Build the initial conversation messages.
let system_text = build_system_text(&ctx);
@@ -248,7 +252,7 @@ impl AgentRuntime for OpenAiRuntime {
text: format!("\n[Tool call: {tool_name}]\n"),
});
let tool_result = call_mcp_tool(&client, &mcp_base, tool_name, &args).await;
let tool_result = dispatch_tool_call(tool_name, args.clone(), &app_ctx).await;
let result_content = match &tool_result {
Ok(result) => {
@@ -313,38 +317,13 @@ fn build_system_text(ctx: &RuntimeContext) -> String {
})
}
/// Fetch MCP tool definitions from huskies' MCP server and convert
/// them to OpenAI function-calling format.
async fn fetch_and_convert_mcp_tools(
client: &Client,
mcp_base: &str,
) -> Result<Vec<Value>, String> {
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
});
let response = client
.post(mcp_base)
.json(&request)
.send()
.await
.map_err(|e| format!("Failed to fetch MCP tools: {e}"))?;
let body: Value = response
.json()
.await
.map_err(|e| format!("Failed to parse MCP tools response: {e}"))?;
let tools = body["result"]["tools"]
.as_array()
.ok_or_else(|| "No tools array in MCP response".to_string())?;
/// Load MCP tool definitions directly and convert to OpenAI function-calling format.
fn convert_mcp_tools_to_openai() -> Vec<Value> {
let tools = list_tools();
let mut openai_tools = Vec::new();
for tool in tools {
for tool in &tools {
let name = tool["name"].as_str().unwrap_or("").to_string();
let description = tool["description"].as_str().unwrap_or("").to_string();
@@ -370,7 +349,7 @@ async fn fetch_and_convert_mcp_tools(
"[openai] Loaded {} MCP tools as function definitions",
openai_tools.len()
);
Ok(openai_tools)
openai_tools
}
/// Convert an MCP inputSchema (JSON Schema) to OpenAI-compatible
@@ -435,53 +414,6 @@ fn clean_schema_properties(properties: &Value) -> Value {
Value::Object(cleaned)
}
/// Call an MCP tool via huskies' MCP server.
async fn call_mcp_tool(
client: &Client,
mcp_base: &str,
tool_name: &str,
args: &Value,
) -> Result<String, String> {
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": args
}
});
let response = client
.post(mcp_base)
.json(&request)
.send()
.await
.map_err(|e| format!("MCP tool call failed: {e}"))?;
let body: Value = response
.json()
.await
.map_err(|e| format!("Failed to parse MCP tool response: {e}"))?;
if let Some(error) = body.get("error") {
let msg = error["message"].as_str().unwrap_or("Unknown MCP error");
return Err(format!("MCP tool '{tool_name}' error: {msg}"));
}
// MCP tools/call returns { result: { content: [{ type: "text", text: "..." }] } }
let content = &body["result"]["content"];
if let Some(arr) = content.as_array() {
let texts: Vec<&str> = arr.iter().filter_map(|c| c["text"].as_str()).collect();
if !texts.is_empty() {
return Ok(texts.join("\n"));
}
}
// Fall back to serializing the entire result.
Ok(body["result"].to_string())
}
/// Parse token usage from an OpenAI API response.
fn parse_usage(response: &Value) -> Option<TokenUsage> {
let usage = response.get("usage")?;
@@ -506,6 +438,12 @@ fn parse_usage(response: &Value) -> Option<TokenUsage> {
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
fn test_app_ctx() -> Arc<AppContext> {
let tmp = tempfile::tempdir().unwrap();
Arc::new(AppContext::new_test(tmp.path().to_path_buf()))
}
#[test]
fn convert_mcp_schema_simple_object() {
@@ -616,7 +554,7 @@ mod tests {
prompt: "Do the thing".to_string(),
cwd: "/tmp/wt".to_string(),
inactivity_timeout_secs: 300,
mcp_port: 3001,
app_ctx: Some(test_app_ctx()),
session_id_to_resume: None,
fresh_prompt: None,
};
@@ -634,7 +572,7 @@ mod tests {
prompt: "Do the thing".to_string(),
cwd: "/tmp/wt".to_string(),
inactivity_timeout_secs: 300,
mcp_port: 3001,
app_ctx: Some(test_app_ctx()),
session_id_to_resume: None,
fresh_prompt: None,
};
@@ -685,7 +623,7 @@ mod tests {
prompt: "test".to_string(),
cwd: "/tmp".to_string(),
inactivity_timeout_secs: 300,
mcp_port: 3001,
app_ctx: Some(test_app_ctx()),
session_id_to_resume: None,
fresh_prompt: None,
};
@@ -702,7 +640,7 @@ mod tests {
prompt: "test".to_string(),
cwd: "/tmp".to_string(),
inactivity_timeout_secs: 300,
mcp_port: 3001,
app_ctx: Some(test_app_ctx()),
session_id_to_resume: None,
fresh_prompt: None,
};