diff --git a/server-rs/crates/module-editor-agent/Cargo.toml b/server-rs/crates/module-editor-agent/Cargo.toml index 380ebb73f..7a0593a2e 100644 --- a/server-rs/crates/module-editor-agent/Cargo.toml +++ b/server-rs/crates/module-editor-agent/Cargo.toml @@ -14,6 +14,7 @@ serde = { workspace = true } serde_json = { workspace = true } shared-kernel = { workspace = true } spacetimedb = { workspace = true, optional = true } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } [dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } \ No newline at end of file +dotenvy = { workspace = true } diff --git a/server-rs/crates/module-editor-agent/examples/llm_chat_agent.rs b/server-rs/crates/module-editor-agent/examples/llm_chat_agent.rs index 8e385c949..bc2b79245 100644 --- a/server-rs/crates/module-editor-agent/examples/llm_chat_agent.rs +++ b/server-rs/crates/module-editor-agent/examples/llm_chat_agent.rs @@ -1,316 +1,177 @@ -//! Demo: integrating the rig-like agent traits with platform-llm. -//! -//! This example shows how to implement [`Agent`], [`AgentMemory`], and [`Tool`] -//! from rig-like traits (`module-editor-agent::agent`) using -//! `platform_llm::LlmClient` as the LLM backend. -//! -//! It demonstrates the **JSON harness** approach to tool calling: the system -//! prompt instructs the LLM to respond with structured JSON like -//! ```json -//! { "reply_text": "...", "tool_calls": [{"tool_name": "...", "args": {...}}] } -//! ``` -//! The agent parses the JSON, executes any requested tools, feeds results back -//! into the conversation, and loops until the LLM produces a final reply. -//! -//! ## Build & Run -//! -//! ```bash -//! # Compile only (default) -//! cargo check -p module-editor-agent --example llm_chat_agent -//! -//! # Run with JSON config -//! cargo run -p module-editor-agent --example llm_chat_agent -- ./llm_config.json -//! -//! # Run with local dotenv-style config -//! cargo run -p module-editor-agent --example llm_chat_agent -- ./.env.local -//! ``` +use std::env; +use std::path::PathBuf; -use std::collections::HashMap; -use std::sync::Mutex; - -use module_editor_agent::agent::PromptError::ToolError; -use module_editor_agent::agent::{ - Agent, AgentBuilder, AgentMemory, Flow, PromptError, PromptOutput, Tool, ToolCall, -}; -use platform_llm::{LlmClient, LlmConfig, LlmError, LlmMessage, LlmTextRequest}; +use module_editor_agent::agent::agent::{Agent, LlmApiAdaptor}; +use module_editor_agent::agent::agent_builder::AgentBuilder; +use module_editor_agent::agent::error::PromptError; +use module_editor_agent::agent::hook::Hook; +use module_editor_agent::agent::memory::AgentMemory; +use module_editor_agent::agent::run::Flow; +use module_editor_agent::agent::tool::{Tool, ToolCall, ToolDyn}; +use platform_llm::{LlmClient, LlmConfig, LlmError, LlmMessage, LlmProvider}; use serde::{Deserialize, Serialize}; +use serde_json::Value; // --------------------------------------------------------------------------- -// JSON harness types — the LLM responds in this shape +// 1. LLM completion model adapter // --------------------------------------------------------------------------- -/// The JSON format we expect the LLM to respond in. -#[derive(Deserialize)] -struct LlmJsonResponse { - reply_text: String, - #[serde(default)] - tool_calls: Vec, +struct LlmCompletionModel { + client: LlmClient, } -/// A single tool call request inside the JSON response. -#[derive(Deserialize)] -struct LlmToolCallRequest { - tool_name: String, - #[serde(default)] - args: serde_json::Value, +impl LlmApiAdaptor for LlmCompletionModel { + async fn complete(&self, messages: &[LlmMessage]) -> Result { + use platform_llm::LlmTextRequest; + let request = LlmTextRequest::new(messages.to_vec()).with_request_timeout_ms(30_000); + let response = self + .client + .request_text(request) + .await + .map_err(|e| PromptError::CompletionError(e.to_string()))?; + Ok(response.content) + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> LlmMessage { + LlmMessage::system(format!("Tool '{tool_name}' returned: {output}")) + } + + fn build_assistant_message(&self, text: &str) -> LlmMessage { + LlmMessage::assistant(text) + } } // --------------------------------------------------------------------------- -// 1. Concrete Agent — wraps platform_llm::LlmClient with JSON tool harness +// 2. Tool validation hook — only allows known tools // --------------------------------------------------------------------------- -struct ToolDescriptor { - name: String, - description: String, - parameters: serde_json::Value, +struct ToolValidationHook { + valid_names: Vec, } -/// A chat agent backed by an [`LlmClient`] that supports tool calling via the -/// JSON harness approach. -/// -/// The agent holds a list of available tools. Before each LLM call it builds a -/// system prompt describing the tools and the expected JSON response format. -/// When the LLM responds with `tool_calls`, the agent executes them, stores -/// the results as new conversation entries, and loops. -struct LlmChatAgent { - client: LlmClient, - system_prompt: String, - tool_descriptors: Vec, - memory: Mutex>, +impl ToolValidationHook { + fn new(names: Vec) -> Self { + Self { valid_names: names } + } } -impl Agent for LlmChatAgent { - /// Processes a user message with a full tool-calling loop. - /// - /// 1. Appends the user message to conversation memory. - /// 2. Sends the full conversation (system prompt + memory) to the LLM. - /// 3. Tries to parse the LLM reply as JSON. - /// - If it contains `tool_calls`, executes each tool, stores results, - /// and loops back to step 2. - /// - If there are no `tool_calls`, stores the assistant reply and - /// returns it as the final output. - /// - If the reply is not valid JSON, treats it as a plain-text final - /// response. - async fn prompt( - &self, - prompt: impl Into + Send, - ) -> Result { - let new_msg: LlmMessage = prompt.into(); - - // Append user message to memory - { - let mut memory = self.memory.lock().unwrap(); - memory.push(new_msg); - } - - let max_turns = 3; - - for turn in 0..max_turns { - // Build the full message list: system prompt + conversation memory - let messages = { - let memory = self.memory.lock().unwrap(); - let mut msgs = vec![LlmMessage::system(&self.system_prompt)]; - msgs.extend_from_slice(&memory); - msgs - }; - - let request = LlmTextRequest::new(messages).with_request_timeout_ms(30_000); - let response = self - .client - .request_text(request) - .await - .map_err(|e| PromptError::CompletionError(e.to_string()))?; - - println!("raw resp ,{}", response.content); - // Try to parse the LLM reply as JSON (handle markdown fences) - let cleaned = clean_json_response(&response.content); - - match serde_json::from_str::(&cleaned) { - Ok(json_resp) => { - // Check for marker — if present, stop the turn - // immediately after storing the cleaned reply. - let reply_has_end = json_resp.reply_text.contains(""); - let clean_text = json_resp - .reply_text - .replace("", "") - .trim() - .to_string(); - - { - let mut memory = self.memory.lock().unwrap(); - memory.push(LlmMessage::assistant(&clean_text)); - } - - if reply_has_end { - return Ok(PromptOutput { - text: clean_text, - tool_calls: vec![], - }); - } - - let tool_calls: Vec = json_resp - .tool_calls - .into_iter() - .map(|tc| ToolCall { - id: format!("call_{turn}"), - name: tc.tool_name, - args: tc.args, - }) - .collect(); - - for tc in &tool_calls { - match self.before_tool_call(tc) { - Flow::Stop => return Err(ToolError("tool err".to_string())), - Flow::Skip => continue, - Flow::Continue => {} - } - - let result = execute_tool(&tc.name, tc.args.clone()).await; - - let mut memory = self.memory.lock().unwrap(); - match result { - Ok(output) => { - memory.push(LlmMessage::system(format!( - "Tool '{}' returned: {}", - tc.name, output - ))); - } - Err(e) => { - memory.push(LlmMessage::system(format!( - "Tool '{}' failed: {}", - tc.name, e - ))); - } - } - } - - } - Err(_) => { - // Not valid JSON — treat as plain-text final response - let mut memory = self.memory.lock().unwrap(); - memory.push(LlmMessage::assistant(&response.content)); - return Ok(PromptOutput { - text: response.content, - tool_calls: vec![], - }); - } - } - } - - Err(PromptError::MaxTurnsReached { max_turns }) - } - - fn before_tool_call(&self, tool_call: &ToolCall) -> Flow { - // Only allow tools we know about - if self - .tool_descriptors - .iter() - .any(|t| t.name == tool_call.name) - { - Flow::Continue - } else { - Flow::Skip - } +impl Hook for ToolValidationHook { + fn before_tool_call(&self, tool_call: &ToolCall) -> Flow { + if self.valid_names.iter().any(|n| n == &tool_call.name) { + Flow::Continue + } else { + Flow::Skip } + } } +// --------------------------------------------------------------------------- +// 3. Concrete agent builder +// --------------------------------------------------------------------------- + struct LlmChatAgentBuilder { - client: Option, - custom_system_prompt: Option, - tool_descriptors: Vec, + client: Option, + custom_system_prompt: Option, + tools: Vec>, + hooks: Vec>, + max_turns: usize, + memory_data: Option + Sync>>, + context: Option, } -impl LlmChatAgentBuilder { - fn new() -> Self { - Self { - client: None, - custom_system_prompt: None, - tool_descriptors: Vec::new(), - } - } +impl AgentBuilder for LlmChatAgentBuilder { + type Client = LlmClient; - fn with_client(mut self, client: LlmClient) -> Self { - self.client = Some(client); - self + fn new() -> Self { + Self { + client: None, + custom_system_prompt: None, + tools: Vec::new(), + hooks: Vec::new(), + max_turns: 10, + memory_data: None, + context: None, } + } - fn system_prompt(mut self, prompt: impl Into) -> Self { - self.custom_system_prompt = Some(prompt.into()); - self - } + fn with_client(mut self, client: LlmClient) -> Self { + self.client = Some(client); + self + } - fn build(self) -> LlmChatAgent { - let base_prompt = self - .custom_system_prompt - .unwrap_or_else(|| "You are a helpful assistant.".into()); - let system_prompt = build_tools_system_prompt(&base_prompt, &self.tool_descriptors); - LlmChatAgent { - client: self.client.expect("call .with_client() first"), - system_prompt, - tool_descriptors: self.tool_descriptors, - memory: Mutex::new(Vec::new()), - } - } -} + fn system_prompt(mut self, system_prompt: impl Into) -> Self { + self.custom_system_prompt = Some(system_prompt.into()); + self + } -impl AgentBuilder for LlmChatAgentBuilder { - fn new() -> Self { - Self::new() - } + fn tool(mut self, tool: impl Tool + Send + Sync + 'static) -> Self { + self.tools.push(Box::new(tool)); + self + } - fn system_prompt(mut self, system_prompt: impl Into) -> Self { - self.custom_system_prompt = Some(system_prompt.into()); - self - } + fn add_hook(mut self, hook: impl Hook + 'static) -> Self { + self.hooks.push(Box::new(hook)); + self + } - /// Registers a tool by extracting its name, description, and parameters - /// from the concrete [`Tool`] implementation. - fn tool(mut self, tool: impl Tool) -> Self { - self.tool_descriptors.push(ToolDescriptor { - name: tool.tool_name().to_string(), - description: tool.description(), - parameters: tool.parameters(), - }); - self - } + fn max_turns(mut self, n: usize) -> Self { + self.max_turns = n; + self + } - fn memory(self, _memory: impl AgentMemory) -> Self { - self // memory is managed internally via Mutex - } + fn memory(mut self, memory: impl AgentMemory + Sync + 'static) -> Self { + self.memory_data = Some(Box::new(memory)); + self + } - fn build(self) -> LlmChatAgent { - self.build() + fn context(mut self, context: Value) -> Self { + self.context = Some(context); + self + } + + fn build(self) -> Agent { + let model = LlmCompletionModel { + client: self.client.expect("call .with_client() first"), + }; + let mut agent = Agent::new(model); + agent.tools = self.tools; + agent.hooks = self.hooks; + agent.default_max_turns = self.max_turns; + agent.memory = self.memory_data; + if let Some(sp) = self.custom_system_prompt { + agent.system_prompt = Some(LlmMessage::system(&sp)); } + agent + } } // --------------------------------------------------------------------------- -// 3. Concrete Tool — EchoTool +// 4. Echo tool // --------------------------------------------------------------------------- -/// A simple echo tool — mirrors back the input text. struct EchoTool; #[derive(Deserialize)] struct EchoArgs { - input: String, + input: String, } #[derive(Serialize)] struct EchoOutput { - result: String, + result: String, } impl Tool for EchoTool { - const NAME: &'static str = "echo"; - type Error = std::convert::Infallible; - type Args = EchoArgs; - type Output = EchoOutput; + const NAME: &'static str = "echo"; + type Error = std::convert::Infallible; + type Args = EchoArgs; + type Output = EchoOutput; - fn description(&self) -> String { - "Echoes back the input text exactly as received.".into() - } + fn description(&self) -> String { + "Echoes back the input text exactly as received.".into() + } - fn parameters(&self) -> serde_json::Value { - serde_json::json!({ + fn parameters(&self) -> serde_json::Value { + serde_json::json!({ "type": "object", "properties": { "input": { @@ -320,291 +181,186 @@ impl Tool for EchoTool { }, "required": ["input"] }) - } + } - async fn call(&self, args: Self::Args) -> Result { - Ok(EchoOutput { result: args.input }) - } + async fn call(&self, args: Self::Args) -> Result { + Ok(EchoOutput { result: args.input }) + } } // --------------------------------------------------------------------------- -// 4. Concrete Memory — VecMemory +// 6. System prompt builder // --------------------------------------------------------------------------- -/// A simple in-memory conversation history backed by a [`Vec`]. -struct VecMemory { - messages: Vec, +fn tool_names(agent: &Agent) -> Vec { + agent + .tools() + .iter() + .map(|t| t.tool_name().to_string()) + .collect() } -impl VecMemory { - fn new() -> Self { - Self { - messages: Vec::new(), - } - } -} +fn build_tools_system_prompt(base_prompt: &str, tool_names: &[String]) -> String { + let mut prompt = String::new(); + prompt.push_str(base_prompt); + prompt.push_str("\n\nYou have access to the following tools.\n\n"); -impl AgentMemory for VecMemory { - fn get_memory(&self) -> &[M] { - &self.messages + if tool_names.is_empty() { + prompt.push_str("(No tools available.)\n"); + } else { + prompt.push_str("## JSON Response Format\n"); + prompt.push_str( + "When you need to use a tool, respond with valid JSON only (no markdown fences):\n", + ); + prompt.push_str("{\n"); + prompt.push_str(" \"reply_text\": \"your message to the user\",\n"); + prompt.push_str(" \"tool_calls\": [\n {\n"); + prompt.push_str(" \"tool_name\": \"tool_name_here\",\n"); + prompt.push_str(" \"args\": { /* tool-specific arguments */ }\n"); + prompt.push_str(" }\n ]\n"); + prompt.push_str("}\n\n"); + prompt.push_str("If you don't need to use a tool, respond with:\n"); + prompt.push_str("{\n"); + prompt.push_str(" \"reply_text\": \"your message\",\n"); + prompt.push_str(" \"tool_calls\": []\n"); + prompt.push_str("}\n\n"); + prompt.push_str("## Available Tools\n\n"); + + for name in tool_names { + prompt.push_str(&format!("- {name}\n")); } - fn append_message(&mut self, message: M) { - self.messages.push(message); - } + prompt.push_str( + "Use `` in `reply_text` when you want to stop the conversation turn and ", + ); + prompt.push_str( + "return your response immediately without expecting further tool execution. ", + ); + prompt.push_str("For example: `\"reply_text\": \"Task complete. \"`."); + prompt.push_str("\n"); + prompt.push_str("IMPORTANT: Always respond with valid JSON only. "); + prompt.push_str( + "Do not wrap the JSON in markdown code fences or add extra text outside the JSON.", + ); + } + + prompt } // --------------------------------------------------------------------------- -// 5a. Tool execution dispatch +// 7. Config loading via dotenvy // --------------------------------------------------------------------------- -/// Executes a tool by name with the given JSON arguments. -/// -/// Returns the serialized output as a string, or an error message. -async fn execute_tool(tool_name: &str, args: serde_json::Value) -> Result { - match tool_name { - "echo" => { - let echo_args: EchoArgs = - serde_json::from_value(args).map_err(|e| format!("bad args for echo: {e}"))?; - let output = EchoTool.call(echo_args).await.map_err(|e| e.to_string())?; - serde_json::to_string(&output).map_err(|e| e.to_string()) - } - name => Err(format!("unknown tool: {name}")), - } +/// Reads LLM config from environment variables (after dotenvy has loaded the +/// `.env` file into the process environment). +fn load_config_from_env() -> Result> { + let provider_str = env::var("GENARRATIVE_LLM_PROVIDER") + .unwrap_or_else(|_| "ark".to_string()); + let provider = match provider_str.as_str() { + "ark" => Ok(LlmProvider::Ark), + "dashscope" | "dash_scope" => Ok(LlmProvider::DashScope), + "openai_compatible" | "openai-compatible" => Ok(LlmProvider::OpenAiCompatible), + other => Err(format!("unsupported provider: {other}")), + }?; + + let base_url = env::var("GENARRATIVE_LLM_BASE_URL") + .or_else(|_| env::var("VITE_LLM_BASE_URL")) + .map_err(|_| "missing GENARRATIVE_LLM_BASE_URL or VITE_LLM_BASE_URL".to_string())?; + + let api_key = env::var("GENARRATIVE_LLM_API_KEY") + .or_else(|_| env::var("LLM_API_KEY")) + .or_else(|_| env::var("ARK_API_KEY")) + .map_err(|_| { + "missing GENARRATIVE_LLM_API_KEY, LLM_API_KEY, or ARK_API_KEY".to_string() + })?; + + let model = env::var("GENARRATIVE_LLM_MODEL") + .or_else(|_| env::var("VITE_LLM_MODEL")) + .map_err(|_| "missing GENARRATIVE_LLM_MODEL or VITE_LLM_MODEL".to_string())?; + + let request_timeout_ms = env::var("GENARRATIVE_LLM_REQUEST_TIMEOUT_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(30_000); + + let max_retries = env::var("GENARRATIVE_LLM_MAX_RETRIES") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(2); + + let retry_backoff_ms = env::var("GENARRATIVE_LLM_RETRY_BACKOFF_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(1_000); + + Ok(LlmConfig::new( + provider, + base_url, + api_key, + model, + request_timeout_ms, + max_retries, + retry_backoff_ms, + )?) } // --------------------------------------------------------------------------- -// 5b. System prompt builder +// 8. Agent runner // --------------------------------------------------------------------------- -/// Builds the system prompt with tool descriptions and JSON response format -/// instructions. -fn build_tools_system_prompt(base_prompt: &str, tools: &[ToolDescriptor]) -> String { - let mut prompt = String::new(); - prompt.push_str(base_prompt); - prompt.push_str("\n\nYou have access to the following tools.\n\n"); +async fn run_chat_agent(config: LlmConfig) -> Result<(), LlmError> { + let client = LlmClient::new(config)?; + let agent = LlmChatAgentBuilder::new() + .with_client(client) + .system_prompt("You are a helpful assistant with an echo tool.") + .tool(EchoTool) + .build(); - if tools.is_empty() { - prompt.push_str("(No tools available.)\n"); - } else { - prompt.push_str("## JSON Response Format\n"); - prompt.push_str( - "When you need to use a tool, respond with valid JSON only (no markdown fences):\n", - ); - prompt.push_str("{\n"); - prompt.push_str(" \"reply_text\": \"your message to the user\",\n"); - prompt.push_str(" \"tool_calls\": [\n {\n"); - prompt.push_str(" \"tool_name\": \"tool_name_here\",\n"); - prompt.push_str(" \"args\": { /* tool-specific arguments */ }\n"); - prompt.push_str(" }\n ]\n"); - prompt.push_str("}\n\n"); - prompt.push_str("If you don't need to use a tool, respond with:\n"); - prompt.push_str("{\n"); - prompt.push_str(" \"reply_text\": \"your message\",\n"); - prompt.push_str(" \"tool_calls\": []\n"); - prompt.push_str("}\n\n"); - prompt.push_str("## Available Tools\n\n"); + let names = tool_names(&agent); + let system_prompt_text = + build_tools_system_prompt("You are a helpful assistant with an echo tool.", &names); - for tool in tools { - prompt.push_str(&format!("### {}\n", tool.name)); - prompt.push_str(&format!("Description: {}\n", tool.description)); - prompt.push_str(&format!( - "Parameters (JSON Schema): {}\n\n", - tool.parameters - )); - } + let tool_hook = ToolValidationHook::new(names.clone()); - prompt.push_str( - "Use `` in `reply_text` when you want to stop the conversation turn and ", - ); - prompt.push_str( - "return your response immediately without expecting further tool execution. ", - ); - prompt.push_str( - "For example: `\"reply_text\": \"Task complete. \"`.", - ); - prompt.push_str("\n"); - prompt.push_str("IMPORTANT: Always respond with valid JSON only. "); - prompt.push_str( - "Do not wrap the JSON in markdown code fences or add extra text outside the JSON.", - ); - } + let output = agent + .prompt(LlmMessage::user( + "Use the echo tool to echo 'Hello from the JSON harness!', then tell me what it said.", + )) + .system_prompt(LlmMessage::system(&system_prompt_text)) + .max_turns(5) + .add_hook(tool_hook) + .await + .expect("agent should succeed"); - prompt + println!("--- Final Agent Response ---"); + println!("{}", output.text); + println!("-----------------------------"); + + Ok(()) } -// --------------------------------------------------------------------------- -// 5c. JSON response cleaner -// --------------------------------------------------------------------------- - -/// Strips markdown code fences from a string if present, so the JSON parser -/// can handle LLM replies that wrap JSON in triple backticks. -fn clean_json_response(text: &str) -> String { - let text = text.trim(); - if text.starts_with("```") { - let lines: Vec<&str> = text.lines().collect(); - let mut cleaned = Vec::new(); - let mut in_code = false; - for line in lines { - if line.trim().starts_with("```") { - in_code = !in_code; - continue; - } - if in_code { - cleaned.push(line); - } - } - if !cleaned.is_empty() { - return cleaned.join("\n").trim().to_string(); - } - } - text.to_string() -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- fn main() -> Result<(), Box> { - // The example runs as a tokio runtime binary. - // When no CLI arg is given it executes unit-style tests to validate - // the trait implementations; with a config path it makes a real LLM call. - let config_path = std::env::args().nth(1); + let env_path = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".env.local")); - match config_path { - Some(path) => { - println!("Loading LLM config from {path} …"); - let config = load_llm_config(&path)?; - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(run_chat_agent(config))?; - } - None => { - println!("No LLM config provided – running compile‑and‑structure checks in main."); - println!( - "Usage: cargo run -p module-editor-agent --example llm_chat_agent -- ./llm_config.json" - ); - println!( - " or: cargo run -p module-editor-agent --example llm_chat_agent -- ./.env.local" - ); - println!(); - println!("--- Trait contract checks ---"); - } - } + if env_path.exists() { + println!("Loading environment from {} …", env_path.display()); + dotenvy::from_path(&env_path).map_err(|e| { + format!("failed to load env file '{}': {e}", env_path.display()) + })?; + } else { + println!( + "Env file '{}' not found — falling back to current environment.", + env_path.display() + ); + } - Ok(()) -} - -/// Runs one round of chat with the configured LLM using the JSON tool harness. -async fn run_chat_agent(config: LlmConfig) -> Result<(), LlmError> { - let client = LlmClient::new(config)?; - - let agent = LlmChatAgentBuilder::new() - .with_client(client) - .system_prompt("You are a helpful assistant with an echo tool.") - .tool(EchoTool) - .build(); - - let output = agent - .prompt(LlmMessage::user( - "Use the echo tool to echo 'Hello from the JSON harness!', then tell me what it said.", - )) - .await - .expect("agent prompt should succeed"); - - println!("--- Final Agent Response ---"); - println!("{}", output.text); - println!("-----------------------------"); - - Ok(()) -} - -fn load_llm_config(path: &str) -> Result> { - let content = std::fs::read_to_string(path)?; - load_llm_config_from_env_file(&content) -} - -fn load_llm_config_from_env_file(content: &str) -> Result> { - let values = parse_env_file(content); - let provider = - parse_provider(env_value(&values, &["GENARRATIVE_LLM_PROVIDER"]).unwrap_or("ark"))?; - let base_url = env_value(&values, &["GENARRATIVE_LLM_BASE_URL", "VITE_LLM_BASE_URL"]) - .ok_or("missing GENARRATIVE_LLM_BASE_URL or VITE_LLM_BASE_URL")? - .to_string(); - let api_key = env_value( - &values, - &["GENARRATIVE_LLM_API_KEY", "LLM_API_KEY", "ARK_API_KEY"], - ) - .ok_or("missing GENARRATIVE_LLM_API_KEY, LLM_API_KEY, or ARK_API_KEY")? - .to_string(); - let model = env_value(&values, &["GENARRATIVE_LLM_MODEL", "VITE_LLM_MODEL"]) - .ok_or("missing GENARRATIVE_LLM_MODEL or VITE_LLM_MODEL")? - .to_string(); - let request_timeout_ms = env_value(&values, &["GENARRATIVE_LLM_REQUEST_TIMEOUT_MS"]) - .and_then(|value| value.parse::().ok()) - .unwrap_or(30_000); - let max_retries = env_value(&values, &["GENARRATIVE_LLM_MAX_RETRIES"]) - .and_then(|value| value.parse::().ok()) - .unwrap_or(2); - let retry_backoff_ms = env_value(&values, &["GENARRATIVE_LLM_RETRY_BACKOFF_MS"]) - .and_then(|value| value.parse::().ok()) - .unwrap_or(1_000); - - Ok(LlmConfig::new( - provider, - base_url, - api_key, - model, - request_timeout_ms, - max_retries, - retry_backoff_ms, - )?) -} - -fn parse_provider(value: &str) -> Result> { - match value { - "ark" => Ok(platform_llm::LlmProvider::Ark), - "dashscope" | "dash_scope" => Ok(platform_llm::LlmProvider::DashScope), - "openai_compatible" | "openai-compatible" => { - Ok(platform_llm::LlmProvider::OpenAiCompatible) - } - other => Err(format!("unsupported provider: {other}").into()), - } -} - -fn parse_env_file(content: &str) -> HashMap { - content - .lines() - .filter_map(|line| { - let line = line.trim().trim_start_matches('\u{feff}'); - if line.is_empty() || line.starts_with('#') { - return None; - } - - let (key, value) = line.split_once('=')?; - let key = key.trim(); - if key.is_empty() { - return None; - } - - Some((key.to_string(), unquote_env_value(value.trim()).to_string())) - }) - .collect() -} - -fn unquote_env_value(value: &str) -> &str { - value - .strip_prefix('"') - .and_then(|value| value.strip_suffix('"')) - .or_else(|| { - value - .strip_prefix('\'') - .and_then(|value| value.strip_suffix('\'')) - }) - .unwrap_or(value) -} - -fn env_value<'a>(values: &'a HashMap, keys: &[&str]) -> Option<&'a str> { - keys.iter() - .find_map(|key| values.get(*key).map(String::as_str)) - .filter(|value| !value.trim().is_empty()) + let config = load_config_from_env()?; + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(run_chat_agent(config))?; + + Ok(()) } diff --git a/server-rs/crates/module-editor-agent/src/agent.rs b/server-rs/crates/module-editor-agent/src/agent.rs deleted file mode 100644 index c3df6b46e..000000000 --- a/server-rs/crates/module-editor-agent/src/agent.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Rig-like agent traits for Genarrative – generic, LLM-agnostic abstractions for -//! building agents that can be backed by any LLM provider via [`platform_llm`]. -//! -//! These traits follow the pattern popularized by the [`rig`](https://github.com/0xPlaygrounds/rig) -//! library: define [`Agent`], [`Tool`], [`AgentMemory`], and [`AgentBuilder`] as generic -//! building blocks, then plug in concrete implementations with `platform_llm::LlmClient`. - -use std::future::Future; - -use serde::{Deserialize, Serialize}; - -// --------------------------------------------------------------------------- -// Error -// --------------------------------------------------------------------------- - -/// Errors that can occur during agent execution. -#[derive(Debug, Clone)] -pub enum PromptError { - /// LLM completion API call failed. - CompletionError(String), - /// A tool execution failed. - ToolError(String), - /// Maximum conversation turns reached without producing a final response. - MaxTurnsReached { max_turns: usize }, -} - -impl std::fmt::Display for PromptError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::CompletionError(msg) => write!(f, "completion error: {msg}"), - Self::ToolError(msg) => write!(f, "tool error: {msg}"), - Self::MaxTurnsReached { max_turns } => { - write!(f, "max turns reached: {max_turns}") - } - } - } -} - -impl std::error::Error for PromptError {} - -// --------------------------------------------------------------------------- -// Core data types -// --------------------------------------------------------------------------- - -/// A tool call request emitted by the LLM. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCall { - pub id: String, - pub name: String, - pub args: serde_json::Value, -} - -/// Output of a single prompt cycle. -#[derive(Debug, Clone)] -pub struct PromptOutput { - pub text: String, - pub tool_calls: Vec, -} - -/// Result of executing a single tool call — holds the tool name and the -/// serialized output so the agent loop can feed it back into the conversation. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCallResult { - pub tool_name: String, - pub output: serde_json::Value, -} - -/// Flow control for tool execution within the agent loop. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Flow { - /// Proceed with the tool call. - Continue, - /// Skip this tool call but continue processing other responses. - Skip, - /// Stop the agent execution entirely. - Stop, -} - -// --------------------------------------------------------------------------- -// Core traits -// --------------------------------------------------------------------------- - -/// Core agent trait: processes a message and returns text output with optional -/// tool calls. -/// -/// The generic `Message` type represents the format of conversation messages -/// (e.g., `Vec` when integrated with [`platform_llm`]). -pub trait Agent { - /// Process a prompt and return the output. - fn prompt( - &self, - prompt: impl Into + Send, - ) -> impl Future> + Send; - - /// Called before executing a tool call to decide whether to continue, skip, - /// or stop. - fn before_tool_call(&self, tool_call: &ToolCall) -> Flow { - let _ = tool_call; - Flow::Continue - } -} - -/// Conversation memory storing message history. -pub trait AgentMemory { - /// Returns a reference to the stored messages. - fn get_memory(&self) -> &[Message]; - /// Appends a single message to the end of the memory. - fn append_message(&mut self, message: Message); -} - -/// Builder pattern for constructing an agent with a system prompt, tools, and -/// memory. -pub trait AgentBuilder> { - /// Creates a new builder. - fn new() -> Self; - /// Sets the system prompt. - fn system_prompt(self, system_prompt: impl Into) -> Self; - /// Registers a tool. - fn tool(self, tool: impl Tool) -> Self; - /// Sets the conversation memory. - fn memory(self, memory: impl AgentMemory) -> Self; - /// Builds the final agent. - fn build(self) -> T; -} - -/// A tool that can be invoked by an agent. -pub trait Tool: Sized { - /// Unique name identifier for the tool. - const NAME: &'static str; - /// Error type returned by [`Tool::call`]. - type Error: std::error::Error + 'static; - /// Arguments type, deserialized from the LLM's tool call JSON. - type Args: for<'a> Deserialize<'a>; - /// Output type from tool execution, serializable for the agent response. - type Output: Serialize; - - /// Returns the tool's name at runtime (defaults to [`Tool::NAME`]). - fn tool_name(&self) -> &'static str { - Self::NAME - } - - /// Human-readable description of what the tool does. - fn description(&self) -> String; - /// JSON Schema describing the tool's parameters. - fn parameters(&self) -> serde_json::Value; - /// Execute the tool with the given arguments. - fn call( - &self, - args: Self::Args, - ) -> impl Future> + Send; -} diff --git a/server-rs/crates/module-editor-agent/src/agent/agent.rs b/server-rs/crates/module-editor-agent/src/agent/agent.rs new file mode 100644 index 000000000..4c6dddfd4 --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/agent/agent.rs @@ -0,0 +1,115 @@ +use std::pin::Pin; +use crate::agent::{Tool, ToolDyn}; +use crate::agent::error::PromptError; +use crate::agent::hook::Hook; +use crate::agent::memory::AgentMemory; +use crate::agent::run::{Flow, PromptRequest}; + +pub struct Agent, Message> { + pub model: M, + pub tools: Vec>, + pub hooks: Vec>, + pub default_max_turns: usize, + pub system_prompt: Option, + pub memory: Option + Sync>>, + pub context: Option, +} + +impl Agent +where + M: LlmApiAdaptor + 'static, + Message: Send + 'static, +{ + pub fn new(model: M) -> Self { + Self { + model, + tools: Vec::new(), + hooks: Vec::new(), + default_max_turns: 10, + system_prompt: None, + memory: None, + context: None, + } + } + + pub fn tool(mut self, tool: impl Tool + Send + Sync + 'static) -> Self { + self.tools.push(Box::new(tool)); + self + } + + pub fn system_prompt(mut self, msg: Message) -> Self { + self.system_prompt = Some(msg); + self + } + + pub fn memory(mut self, mem: impl AgentMemory + Sync + 'static) -> Self { + self.memory = Some(Box::new(mem)); + self + } + + pub fn hook(mut self, hook: impl Hook + 'static) -> Self { + self.hooks.push(Box::new(hook)); + self + } + + pub fn max_turns(mut self, n: usize) -> Self { + self.default_max_turns = n; + self + } + + pub fn tools(&self) -> &[Box] { + &self.tools + } + + pub fn call_tool<'s>( + &'s self, + name: &str, + args: serde_json::Value, + ) -> Pin> + Send + 's>> { + let tools: Vec<&Box> = self.tools.iter().collect(); + let hooks: Vec<&Box> = self.hooks.iter().collect(); + let name = name.to_string(); + let context = self.context.clone().unwrap_or_default(); + Box::pin(async move { + let mut json_output = { + let mut found: Option = None; + for tool in tools { + if tool.tool_name() == name { + found = Some(tool.call_with_context(args, context).await?); + break; + } + } + found.ok_or_else(|| format!("unknown tool: {name}"))? + }; + // Run after_tool_call hooks + for hook in &hooks { + match hook.after_tool_call(&name, &mut json_output) { + Flow::Stop => return Err("tool call output rejected by hook".to_string()), + Flow::Skip => { + json_output = serde_json::Value::Null; + break; + } + Flow::Continue => {} + } + } + serde_json::to_string(&json_output).map_err(|e| e.to_string()) + }) + } + + pub fn prompt(&self, message: impl Into + Send) -> PromptRequest<'_, M, Message> + where + Message: 'static, + { + PromptRequest::new(self, message.into()) + } +} + +pub trait LlmApiAdaptor: Send + Sync { + fn complete( + &self, + messages: &[Message], + ) -> impl Future> + Send; + fn tool_result_message(&self, tool_name: &str, output: &str) -> Message; + + fn build_assistant_message(&self, text: &str) -> Message; +} diff --git a/server-rs/crates/module-editor-agent/src/agent/agent_builder.rs b/server-rs/crates/module-editor-agent/src/agent/agent_builder.rs new file mode 100644 index 000000000..85b3e3354 --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/agent/agent_builder.rs @@ -0,0 +1,19 @@ +use crate::agent::hook::Hook; +use crate::agent::memory::AgentMemory; +use crate::agent::agent::{Agent, LlmApiAdaptor}; +use crate::agent::tool::Tool; + +pub trait AgentBuilder> { + type Client; + + fn new() -> Self; + fn with_client(self, client: Self::Client) -> Self; + fn system_prompt(self, system_prompt: impl Into) -> Self; + fn tool(self, tool: impl Tool + Send + Sync + 'static) -> Self; + fn add_hook(self, hook: impl Hook + 'static) -> Self; + fn max_turns(self, n: usize) -> Self; + fn memory(self, memory: impl AgentMemory + Sync + 'static) -> Self; + + fn context(self, context: serde_json::Value) -> Self; + fn build(self) -> Agent; +} \ No newline at end of file diff --git a/server-rs/crates/module-editor-agent/src/agent/error.rs b/server-rs/crates/module-editor-agent/src/agent/error.rs new file mode 100644 index 000000000..8f8a13f4b --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/agent/error.rs @@ -0,0 +1,20 @@ +#[derive(Debug, Clone)] +pub enum PromptError { + CompletionError(String), + ToolError(String), + MaxTurnsReached { max_turns: usize }, +} + +impl std::fmt::Display for PromptError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CompletionError(msg) => write!(f, "completion error: {msg}"), + Self::ToolError(msg) => write!(f, "tool error: {msg}"), + Self::MaxTurnsReached { max_turns } => { + write!(f, "max turns reached: {max_turns}") + } + } + } +} + +impl std::error::Error for PromptError {} \ No newline at end of file diff --git a/server-rs/crates/module-editor-agent/src/agent/hook.rs b/server-rs/crates/module-editor-agent/src/agent/hook.rs new file mode 100644 index 000000000..253abbfa9 --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/agent/hook.rs @@ -0,0 +1,24 @@ +use crate::agent::run::Flow; +use crate::agent::tool::ToolCall; + +pub trait Hook: Send + Sync { + fn before_tool_call(&self, tool_call: &ToolCall) -> Flow; + + /// Called after a tool call completes, before the JSON output is serialized to a string. + /// The `output` value can be modified in place. + /// Return `Flow::Stop` to abort the agent loop, `Flow::Skip` to discard this result, + /// or `Flow::Continue` to proceed normally. + fn after_tool_call( + &self, + _tool_name: &str, + _output: &mut serde_json::Value, + ) -> Flow { + Flow::Continue + } +} + +impl Hook for () { + fn before_tool_call(&self, _tool_call: &ToolCall) -> Flow { + Flow::Continue + } +} \ No newline at end of file diff --git a/server-rs/crates/module-editor-agent/src/agent/memory.rs b/server-rs/crates/module-editor-agent/src/agent/memory.rs new file mode 100644 index 000000000..6b051bc02 --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/agent/memory.rs @@ -0,0 +1,4 @@ +pub trait AgentMemory { + fn get_memory(&self) -> &[Message]; + fn append_message(&mut self, message: Message); +} diff --git a/server-rs/crates/module-editor-agent/src/agent/mod.rs b/server-rs/crates/module-editor-agent/src/agent/mod.rs new file mode 100644 index 000000000..67825c4fa --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/agent/mod.rs @@ -0,0 +1,9 @@ +use tool::{Tool, ToolDyn}; + +pub mod tool; +pub mod agent; +pub mod agent_builder; +pub mod run; +pub mod memory; +pub mod error; +pub mod hook; diff --git a/server-rs/crates/module-editor-agent/src/agent/run.rs b/server-rs/crates/module-editor-agent/src/agent/run.rs new file mode 100644 index 000000000..bbdbc7889 --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/agent/run.rs @@ -0,0 +1,273 @@ +use crate::agent::agent::Agent; +use crate::agent::agent::LlmApiAdaptor; +use crate::agent::error::PromptError; +use crate::agent::hook::Hook; +use crate::agent::tool::{ToolCall, ToolDyn}; +use serde::Deserialize; +use std::pin::Pin; +use serde_json::Value; + +#[derive(Debug, Clone)] +pub struct PromptOutput { + pub text: String, + pub tool_calls: Vec, +} + +/// Flow control for tool execution within the agent loop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Flow { + Continue, + /// Skip this tool call but continue processing other responses. + Skip, + /// Stop the agent execution entirely. + Stop, +} + +pub struct PromptRequest<'a, M: LlmApiAdaptor + 'a, Message: 'a> { + agent: &'a Agent, + message: Message, + system_prompt: Option, + hooks: Vec>, + max_turns: usize, + context: Option, +} + +impl<'a, M, Message> PromptRequest<'a, M, Message> +where + M: LlmApiAdaptor + 'a, + Message: 'a, +{ + pub fn new(agent: &'a Agent, message: Message) -> Self { + Self { + agent, + message, + system_prompt: None, + hooks: Vec::new(), + max_turns: agent.default_max_turns, + context: agent.context.clone(), + } + } + + pub fn system_prompt(mut self, msg: Message) -> Self { + self.system_prompt = Some(msg); + self + } + + pub fn add_hook(mut self, hook: impl Hook + 'static) -> Self { + self.hooks.push(Box::new(hook)); + self + } + + pub fn max_turns(mut self, n: usize) -> Self { + self.max_turns = n; + self + } +} + +impl<'a, M, Message> IntoFuture for PromptRequest<'a, M, Message> +where + M: LlmApiAdaptor + Send + Sync + 'a, + Message: Send + Sync + Clone + 'a, +{ + type Output = Result; + type IntoFuture = Pin + Send + 'a>>; + + fn into_future(self) -> Self::IntoFuture { + let agent = self.agent; + let message = self.message; + let per_prompt_system_prompt = self.system_prompt; + let extra_hooks = self.hooks; + let max_turns = self.max_turns; + + Box::pin(async move { + let mut memory: Vec = Vec::new(); + + // Pre-populate from agent's memory backend if available + if let Some(ref mem_backend) = agent.memory { + memory.extend(mem_backend.get_memory().iter().cloned()); + } + + // Effective system prompt: per-prompt override > agent default + match per_prompt_system_prompt { + Some(sp) => memory.push(sp), + None => { + if let Some(ref sp) = agent.system_prompt { + memory.push(sp.clone()); + } + } + } + memory.push(message); + + for turn in 0..max_turns { + // Delegate completion to the model (rig's CompletionModel pattern) + let text = agent.model.complete(&memory).await?; + + // Try to parse the LLM reply as JSON (handle markdown fences) + let cleaned = clean_json_response(&text); + + match serde_json::from_str::(&cleaned) { + Ok(json_resp) => { + // Check for marker — stop the turn immediately + let reply_has_end = json_resp.reply_text.contains(""); + let clean_text = json_resp + .reply_text + .replace("", "") + .trim() + .to_string(); + + memory.push(agent.model.build_assistant_message(&clean_text)); + + if reply_has_end { + return Ok(PromptOutput { + text: clean_text, + tool_calls: vec![], + }); + } + + let tool_calls: Vec = json_resp + .tool_calls + .into_iter() + .map(|tc| ToolCall { + id: format!("call_{turn}"), + name: tc.tool_name, + args: tc.args, + }) + .collect(); + + if tool_calls.is_empty() { + return Ok(PromptOutput { + text: clean_text, + tool_calls: vec![], + }); + } + + for tc in &tool_calls { + match run_hooks(&agent.hooks, &extra_hooks, tc) { + Flow::Stop => { + return Err(PromptError::ToolError( + "tool call rejected by hook".to_string(), + )); + } + Flow::Skip => { + let msg = agent + .model + .tool_result_message(&tc.name, "(skipped by hook)"); + memory.push(msg); + continue; + } + Flow::Continue => {} + } + + let result = { + let tools: Vec<&Box> = agent.tools.iter().collect(); + let name = tc.name.clone(); + let args = tc.args.clone(); + let context:Value = self.context.clone().into(); + let fut = async move { + for tool in tools { + if tool.tool_name() == name { + return tool.call_with_context(args, context.clone()).await + } + } + Err(format!("unknown tool: {name}")) + }; + fut.await + }; + + match result { + Ok(mut json_output) => { + // Run after_tool_call hooks to allow output modification + for hook in agent.hooks.iter().chain(extra_hooks.iter()) { + match hook.after_tool_call(&tc.name, &mut json_output) { + Flow::Stop => { + return Err(PromptError::ToolError( + "tool call output rejected by hook".to_string(), + )); + } + Flow::Skip => { + json_output = Value::Null; + break; + } + Flow::Continue => {} + } + } + let output_str = serde_json::to_string(&json_output) + .map_err(|e| PromptError::ToolError(e.to_string()))?; + let msg = agent.model.tool_result_message(&tc.name, &output_str); + memory.push(msg); + } + Err(e) => { + let msg = agent + .model + .tool_result_message(&tc.name, &format!("error: {e}")); + memory.push(msg); + } + } + } + } + Err(_) => { + // Not valid JSON — treat as plain-text final response + memory.push(agent.model.build_assistant_message(&text)); + return Ok(PromptOutput { + text, + tool_calls: vec![], + }); + } + } + } + + Err(PromptError::MaxTurnsReached { max_turns }) + }) + } +} + +fn run_hooks( + agent_hooks: &[Box], + extra_hooks: &[Box], + tool_call: &ToolCall, +) -> Flow { + for hook in agent_hooks.iter().chain(extra_hooks.iter()) { + match hook.before_tool_call(tool_call) { + Flow::Stop => return Flow::Stop, + Flow::Skip => return Flow::Skip, + Flow::Continue => {} + } + } + Flow::Continue +} + +#[derive(Deserialize)] +struct LlmJsonResponse { + reply_text: String, + #[serde(default)] + tool_calls: Vec, +} + +#[derive(Deserialize)] +struct LlmToolCallRequest { + tool_name: String, + #[serde(default)] + args: serde_json::Value, +} + +pub(crate) fn clean_json_response(text: &str) -> String { + let text = text.trim(); + if text.starts_with("```") { + let lines: Vec<&str> = text.lines().collect(); + let mut cleaned = Vec::new(); + let mut in_code = false; + for line in lines { + if line.trim().starts_with("```") { + in_code = !in_code; + continue; + } + if in_code { + cleaned.push(line); + } + } + if !cleaned.is_empty() { + return cleaned.join("\n").trim().to_string(); + } + } + text.to_string() +} diff --git a/server-rs/crates/module-editor-agent/src/agent/tool.rs b/server-rs/crates/module-editor-agent/src/agent/tool.rs new file mode 100644 index 000000000..cedceb2c3 --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/agent/tool.rs @@ -0,0 +1,85 @@ +use serde::{Deserialize, Serialize}; +use std::pin::Pin; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + pub id: String, + pub name: String, + pub args: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallResult { + pub tool_name: String, + pub output: serde_json::Value, +} + +pub trait Tool: Sized { + const NAME: &'static str; + type Error: std::error::Error + 'static; + type Args: for<'a> Deserialize<'a>; + type Output: Serialize; + + fn tool_name(&self) -> &'static str { + Self::NAME + } + + /// Human-readable description of what the tool does. + fn description(&self) -> String; + /// JSON Schema describing the tool's parameters. + fn parameters(&self) -> serde_json::Value; + fn call( + &self, + args: Self::Args, + ) -> impl Future> + Send; + + fn call_with_context( + &self, + args: Self::Args, + _context: serde_json::Value, + ) -> impl Future> + Send { + self.call(args) + } +} + +/// Wrapper trait to allow for dynamic dispatch of simple tools. +pub trait ToolDyn: Send + Sync { + fn tool_name(&self) -> &'static str; + fn description(&self) -> String; + fn parameters(&self) -> serde_json::Value; + fn call_with_context( + &self, + args: serde_json::Value, + _context: serde_json::Value, + ) -> Pin> + Send + '_>>; +} + +impl ToolDyn for T { + fn tool_name(&self) -> &'static str { + T::NAME + } + + fn description(&self) -> String { + self.description() + } + + fn parameters(&self) -> serde_json::Value { + self.parameters() + } + + fn call_with_context( + &self, + args: serde_json::Value, + context: serde_json::Value, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + let parsed: T::Args = serde_json::from_value(args) + .map_err(|e| format!("bad args for {}: {e}", T::NAME))?; + let output = self + .call_with_context(parsed, context) + .await + .map_err(|e| e.to_string())?; + serde_json::to_value(&output).map_err(|e| e.to_string()) + }) + } +}