Skip to main content

gate4agent_adapters/
one_shot.rs

1use gate4agent_types::{
2    AdapterId, LaunchSpec, SessionOptionSelection, SessionOptionValue,
3    PROVIDER_EVENT_TEXT_MAX_BYTES,
4};
5use thiserror::Error;
6
7pub const ONE_SHOT_REVISION: &str = "gate4agent-one-shot/orca-d8629c4/v2";
8pub const CLAUDE_CODE_INLINE_REVISION: &str = "gate4agent-inline/claude-code-2.1/v1";
9pub const CODEX_CLI_INLINE_REVISION: &str = "gate4agent-inline/codex-cli-0.144/v1";
10pub const KIMI_CODE_INLINE_REVISION: &str = "gate4agent-inline/kimi-code-0.31/v1";
11pub const ONE_SHOT_OUTPUT_MAX_BYTES: usize = 4 * 1024 * 1024;
12pub const ONE_SHOT_TIMEOUT_SECONDS: u64 = 60;
13pub const ONE_SHOT_THINKING_OPTION_ID: &str = "thinking-level";
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum OneShotPromptDelivery {
17    StdinClose,
18    Positional,
19}
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum OneShotModelSource {
23    Static,
24    Dynamic,
25}
26
27#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
28pub enum OneShotSessionPersistence {
29    #[default]
30    Ephemeral,
31    Persist,
32}
33
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct OneShotThinkingLevel {
36    pub id: String,
37    pub label: String,
38}
39
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct OneShotModelSpec {
42    pub id: String,
43    pub label: String,
44    pub thinking_levels: Vec<OneShotThinkingLevel>,
45    pub default_thinking_level: Option<String>,
46}
47
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct OneShotAdapterSpec {
50    pub adapter_id: AdapterId,
51    pub label: String,
52    pub prompt_delivery: OneShotPromptDelivery,
53    pub model_source: OneShotModelSource,
54    pub models: Vec<OneShotModelSpec>,
55    pub default_model_id: String,
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct OneShotPlan {
60    pub program: String,
61    pub args: Vec<String>,
62    pub stdin_payload: Option<String>,
63    pub label: String,
64    pub applied: SessionOptionSelection,
65}
66
67pub fn one_shot_specs() -> Vec<OneShotAdapterSpec> {
68    ["claude", "codex", "kimi"]
69        .into_iter()
70        .map(|id| spec_for(id).expect("hardcoded one-shot adapter"))
71        .collect()
72}
73
74pub fn one_shot_spec(adapter_id: &AdapterId) -> Result<OneShotAdapterSpec, OneShotAdapterError> {
75    spec_for(adapter_id.as_str())
76}
77
78pub fn resolve_one_shot_plan(
79    adapter_id: &AdapterId,
80    launch: &LaunchSpec,
81    prompt: &str,
82    selection: Option<&SessionOptionSelection>,
83) -> Result<OneShotPlan, OneShotAdapterError> {
84    resolve_one_shot_plan_with_persistence(
85        adapter_id,
86        launch,
87        prompt,
88        selection,
89        OneShotSessionPersistence::Ephemeral,
90    )
91}
92
93pub fn resolve_one_shot_plan_with_persistence(
94    adapter_id: &AdapterId,
95    launch: &LaunchSpec,
96    prompt: &str,
97    selection: Option<&SessionOptionSelection>,
98    persistence: OneShotSessionPersistence,
99) -> Result<OneShotPlan, OneShotAdapterError> {
100    if prompt.trim().is_empty()
101        || prompt.len() > PROVIDER_EVENT_TEXT_MAX_BYTES
102        || prompt.contains('\0')
103    {
104        return Err(OneShotAdapterError::InvalidPrompt);
105    }
106    if launch.program.trim().is_empty() || launch.program.contains('\0') {
107        return Err(OneShotAdapterError::InvalidLaunch);
108    }
109    if launch.fixed_args.iter().any(|arg| arg.contains('\0')) {
110        return Err(OneShotAdapterError::InvalidLaunch);
111    }
112
113    let spec = one_shot_spec(adapter_id)?;
114    if persistence == OneShotSessionPersistence::Persist && adapter_id.as_str() != "codex" {
115        return Err(OneShotAdapterError::PersistentSessionsUnsupported(
116            adapter_id.as_str().to_owned(),
117        ));
118    }
119    let (model, thinking) = resolve_selection(&spec, selection)?;
120    let mut args = launch.fixed_args.clone();
121    match adapter_id.as_str() {
122        "claude" => {
123            args.extend(strings(&[
124                "-p",
125                "--output-format",
126                "text",
127                "--model",
128                &model,
129                "--permission-mode",
130                "plan",
131            ]));
132            if let Some(thinking) = &thinking {
133                args.extend(["--effort".to_owned(), thinking.clone()]);
134            }
135        }
136        "codex" => {
137            args.push("exec".to_owned());
138            if persistence == OneShotSessionPersistence::Ephemeral {
139                args.push("--ephemeral".to_owned());
140            }
141            args.extend(strings(&[
142                "--skip-git-repo-check", "-s", "read-only", "--model", &model,
143            ]));
144            if let Some(thinking) = &thinking {
145                args.extend([
146                    "-c".to_owned(),
147                    format!("model_reasoning_effort={thinking}"),
148                ]);
149            }
150        }
151        "kimi" => {
152            args.extend(strings(&["-p", prompt, "--output-format", "text"]));
153        }
154        id => return Err(OneShotAdapterError::UnsupportedAdapter(id.to_owned())),
155    }
156
157    let mut applied = SessionOptionSelection::new(model);
158    if let Some(thinking) = thinking {
159        applied
160            .values
161            .insert(ONE_SHOT_THINKING_OPTION_ID.to_owned(), thinking.into());
162    }
163    Ok(OneShotPlan {
164        program: launch.program.clone(),
165        args,
166        stdin_payload: (spec.prompt_delivery == OneShotPromptDelivery::StdinClose)
167            .then(|| prompt.to_owned()),
168        label: spec.label,
169        applied,
170    })
171}
172
173fn resolve_selection(
174    spec: &OneShotAdapterSpec,
175    selection: Option<&SessionOptionSelection>,
176) -> Result<(String, Option<String>), OneShotAdapterError> {
177    if let Some(selection) = selection {
178        selection
179            .validate()
180            .map_err(|_| OneShotAdapterError::InvalidSelection)?;
181        if selection
182            .values
183            .keys()
184            .any(|key| key != ONE_SHOT_THINKING_OPTION_ID)
185        {
186            return Err(OneShotAdapterError::UnknownOption);
187        }
188    }
189    let model_id = selection
190        .map(|selection| selection.model.clone())
191        .unwrap_or_else(|| spec.default_model_id.clone());
192    let known = spec.models.iter().find(|model| model.id == model_id);
193    if known.is_none() && spec.model_source == OneShotModelSource::Static {
194        return Err(OneShotAdapterError::UnknownModel(model_id));
195    }
196    let requested_thinking = selection
197        .and_then(|selection| selection.values.get(ONE_SHOT_THINKING_OPTION_ID))
198        .map(|value| match value {
199            SessionOptionValue::String(value) => Ok(value.clone()),
200            SessionOptionValue::Boolean(_) => Err(OneShotAdapterError::InvalidSelection),
201        })
202        .transpose()?;
203    let default_thinking = known
204        .and_then(|model| model.default_thinking_level.clone())
205        .or_else(|| {
206            (spec.model_source == OneShotModelSource::Dynamic
207                && supports_openai_thinking(&model_id))
208            .then(|| "low".to_owned())
209        });
210    let thinking = requested_thinking.or(default_thinking);
211    if let (Some(model), Some(thinking)) = (known, &thinking) {
212        if !model.thinking_levels.is_empty()
213            && !model
214                .thinking_levels
215                .iter()
216                .any(|level| &level.id == thinking)
217        {
218            return Err(OneShotAdapterError::UnknownThinkingLevel(thinking.clone()));
219        }
220        if model.thinking_levels.is_empty() && spec.model_source == OneShotModelSource::Static {
221            return Err(OneShotAdapterError::ThinkingUnsupported(model.id.clone()));
222        }
223    }
224    Ok((model_id, thinking))
225}
226
227fn spec_for(id: &str) -> Result<OneShotAdapterSpec, OneShotAdapterError> {
228    let (label, delivery, source, models, default_model_id) = match id {
229        "claude" => (
230            "Claude",
231            OneShotPromptDelivery::StdinClose,
232            OneShotModelSource::Static,
233            vec![
234                model("haiku", "Haiku", &[], None),
235                model("sonnet", "Sonnet", &claude_thinking(), Some("low")),
236                model("opus", "Opus", &claude_thinking(), Some("low")),
237            ],
238            "sonnet",
239        ),
240        "codex" => (
241            "Codex",
242            OneShotPromptDelivery::StdinClose,
243            OneShotModelSource::Dynamic,
244            [
245                "gpt-5.5",
246                "gpt-5.4",
247                "gpt-5.4-mini",
248                "gpt-5.3-codex",
249                "gpt-5.3-codex-spark",
250                "gpt-5.2",
251            ]
252            .into_iter()
253            .map(|id| model(id, id, &openai_thinking(), Some("low")))
254            .collect(),
255            "gpt-5.5",
256        ),
257        "kimi" => (
258            "Kimi Code",
259            OneShotPromptDelivery::Positional,
260            OneShotModelSource::Static,
261            vec![model("default", "Provider default", &[], None)],
262            "default",
263        ),
264        id => return Err(OneShotAdapterError::UnsupportedAdapter(id.to_owned())),
265    };
266    Ok(OneShotAdapterSpec {
267        adapter_id: AdapterId::new(id).expect("hardcoded adapter ID"),
268        label: label.to_owned(),
269        prompt_delivery: delivery,
270        model_source: source,
271        models,
272        default_model_id: default_model_id.to_owned(),
273    })
274}
275
276fn model(
277    id: &str,
278    label: &str,
279    thinking: &[(&str, &str)],
280    default_thinking: Option<&str>,
281) -> OneShotModelSpec {
282    OneShotModelSpec {
283        id: id.to_owned(),
284        label: label.to_owned(),
285        thinking_levels: thinking
286            .iter()
287            .map(|(id, label)| OneShotThinkingLevel {
288                id: (*id).to_owned(),
289                label: (*label).to_owned(),
290            })
291            .collect(),
292        default_thinking_level: default_thinking.map(str::to_owned),
293    }
294}
295
296fn openai_thinking() -> [(&'static str, &'static str); 4] {
297    [
298        ("low", "Low"),
299        ("medium", "Medium"),
300        ("high", "High"),
301        ("xhigh", "Extra High"),
302    ]
303}
304
305fn claude_thinking() -> [(&'static str, &'static str); 5] {
306    [
307        ("low", "Low"),
308        ("medium", "Medium"),
309        ("high", "High"),
310        ("xhigh", "Extra High"),
311        ("max", "Max"),
312    ]
313}
314
315fn supports_openai_thinking(id: &str) -> bool {
316    let id = id.to_ascii_lowercase();
317    id.contains("gpt-5") || id.contains("codex")
318}
319
320fn strings(values: &[&str]) -> Vec<String> {
321    values.iter().map(|value| (*value).to_owned()).collect()
322}
323
324#[derive(Clone, Debug, Error, Eq, PartialEq)]
325pub enum OneShotAdapterError {
326    #[error("unsupported one-shot adapter '{0}'")]
327    UnsupportedAdapter(String),
328    #[error("one-shot prompt is empty, contains NUL, or exceeds its bound")]
329    InvalidPrompt,
330    #[error("one-shot launch contract is invalid")]
331    InvalidLaunch,
332    #[error("one-shot session-option selection is invalid")]
333    InvalidSelection,
334    #[error("one-shot selection contains an unknown option")]
335    UnknownOption,
336    #[error("one-shot model '{0}' is not declared by the provider adapter")]
337    UnknownModel(String),
338    #[error("one-shot model '{0}' does not support thinking effort")]
339    ThinkingUnsupported(String),
340    #[error("one-shot thinking level '{0}' is not declared by the model")]
341    UnknownThinkingLevel(String),
342    #[error("one-shot adapter '{0}' does not support persistent sessions")]
343    PersistentSessionsUnsupported(String),
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use std::collections::BTreeSet;
350
351    fn launch(program: &str) -> LaunchSpec {
352        LaunchSpec {
353            program: program.to_owned(),
354            fixed_args: vec!["fixed".to_owned()],
355        }
356    }
357
358    #[test]
359    fn one_shot_inventory_includes_current_target_contracts() {
360        let specs = one_shot_specs();
361        let actual = specs
362            .iter()
363            .map(|spec| spec.adapter_id.as_str())
364            .collect::<BTreeSet<_>>();
365        assert_eq!(actual, ["claude", "codex", "kimi"].into_iter().collect());
366        assert_eq!(
367            specs
368                .iter()
369                .find(|spec| spec.adapter_id.as_str() == "codex")
370                .unwrap()
371                .default_model_id,
372            "gpt-5.5"
373        );
374    }
375
376    #[test]
377    fn exact_provider_plans_preserve_prompt_delivery_and_safety_flags() {
378        let claude = resolve_one_shot_plan(
379            &AdapterId::new("claude").unwrap(),
380            &launch("claude-custom"),
381            "large prompt",
382            None,
383        )
384        .unwrap();
385        assert_eq!(claude.program, "claude-custom");
386        assert_eq!(claude.stdin_payload.as_deref(), Some("large prompt"));
387        assert!(claude
388            .args
389            .windows(2)
390            .any(|pair| pair == ["--permission-mode", "plan"]));
391        assert!(claude
392            .args
393            .windows(2)
394            .any(|pair| pair == ["--effort", "low"]));
395
396        let codex = resolve_one_shot_plan(
397            &AdapterId::new("codex").unwrap(),
398            &launch("codex"),
399            "prompt",
400            Some(
401                &SessionOptionSelection::new("gpt-5.4")
402                    .with_value(ONE_SHOT_THINKING_OPTION_ID, "xhigh"),
403            ),
404        )
405        .unwrap();
406        assert!(codex.args.contains(&"--ephemeral".to_owned()));
407        assert!(codex.args.contains(&"read-only".to_owned()));
408        assert!(codex
409            .args
410            .contains(&"model_reasoning_effort=xhigh".to_owned()));
411
412        let kimi = resolve_one_shot_plan(
413            &AdapterId::new("kimi").unwrap(),
414            &launch("kimi"),
415            "prompt in argv",
416            None,
417        )
418        .unwrap();
419        assert_eq!(kimi.stdin_payload, None);
420        assert_eq!(
421            kimi.args,
422            ["fixed", "-p", "prompt in argv", "--output-format", "text"]
423        );
424    }
425
426    #[test]
427    fn codex_persistence_is_explicit_and_preserves_the_exact_safe_plan() {
428        assert_eq!(
429            OneShotSessionPersistence::default(),
430            OneShotSessionPersistence::Ephemeral
431        );
432        let adapter_id = AdapterId::new("codex").unwrap();
433        let selection = SessionOptionSelection::new("gpt-5.4")
434            .with_value(ONE_SHOT_THINKING_OPTION_ID, "xhigh");
435        let legacy = resolve_one_shot_plan(
436            &adapter_id,
437            &launch("codex"),
438            "prompt",
439            Some(&selection),
440        )
441        .unwrap();
442        let ephemeral = resolve_one_shot_plan_with_persistence(
443            &adapter_id,
444            &launch("codex"),
445            "prompt",
446            Some(&selection),
447            OneShotSessionPersistence::Ephemeral,
448        )
449        .unwrap();
450        assert_eq!(legacy, ephemeral);
451
452        let persistent = resolve_one_shot_plan_with_persistence(
453            &adapter_id,
454            &launch("codex"),
455            "prompt",
456            Some(&selection),
457            OneShotSessionPersistence::Persist,
458        )
459        .unwrap();
460        assert_eq!(
461            persistent.args,
462            [
463                "fixed",
464                "exec",
465                "--skip-git-repo-check",
466                "-s",
467                "read-only",
468                "--model",
469                "gpt-5.4",
470                "-c",
471                "model_reasoning_effort=xhigh",
472            ]
473        );
474        assert_eq!(persistent.stdin_payload.as_deref(), Some("prompt"));
475    }
476
477    #[test]
478    fn persistent_sessions_fail_closed_for_non_codex_adapters() {
479        assert!(matches!(
480            resolve_one_shot_plan_with_persistence(
481                &AdapterId::new("claude").unwrap(),
482                &launch("claude"),
483                "prompt",
484                None,
485                OneShotSessionPersistence::Persist,
486            ),
487            Err(OneShotAdapterError::PersistentSessionsUnsupported(id)) if id == "claude"
488        ));
489    }
490
491    #[test]
492    fn every_registered_provider_builds_its_default_execution_path() {
493        let expected_flag = [
494            ("claude", "--permission-mode"),
495            ("codex", "--ephemeral"),
496            ("kimi", "-p"),
497        ];
498        for (id, flag) in expected_flag {
499            let plan = resolve_one_shot_plan(
500                &AdapterId::new(id).unwrap(),
501                &launch(&format!("{id}-binary")),
502                "provider prompt",
503                None,
504            )
505            .unwrap();
506            assert_eq!(plan.program, format!("{id}-binary"));
507            assert_eq!(plan.args.first().map(String::as_str), Some("fixed"));
508            assert!(plan.args.iter().any(|arg| arg == flag), "{id}: {flag}");
509            let delivery = one_shot_spec(&AdapterId::new(id).unwrap())
510                .unwrap()
511                .prompt_delivery;
512            assert_eq!(
513                plan.stdin_payload.is_some(),
514                delivery == OneShotPromptDelivery::StdinClose,
515                "{id}"
516            );
517        }
518    }
519
520    #[test]
521    fn static_models_fail_closed_while_dynamic_models_remain_discovery_compatible() {
522        assert!(matches!(
523            resolve_one_shot_plan(
524                &AdapterId::new("claude").unwrap(),
525                &launch("claude"),
526                "prompt",
527                Some(&SessionOptionSelection::new("unknown")),
528            ),
529            Err(OneShotAdapterError::UnknownModel(_))
530        ));
531        let dynamic = resolve_one_shot_plan(
532            &AdapterId::new("codex").unwrap(),
533            &launch("codex"),
534            "prompt",
535            Some(&SessionOptionSelection::new("vendor/new-model")),
536        )
537        .unwrap();
538        assert!(dynamic.args.contains(&"vendor/new-model".to_owned()));
539    }
540}