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}