Skip to main content

agent_abstraction/
request.rs

1//! Describing a run before it happens.
2
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use crate::agent::{
7    Agent, Continue, EnvPolicy, Format, MAX_COMMAND_LINE, Permission, Plan, STDIN_THRESHOLD,
8};
9use crate::error::Result;
10use crate::session::{Phase, SessionStore};
11
12/// A run, described but not yet started.
13///
14/// Built fluently and then handed to [`crate::run`] or [`crate::stream`]:
15///
16/// ```no_run
17/// use agent_abstraction::{Agent, Permission, Request};
18///
19/// let request = Request::new(Agent::Claude, "summarize this repo")
20///     .model("sonnet")
21///     .permission(Permission::ReadOnly);
22/// ```
23#[derive(Debug, Clone)]
24pub struct Request {
25    pub(crate) agent: Agent,
26    pub(crate) bin: Option<String>,
27    pub(crate) prompt: String,
28    pub(crate) system: Option<String>,
29    pub(crate) model: Option<String>,
30    pub(crate) effort: Option<String>,
31    pub(crate) permission: Permission,
32    pub(crate) format: Option<Format>,
33    pub(crate) cont: Continue,
34    pub(crate) cwd: Option<PathBuf>,
35    pub(crate) env: Vec<(String, String)>,
36    pub(crate) extra_args: Vec<String>,
37    pub(crate) env_policy: EnvPolicy,
38    pub(crate) schema: Option<String>,
39    /// Set by the runner for agents that read the schema from a file.
40    pub(crate) schema_file: Option<String>,
41    pub(crate) timeout: Option<Duration>,
42    /// Set when [`Request::session`] resolved a named session, so the runner
43    /// knows to write the binding back.
44    pub(crate) binding: Option<Binding>,
45}
46
47/// A named session this run is attached to.
48#[derive(Debug, Clone)]
49pub(crate) struct Binding {
50    pub(crate) store: SessionStore,
51    pub(crate) project: PathBuf,
52    pub(crate) name: String,
53    pub(crate) phase: Phase,
54}
55
56impl Request {
57    /// A request for `agent` with `prompt`.
58    ///
59    /// Defaults are deliberately conservative: [`Permission::ReadOnly`],
60    /// [`EnvPolicy::Minimal`], and the agent's structured output format. Widen
61    /// them explicitly.
62    pub fn new(agent: Agent, prompt: impl Into<String>) -> Self {
63        Self {
64            agent,
65            bin: None,
66            prompt: prompt.into(),
67            system: None,
68            model: None,
69            effort: None,
70            permission: Permission::ReadOnly,
71            format: None,
72            cont: Continue::New,
73            cwd: None,
74            env: Vec::new(),
75            extra_args: Vec::new(),
76            env_policy: EnvPolicy::Minimal,
77            schema: None,
78            schema_file: None,
79            timeout: None,
80            binding: None,
81        }
82    }
83
84    /// Override the binary. Defaults to the agent's own name on `PATH`.
85    #[must_use]
86    pub fn bin(mut self, bin: impl Into<String>) -> Self {
87        self.bin = Some(bin.into());
88        self
89    }
90
91    /// A system prompt. Delivered by flag where the agent has one and prepended
92    /// to the prompt where it does not. It is never dropped.
93    #[must_use]
94    pub fn system(mut self, system: impl Into<String>) -> Self {
95        self.system = Some(system.into());
96        self
97    }
98
99    /// Pin the model. Passed through verbatim; this crate does not validate
100    /// model names, so an unknown one surfaces as the agent's own error.
101    #[must_use]
102    pub fn model(mut self, model: impl Into<String>) -> Self {
103        self.model = Some(model.into());
104        self
105    }
106
107    /// Set the reasoning effort level.
108    ///
109    /// Passed through verbatim, exactly like [`Request::model`] and for the same
110    /// reason: the accepted set belongs to the provider, differs between agents,
111    /// and has already grown once. [`crate::Model::efforts`] lists what each
112    /// model is known to take, and nothing here validates against it.
113    ///
114    /// Delivered as `--effort` on Claude and Copilot, and as
115    /// `-c model_reasoning_effort=<level>` on Codex, which has no flag for it.
116    #[must_use]
117    pub fn effort(mut self, effort: impl Into<String>) -> Self {
118        self.effort = Some(effort.into());
119        self
120    }
121
122    /// Set the permission posture.
123    #[must_use]
124    pub fn permission(mut self, permission: Permission) -> Self {
125        self.permission = permission;
126        self
127    }
128
129    /// Pin the output format. Left unset, a run picks the agent's structured
130    /// format, which is also the one that carries a session id.
131    #[must_use]
132    pub fn format(mut self, format: Format) -> Self {
133        self.format = Some(format);
134        self
135    }
136
137    /// The working directory the agent runs in.
138    #[must_use]
139    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
140        self.cwd = Some(cwd.into());
141        self
142    }
143
144    /// Set an environment variable for the child. Repeatable.
145    #[must_use]
146    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
147        self.env.push((key.into(), value.into()));
148        self
149    }
150
151    /// Choose which of the host's environment variables reach the agent.
152    ///
153    /// Defaults to [`EnvPolicy::Minimal`], which passes through only what the
154    /// selected agent needs. Reach for [`EnvPolicy::Inherit`] when the host
155    /// holds nothing the agent should not see, or when something this crate
156    /// does not know about has to reach the CLI.
157    ///
158    /// ```no_run
159    /// # use agent_abstraction::{Agent, EnvPolicy, Request};
160    /// let request = Request::new(Agent::Claude, "review this")
161    ///     .env_policy(EnvPolicy::Inherit);
162    /// ```
163    #[must_use]
164    pub fn env_policy(mut self, policy: EnvPolicy) -> Self {
165        self.env_policy = policy;
166        self
167    }
168
169    /// Kill the run if it has not finished within `timeout`.
170    #[must_use]
171    pub fn timeout(mut self, timeout: Duration) -> Self {
172        self.timeout = Some(timeout);
173        self
174    }
175
176    /// Append raw arguments after everything this crate builds.
177    ///
178    /// The escape hatch for agent-specific flags with no unified spelling.
179    ///
180    /// **This voids the crate's guarantees.** Arguments land after the generated
181    /// ones, so they can contradict [`Request::permission`], redirect the output
182    /// format the parser expects, or point the run at a different session.
183    /// Codex's `-c key=value` in particular can rewrite sandbox and approval
184    /// policy for the invocation. Nothing here is validated, and a security
185    /// review of the permission posture means little without also reviewing
186    /// whatever is passed here.
187    ///
188    /// Arguments are passed straight to the binary without a shell.
189    #[must_use]
190    pub fn unchecked_args<I, S>(mut self, args: I) -> Self
191    where
192        I: IntoIterator<Item = S>,
193        S: Into<String>,
194    {
195        self.extra_args.extend(args.into_iter().map(Into::into));
196        self
197    }
198
199    /// Constrain the answer to a JSON Schema.
200    ///
201    /// The agent is asked to return a value conforming to `schema`, which
202    /// [`Outcome::structured`] then carries already parsed. Useful when the
203    /// answer is data rather than prose: a set of review findings, an
204    /// extraction, a classification. Reading it beats parsing prose, which is
205    /// a guess about formatting the model never promised.
206    ///
207    /// The two CLIs that support this take it differently, and the difference
208    /// is hidden: Claude accepts the schema inline, Codex reads it from a file
209    /// this crate writes for the run and removes afterwards. **Copilot 1.0.75
210    /// has no schema support**, so asking is [`crate::Error::Unsupported`]
211    /// rather than a prose answer presented as data.
212    ///
213    /// The schema is passed through unvalidated; a malformed one surfaces as
214    /// the agent's own error.
215    ///
216    /// # Write the schema strictly
217    ///
218    /// Codex sends it to `OpenAI`'s structured-output API, which rejects anything
219    /// permissive. Every object needs `"additionalProperties": false` and every
220    /// property listed in `required`, or the request fails with a 400 before
221    /// the model runs:
222    ///
223    /// ```text
224    /// 'additionalProperties' is required to be supplied and to be false
225    /// ```
226    ///
227    /// Claude is more forgiving, so a schema that works there can still fail on
228    /// Codex. Writing to the stricter rule keeps one schema usable for both.
229    ///
230    /// [`Outcome::structured`]: crate::Outcome::structured
231    #[must_use]
232    pub fn schema(mut self, schema: impl Into<String>) -> Self {
233        self.schema = Some(schema.into());
234        self
235    }
236
237    /// Continue an earlier conversation by its native id, bypassing the session
238    /// store. Prefer [`Request::session`] unless you are tracking ids yourself.
239    #[must_use]
240    pub fn resume(mut self, id: impl Into<String>) -> Self {
241        self.cont = Continue::Resume(id.into());
242        self
243    }
244
245    /// Start a **new** conversation under an id you choose, rather than one the
246    /// agent picks.
247    ///
248    /// Useful when a host already has its own identifier for a thread and wants
249    /// the agent's session to match it, with no mapping table in between. The
250    /// id is known before the process starts, so the association survives a run
251    /// that dies mid-turn.
252    ///
253    /// Only Claude and Copilot accept an assigned id
254    /// ([`SessionSupport::Minted`]). Codex reveals its `thread_id` only in its
255    /// own output, so this is [`crate::Error::Unsupported`] for it, raised when
256    /// the argv is built rather than silently starting an unrelated session.
257    ///
258    /// Both CLIs require a valid UUID here; this crate passes the string through
259    /// without checking, so a non-UUID surfaces as the agent's own error.
260    ///
261    /// [`SessionSupport::Minted`]: crate::SessionSupport::Minted
262    #[must_use]
263    pub fn session_id(mut self, id: impl Into<String>) -> Self {
264        self.cont = Continue::NewWith(id.into());
265        self
266    }
267
268    /// Attach this run to a caller-owned session name.
269    ///
270    /// The store decides whether this turn creates, continues, or forks, and the
271    /// binding is written back once the run yields an id. `fork` branches a new
272    /// conversation off the stored one instead of appending to it.
273    ///
274    /// The store is cloned into the request so the run can write the binding
275    /// back without borrowing it. That clone is a [`PathBuf`], not the sessions
276    /// themselves: records are read and written on demand and never held in
277    /// memory, so this stays cheap however many sessions exist.
278    ///
279    /// # Errors
280    /// [`crate::Error::SessionConflict`] if the name belongs to another agent,
281    /// or [`crate::Error::Unsupported`] if this agent cannot fork or has no
282    /// session id at all.
283    pub fn session(
284        mut self,
285        store: &SessionStore,
286        project: impl AsRef<Path>,
287        name: impl Into<String>,
288        fork: bool,
289    ) -> Result<Self> {
290        let project = project.as_ref().to_path_buf();
291        let name = name.into();
292        let (phase, cont) = store.plan(self.agent, &project, &name, fork)?;
293        self.cont = cont;
294        self.binding = Some(Binding {
295            store: store.clone(),
296            project,
297            name,
298            phase,
299        });
300        // A named session needs an id back. The default format carries one, so
301        // this only has to refuse a format the caller pinned that cannot:
302        // otherwise the run would succeed and then silently fail to bind.
303        //
304        // Deliberately no longer *sets* the format. Doing so overrode the
305        // caller's streaming intent, which is how a named session, the case a
306        // chat UI always uses, ended up unable to stream.
307        if let Some(format) = self.format
308            && !self.agent.format_carries_session(format)
309        {
310            return Err(crate::Error::Unsupported {
311                agent: self.agent,
312                what: "a named session under an output format that carries no session id",
313            });
314        }
315        Ok(self)
316    }
317
318    /// Roughly how many bytes of command line this request needs.
319    ///
320    /// Only the caller-supplied text is counted; the flags themselves are a
321    /// bounded handful of short literals. Used to decide whether the prompt has
322    /// to move to stdin.
323    fn argv_weight(&self) -> usize {
324        self.prompt.len()
325            + self.system.as_ref().map_or(0, String::len)
326            + self.extra_args.iter().map(String::len).sum::<usize>()
327            // Claude's schema rides the command line too.
328            + self.schema.as_ref().map_or(0, String::len)
329    }
330
331    /// The format this request will actually use.
332    #[must_use]
333    pub fn effective_format(&self) -> Format {
334        self.format.unwrap_or_default()
335    }
336
337    /// Whether this turn opens, continues, or branches its named session.
338    /// `None` when the request is not attached to one.
339    ///
340    /// Known before the run starts, so a UI can label the turn up front.
341    #[must_use]
342    pub fn session_phase(&self) -> Option<Phase> {
343        self.binding.as_ref().map(|b| b.phase)
344    }
345
346    /// Freeze the request into the [`Plan`] an argv is built from.
347    ///
348    /// Crate-internal: `Plan` is how the crate works, not what it promises, and
349    /// a caller that wants to see the command line should use
350    /// [`Request::argv`].
351    #[must_use]
352    pub(crate) fn plan(&self) -> Plan {
353        Plan {
354            bin: self
355                .bin
356                .clone()
357                .unwrap_or_else(|| self.agent.bin().to_string()),
358            prompt: self.prompt.clone(),
359            system: self.system.clone(),
360            model: self.model.clone(),
361            effort: self.effort.clone(),
362            permission: self.permission,
363            format: self.effective_format(),
364            cont: self.cont.clone(),
365            // Measure the whole command line, not just the prompt: for Codex
366            // and Copilot the system text is prepended to it, and for Claude the
367            // system prompt rides its own argument. A small prompt with a large
368            // system prompt would otherwise still hit E2BIG.
369            stdin_prompt: self.argv_weight() >= STDIN_THRESHOLD,
370            schema: self.schema.clone(),
371            schema_file: self.schema_file.clone(),
372        }
373    }
374
375    /// The full command line, for logging or for showing a user exactly what
376    /// will run before they approve it.
377    ///
378    /// # Errors
379    /// [`crate::Error::Unsupported`] if the agent cannot honour this request.
380    pub fn argv(&self) -> Result<Vec<String>> {
381        Ok(self
382            .typed_argv()?
383            .into_iter()
384            .map(|arg| arg.value)
385            .collect())
386    }
387
388    /// The command line with per-argument sensitivity, the single source both
389    /// the executable and the redacted forms are derived from.
390    pub(crate) fn typed_argv(&self) -> Result<Vec<crate::agent::Arg>> {
391        use crate::agent::{Arg, Sensitivity};
392
393        let plan = self.plan();
394        let mut argv = self.agent.typed_argv(&plan)?;
395        // Raw arguments have no known shape, so they are assumed to carry
396        // secrets rather than assumed not to.
397        argv.extend(self.extra_args.iter().map(|value| Arg {
398            value: value.clone(),
399            sensitivity: Sensitivity::Unchecked,
400        }));
401
402        // Moving the prompt to stdin does not move anything else: Claude keeps
403        // the system prompt on its own argument, and raw arguments are always on
404        // the line, so a small prompt with a large system prompt still
405        // overflows. Name the culprit rather than letting the OS answer E2BIG.
406        let total: usize = argv.iter().map(|a| a.value.len()).sum();
407        if total > MAX_COMMAND_LINE {
408            let system = self.system.as_ref().map_or(0, String::len);
409            let extra: usize = self.extra_args.iter().map(String::len).sum();
410            let prompt = if plan.stdin_prompt {
411                0
412            } else {
413                self.prompt.len()
414            };
415            let (what, size) = if system >= extra && system >= prompt {
416                ("the system prompt", system)
417            } else if extra >= prompt {
418                ("the unchecked arguments", extra)
419            } else {
420                ("the prompt", prompt)
421            };
422            return Err(crate::Error::CommandLineTooLarge {
423                agent: self.agent,
424                what,
425                size,
426                limit: MAX_COMMAND_LINE,
427            });
428        }
429        Ok(argv)
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    /// The defaults are the safe posture, so a caller who configures nothing
438    /// does not get the permissive one by accident.
439    #[test]
440    fn defaults_are_read_only_isolated_and_structured() {
441        let request = Request::new(Agent::Claude, "hi");
442        assert_eq!(request.permission, Permission::ReadOnly);
443        assert_eq!(
444            request.env_policy,
445            EnvPolicy::Minimal,
446            "full environment inheritance must be an explicit decision"
447        );
448        assert_eq!(
449            request.effective_format(),
450            Format::Stream,
451            "the default must be watchable: Json reports nothing until the turn ends"
452        );
453        let argv = request.argv().unwrap();
454        assert!(argv.contains(&"--disallowedTools".to_string()));
455    }
456
457    #[test]
458    fn extra_args_land_after_everything_the_crate_builds() {
459        let argv = Request::new(Agent::Claude, "hi")
460            .unchecked_args(["--add-dir", "/tmp/extra"])
461            .argv()
462            .unwrap();
463        assert_eq!(argv[argv.len() - 2..], ["--add-dir", "/tmp/extra"]);
464    }
465
466    #[test]
467    fn a_large_prompt_moves_to_stdin() {
468        let big = "x".repeat(STDIN_THRESHOLD + 1);
469        let plan = Request::new(Agent::Claude, big.clone()).plan();
470        assert!(plan.stdin_prompt);
471        let argv = Request::new(Agent::Claude, big).argv().unwrap();
472        assert!(
473            !argv.iter().any(|a| a.len() > STDIN_THRESHOLD),
474            "a large prompt must not ride the argv"
475        );
476    }
477
478    /// Moving the prompt to stdin does not move the system prompt, so a small
479    /// prompt with a huge system prompt still overflows the command line. The
480    /// OS would answer `E2BIG` naming nothing; this names the culprit.
481    #[test]
482    fn an_oversized_system_prompt_is_reported_rather_than_left_to_e2big() {
483        let err = Request::new(Agent::Claude, "tiny")
484            .system("s".repeat(MAX_COMMAND_LINE + 1))
485            .argv()
486            .unwrap_err();
487        let crate::Error::CommandLineTooLarge { what, .. } = err else {
488            panic!("expected CommandLineTooLarge, got {err:?}")
489        };
490        assert_eq!(what, "the system prompt");
491    }
492
493    #[test]
494    fn oversized_unchecked_arguments_are_named_too() {
495        let err = Request::new(Agent::Claude, "tiny")
496            .unchecked_args([format!("--x={}", "y".repeat(MAX_COMMAND_LINE))])
497            .argv()
498            .unwrap_err();
499        assert!(matches!(
500            err,
501            crate::Error::CommandLineTooLarge {
502                what: "the unchecked arguments",
503                ..
504            }
505        ));
506    }
507
508    #[test]
509    fn a_small_prompt_stays_on_the_argv() {
510        assert!(!Request::new(Agent::Claude, "hi").plan().stdin_prompt);
511    }
512
513    #[test]
514    fn a_named_session_selects_a_format_that_carries_an_id() {
515        let dir = std::env::temp_dir().join(format!("aa-req-{}", std::process::id()));
516        let store = SessionStore::open(&dir);
517        let request = Request::new(Agent::Claude, "hi")
518            .session(&store, "/proj", "chat", false)
519            .unwrap();
520        assert_eq!(
521            request.effective_format(),
522            Format::Stream,
523            "the default must be watchable: Json reports nothing until the turn ends"
524        );
525
526        // An explicit format is respected over the automatic upgrade.
527        let pinned = Request::new(Agent::Claude, "hi")
528            .format(Format::Stream)
529            .session(&store, "/proj", "chat2", false)
530            .unwrap();
531        assert_eq!(pinned.effective_format(), Format::Stream);
532        std::fs::remove_dir_all(&dir).ok();
533    }
534
535    #[test]
536    fn resume_bypasses_the_store() {
537        let argv = Request::new(Agent::Claude, "hi")
538            .resume("sess-9")
539            .argv()
540            .unwrap();
541        let at = argv.iter().position(|a| a == "--resume").unwrap();
542        assert_eq!(argv[at + 1], "sess-9");
543    }
544}