Skip to main content

context_harness/
server.rs

1//! MCP-compatible HTTP server.
2//!
3//! Exposes Context Harness functionality via a JSON HTTP API suitable for
4//! integration with Cursor, Claude, and other MCP-compatible AI tools.
5//!
6//! All tools — built-in (search, get, sources), Lua scripts, and custom Rust
7//! trait implementations — are registered in a unified [`ToolRegistry`] and
8//! dispatched through the same `POST /tools/{name}` handler.
9//!
10//! Agents (named personas with system prompts and tool scoping) are registered
11//! in an [`AgentRegistry`] and discoverable/resolvable via dedicated endpoints.
12//!
13//! # Endpoints
14//!
15//! | Method | Path | Description |
16//! |--------|------|-------------|
17//! | `GET`  | `/tools/list` | List all registered tools with schemas |
18//! | `POST` | `/tools/{name}` | Call any registered tool by name |
19//! | `GET`  | `/agents/list` | List all registered agents with metadata |
20//! | `POST` | `/agents/{name}/prompt` | Resolve an agent's system prompt |
21//! | `GET`  | `/health` | Health check (returns version) |
22//!
23//! # Error Contract
24//!
25//! All error responses follow the schema defined in `docs/SCHEMAS.md`:
26//!
27//! ```json
28//! { "error": { "code": "bad_request", "message": "query must not be empty" } }
29//! ```
30//!
31//! Error codes: `bad_request` (400), `not_found` (404), `embeddings_disabled` (400),
32//! `timeout` (408), `tool_error` (500), `internal` (500).
33//!
34//! # CORS
35//!
36//! All origins, methods, and headers are permitted to support browser-based
37//! clients and cross-origin MCP tool calls.
38//!
39//! # Cursor Integration
40//!
41//! Start the server and point Cursor at the `/mcp` endpoint:
42//!
43//! ```json
44//! {
45//!   "mcpServers": {
46//!     "context-harness": {
47//!       "url": "http://127.0.0.1:7331/mcp"
48//!     }
49//!   }
50//! }
51//! ```
52
53use axum::{
54    extract::{Path, State},
55    http::StatusCode,
56    response::{IntoResponse, Response},
57    routing::{get, post},
58    Json, Router,
59};
60use rmcp::transport::streamable_http_server::{
61    session::local::LocalSessionManager, StreamableHttpService,
62};
63use serde::Serialize;
64use std::sync::Arc;
65use tower_http::cors::{Any, CorsLayer};
66
67use crate::agent_script::{load_agent_definitions, LuaAgentAdapter};
68use crate::agents::{AgentInfo, AgentRegistry};
69use crate::config::Config;
70use crate::mcp::McpBridge;
71use crate::registry::RegistryManager;
72use crate::tool_script::{load_tool_definitions, validate_params, LuaToolAdapter, ToolInfo};
73use crate::traits::{ToolContext, ToolRegistry};
74use crate::workspace::{RouterError, ServerMode, WorkspaceRouter};
75
76/// Shared application state passed to all route handlers via Axum's `State` extractor.
77#[derive(Clone)]
78struct AppState {
79    /// Workspace router (one-workspace in compatibility mode; multi under
80    /// `--workspaces`). Handlers build a [`ToolContext`] from this per request.
81    router: Arc<WorkspaceRouter>,
82    /// Whether the server emits flat (compat) or workspace-labeled (multi) shapes.
83    mode: ServerMode,
84    /// Unified tool registry containing built-in, Lua, and custom Rust tools.
85    tools: Arc<ToolRegistry>,
86    /// Agent registry containing TOML, Lua, and custom Rust agents.
87    agents: Arc<AgentRegistry>,
88}
89
90/// Extra extensions (custom Rust tools and agents) passed alongside the main `AppState`.
91type ExtState = (Arc<ToolRegistry>, Arc<AgentRegistry>);
92
93/// Starts the MCP-compatible HTTP server.
94///
95/// Binds to the address configured in `[server].bind` and registers all
96/// route handlers. The server runs indefinitely until the process is terminated.
97///
98/// This is the standard entry point used by the `ctx serve mcp` command.
99/// For custom binaries with Rust extensions, use
100/// [`run_server_with_extensions`] instead.
101///
102/// # Arguments
103///
104/// - `config` — application configuration (database path, retrieval settings, bind address).
105///
106/// # Returns
107///
108/// Returns `Ok(())` when the server shuts down, or an error if binding fails.
109pub async fn run_server(config: &Config) -> anyhow::Result<()> {
110    run_server_with_extensions(
111        config,
112        Arc::new(ToolRegistry::new()),
113        Arc::new(AgentRegistry::new()),
114    )
115    .await
116}
117
118/// Starts the MCP server with custom Rust tool and agent extensions.
119///
120/// Like [`run_server`], but accepts a [`ToolRegistry`] and [`AgentRegistry`]
121/// containing custom extensions that will be served alongside built-in,
122/// TOML-defined, and Lua-scripted entries.
123///
124/// Custom tools appear in `GET /tools/list` and can be called via
125/// `POST /tools/{name}`. Custom agents appear in `GET /agents/list` and
126/// can be resolved via `POST /agents/{name}/prompt`.
127///
128/// # Example
129///
130/// ```rust,no_run
131/// use context_harness::server::run_server_with_extensions;
132/// use context_harness::traits::ToolRegistry;
133/// use context_harness::agents::AgentRegistry;
134/// use std::sync::Arc;
135///
136/// # async fn example(config: &context_harness::config::Config) -> anyhow::Result<()> {
137/// let tools = ToolRegistry::new();
138/// let agents = AgentRegistry::new();
139/// run_server_with_extensions(config, Arc::new(tools), Arc::new(agents)).await?;
140/// # Ok(())
141/// # }
142/// ```
143pub async fn run_server_with_extensions(
144    config: &Config,
145    extra_tools: Arc<ToolRegistry>,
146    extra_agents: Arc<AgentRegistry>,
147) -> anyhow::Result<()> {
148    let bind_addr = config.server.bind.clone();
149    let config = Arc::new(config.clone());
150
151    // ── Tools ──
152    let mut tool_registry = ToolRegistry::with_builtins();
153
154    // Load and register Lua tools from config
155    let lua_defs = load_tool_definitions(&config)?;
156    let configured_tool_names: Vec<String> = lua_defs.iter().map(|d| d.name.clone()).collect();
157    for def in lua_defs {
158        tool_registry.register(Box::new(LuaToolAdapter::new(def, config.clone())));
159    }
160
161    // Auto-discover tools from registries (lower precedence than config)
162    let reg_mgr = RegistryManager::from_config(&config);
163    for ext in reg_mgr.list_tools() {
164        if configured_tool_names.iter().any(|n| n == &ext.name) {
165            continue;
166        }
167        if !ext.script_path.exists() {
168            continue;
169        }
170        let tool_cfg = crate::config::ScriptToolConfig {
171            path: ext.script_path.clone(),
172            timeout: 30,
173            extra: toml::Table::new(),
174        };
175        match crate::tool_script::load_single_tool(&ext.name, &tool_cfg) {
176            Ok(def) => {
177                tool_registry.register(Box::new(LuaToolAdapter::new(def, config.clone())));
178            }
179            Err(e) => {
180                eprintln!(
181                    "Warning: failed to load registry tool '{}': {}",
182                    ext.name, e
183                );
184            }
185        }
186    }
187
188    // Print registered tools
189    let tool_count = tool_registry.len() + extra_tools.len();
190    if tool_count > 3 {
191        println!("Registered {} tools:", tool_count);
192        for t in tool_registry.tools() {
193            let tag = if t.is_builtin() { "builtin" } else { "lua" };
194            println!("  POST /tools/{} — {} ({})", t.name(), t.description(), tag);
195        }
196        for t in extra_tools.tools() {
197            println!("  POST /tools/{} — {} (rust)", t.name(), t.description());
198        }
199    }
200
201    // ── Agents ──
202    let mut agent_registry = AgentRegistry::from_config(&config)?;
203
204    // Load and register Lua agents from config
205    let lua_agents = load_agent_definitions(&config)?;
206    let configured_agent_names: Vec<String> = lua_agents.iter().map(|d| d.name.clone()).collect();
207    for def in lua_agents {
208        agent_registry.register(Box::new(LuaAgentAdapter::new(def, config.clone())));
209    }
210
211    // Auto-discover agents from registries (lower precedence than config)
212    for ext in reg_mgr.list_agents() {
213        if configured_agent_names.iter().any(|n| n == &ext.name) {
214            continue;
215        }
216        if !ext.script_path.exists() {
217            continue;
218        }
219        if ext.script_path.extension().is_some_and(|e| e == "lua") {
220            let agent_cfg = crate::config::ScriptAgentConfig {
221                path: ext.script_path.clone(),
222                timeout: 30,
223                extra: toml::Table::new(),
224            };
225            match crate::agent_script::load_single_agent(&ext.name, &agent_cfg) {
226                Ok(def) => {
227                    agent_registry.register(Box::new(LuaAgentAdapter::new(def, config.clone())));
228                }
229                Err(e) => {
230                    eprintln!(
231                        "Warning: failed to load registry agent '{}': {}",
232                        ext.name, e
233                    );
234                }
235            }
236        }
237    }
238
239    let agent_count = agent_registry.len() + extra_agents.len();
240    if agent_count > 0 {
241        println!("Registered {} agents:", agent_count);
242        for a in agent_registry.agents() {
243            println!(
244                "  POST /agents/{}/prompt — {} ({})",
245                a.name(),
246                a.description(),
247                a.source()
248            );
249        }
250        for a in extra_agents.agents() {
251            println!(
252                "  POST /agents/{}/prompt — {} ({})",
253                a.name(),
254                a.description(),
255                a.source()
256            );
257        }
258    }
259
260    let tools = Arc::new(tool_registry);
261    let agents = Arc::new(agent_registry);
262
263    // Compatibility mode is a router with one workspace; the wire contract is
264    // selected by mode, not by workspace count (SPEC-0014 R14/R15).
265    let router = Arc::new(WorkspaceRouter::single(config.clone()));
266
267    serve_router(
268        router,
269        ServerMode::Compat,
270        bind_addr,
271        false,
272        tools,
273        agents,
274        extra_tools,
275        extra_agents,
276    )
277    .await
278}
279
280/// Starts the multi-workspace MCP server (`ctx serve mcp --workspaces`).
281///
282/// Routes the built-in `search` / `get` / `sources` / `workspaces` tools across
283/// the registered workspaces in `router`. Per SPEC-0014 R54, only built-in
284/// tools are exposed in multi-workspace mode in Phase 1 — workspace-local Lua
285/// and registry tools/agents are not loaded. `bind` comes from the registry's
286/// `[defaults].bind` (R16); `allow_remote` permits a non-loopback bind.
287pub async fn run_server_multi(
288    router: Arc<WorkspaceRouter>,
289    bind: String,
290    allow_remote: bool,
291) -> anyhow::Result<()> {
292    let tools = Arc::new(ToolRegistry::with_builtins_multi());
293    let agents = Arc::new(AgentRegistry::new());
294    let extra_tools = Arc::new(ToolRegistry::new());
295    let extra_agents = Arc::new(AgentRegistry::new());
296
297    println!("Multi-workspace MCP mode. Registered workspaces:");
298    for rt in router.list() {
299        let default = if Some(rt.id.as_str()) == router.default_id() {
300            " (default)"
301        } else {
302            ""
303        };
304        let health = if rt.health.is_ok() {
305            "ok"
306        } else {
307            "unavailable"
308        };
309        println!(
310            "  {}{} — enabled={} health={} resolution={}",
311            rt.id,
312            default,
313            rt.enabled,
314            health,
315            rt.resolution.as_str()
316        );
317    }
318
319    serve_router(
320        router,
321        ServerMode::Multi,
322        bind,
323        allow_remote,
324        tools,
325        agents,
326        extra_tools,
327        extra_agents,
328    )
329    .await
330}
331
332/// Whether a `host:port` bind address targets the loopback interface.
333fn is_loopback_bind(bind: &str) -> bool {
334    let host = bind.rsplit_once(':').map(|(h, _)| h).unwrap_or(bind);
335    let host = host.trim_start_matches('[').trim_end_matches(']');
336    if host.eq_ignore_ascii_case("localhost") {
337        return true;
338    }
339    host.parse::<std::net::IpAddr>()
340        .map(|ip| ip.is_loopback())
341        .unwrap_or(false)
342}
343
344/// Shared Axum wiring for both compatibility and multi-workspace modes.
345#[allow(clippy::too_many_arguments)]
346async fn serve_router(
347    router: Arc<WorkspaceRouter>,
348    mode: ServerMode,
349    bind_addr: String,
350    allow_remote: bool,
351    tools: Arc<ToolRegistry>,
352    agents: Arc<AgentRegistry>,
353    extra_tools: Arc<ToolRegistry>,
354    extra_agents: Arc<AgentRegistry>,
355) -> anyhow::Result<()> {
356    // Trust model (SPEC-0014 trust-model section): loopback bind is the
357    // load-bearing control. A non-loopback bind is refused in multi-workspace
358    // mode (which fronts every registered store) unless explicitly allowed.
359    if !is_loopback_bind(&bind_addr) {
360        if mode == ServerMode::Multi && !allow_remote {
361            anyhow::bail!(
362                "refusing to bind multi-workspace server to non-loopback address '{bind_addr}': \
363                 this exposes every registered workspace to other hosts. Bind to 127.0.0.1, or \
364                 pass --allow-remote to override."
365            );
366        }
367        eprintln!(
368            "warning: binding to non-loopback address '{bind_addr}'. The MCP server has \
369             permissive CORS and no authentication; only bind to addresses reachable by \
370             trusted clients."
371        );
372    }
373
374    let state = AppState {
375        router: router.clone(),
376        mode,
377        tools: tools.clone(),
378        agents: agents.clone(),
379    };
380
381    // MCP Streamable HTTP endpoint at /mcp — clone before moving into extra_state
382    let mcp_tools = tools.clone();
383    let mcp_extra = extra_tools.clone();
384    let mcp_agents = agents.clone();
385    let mcp_extra_agents = extra_agents.clone();
386    let mcp_router = router.clone();
387
388    let extra_state = (extra_tools.clone(), extra_agents);
389    let mcp_service = StreamableHttpService::new(
390        move || {
391            Ok(McpBridge::new(
392                mcp_router.clone(),
393                mode,
394                mcp_tools.clone(),
395                mcp_extra.clone(),
396                mcp_agents.clone(),
397                mcp_extra_agents.clone(),
398            ))
399        },
400        Arc::new(LocalSessionManager::default()),
401        Default::default(),
402    );
403
404    let cors = CorsLayer::new()
405        .allow_origin(Any)
406        .allow_methods(Any)
407        .allow_headers(Any);
408
409    let app = Router::new()
410        .route("/tools/list", get(handle_list_tools))
411        .route("/tools/{name}", post(handle_tool_call))
412        .route("/agents/list", get(handle_list_agents))
413        .route("/agents/{name}/prompt", post(handle_resolve_agent))
414        .route("/health", get(handle_health))
415        .with_state((state, extra_state))
416        .nest_service("/mcp", mcp_service)
417        .layer(cors);
418
419    println!("MCP server listening on http://{}", bind_addr);
420    println!("  MCP endpoint: http://{}/mcp", bind_addr);
421
422    let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
423    axum::serve(listener, app).await?;
424
425    Ok(())
426}
427
428// ============ Error response ============
429
430/// JSON error response body, matching `docs/SCHEMAS.md` error schema.
431#[derive(Serialize)]
432struct ErrorBody {
433    error: ErrorDetail,
434}
435
436/// Inner error detail with a machine-readable code and human-readable message.
437#[derive(Serialize)]
438struct ErrorDetail {
439    /// Machine-readable error code (e.g., `"bad_request"`, `"not_found"`).
440    code: String,
441    /// Human-readable error message.
442    message: String,
443}
444
445/// Internal error type that converts into an Axum HTTP response.
446struct AppError {
447    status: StatusCode,
448    code: String,
449    message: String,
450}
451
452impl IntoResponse for AppError {
453    fn into_response(self) -> Response {
454        let body = ErrorBody {
455            error: ErrorDetail {
456                code: self.code,
457                message: self.message,
458            },
459        };
460        (self.status, Json(body)).into_response()
461    }
462}
463
464/// Constructs a 400 Bad Request error.
465fn bad_request(message: impl Into<String>) -> AppError {
466    AppError {
467        status: StatusCode::BAD_REQUEST,
468        code: "bad_request".to_string(),
469        message: message.into(),
470    }
471}
472
473/// Constructs a 404 Not Found error.
474fn not_found(message: impl Into<String>) -> AppError {
475    AppError {
476        status: StatusCode::NOT_FOUND,
477        code: "not_found".to_string(),
478        message: message.into(),
479    }
480}
481
482/// Constructs a 408 Request Timeout error.
483fn timeout_error(message: impl Into<String>) -> AppError {
484    AppError {
485        status: StatusCode::REQUEST_TIMEOUT,
486        code: "timeout".to_string(),
487        message: message.into(),
488    }
489}
490
491/// Constructs a 500 error for tool execution failures.
492fn tool_error(message: impl Into<String>) -> AppError {
493    AppError {
494        status: StatusCode::INTERNAL_SERVER_ERROR,
495        code: "tool_error".to_string(),
496        message: message.into(),
497    }
498}
499
500/// Inspects tool execution errors and maps them to the most appropriate
501/// HTTP status code. This allows built-in tools to signal client errors
502/// (e.g. empty query → 400, document not found → 404) without needing
503/// a custom error type in the `Tool` trait.
504fn classify_tool_error(tool_name: &str, err: anyhow::Error) -> AppError {
505    // Router errors carry their own SPEC-0014 R64 code; surface it directly.
506    if let Some(re) = err.downcast_ref::<RouterError>() {
507        let status = match re {
508            RouterError::UnknownWorkspace(_) => StatusCode::NOT_FOUND,
509            RouterError::WorkspaceUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE,
510            RouterError::WorkspaceTimeout { .. } => StatusCode::SERVICE_UNAVAILABLE,
511            _ => StatusCode::BAD_REQUEST,
512        };
513        return AppError {
514            status,
515            code: re.code().to_string(),
516            message: format!("{tool_name}: {re}"),
517        };
518    }
519
520    let msg = err.to_string();
521
522    if msg.contains("not found") {
523        not_found(format!("{}: {}", tool_name, msg))
524    } else if msg.contains("must not be empty")
525        || msg.contains("embeddings")
526        || msg.contains("disabled")
527        || msg.contains("invalid")
528    {
529        // Validation / configuration errors → 400
530        let mut e = bad_request(format!("{}: {}", tool_name, msg));
531        // Preserve more specific error codes for known patterns
532        if msg.contains("embeddings") || msg.contains("disabled") {
533            e.code = "embeddings_disabled".to_string();
534        }
535        e
536    } else if msg.contains("timed out") {
537        timeout_error(format!("{}: {}", tool_name, msg))
538    } else {
539        tool_error(format!("{}: {}", tool_name, msg))
540    }
541}
542
543// ============ GET /health ============
544
545/// JSON response body for `GET /health`.
546#[derive(Serialize)]
547struct HealthResponse {
548    /// Always `"ok"` when the server is running.
549    status: String,
550    /// The crate version from `Cargo.toml`.
551    version: String,
552}
553
554/// Handler for `GET /health`.
555///
556/// Returns a simple health check response with the server status and version.
557/// This endpoint is used by load balancers and monitoring tools.
558async fn handle_health() -> Json<HealthResponse> {
559    Json(HealthResponse {
560        status: "ok".to_string(),
561        version: env!("CARGO_PKG_VERSION").to_string(),
562    })
563}
564
565// ============ GET /tools/list ============
566
567/// JSON response body for `GET /tools/list`.
568#[derive(Serialize)]
569struct ToolListResponse {
570    /// All registered tools.
571    tools: Vec<ToolInfo>,
572}
573
574/// Handler for `GET /tools/list`.
575///
576/// Returns all registered tools with their OpenAI function-calling parameter
577/// schemas. Built-in tools have `builtin: true`; Lua and custom Rust tools
578/// have `builtin: false`.
579async fn handle_list_tools(
580    State((state, (extra_tools, _extra_agents))): State<(AppState, ExtState)>,
581) -> Json<ToolListResponse> {
582    let mut tools: Vec<ToolInfo> = state
583        .tools
584        .tools()
585        .iter()
586        .map(|t| ToolInfo {
587            name: t.name().to_string(),
588            description: t.description().to_string(),
589            builtin: t.is_builtin(),
590            parameters: t.parameters_schema(),
591        })
592        .collect();
593
594    // Append extra custom Rust tools
595    for t in extra_tools.tools() {
596        tools.push(ToolInfo {
597            name: t.name().to_string(),
598            description: t.description().to_string(),
599            builtin: false,
600            parameters: t.parameters_schema(),
601        });
602    }
603
604    Json(ToolListResponse { tools })
605}
606
607// ============ POST /tools/{name} ============
608
609/// Handler for `POST /tools/{name}`.
610///
611/// Unified tool dispatch. Looks up the tool by name in the registry
612/// (checking the main registry first, then extras), validates parameters,
613/// and executes it.
614///
615/// Returns `404` if the tool is not found, `400` for parameter validation
616/// errors, `408` for timeout, and `500` for execution errors.
617async fn handle_tool_call(
618    State((state, (extra_tools, _extra_agents))): State<(AppState, ExtState)>,
619    Path(name): Path<String>,
620    Json(params): Json<serde_json::Value>,
621) -> Result<Json<serde_json::Value>, AppError> {
622    // Look up the tool in the main registry, then extras
623    let tool = state
624        .tools
625        .find(&name)
626        .or_else(|| extra_tools.find(&name))
627        .ok_or_else(|| not_found(format!("no tool registered with name: {}", name)))?;
628
629    // Validate parameters against the tool's schema
630    let validated_params = validate_params(&tool.parameters_schema(), &params)
631        .map_err(|e| bad_request(e.to_string()))?;
632
633    // Execute via the Tool trait
634    let ctx = ToolContext::routed(state.router.clone(), state.mode);
635    let result = tool
636        .execute(validated_params, &ctx)
637        .await
638        .map_err(|e| classify_tool_error(&name, e))?;
639
640    Ok(Json(serde_json::json!({ "result": result })))
641}
642
643// ============ GET /agents/list ============
644
645/// JSON response body for `GET /agents/list`.
646#[derive(Serialize)]
647struct AgentListResponse {
648    /// All registered agents.
649    agents: Vec<AgentInfo>,
650}
651
652/// Handler for `GET /agents/list`.
653///
654/// Returns all registered agents with their metadata, tool lists, and
655/// argument schemas. Includes TOML, Lua, and custom Rust agents.
656async fn handle_list_agents(
657    State((state, (_extra_tools, extra_agents))): State<(AppState, ExtState)>,
658) -> Json<AgentListResponse> {
659    let mut agents: Vec<AgentInfo> = state
660        .agents
661        .agents()
662        .iter()
663        .map(|a| AgentInfo {
664            name: a.name().to_string(),
665            description: a.description().to_string(),
666            tools: a.tools(),
667            source: a.source().to_string(),
668            arguments: a.arguments(),
669        })
670        .collect();
671
672    // Append extra custom Rust agents
673    for a in extra_agents.agents() {
674        agents.push(AgentInfo {
675            name: a.name().to_string(),
676            description: a.description().to_string(),
677            tools: a.tools(),
678            source: a.source().to_string(),
679            arguments: a.arguments(),
680        });
681    }
682
683    Json(AgentListResponse { agents })
684}
685
686// ============ POST /agents/{name}/prompt ============
687
688/// Handler for `POST /agents/{name}/prompt`.
689///
690/// Resolves an agent's system prompt by calling its `resolve()` method.
691/// For TOML agents, this returns the static prompt. For Lua agents, this
692/// executes the script's `agent.resolve()` function with the provided
693/// arguments and access to the context bridge (search, get, sources).
694///
695/// Returns `404` if the agent is not found.
696async fn handle_resolve_agent(
697    State((state, (_extra_tools, extra_agents))): State<(AppState, ExtState)>,
698    Path(name): Path<String>,
699    Json(args): Json<serde_json::Value>,
700) -> Result<Json<serde_json::Value>, AppError> {
701    let agent = state
702        .agents
703        .find(&name)
704        .or_else(|| extra_agents.find(&name))
705        .ok_or_else(|| not_found(format!("no agent registered with name: {}", name)))?;
706
707    let ctx = ToolContext::routed(state.router.clone(), state.mode);
708    let prompt = agent
709        .resolve(args, &ctx)
710        .await
711        .map_err(|e| tool_error(format!("agent '{}': {}", name, e)))?;
712
713    Ok(Json(serde_json::to_value(prompt).map_err(|e| {
714        tool_error(format!("failed to serialize agent prompt: {}", e))
715    })?))
716}