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