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