basis-tasks 0.25.0

The durable task layer over basis: a filesystem-coordinated lifecycle for spawned agents, reachable from Rust and not only from the CLI.
Documentation
//! `RunSpec`: one task's worth of spawn request.
//!
//! Built the way `basis::RunSpec` is — `new` plus `with_*` methods that
//! return new values — because this is the same kind of thing one layer up:
//! `basis::RunSpec` is a run's per-turn intent against an already-open
//! [`basis::Workspace`]; this is what a *durable* task additionally records
//! so a later attach, in a process that may not be this one, can open that
//! workspace itself and mint the run.

use std::time::Duration;

use basis::{ConfigLookup, Effort, SkillRoster, SystemPrompt, ToolRoster};

use crate::{approve::Approve, handle::TaskHandle};

/// The default deadline an unattended task is given when nothing else names
/// one: 30 minutes. A spawned task may never be waited on by an attentive
/// caller, so — unlike an attended one-shot, which is unbounded unless asked
/// — it always gets a finite service bound (`with_deadline` narrows it).
pub const DEFAULT_DEADLINE: Duration = Duration::from_secs(30 * 60);

/// Which conversation a spawned task picks up, if any.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum Continuation {
    /// Opens a new conversation. The ordinary case.
    #[default]
    New,
    /// The conversation this workspace was last worked in — what a bare
    /// `--continue` resolves against.
    Latest,
    /// A specific conversation, by the task that opened or last continued
    /// it — what `--continue --session <ID>` resolves against.
    Named(TaskHandle),
}

/// One task's worth of spawn request: `basis::RunSpec`'s per-run intent,
/// plus the workspace-level overrides a [`basis::Workspace`] normally fixes
/// once, and the facts a task that may run unattended, in another process,
/// additionally needs recorded.
#[derive(Debug, Clone, PartialEq)]
pub struct RunSpec {
    pub(crate) prompt: String,
    pub(crate) provider: Option<String>,
    pub(crate) base_url: Option<String>,
    pub(crate) model: Option<String>,
    pub(crate) shell: bool,
    pub(crate) system_prompt: Option<SystemPrompt>,
    pub(crate) effort: Option<Effort>,
    pub(crate) approve: Approve,
    pub(crate) deadline: Option<Duration>,
    pub(crate) tool_budget: Option<usize>,
    pub(crate) token_budget: Option<u64>,
    pub(crate) detached: bool,
    pub(crate) continuation: Continuation,
    pub(crate) hop: u32,
    pub(crate) not_after_ms: Option<u64>,
    pub(crate) answering: bool,
    pub(crate) config: ConfigLookup,
    pub(crate) tool_roster: Option<ToolRoster>,
    pub(crate) skill_roster: Option<SkillRoster>,
}

impl RunSpec {
    pub fn new(prompt: impl Into<String>) -> Self {
        Self {
            prompt: prompt.into(),
            provider: None,
            base_url: None,
            model: None,
            shell: true,
            system_prompt: None,
            effort: None,
            approve: Approve::default(),
            deadline: None,
            tool_budget: None,
            token_budget: None,
            detached: false,
            continuation: Continuation::default(),
            hop: 0,
            not_after_ms: None,
            answering: false,
            config: ConfigLookup::None,
            tool_roster: None,
            skill_roster: None,
        }
    }

    /// Offers the task's model exactly this roster of tools, on every turn
    /// it runs — recorded with the task, so whichever process attaches
    /// builds the run with it.
    ///
    /// For a task that [continues](Self::continuing) a conversation this
    /// replaces the roster the conversation was last run with: the roster is
    /// this task's, and a conversation is not held to the one its first task
    /// was given. Unset, a new conversation gets the workspace's roster and a
    /// continued one keeps its own.
    ///
    /// A roster built with [`ToolRoster::only`] offers nothing it does not
    /// name — including [`ATTACH_FILE`](crate::ATTACH_FILE), which an
    /// [`answering`](Self::answering) task needs named to hand back a file.
    pub fn with_tool_roster(self, tool_roster: ToolRoster) -> Self {
        Self {
            tool_roster: Some(tool_roster),
            ..self
        }
    }

