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