huskies: merge 855

This commit is contained in:
dave
2026-04-29 21:35:55 +00:00
parent a7b1572693
commit 4d24b5b661
17 changed files with 204 additions and 973 deletions
+9 -2
View File
@@ -93,6 +93,13 @@ pub(super) fn parse_usage_metadata(response: &Value) -> Option<TokenUsage> {
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
use std::sync::Arc;
fn test_app_ctx() -> Arc<AppContext> {
let tmp = tempfile::tempdir().unwrap();
Arc::new(AppContext::new_test(tmp.path().to_path_buf()))
}
#[test]
fn build_system_instruction_uses_args() {
@@ -107,7 +114,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,
};
@@ -126,7 +133,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,
};
+13 -83
View File
@@ -1,45 +1,25 @@
//! MCP tool fetching, schema conversion, and tool invocation for the Gemini runtime.
use reqwest::Client;
//! MCP tool schema conversion for the Gemini runtime.
//!
//! Tool definitions are loaded directly from `list_tools()` and tool
//! invocations go through `dispatch_tool_call()` — no HTTP round-trip.
use serde_json::{Value, json};
use crate::slog;
use crate::http::mcp::tools_list::list_tools;
use super::api::GeminiFunctionDeclaration;
// ── MCP tool fetching ────────────────────────────────────────────────
// ── MCP tool loading ────────────────────────────────────────────────
/// Fetch MCP tool definitions from huskies' MCP server and convert
/// them to Gemini function declaration format.
pub(super) async fn fetch_and_convert_mcp_tools(
client: &Client,
mcp_base: &str,
) -> Result<Vec<GeminiFunctionDeclaration>, 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 Gemini function
/// declaration format.
pub(super) fn convert_mcp_tools_to_gemini() -> Vec<GeminiFunctionDeclaration> {
let tools = list_tools();
let mut declarations = 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();
@@ -47,9 +27,6 @@ pub(super) async fn fetch_and_convert_mcp_tools(
continue;
}
// Convert MCP inputSchema (JSON Schema) to Gemini parameters
// (OpenAPI-subset schema). They are structurally compatible for
// simple object schemas.
let parameters = convert_mcp_schema_to_gemini(tool.get("inputSchema"));
declarations.push(GeminiFunctionDeclaration {
@@ -63,54 +40,7 @@ pub(super) async fn fetch_and_convert_mcp_tools(
"[gemini] Loaded {} MCP tools as function declarations",
declarations.len()
);
Ok(declarations)
}
/// Call an MCP tool via huskies' MCP server.
pub(super) 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())
declarations
}
// ── Schema conversion ────────────────────────────────────────────────
+16 -7
View File
@@ -7,6 +7,7 @@ use serde_json::json;
use tokio::sync::broadcast;
use crate::agent_log::AgentLogWriter;
use crate::http::mcp::dispatch::dispatch_tool_call;
use crate::slog;
use super::super::{AgentEvent, TokenUsage};
@@ -16,7 +17,7 @@ mod api;
mod mcp;
use api::{build_generate_content_request, build_system_instruction, parse_usage_metadata};
use mcp::{call_mcp_tool, fetch_and_convert_mcp_tools};
use mcp::convert_mcp_tools_to_gemini;
// ── Internal types ───────────────────────────────────────────────────
@@ -79,14 +80,16 @@ impl AgentRuntime for GeminiRuntime {
.unwrap_or_else(|| "gemini-2.5-pro".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(|| "Gemini 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 Gemini format.
let gemini_tools = fetch_and_convert_mcp_tools(&client, &mcp_base).await?;
// Step 1: Load MCP tool definitions and convert to Gemini format.
let gemini_tools = convert_mcp_tools_to_gemini();
// Step 2: Build the initial conversation contents.
let system_instruction = build_system_instruction(&ctx);
@@ -276,7 +279,7 @@ impl AgentRuntime for GeminiRuntime {
text: format!("\n[Tool call: {}]\n", fc.name),
});
let tool_result = call_mcp_tool(&client, &mcp_base, &fc.name, &fc.args).await;
let tool_result = dispatch_tool_call(&fc.name, fc.args.clone(), &app_ctx).await;
let response_value = match &tool_result {
Ok(result) => {
@@ -348,6 +351,12 @@ impl AgentRuntime for GeminiRuntime {
#[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 gemini_runtime_stop_sets_cancelled() {
@@ -368,7 +377,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,
};