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