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