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