Skip to main content

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, SkillRoster, SystemPrompt, ToolRoster};
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    pub(crate) tool_roster: Option<ToolRoster>,
60    pub(crate) skill_roster: Option<SkillRoster>,
61}
62
63impl RunSpec {
64    pub fn new(prompt: impl Into<String>) -> Self {
65        Self {
66            prompt: prompt.into(),
67            provider: None,
68            base_url: None,
69            model: None,
70            shell: true,
71            system_prompt: None,
72            effort: None,
73            approve: Approve::default(),
74            deadline: None,
75            tool_budget: None,
76            token_budget: None,
77            detached: false,
78            continuation: Continuation::default(),
79            hop: 0,
80            not_after_ms: None,
81            answering: false,
82            config: ConfigLookup::None,
83            tool_roster: None,
84            skill_roster: None,
85        }
86    }
87
88    /// Offers the task's model exactly this roster of tools, on every turn
89    /// it runs — recorded with the task, so whichever process attaches
90    /// builds the run with it.
91    ///
92    /// For a task that [continues](Self::continuing) a conversation this
93    /// replaces the roster the conversation was last run with: the roster is
94    /// this task's, and a conversation is not held to the one its first task
95    /// was given. Unset, a new conversation gets the workspace's roster and a
96    /// continued one keeps its own.
97    ///
98    /// A roster built with [`ToolRoster::only`] offers nothing it does not
99    /// name — including [`ATTACH_FILE`](crate::ATTACH_FILE), which an
100    /// [`answering`](Self::answering) task needs named to hand back a file.
101    pub fn with_tool_roster(self, tool_roster: ToolRoster) -> Self {
102        Self {
103            tool_roster: Some(tool_roster),
104            ..self
105        }
106    }
107
108    /// Lets the task's model list and load exactly this roster of the
109    /// workspace's skills, recorded and restated as
110    /// [`with_tool_roster`](Self::with_tool_roster) is.
111    pub fn with_skill_roster(self, skill_roster: SkillRoster) -> Self {
112        Self {
113            skill_roster: Some(skill_roster),
114            ..self
115        }
116    }
117
118    /// Where the task's workspace gets its `config.json` answers, every time
119    /// it is attached — which may be in another process, so the choice is
120    /// recorded rather than left to whoever attaches. Unset, none: the SDK
121    /// reads no configuration file it was not handed.
122    /// [`ConfigLookup::Discover`] is what `basis spawn` records.
123    pub fn with_config(self, config: ConfigLookup) -> Self {
124        Self { config, ..self }
125    }
126
127    /// Marks the task as answering a caller, who can be handed files: the
128    /// task is offered [`ATTACH_FILE`](crate::ATTACH_FILE) to add a
129    /// workspace file to its answer, read back with
130    /// [`Tasks::answer_files`](crate::Tasks::answer_files) (ADR-0041 §7).
131    pub fn answering(self) -> Self {
132        Self {
133            answering: true,
134            ..self
135        }
136    }
137
138    pub fn with_provider(self, provider: impl Into<String>) -> Self {
139        Self {
140            provider: Some(provider.into()),
141            ..self
142        }
143    }
144
145    pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
146        Self {
147            base_url: Some(base_url.into()),
148            ..self
149        }
150    }
151
152    pub fn with_model(self, model: impl Into<String>) -> Self {
153        Self {
154            model: Some(model.into()),
155            ..self
156        }
157    }
158
159    /// Refuses the task shell access — sugar over the workspace's own
160    /// `ShellAccess::from_flag`.
161    pub fn without_shell(self) -> Self {
162        Self {
163            shell: false,
164            ..self
165        }
166    }
167
168    pub fn with_system_prompt(self, system_prompt: SystemPrompt) -> Self {
169        Self {
170            system_prompt: Some(system_prompt),
171            ..self
172        }
173    }
174
175    pub fn with_effort(self, effort: Effort) -> Self {
176        Self {
177            effort: Some(effort),
178            ..self
179        }
180    }
181
182    /// Every consequential call this task's turns make is put to this.
183    pub fn with_approve(self, approve: Approve) -> Self {
184        Self { approve, ..self }
185    }
186
187    /// Gives up on the task after `deadline`, counted from spawn. Defaults to
188    /// [`DEFAULT_DEADLINE`] when never set — an unattended task always gets a
189    /// finite service bound.
190    pub fn with_deadline(self, deadline: Duration) -> Self {
191        Self {
192            deadline: Some(deadline),
193            ..self
194        }
195    }
196
197    /// Never gives the task longer than the instant `not_after_ms`
198    /// (milliseconds since the epoch), whatever [`with_deadline`](Self::with_deadline)
199    /// adds up to.
200    ///
201    /// For a deadline that is someone else's: a caller over A2A says when it
202    /// stops waiting, as an instant, and re-anchoring that as a duration at
203    /// spawn would hand the task a little more than the caller has.
204    pub fn with_not_after_ms(self, not_after_ms: u64) -> Self {
205        Self {
206            not_after_ms: Some(not_after_ms),
207            ..self
208        }
209    }
210
211    pub fn with_tool_budget(self, tool_budget: usize) -> Self {
212        Self {
213            tool_budget: Some(tool_budget),
214            ..self
215        }
216    }
217
218    pub fn with_token_budget(self, token_budget: u64) -> Self {
219        Self {
220            token_budget: Some(token_budget),
221            ..self
222        }
223    }
224
225    /// Spawns outside the calling task's ownership tree even when this
226    /// process is itself executing one — see [`crate::current_task`]. A
227    /// detached task inherits no scope: nothing cancels it downward, and
228    /// nothing waits for it before its would-be parent settles.
229    pub fn detached(self) -> Self {
230        Self {
231            detached: true,
232            ..self
233        }
234    }
235
236    /// How many agent-to-agent hops away from a top-level caller this task's
237    /// work arrived — `0` (the default) for work nobody handed over a wire.
238    /// A host serving tasks to other agents records the hop a request came
239    /// in at, so the task's own delegations can say how far along they are
240    /// (ADR-0030 §9). Recorded at spawn, published to the task's commands as
241    /// [`BASIS_HOP`](crate::BASIS_HOP), and inherited by its children, which
242    /// are the same work one level down in-process, not a hop further.
243    pub fn with_hop(self, hop: u32) -> Self {
244        Self { hop, ..self }
245    }
246
247    /// Picks up an existing conversation instead of opening a new one.
248    pub fn continuing(self, continuation: Continuation) -> Self {
249        Self {
250            continuation,
251            ..self
252        }
253    }
254}