Skip to main content

speccoder_runtime/substrate/attempt/
loop_spec.rs

1//! What a governed tool loop is given, what it has left, and what it shows
2//! as it goes.
3
4use super::*;
5
6/// What a governed tool loop offers the model and asks of it.
7pub(crate) struct LoopSpec {
8    /// The tools declared to the model; any other name is refused.
9    pub tools: Vec<ToolDeclaration>,
10    /// Classifies every call before dispatch.
11    pub policy: Policy,
12    /// Model turns.
13    pub max_turns: u32,
14    /// Tool calls.
15    pub max_tool_calls: u32,
16    /// Ask each turn for the model's reasoning summary.
17    pub reasoning: bool,
18    /// The closing answer's schema; `None`: a text answer ends the turn.
19    pub schema: Option<Value>,
20    /// Declared tools served elsewhere: (name, server, tool) — MCP.
21    pub routes: Vec<(String, String, String)>,
22    /// The workspace's absolute path, from the journaled input: absolute
23    /// paths inside it are made relative before the policy sees them.
24    pub root: Option<String>,
25    /// A write to a frozen test that only adds lines needs no approval.
26    pub frozen_additions: bool,
27    /// The status for a closing answer.
28    pub finish: fn(&Value) -> &'static str,
29    /// Ask a person instead of stopping when a call needs approval.
30    pub ask: Option<Asker>,
31    /// Objects a person approved for this run: allowed without asking.
32    pub run_approved: Vec<Object>,
33    /// Objects a person approved for one call each: the first call of
34    /// each is allowed without asking, later ones ask again.
35    pub once: Vec<Object>,
36    /// Live progress: the task ID and where to report.
37    pub observer: Option<(String, std::sync::Arc<dyn crate::progress::Progress>)>,
38    /// The run the attempt belongs to (`run-<secs>-<hex>`).
39    pub run: Option<String>,
40    /// How a run asks: its count so far, its budget and whether a person is
41    /// prompted at all.
42    pub asking: Asking,
43    /// Untrusted content the prompt carries (inlined worktree text, the
44    /// verifier's digest): what derived arguments are found in.
45    pub parts: Vec<crate::approvals::derived::Part>,
46    /// MCP tools' digests by model-facing name: their objects.
47    pub mcp_digests: std::collections::BTreeMap<String, String>,
48    /// What a `report_blocked` call's evidence is checked against.
49    pub stop: StopGate,
50    /// Tokens a turn may use (Ask); `None`: the plane's budget alone.
51    pub max_tokens: Option<u64>,
52    /// What is left of the run's cap for this attempt.
53    pub left: Option<Left>,
54    /// The first read id to issue (`r<n>`), when the answer cites reads.
55    pub reads: Option<u32>,
56    /// Pressed when a person stops the turn (Ask): the model call streaming
57    /// ends where it is (a recorded, metered completion) and none of its
58    /// calls run. Not effect identity — replay returns the recorded call.
59    pub halt: Option<agentplane::model::Stop>,
60}
61
62/// A run's asking budget as an attempt sees it (all journaled).
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct Asking {
65    /// Prompts shown to a person in the run so far.
66    pub asked: u32,
67    /// `[approvals] budget`: at most this many per run.
68    pub budget: u32,
69    /// A person is prompted inline; `false` with `--no-prompt` (calls wait
70    /// as ordinary approvals, nothing is counted).
71    pub prompts: bool,
72}
73
74impl Default for Asking {
75    fn default() -> Self {
76        Self {
77            asked: 0,
78            budget: DEFAULT_BUDGET,
79            prompts: false,
80        }
81    }
82}
83
84/// `[approvals] budget` unless configured.
85pub use crate::substrate::run_config::DEFAULT_BUDGET;
86
87/// What is left of the run's cap for one attempt, and the prices a dollar
88/// cap is counted at (journaled with the attempt's settings).
89#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
90pub struct Left {
91    /// Tokens or dollars left.
92    pub cap: crate::substrate::run_config::Cap,
93    /// `[model]`'s prices; a dollar cap is refused without them.
94    pub prices: Option<crate::substrate::model_config::Prices>,
95}
96
97impl Left {
98    /// What `usage` spent, in the cap's own unit (tokens, or millionths of a dollar).
99    #[must_use]
100    pub fn spent(&self, usage: &agentplane::model::Usage) -> u64 {
101        match self.cap {
102            crate::substrate::run_config::Cap::Tokens(_) => {
103                usage.input_tokens + usage.output_tokens
104            }
105            crate::substrate::run_config::Cap::Usd(_) => self.prices.map_or(0, |p| {
106                micros(p.usd(
107                    usage.input_tokens,
108                    usage.output_tokens,
109                    usage.cache_read_tokens,
110                    usage.cache_write_tokens,
111                ))
112            }),
113        }
114    }
115
116    /// Whether `usage` used all that is left.
117    #[must_use]
118    pub fn reached(&self, usage: &agentplane::model::Usage) -> bool {
119        self.spent(usage) >= self.cap.amount()
120    }
121
122    /// The stop's words: what the attempt used of what was left.
123    #[must_use]
124    pub fn reason(&self, usage: &agentplane::model::Usage) -> String {
125        format!(
126            "the run's cap: this attempt used {} of the {} left (`run --cap`, `[budget] cap`)",
127            self.cap.with(self.spent(usage)).rounded(),
128            self.cap.rounded()
129        )
130    }
131}
132
133/// A recorded usage object (`input_tokens`, …) as the model's usage.
134#[must_use]
135pub fn usage_of(v: Option<&Value>) -> agentplane::model::Usage {
136    let n = |k: &str| v.and_then(|v| v[k].as_u64()).unwrap_or(0);
137    agentplane::model::Usage {
138        input_tokens: n("input_tokens"),
139        output_tokens: n("output_tokens"),
140        cache_read_tokens: n("cache_read_tokens"),
141        cache_write_tokens: n("cache_write_tokens"),
142        ..agentplane::model::Usage::default()
143    }
144}
145
146/// Dollars as millionths, rounded.
147#[must_use]
148#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
149pub fn micros(usd: f64) -> u64 {
150    (usd * 1e6).round().max(0.0) as u64
151}
152
153/// Turns an absolute `path` argument inside the workspace `root` into a
154/// workspace-relative one. Tools print absolute paths (Spec Kit's scripts
155/// do), and a model passes them on; outside the root a path stays as it is
156/// — and the policy refuses it.
157pub(super) fn relativise(arguments: &mut Value, root: Option<&str>) {
158    let (Some(root), Some(path)) = (root, arguments.get("path").and_then(Value::as_str)) else {
159        return;
160    };
161    let root = root.trim_end_matches('/');
162    if let Some(rel) = path.strip_prefix(root).and_then(|r| r.strip_prefix('/')) {
163        let rel = if rel.is_empty() {
164            ".".to_owned()
165        } else {
166            rel.to_owned()
167        };
168        arguments["path"] = Value::String(rel);
169    }
170}
171
172/// Reports one logged tool call to whoever watches.
173pub(super) fn emit(
174    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
175    turn: u32,
176    entry: &Value,
177) {
178    emit_with_tail(observer, turn, entry, Vec::new());
179}
180
181/// [`emit`] with the last lines of a failed command's output (live only,
182/// never journaled).
183pub(super) fn emit_with_tail(
184    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
185    turn: u32,
186    entry: &Value,
187    tail: Vec<String>,
188) {
189    if let Some((task, p)) = observer {
190        let tool = entry["tool"].as_str().unwrap_or_default();
191        p.event(&crate::progress::Event::ToolCall {
192            task: task.clone(),
193            turn,
194            tool: tool.to_owned(),
195            target: crate::progress::target(tool, &entry["arguments"]),
196            decision: entry["decision"].as_str().unwrap_or_default().to_owned(),
197            ok: entry["ok"].as_bool(),
198            tail,
199            contained: entry["contained"] == true,
200        });
201    }
202}