basis_tasks/spec.rs
1//! `RunSpec`: one task's worth of spawn request.
2//!
3//! Built the way `basis::RunSpec` is — `new` plus `with_*` methods that
4//! return new values — because this is the same kind of thing one layer up:
5//! `basis::RunSpec` is a run's per-turn intent against an already-open
6//! [`basis::Workspace`]; this is what a *durable* task additionally records
7//! so a later attach, in a process that may not be this one, can open that
8//! workspace itself and mint the run.
9
10use std::time::Duration;
11
12use basis::{ConfigLookup, Effort, SystemPrompt};
13
14use crate::{approve::Approve, handle::TaskHandle};
15
16/// The default deadline an unattended task is given when nothing else names
17/// one: 30 minutes. A spawned task may never be waited on by an attentive
18/// caller, so — unlike an attended one-shot, which is unbounded unless asked
19/// — it always gets a finite service bound (`with_deadline` narrows it).
20pub const DEFAULT_DEADLINE: Duration = Duration::from_secs(30 * 60);
21
22/// Which conversation a spawned task picks up, if any.
23#[derive(Debug, Clone, Default, PartialEq, Eq)]
24pub enum Continuation {
25 /// Opens a new conversation. The ordinary case.
26 #[default]
27 New,
28 /// The conversation this workspace was last worked in — what a bare
29 /// `--continue` resolves against.
30 Latest,
31 /// A specific conversation, by the task that opened or last continued
32 /// it — what `--continue --session <ID>` resolves against.
33 Named(TaskHandle),
34}
35
36/// One task's worth of spawn request: `basis::RunSpec`'s per-run intent,
37/// plus the workspace-level overrides a [`basis::Workspace`] normally fixes
38/// once, and the facts a task that may run unattended, in another process,
39/// additionally needs recorded.
40#[derive(Debug, Clone, PartialEq)]
41pub struct RunSpec {
42 pub(crate) prompt: String,
43 pub(crate) provider: Option<String>,
44 pub(crate) base_url: Option<String>,
45 pub(crate) model: Option<String>,
46 pub(crate) shell: bool,
47 pub(crate) system_prompt: Option<SystemPrompt>,
48 pub(crate) effort: Option<Effort>,
49 pub(crate) approve: Approve,
50 pub(crate) deadline: Option<Duration>,
51 pub(crate) tool_budget: Option<usize>,
52 pub(crate) token_budget: Option<u64>,
53 pub(crate) detached: bool,
54 pub(crate) continuation: Continuation,
55 pub(crate) hop: u32,
56 pub(crate) not_after_ms: Option<u64>,
57 pub(crate) answering: bool,
58 pub(crate) config: ConfigLookup,
59}
60
61impl RunSpec {
62 pub fn new(prompt: impl Into<String>) -> Self {
63 Self {
64 prompt: prompt.into(),
65 provider: None,
66 base_url: None,
67 model: None,
68 shell: true,
69 system_prompt: None,
70 effort: None,
71 approve: Approve::default(),
72 deadline: None,
73 tool_budget: None,
74 token_budget: None,
75 detached: false,
76 continuation: Continuation::default(),
77 hop: 0,
78 not_after_ms: None,
79 answering: false,
80 config: ConfigLookup::None,
81 }
82 }
83
84 /// Where the task's workspace gets its `config.json` answers, every time
85 /// it is attached — which may be in another process, so the choice is
86 /// recorded rather than left to whoever attaches. Unset, none: the SDK
87 /// reads no configuration file it was not handed.
88 /// [`ConfigLookup::Discover`] is what `basis spawn` records.
89 pub fn with_config(self, config: ConfigLookup) -> Self {
90 Self { config, ..self }
91 }
92
93 /// Marks the task as answering a caller, who can be handed files: the
94 /// task is offered [`ATTACH_FILE`](crate::ATTACH_FILE) to add a
95 /// workspace file to its answer, read back with
96 /// [`Tasks::answer_files`](crate::Tasks::answer_files) (ADR-0041 §7).
97 pub fn answering(self) -> Self {
98 Self {
99 answering: true,
100 ..self
101 }
102 }
103
104 pub fn with_provider(self, provider: impl Into<String>) -> Self {
105 Self {
106 provider: Some(provider.into()),
107 ..self
108 }
109 }
110
111 pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
112 Self {
113 base_url: Some(base_url.into()),
114 ..self
115 }
116 }
117
118 pub fn with_model(self, model: impl Into<String>) -> Self {
119 Self {
120 model: Some(model.into()),
121 ..self
122 }
123 }
124
125 /// Refuses the task shell access — sugar over the workspace's own
126 /// `ShellAccess::from_flag`.
127 pub fn without_shell(self) -> Self {
128 Self {
129 shell: false,
130 ..self
131 }
132 }
133
134 pub fn with_system_prompt(self, system_prompt: SystemPrompt) -> Self {
135 Self {
136 system_prompt: Some(system_prompt),
137 ..self
138 }
139 }
140
141 pub fn with_effort(self, effort: Effort) -> Self {
142 Self {
143 effort: Some(effort),
144 ..self
145 }
146 }
147
148 /// Every consequential call this task's turns make is put to this.
149 pub fn with_approve(self, approve: Approve) -> Self {
150 Self { approve, ..self }
151 }
152
153 /// Gives up on the task after `deadline`, counted from spawn. Defaults to
154 /// [`DEFAULT_DEADLINE`] when never set — an unattended task always gets a
155 /// finite service bound.
156 pub fn with_deadline(self, deadline: Duration) -> Self {
157 Self {
158 deadline: Some(deadline),
159 ..self
160 }
161 }
162
163 /// Never gives the task longer than the instant `not_after_ms`
164 /// (milliseconds since the epoch), whatever [`with_deadline`](Self::with_deadline)
165 /// adds up to.
166 ///
167 /// For a deadline that is someone else's: a caller over A2A says when it
168 /// stops waiting, as an instant, and re-anchoring that as a duration at
169 /// spawn would hand the task a little more than the caller has.
170 pub fn with_not_after_ms(self, not_after_ms: u64) -> Self {
171 Self {
172 not_after_ms: Some(not_after_ms),
173 ..self
174 }
175 }
176
177 pub fn with_tool_budget(self, tool_budget: usize) -> Self {
178 Self {
179 tool_budget: Some(tool_budget),
180 ..self
181 }
182 }
183
184 pub fn with_token_budget(self, token_budget: u64) -> Self {
185 Self {
186 token_budget: Some(token_budget),
187 ..self
188 }
189 }
190
191 /// Spawns outside the calling task's ownership tree even when this
192 /// process is itself executing one — see [`crate::current_task`]. A
193 /// detached task inherits no scope: nothing cancels it downward, and
194 /// nothing waits for it before its would-be parent settles.
195 pub fn detached(self) -> Self {
196 Self {
197 detached: true,
198 ..self
199 }
200 }
201
202 /// How many agent-to-agent hops away from a top-level caller this task's
203 /// work arrived — `0` (the default) for work nobody handed over a wire.
204 /// A host serving tasks to other agents records the hop a request came
205 /// in at, so the task's own delegations can say how far along they are
206 /// (ADR-0030 §9). Recorded at spawn, published to the task's commands as
207 /// [`BASIS_HOP`](crate::BASIS_HOP), and inherited by its children, which
208 /// are the same work one level down in-process, not a hop further.
209 pub fn with_hop(self, hop: u32) -> Self {
210 Self { hop, ..self }
211 }
212
213 /// Picks up an existing conversation instead of opening a new one.
214 pub fn continuing(self, continuation: Continuation) -> Self {
215 Self {
216 continuation,
217 ..self
218 }
219 }
220}