Skip to main content

roder_ext_claude_code/
options.rs

1use std::path::Path;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use claude_code_sdk_rust::mcp::{MCPContent, SdkMcpTool, SimpleMCPServer};
6use claude_code_sdk_rust::{
7    ClaudeAgentOptions, EffortLevel, PermissionMode, PermissionResult, SettingSource,
8};
9use roder_api::inference::{AgentInferenceRequest, ToolCallCompleted, TurnToolExecutor};
10use roder_api::tools::ToolChoice;
11
12use crate::provider::ClaudeCodeConfig;
13
14pub fn build_options(
15    config: &ClaudeCodeConfig,
16    request: &AgentInferenceRequest,
17    tool_executor: Option<Arc<dyn TurnToolExecutor>>,
18    cwd: Option<&Path>,
19    resume_session_id: Option<&str>,
20) -> anyhow::Result<ClaudeAgentOptions> {
21    let mut builder = ClaudeAgentOptions::builder()
22        .model(request.model.model.clone())
23        .include_partial_messages(true);
24    // Resume the persisted CLI session so the `claude` process keeps the prior
25    // conversation server-side and applies its own auto-compaction. When set,
26    // the provider only sends the new transcript tail as the prompt instead of
27    // replaying the whole transcript every turn (which is what overflowed the
28    // 1M context window with "Prompt is too long").
29    if let Some(session_id) = resume_session_id.filter(|value| !value.trim().is_empty()) {
30        builder = builder.resume(session_id.to_string());
31    }
32    if let Some(cli_path) = config
33        .cli_path
34        .as_deref()
35        .filter(|value| !value.trim().is_empty())
36    {
37        builder = builder.cli_path(cli_path.to_string());
38    }
39    if let Some(cwd) = cwd {
40        builder = builder.cwd(cwd.display().to_string());
41    }
42    if let Some(system) = merged_system_prompt(request) {
43        builder = builder.system_prompt(system);
44    }
45    if let Some(mode) = config.permission_mode.as_deref() {
46        builder = builder.permission_mode(parse_permission_mode(mode)?);
47    }
48    if let Some(setting_sources) = &config.setting_sources {
49        builder = builder.setting_sources(parse_setting_sources(setting_sources)?);
50    }
51    if request.reasoning.enabled
52        && let Some(level) = request.reasoning.level.as_deref()
53        && let Some(effort) = parse_effort_level(level)
54    {
55        builder = builder.effort(effort);
56    }
57    if let Some(max_tokens) = request.output.max_tokens {
58        let budget = i32::try_from(max_tokens).unwrap_or(i32::MAX);
59        builder = builder.max_thinking_tokens(budget);
60    }
61    let allowed_tool_names = allowed_claude_tool_names(request);
62    if !request.tools.is_empty() && !matches!(request.tool_choice, ToolChoice::None) {
63        let executor = tool_executor.ok_or_else(|| {
64            anyhow::anyhow!("Claude Code provider requires a Roder tool executor")
65        })?;
66        let server = roder_sdk_mcp_server(request, executor);
67        builder = builder.sdk_mcp_server("roder", server);
68        // Disable every built-in Claude Code tool (Read/Bash/Edit/Glob/...).
69        // Roder mediates all tool access through its own executor, so the
70        // model must use the `mcp__roder__*` tools we advertise. Leaving the
71        // built-ins enabled lets the model call e.g. bare `Read`, which our
72        // `can_use_tool` callback then denies -- burning retries until the
73        // turn trips the consecutive-tool-failure reliability limit.
74        builder = builder.tools(Vec::new());
75        builder = builder.allowed_tools(
76            allowed_tool_names
77                .iter()
78                .map(|name| format!("mcp__roder__{name}"))
79                .collect(),
80        );
81    }
82    builder = builder.can_use_tool(move |tool_name, _input, _context| {
83        let allowed_tool_names = allowed_tool_names.clone();
84        async move {
85            let Some(claude_tool_name) = tool_name.strip_prefix("mcp__roder__") else {
86                return Ok(PermissionResult::deny(format!(
87                    "Claude Code tool {tool_name} is not managed by Roder"
88                )));
89            };
90            if allowed_tool_names
91                .iter()
92                .any(|name| name == claude_tool_name)
93            {
94                Ok(PermissionResult::allow())
95            } else {
96                Ok(PermissionResult::deny(format!(
97                    "Claude Code tool {tool_name} was not advertised for this Roder turn"
98                )))
99            }
100        }
101    });
102    Ok(builder.build())
103}
104
105fn allowed_claude_tool_names(request: &AgentInferenceRequest) -> Vec<String> {
106    request
107        .tools
108        .iter()
109        .flat_map(|tool| {
110            let mut names = vec![tool.name.clone()];
111            names.extend(claude_aliases_for_roder_tool(&tool.name).map(str::to_string));
112            names
113        })
114        .collect()
115}
116
117fn roder_sdk_mcp_server(
118    request: &AgentInferenceRequest,
119    executor: Arc<dyn TurnToolExecutor>,
120) -> SimpleMCPServer {
121    let tools = request
122        .tools
123        .iter()
124        .flat_map(|spec| {
125            let mut names = vec![spec.name.clone()];
126            names.extend(claude_aliases_for_roder_tool(&spec.name).map(str::to_string));
127            names.into_iter().map(|claude_name| {
128                sdk_tool_for_spec(
129                    claude_name,
130                    spec.name.clone(),
131                    spec.description.clone(),
132                    spec.parameters.clone(),
133                    Arc::clone(&executor),
134                )
135            })
136        })
137        .collect();
138    claude_code_sdk_rust::mcp::create_sdk_mcp_server("roder", tools)
139}
140
141/// Monotonic counter making every claude-code tool-call id unique across the
142/// process, so repeated calls of the same tool render as distinct rows.
143static NEXT_TOOL_CALL_SEQ: AtomicU64 = AtomicU64::new(0);
144
145fn sdk_tool_for_spec(
146    claude_name: String,
147    roder_name: String,
148    description: String,
149    schema: serde_json::Value,
150    executor: Arc<dyn TurnToolExecutor>,
151) -> SdkMcpTool {
152    let call_schema = schema.clone();
153    SdkMcpTool::new(
154        claude_name.clone(),
155        description,
156        schema,
157        None,
158        move |input| {
159            let input =
160                repair_sdk_mcp_input_for_tool(input, &call_schema, &claude_name, &roder_name);
161            // Each invocation must carry a unique tool-call id. The TUI and
162            // runtime key tool-call rows by id, so reusing a name-derived id
163            // (e.g. `claude-code-Bash`) collapses every later call of the same
164            // tool into the first row and only the first one ever renders.
165            let seq = NEXT_TOOL_CALL_SEQ.fetch_add(1, Ordering::Relaxed);
166            let call = ToolCallCompleted {
167                id: format!("claude-code-{claude_name}-{seq}"),
168                name: roder_name.clone(),
169                arguments: input.to_string(),
170            };
171            let handle = tokio::runtime::Handle::current();
172            let outcome = tokio::task::block_in_place(|| handle.block_on(executor.execute(call)));
173            match outcome {
174                Ok(outcome) => {
175                    let text = if outcome.is_error {
176                        format!("Tool returned an error:\n{}", outcome.result)
177                    } else {
178                        outcome.result
179                    };
180                    Ok(vec![MCPContent::Text { text }])
181                }
182                Err(err) => Err(err.to_string()),
183            }
184        },
185    )
186}
187
188fn claude_aliases_for_roder_tool(name: &str) -> impl Iterator<Item = &'static str> {
189    match name {
190        "shell" => ["Bash"].as_slice().iter().copied(),
191        "read_file" => ["Read"].as_slice().iter().copied(),
192        "list_files" => ["LS"].as_slice().iter().copied(),
193        "grep" => ["Grep"].as_slice().iter().copied(),
194        "glob" => ["Glob"].as_slice().iter().copied(),
195        "write_file" => ["Write"].as_slice().iter().copied(),
196        "edit" => ["Edit"].as_slice().iter().copied(),
197        "multi_edit" => ["MultiEdit"].as_slice().iter().copied(),
198        _ => [].as_slice().iter().copied(),
199    }
200}
201
202fn retain_schema_properties(
203    object: &mut serde_json::Map<String, serde_json::Value>,
204    schema: &serde_json::Value,
205) {
206    let Some(properties) = schema
207        .get("properties")
208        .and_then(serde_json::Value::as_object)
209    else {
210        return;
211    };
212    object.retain(|key, _| properties.contains_key(key));
213}
214
215fn repair_sdk_mcp_input_for_tool(
216    input: serde_json::Value,
217    schema: &serde_json::Value,
218    claude_name: &str,
219    roder_name: &str,
220) -> serde_json::Value {
221    if let Some(mut object) = input.as_object().cloned() {
222        normalize_claude_tool_input_aliases(&mut object, claude_name, roder_name);
223        normalize_sdk_mcp_aliases(&mut object, schema);
224        retain_schema_properties(&mut object, schema);
225        return serde_json::Value::Object(object);
226    }
227    let Some(required) = schema.get("required").and_then(serde_json::Value::as_array) else {
228        return input;
229    };
230    let [only_required] = required.as_slice() else {
231        return input;
232    };
233    let Some(property) = only_required.as_str() else {
234        return input;
235    };
236    let value = if property == "command" {
237        command_value_from_sdk_mcp_input(input)
238    } else {
239        input
240    };
241    serde_json::json!({ property: value })
242}
243
244fn normalize_claude_tool_input_aliases(
245    object: &mut serde_json::Map<String, serde_json::Value>,
246    claude_name: &str,
247    roder_name: &str,
248) {
249    match (claude_name, roder_name) {
250        ("Bash", "shell") => move_key_if_missing(object, "command", "command"),
251        ("Read", "read_file") => move_key_if_missing(object, "file_path", "path"),
252        ("LS", "list_files") => move_key_if_missing(object, "path", "path"),
253        ("Grep", "grep") => {
254            move_key_if_missing(object, "pattern", "query");
255            move_key_if_missing(object, "path", "path");
256            move_key_if_missing(object, "glob", "glob");
257        }
258        ("Glob", "glob") => move_key_if_missing(object, "pattern", "pattern"),
259        ("Write", "write_file") => move_key_if_missing(object, "file_path", "path"),
260        ("Edit", "edit") | ("MultiEdit", "multi_edit") => {
261            move_key_if_missing(object, "file_path", "path")
262        }
263        _ => {}
264    }
265}
266
267fn move_key_if_missing(
268    object: &mut serde_json::Map<String, serde_json::Value>,
269    from: &str,
270    to: &str,
271) {
272    if !object.contains_key(to)
273        && let Some(value) = object.remove(from)
274    {
275        object.insert(to.to_string(), value);
276    }
277}
278
279fn command_value_from_sdk_mcp_input(input: serde_json::Value) -> serde_json::Value {
280    let Some(items) = input.as_array() else {
281        return input;
282    };
283    let command = items
284        .iter()
285        .map(|item| match item {
286            serde_json::Value::String(text) => text.clone(),
287            other => other.to_string(),
288        })
289        .collect::<Vec<_>>()
290        .join(" ");
291    serde_json::Value::String(command)
292}
293
294fn normalize_sdk_mcp_aliases(
295    object: &mut serde_json::Map<String, serde_json::Value>,
296    schema: &serde_json::Value,
297) {
298    if schema
299        .get("properties")
300        .and_then(serde_json::Value::as_object)
301        .is_some_and(|properties| properties.contains_key("path"))
302        && !object.contains_key("path")
303        && let Some(value) = object.remove("file_path")
304    {
305        object.insert("path".to_string(), value);
306    }
307}
308
309fn merged_system_prompt(request: &AgentInferenceRequest) -> Option<String> {
310    match (
311        request.instructions.system.as_deref(),
312        request.instructions.developer.as_deref(),
313    ) {
314        (Some(system), Some(developer)) => Some(format!("{system}\n\n{developer}")),
315        (Some(system), None) => Some(system.to_string()),
316        (None, Some(developer)) => Some(developer.to_string()),
317        (None, None) => None,
318    }
319}
320
321/// Maps a Roder reasoning level to the SDK's `EffortLevel`, returning `None`
322/// for unrecognized values so the `--effort` flag is simply omitted.
323fn parse_effort_level(value: &str) -> Option<EffortLevel> {
324    match value.trim().to_ascii_lowercase().as_str() {
325        "low" | "minimal" => Some(EffortLevel::Low),
326        "medium" => Some(EffortLevel::Medium),
327        "high" => Some(EffortLevel::High),
328        "xhigh" | "very-high" | "veryhigh" => Some(EffortLevel::Xhigh),
329        "max" | "maximum" => Some(EffortLevel::Max),
330        _ => None,
331    }
332}
333
334pub fn parse_permission_mode(value: &str) -> anyhow::Result<PermissionMode> {
335    match value.trim().to_ascii_lowercase().replace('_', "-").as_str() {
336        "default" => Ok(PermissionMode::Default),
337        "accept-edits" | "acceptedits" => Ok(PermissionMode::AcceptEdits),
338        "plan" => Ok(PermissionMode::Plan),
339        "bypass-permissions" | "bypasspermissions" => Ok(PermissionMode::BypassPermissions),
340        "dont-ask" | "dontask" => Ok(PermissionMode::DontAsk),
341        "auto" => Ok(PermissionMode::Auto),
342        other => anyhow::bail!(
343            "unsupported Claude Code permission mode {other:?}; expected default, accept-edits, plan, bypass-permissions, dont-ask, or auto"
344        ),
345    }
346}
347
348pub fn parse_setting_sources(values: &[String]) -> anyhow::Result<Vec<SettingSource>> {
349    values
350        .iter()
351        .map(|value| match value.trim().to_ascii_lowercase().as_str() {
352            "user" => Ok(SettingSource::User),
353            "project" => Ok(SettingSource::Project),
354            "local" => Ok(SettingSource::Local),
355            other => anyhow::bail!(
356                "unsupported Claude Code setting source {other:?}; expected user, project, or local"
357            ),
358        })
359        .collect()
360}
361
362#[cfg(test)]
363mod tests {
364    use roder_api::inference::{
365        AgentInferenceRequest, HostedWebSearchConfig, InstructionBundle, ModelSelection,
366        OutputConfig, ReasoningConfig, RuntimeHints, TurnToolOutcome,
367    };
368    use roder_api::tools::{ToolChoice, ToolSpec};
369
370    use super::*;
371
372    #[test]
373    fn parses_permission_modes() {
374        assert!(matches!(
375            parse_permission_mode("accept-edits").unwrap(),
376            PermissionMode::AcceptEdits
377        ));
378        assert!(parse_permission_mode("root").is_err());
379    }
380
381    #[test]
382    fn builds_options_without_running_claude() {
383        let request = AgentInferenceRequest {
384            model: ModelSelection {
385                provider: "claude-code".to_string(),
386                model: "sonnet".to_string(),
387            },
388            instructions: InstructionBundle {
389                system: Some("system".to_string()),
390                developer: Some("developer".to_string()),
391                developer_context: None,
392            },
393            transcript: Vec::new(),
394            tools: Vec::new(),
395            tool_choice: ToolChoice::Auto,
396            reasoning: ReasoningConfig {
397                enabled: true,
398                level: Some("medium".to_string()),
399            },
400            output: OutputConfig {
401                max_tokens: Some(1024),
402                temperature: None,
403                top_p: None,
404                response_format: None,
405            },
406            runtime: RuntimeHints {
407                hosted_web_search: HostedWebSearchConfig::disabled(),
408                ..RuntimeHints::default()
409            },
410            metadata: serde_json::json!({}),
411        };
412        let options = build_options(
413            &ClaudeCodeConfig {
414                cli_path: Some("/bin/claude".to_string()),
415                permission_mode: Some("default".to_string()),
416                setting_sources: Some(vec!["user".to_string(), "project".to_string()]),
417                workspace: None,
418                reuse_cli_session: None,
419            },
420            &request,
421            None,
422            None,
423            None,
424        )
425        .unwrap();
426        assert_eq!(options.model.as_deref(), Some("sonnet"));
427        assert_eq!(options.cli_path.as_deref(), Some("/bin/claude"));
428        assert_eq!(
429            options.system_prompt.as_deref(),
430            Some("system\n\ndeveloper")
431        );
432        assert_eq!(options.effort.map(|effort| effort.as_cli()), Some("medium"));
433        assert!(options.can_use_tool.is_some());
434    }
435
436    struct FakeToolExecutor;
437
438    #[async_trait::async_trait]
439    impl TurnToolExecutor for FakeToolExecutor {
440        async fn execute(&self, _call: ToolCallCompleted) -> anyhow::Result<TurnToolOutcome> {
441            Ok(TurnToolOutcome {
442                result: "ok".to_string(),
443                is_error: false,
444            })
445        }
446    }
447
448    #[test]
449    fn registers_roder_tools_as_sdk_mcp_server() {
450        let mut request = AgentInferenceRequest {
451            model: ModelSelection {
452                provider: "claude-code".to_string(),
453                model: "sonnet".to_string(),
454            },
455            instructions: InstructionBundle::default(),
456            transcript: Vec::new(),
457            tools: vec![ToolSpec {
458                name: "grep".to_string(),
459                description: "Search text".to_string(),
460                parameters: serde_json::json!({
461                    "type": "object",
462                    "properties": {"query": {"type": "string"}},
463                    "required": ["query"],
464                    "additionalProperties": false
465                }),
466            }],
467            tool_choice: ToolChoice::Auto,
468            reasoning: ReasoningConfig::default(),
469            output: OutputConfig::default(),
470            runtime: RuntimeHints {
471                hosted_web_search: HostedWebSearchConfig::disabled(),
472                ..RuntimeHints::default()
473            },
474            metadata: serde_json::json!({}),
475        };
476
477        let options = build_options(
478            &ClaudeCodeConfig::default(),
479            &request,
480            Some(Arc::new(FakeToolExecutor)),
481            None,
482            None,
483        )
484        .unwrap();
485
486        assert!(options.mcp_servers.contains_key("roder"));
487        assert_eq!(
488            options.allowed_tools,
489            vec!["mcp__roder__grep", "mcp__roder__Grep"]
490        );
491        let server = options.sdk_mcp_servers.get("roder").unwrap();
492        let mut names = server
493            .list_tools()
494            .into_iter()
495            .map(|tool| tool.name.clone())
496            .collect::<Vec<_>>();
497        names.sort();
498        assert_eq!(names, vec!["Grep", "grep"]);
499
500        // Built-in Claude Code tools must be disabled so the model uses the
501        // Roder MCP tools instead of bare built-ins (which would be denied).
502        assert!(options.tools_set);
503        assert!(options.tools.is_empty());
504
505        request.tool_choice = ToolChoice::None;
506        let options =
507            build_options(&ClaudeCodeConfig::default(), &request, None, None, None).unwrap();
508        assert!(options.sdk_mcp_servers.is_empty());
509        assert!(options.allowed_tools.is_empty());
510        // With no Roder tools advertised we leave the built-in tool set alone.
511        assert!(!options.tools_set);
512    }
513
514    #[test]
515    fn resume_session_id_is_passed_through_to_options() {
516        let request = AgentInferenceRequest {
517            model: ModelSelection {
518                provider: "claude-code".to_string(),
519                model: "sonnet".to_string(),
520            },
521            instructions: InstructionBundle::default(),
522            transcript: Vec::new(),
523            tools: Vec::new(),
524            tool_choice: ToolChoice::None,
525            reasoning: ReasoningConfig::default(),
526            output: OutputConfig::default(),
527            runtime: RuntimeHints {
528                hosted_web_search: HostedWebSearchConfig::disabled(),
529                ..RuntimeHints::default()
530            },
531            metadata: serde_json::json!({}),
532        };
533
534        let options = build_options(
535            &ClaudeCodeConfig::default(),
536            &request,
537            None,
538            None,
539            Some("session-123"),
540        )
541        .unwrap();
542        assert_eq!(options.resume.as_deref(), Some("session-123"));
543
544        // Blank/whitespace ids never resume a session.
545        let options = build_options(
546            &ClaudeCodeConfig::default(),
547            &request,
548            None,
549            None,
550            Some("  "),
551        )
552        .unwrap();
553        assert!(options.resume.is_none());
554    }
555
556    #[test]
557    fn repairs_raw_string_sdk_mcp_input_for_single_required_property() {
558        let schema = serde_json::json!({
559            "type": "object",
560            "properties": {
561                "path": { "type": "string" }
562            },
563            "required": ["path"],
564            "additionalProperties": false
565        });
566
567        assert_eq!(
568            repair_sdk_mcp_input_for_tool(
569                serde_json::json!("crates/roder-ext-claude-code"),
570                &schema,
571                "",
572                "",
573            ),
574            serde_json::json!({"path": "crates/roder-ext-claude-code"})
575        );
576        assert_eq!(
577            repair_sdk_mcp_input_for_tool(
578                serde_json::json!({"path": "Cargo.toml"}),
579                &schema,
580                "",
581                "",
582            ),
583            serde_json::json!({"path": "Cargo.toml"})
584        );
585        assert_eq!(
586            repair_sdk_mcp_input_for_tool(
587                serde_json::json!({"file_path": "README.md"}),
588                &schema,
589                "",
590                "",
591            ),
592            serde_json::json!({"path": "README.md"})
593        );
594    }
595
596    #[test]
597    fn repairs_array_sdk_mcp_input_for_command_property() {
598        let schema = serde_json::json!({
599            "type": "object",
600            "properties": {
601                "command": { "type": "string" }
602            },
603            "required": ["command"],
604            "additionalProperties": false
605        });
606
607        assert_eq!(
608            repair_sdk_mcp_input_for_tool(serde_json::json!(["ls", "-la"]), &schema, "", ""),
609            serde_json::json!({"command": "ls -la"})
610        );
611    }
612
613    #[test]
614    fn maps_claude_native_alias_inputs_to_roder_arguments() {
615        let grep_schema = serde_json::json!({
616            "type": "object",
617            "properties": {
618                "query": { "type": "string" },
619                "path": { "type": "string" }
620            },
621            "required": ["query"],
622            "additionalProperties": false
623        });
624        assert_eq!(
625            repair_sdk_mcp_input_for_tool(
626                serde_json::json!({"pattern": "TokenUsage", "path": "crates", "include": "*.rs"}),
627                &grep_schema,
628                "Grep",
629                "grep",
630            ),
631            serde_json::json!({"query": "TokenUsage", "path": "crates"})
632        );
633
634        let read_schema = serde_json::json!({
635            "type": "object",
636            "properties": {"path": {"type": "string"}},
637            "required": ["path"],
638            "additionalProperties": false
639        });
640        assert_eq!(
641            repair_sdk_mcp_input_for_tool(
642                serde_json::json!({"file_path": "README.md"}),
643                &read_schema,
644                "Read",
645                "read_file",
646            ),
647            serde_json::json!({"path": "README.md"})
648        );
649    }
650}