    /// Lets the task's model list and load exactly this roster of the
    /// workspace's skills, recorded and restated as
    /// [`with_tool_roster`](Self::with_tool_roster) is.
    pub fn with_skill_roster(self, skill_roster: SkillRoster) -> Self {
        Self {
            skill_roster: Some(skill_roster),
            ..self
        }
    }

    /// Where the task's workspace gets its `config.json` answers, every time
    /// it is attached — which may be in another process, so the choice is
    /// recorded rather than left to whoever attaches. Unset, none: the SDK
    /// reads no configuration file it was not handed.
    /// [`ConfigLookup::Discover`] is what `basis spawn` records.
    pub fn with_config(self, config: ConfigLookup) -> Self {
        Self { config, ..self }
    }

    /// Marks the task as answering a caller, who can be handed files: the
    /// task is offered [`ATTACH_FILE`](crate::ATTACH_FILE) to add a
    /// workspace file to its answer, read back with
    /// [`Tasks::answer_files`](crate::Tasks::answer_files) (ADR-0041 §7).
    pub fn answering(self) -> Self {
        Self {
            answering: true,
            ..self
        }
    }

    pub fn with_provider(self, provider: impl Into<String>) -> Self {
        Self {
            provider: Some(provider.into()),
            ..self
        }
    }

    pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
        Self {
            base_url: Some(base_url.into()),
            ..self
        }
    }

    pub fn with_model(self, model: impl Into<String>) -> Self {
        Self {
            model: Some(model.into()),
            ..self
        }
    }

    /// Refuses the task shell access — sugar over the workspace's own
    /// `ShellAccess::from_flag`.
    pub fn without_shell(self) -> Self {
        Self {
            shell: false,
            ..self
        }
    }

    pub fn with_system_prompt(self, system_prompt: SystemPrompt) -> Self {
        Self {
            system_prompt: Some(system_prompt),
            ..self
        }
    }

    pub fn with_effort(self, effort: Effort) -> Self {
        Self {
            effort: Some(effort),
            ..self
        }
    }

    /// Every consequential call this task's turns make is put to this.
    pub fn with_approve(self, approve: Approve) -> Self {
        Self { approve, ..self }
    }

    /// Gives up on the task after `deadline`, counted from spawn. Defaults to
    /// [`DEFAULT_DEADLINE`] when never set — an unattended task always gets a
    /// finite service bound.
    pub fn with_deadline(self, deadline: Duration) -> Self {
        Self {
            deadline: Some(deadline),
            ..self
        }
    }

    /// Never gives the task longer than the instant `not_after_ms`
    /// (milliseconds since the epoch), whatever [`with_deadline`](Self::with_deadline)
    /// adds up to.
    ///
    /// For a deadline that is someone else's: a caller over A2A says when it
    /// stops waiting, as an instant, and re-anchoring that as a duration at
    /// spawn would hand the task a little more than the caller has.
    pub fn with_not_after_ms(self, not_after_ms: u64) -> Self {
        Self {
            not_after_ms: Some(not_after_ms),
            ..self
        }
    }

    pub fn with_tool_budget(self, tool_budget: usize) -> Self {
        Self {
            tool_budget: Some(tool_budget),
            ..self
        }
    }

    pub fn with_token_budget(self, token_budget: u64) -> Self {
        Self {
            token_budget: Some(token_budget),
            ..self
        }
    }

    /// Spawns outside the calling task's ownership tree even when this
    /// process is itself executing one — see [`crate::current_task`]. A
    /// detached task inherits no scope: nothing cancels it downward, and
    /// nothing waits for it before its would-be parent settles.
    pub fn detached(self) -> Self {
        Self {
            detached: true,
            ..self
        }
    }

    /// How many agent-to-agent hops away from a top-level caller this task's
    /// work arrived — `0` (the default) for work nobody handed over a wire.
    /// A host serving tasks to other agents records the hop a request came
    /// in at, so the task's own delegations can say how far along they are
    /// (ADR-0030 §9). Recorded at spawn, published to the task's commands as
    /// [`BASIS_HOP`](crate::BASIS_HOP), and inherited by its children, which
    /// are the same work one level down in-process, not a hop further.
    pub fn with_hop(self, hop: u32) -> Self {
        Self { hop, ..self }
    }

    /// Picks up an existing conversation instead of opening a new one.
    pub fn continuing(self, continuation: Continuation) -> Self {
        Self {
            continuation,
            ..self
        }
    }
}