Files
owlen/crates/integration/jsonrpc/src/lib.rs
vikingowl 84fa08ab45 feat(plan): Add plan execution system with external tool support
Plan Execution System:
- Add PlanStep, AccumulatedPlan types for multi-turn tool call accumulation
- Implement AccumulatedPlanStatus for tracking plan lifecycle
- Support selective approval of proposed tool calls before execution

External Tools Integration:
- Add ExternalToolDefinition and ExternalToolTransport to plugins crate
- Extend ToolContext with external_tools registry
- Add external_tool_to_llm_tool conversion for LLM compatibility

JSON-RPC Communication:
- Add jsonrpc crate for JSON-RPC 2.0 protocol support
- Enable stdio-based communication with external tool servers

UI & Engine Updates:
- Add plan_panel.rs component for displaying accumulated plans
- Wire plan mode into engine loop
- Add plan mode integration tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-26 22:47:54 +01:00

392 lines
12 KiB
Rust

//! JSON-RPC 2.0 client for external tool communication
//!
//! This crate provides a JSON-RPC 2.0 client for invoking external tools
//! via stdio (spawning a process) or HTTP endpoints.
use color_eyre::eyre::{eyre, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::time::timeout;
use uuid::Uuid;
// ============================================================================
// JSON-RPC 2.0 Protocol Types
// ============================================================================
/// JSON-RPC 2.0 request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcRequest {
/// JSON-RPC version (must be "2.0")
pub jsonrpc: String,
/// Request ID for matching responses
pub id: serde_json::Value,
/// Method name to invoke
pub method: String,
/// Method parameters
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<serde_json::Value>,
}
impl JsonRpcRequest {
/// Create a new JSON-RPC request
pub fn new(method: impl Into<String>, params: Option<serde_json::Value>) -> Self {
Self {
jsonrpc: "2.0".to_string(),
id: serde_json::Value::String(Uuid::new_v4().to_string()),
method: method.into(),
params,
}
}
/// Create a request with a specific ID
pub fn with_id(id: impl Into<serde_json::Value>, method: impl Into<String>, params: Option<serde_json::Value>) -> Self {
Self {
jsonrpc: "2.0".to_string(),
id: id.into(),
method: method.into(),
params,
}
}
/// Serialize to JSON string with newline
pub fn to_json_line(&self) -> Result<String> {
let mut json = serde_json::to_string(self)?;
json.push('\n');
Ok(json)
}
}
/// JSON-RPC 2.0 response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcResponse {
/// JSON-RPC version (must be "2.0")
pub jsonrpc: String,
/// Request ID this is responding to
pub id: serde_json::Value,
/// Result (on success)
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
/// Error (on failure)
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcError>,
}
impl JsonRpcResponse {
/// Check if the response is an error
pub fn is_error(&self) -> bool {
self.error.is_some()
}
/// Get the result or error as a Result type
pub fn into_result(self) -> Result<serde_json::Value> {
if let Some(err) = self.error {
Err(eyre!("JSON-RPC error {}: {}", err.code, err.message))
} else {
self.result.ok_or_else(|| eyre!("No result in response"))
}
}
}
/// JSON-RPC 2.0 error
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcError {
/// Error code (standard or application-specific)
pub code: i64,
/// Short error message
pub message: String,
/// Additional error data
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
/// Standard JSON-RPC 2.0 error codes
pub mod error_codes {
/// Invalid JSON was received
pub const PARSE_ERROR: i64 = -32700;
/// The JSON sent is not a valid Request object
pub const INVALID_REQUEST: i64 = -32600;
/// The method does not exist or is not available
pub const METHOD_NOT_FOUND: i64 = -32601;
/// Invalid method parameter(s)
pub const INVALID_PARAMS: i64 = -32602;
/// Internal JSON-RPC error
pub const INTERNAL_ERROR: i64 = -32603;
}
// ============================================================================
// Stdio Client
// ============================================================================
/// JSON-RPC client over stdio (spawned process)
pub struct StdioClient {
/// Spawned child process
child: Child,
/// Request timeout
timeout: Duration,
}
impl StdioClient {
/// Spawn a new stdio client
pub async fn spawn(
command: impl AsRef<str>,
args: &[String],
env: &HashMap<String, String>,
timeout_ms: u64,
) -> Result<Self> {
let mut cmd = Command::new(command.as_ref());
cmd.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
// Add environment variables
for (key, value) in env {
cmd.env(key, value);
}
let child = cmd.spawn()?;
Ok(Self {
child,
timeout: Duration::from_millis(timeout_ms),
})
}
/// Spawn from a path (resolving relative paths)
pub async fn spawn_from_path(
command: &PathBuf,
args: &[String],
env: &HashMap<String, String>,
working_dir: Option<&PathBuf>,
timeout_ms: u64,
) -> Result<Self> {
let mut cmd = Command::new(command);
cmd.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
for (key, value) in env {
cmd.env(key, value);
}
let child = cmd.spawn()?;
Ok(Self {
child,
timeout: Duration::from_millis(timeout_ms),
})
}
/// Send a request and wait for response
pub async fn call(&mut self, method: &str, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
let request = JsonRpcRequest::new(method, params);
let request_id = request.id.clone();
// Get handles to stdin/stdout
let stdin = self.child.stdin.as_mut()
.ok_or_else(|| eyre!("Failed to get stdin handle"))?;
let stdout = self.child.stdout.take()
.ok_or_else(|| eyre!("Failed to get stdout handle"))?;
// Write request
let request_json = request.to_json_line()?;
stdin.write_all(request_json.as_bytes()).await?;
stdin.flush().await?;
// Read response with timeout
let mut reader = BufReader::new(stdout);
let mut line = String::new();
let result = timeout(self.timeout, reader.read_line(&mut line)).await;
// Restore stdout for future calls
self.child.stdout = Some(reader.into_inner());
match result {
Ok(Ok(0)) => Err(eyre!("Process closed stdout")),
Ok(Ok(_)) => {
let response: JsonRpcResponse = serde_json::from_str(&line)?;
// Verify ID matches
if response.id != request_id {
return Err(eyre!(
"Response ID mismatch: expected {:?}, got {:?}",
request_id,
response.id
));
}
response.into_result()
}
Ok(Err(e)) => Err(e.into()),
Err(_) => Err(eyre!("Request timed out after {:?}", self.timeout)),
}
}
/// Check if the process is still running
pub fn is_alive(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(None))
}
/// Kill the process
pub async fn kill(&mut self) -> Result<()> {
self.child.kill().await?;
Ok(())
}
}
impl Drop for StdioClient {
fn drop(&mut self) {
// Try to kill the process on drop
let _ = self.child.start_kill();
}
}
// ============================================================================
// Tool Executor
// ============================================================================
/// Executor for external tools via JSON-RPC
pub struct ToolExecutor {
/// Default timeout for tool calls
default_timeout: Duration,
}
impl Default for ToolExecutor {
fn default() -> Self {
Self {
default_timeout: Duration::from_secs(30),
}
}
}
impl ToolExecutor {
/// Create a new tool executor with custom timeout
pub fn with_timeout(timeout_ms: u64) -> Self {
Self {
default_timeout: Duration::from_millis(timeout_ms),
}
}
/// Execute a tool via stdio
pub async fn execute_stdio(
&self,
command: &str,
args: &[String],
env: &HashMap<String, String>,
tool_params: serde_json::Value,
timeout_ms: Option<u64>,
) -> Result<serde_json::Value> {
let timeout = timeout_ms.unwrap_or(self.default_timeout.as_millis() as u64);
let mut client = StdioClient::spawn(command, args, env, timeout).await?;
// The standard method name for tool execution
let result = client.call("execute", Some(tool_params)).await?;
// Kill the process after we're done
let _ = client.kill().await;
Ok(result)
}
/// Execute a tool via HTTP (not implemented yet)
pub async fn execute_http(
&self,
_url: &str,
_tool_params: serde_json::Value,
_timeout_ms: Option<u64>,
) -> Result<serde_json::Value> {
Err(eyre!("HTTP transport not yet implemented"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_jsonrpc_request_serialization() {
let request = JsonRpcRequest::new("test_method", Some(serde_json::json!({"arg": "value"})));
assert_eq!(request.jsonrpc, "2.0");
assert_eq!(request.method, "test_method");
assert!(request.params.is_some());
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("\"jsonrpc\":\"2.0\""));
assert!(json.contains("\"method\":\"test_method\""));
}
#[test]
fn test_jsonrpc_response_success() {
let json = r#"{"jsonrpc":"2.0","id":"123","result":{"data":"hello"}}"#;
let response: JsonRpcResponse = serde_json::from_str(json).unwrap();
assert!(!response.is_error());
assert_eq!(response.id, serde_json::json!("123"));
assert_eq!(response.result.unwrap()["data"], "hello");
}
#[test]
fn test_jsonrpc_response_error() {
let json = r#"{"jsonrpc":"2.0","id":"123","error":{"code":-32600,"message":"Invalid Request"}}"#;
let response: JsonRpcResponse = serde_json::from_str(json).unwrap();
assert!(response.is_error());
let err = response.error.unwrap();
assert_eq!(err.code, error_codes::INVALID_REQUEST);
assert_eq!(err.message, "Invalid Request");
}
#[test]
fn test_jsonrpc_request_line_format() {
let request = JsonRpcRequest::with_id("test-id", "method", None);
let line = request.to_json_line().unwrap();
assert!(line.ends_with('\n'));
assert!(!line.ends_with("\n\n"));
}
#[test]
fn test_response_into_result_success() {
let response = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::json!("1"),
result: Some(serde_json::json!({"success": true})),
error: None,
};
let result = response.into_result().unwrap();
assert_eq!(result["success"], true);
}
#[test]
fn test_response_into_result_error() {
let response = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::json!("1"),
result: None,
error: Some(JsonRpcError {
code: -32600,
message: "Invalid".to_string(),
data: None,
}),
};
let result = response.into_result();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("-32600"));
}
}