Skip to main content

roder_ext_claude_code/
options.rs

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