Skip to main content

speccoder_runtime/ask/
tools.rs

1//! Ask's readers beyond the workspace tools: history, the journal's
2//! account, evidence and the catalog of codes. Each reads through a
3//! runtime function or a fixed git argv — never a shell, never the network.
4
5use std::path::{Path, PathBuf};
6
7use agentplane::model::ToolDeclaration;
8use agentplane::tools::{Destination, ToolClient, ToolError, ToolId};
9use async_trait::async_trait;
10use schemars::JsonSchema;
11use serde::Deserialize;
12use serde_json::{Value, json};
13
14use crate::git::Git;
15use crate::tools::paths::normalise;
16use crate::tools::policy::Policy;
17
18/// The tool server's name.
19pub const SERVER: &str = "ask";
20
21/// The workspace tools Ask may use: reading only.
22pub const WORKSPACE: [&str; 5] = crate::substrate::converge::READ_ONLY;
23
24/// Commits on a path, newest first.
25#[derive(Debug, Deserialize, JsonSchema)]
26#[serde(deny_unknown_fields)]
27pub struct GitLog {
28    /// A file or directory; the whole repository when absent.
29    pub path: Option<String>,
30    /// How many commits (at most 50; 20 when absent).
31    pub n: Option<u32>,
32}
33
34/// A file as it was at a revision, or a commit's summary.
35#[derive(Debug, Deserialize, JsonSchema)]
36#[serde(deny_unknown_fields)]
37pub struct GitShow {
38    /// A commit, tag or `HEAD~2`-style revision.
39    pub rev: String,
40    /// The file at that revision; the commit's summary when absent.
41    pub path: Option<String>,
42}
43
44/// What the model said in a run, turn by turn (its words: a claim).
45#[derive(Debug, Deserialize, JsonSchema)]
46#[serde(deny_unknown_fields)]
47pub struct JournalAccount {
48    /// The run id.
49    pub run: String,
50    /// The first turn (1-based).
51    pub from: Option<u32>,
52    /// The last turn.
53    pub to: Option<u32>,
54}
55
56/// A task's state and its evidence rows.
57#[derive(Debug, Deserialize, JsonSchema)]
58#[serde(deny_unknown_fields)]
59pub struct TaskEvidence {
60    /// The task id (`T004`).
61    pub task: String,
62    /// The feature; the current one when absent.
63    pub feature: Option<String>,
64}
65
66/// The tasks of a feature and their states.
67#[derive(Debug, Deserialize, JsonSchema)]
68#[serde(deny_unknown_fields)]
69pub struct Report {
70    /// The feature; the current one when absent.
71    pub feature: Option<String>,
72}
73
74/// What waits for a person, across features.
75#[derive(Debug, Deserialize, JsonSchema)]
76#[serde(deny_unknown_fields)]
77pub struct Inbox {}
78
79/// What a speccoder code means and how to resolve it.
80#[derive(Debug, Deserialize, JsonSchema)]
81#[serde(deny_unknown_fields)]
82pub struct Explain {
83    /// The code (`BUDGET-EXHAUSTED`).
84    pub code: String,
85}
86
87/// A change for the person to confirm; nothing runs.
88#[derive(Debug, Deserialize, JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct Propose {
91    /// `quick_change`, `bug_fix`, `nudge`, `answer` or `start_spec`.
92    pub kind: String,
93    /// One line, at most 120 characters.
94    pub title: String,
95    /// The task (nudge) or open question (answer) it is about.
96    pub target: Option<String>,
97    /// A quick change, in words the implementer can act on.
98    pub request: Option<String>,
99    /// When a quick change is done (at most 8).
100    pub checks: Option<Vec<String>>,
101    /// A nudge's note for the task's next attempt.
102    pub note: Option<String>,
103    /// An open question's answer.
104    pub answer: Option<String>,
105    /// A new spec's description.
106    pub description: Option<String>,
107    /// A bug's current behaviour.
108    pub symptom: Option<String>,
109    /// What should happen instead (at least one for a bug).
110    pub expected: Option<Vec<String>>,
111    /// What must stay as it is.
112    pub unchanged: Option<Vec<String>>,
113}
114
115fn declaration<T: JsonSchema>(name: &str, description: &str) -> ToolDeclaration {
116    ToolDeclaration::new(name, description, agentplane::tools::strict_schema::<T>())
117}
118
119/// Ask's own tools, as the model is told them.
120#[must_use]
121pub fn declarations() -> Vec<ToolDeclaration> {
122    vec![
123        declaration::<GitLog>(
124            "git_log",
125            "Commits on a path, newest first: hash, date, author, subject.",
126        ),
127        declaration::<GitShow>(
128            "git_show",
129            "A file as it was at a revision, or a commit's summary and changed files.",
130        ),
131        declaration::<JournalAccount>(
132            "journal_account",
133            "What the implementing model said in a run, turn by turn — its words, a claim, never evidence.",
134        ),
135        declaration::<TaskEvidence>(
136            "task_evidence",
137            "A task's state and the evidence speccoder recorded for it.",
138        ),
139        declaration::<Report>("report", "A feature's tasks and their states."),
140        declaration::<Inbox>(
141            "inbox",
142            "What waits for a person's decision, across features.",
143        ),
144        declaration::<Explain>(
145            "explain",
146            "What a speccoder code (e.g. BUDGET-EXHAUSTED) means and how to resolve it.",
147        ),
148        declaration::<Propose>(
149            crate::ask::PROPOSE,
150            "Suggest a change for the person to review in a sheet: a quick change, a bug fix, a nudge for a task, an answer to an open question, or a new spec. Nothing runs; only the person can confirm. At most one per turn.",
151        ),
152    ]
153}
154
155/// The readers' names, for the manifest's grants.
156#[must_use]
157pub fn names() -> Vec<String> {
158    declarations()
159        .into_iter()
160        .map(|d| d.name)
161        .filter(|n| n != crate::ask::PROPOSE)
162        .collect()
163}
164
165/// Ask's readers over one project.
166#[derive(Debug, Clone)]
167pub struct AskTools {
168    root: PathBuf,
169    /// The project's journal (its runs' accounts).
170    journal: PathBuf,
171    policy: Policy,
172}
173
174impl AskTools {
175    /// Readers over the project at `root`, whose runs are journaled in `journal`.
176    #[must_use]
177    pub fn new(root: &Path, journal: &Path) -> Self {
178        Self {
179            root: root.to_path_buf(),
180            journal: journal.to_path_buf(),
181            policy: Policy::new(&[], &[]),
182        }
183    }
184
185    fn path(&self, p: &str) -> Result<String, String> {
186        let p = normalise(p).map_err(|e| e.0)?;
187        if self.policy.protected().is_secret(&p) {
188            return Err(format!("`{p}` looks like a credential"));
189        }
190        Ok(p)
191    }
192
193    async fn run(&self, tool: &str, args: &Value) -> Value {
194        let parsed = |v: &Value| v.clone();
195        match tool {
196            "git_log" => match serde_json::from_value::<GitLog>(parsed(args)) {
197                Ok(a) => self.git_log(&a),
198                Err(e) => fail(e),
199            },
200            "git_show" => match serde_json::from_value::<GitShow>(parsed(args)) {
201                Ok(a) => self.git_show(&a),
202                Err(e) => fail(e),
203            },
204            "journal_account" => match serde_json::from_value::<JournalAccount>(parsed(args)) {
205                Ok(a) => self.journal_account(&a).await,
206                Err(e) => fail(e),
207            },
208            // The status readers open runtimes of their own: off this one.
209            "task_evidence" => match serde_json::from_value::<TaskEvidence>(parsed(args)) {
210                Ok(a) => off_runtime(|| self.task_evidence(&a)),
211                Err(e) => fail(e),
212            },
213            "report" => match serde_json::from_value::<Report>(parsed(args)) {
214                Ok(a) => off_runtime(|| self.report(a.feature.as_deref())),
215                Err(e) => fail(e),
216            },
217            "inbox" => off_runtime(|| self.inbox()),
218            "explain" => match serde_json::from_value::<Explain>(parsed(args)) {
219                Ok(a) => explain(&a.code),
220                Err(e) => fail(e),
221            },
222            other => fail(format!("no Ask tool `{other}`")),
223        }
224    }
225
226    fn git_log(&self, a: &GitLog) -> Value {
227        let n = a.n.unwrap_or(20).clamp(1, 50).to_string();
228        let mut args = vec![
229            "-c",
230            "core.pager=cat",
231            "log",
232            "--no-color",
233            "--date=short",
234            "--format=%h%x1f%ad%x1f%an%x1f%s",
235            "-n",
236            &n,
237        ];
238        let path;
239        if let Some(p) = &a.path {
240            path = match self.path(p) {
241                Ok(p) => p,
242                Err(e) => return fail(e),
243            };
244            args.extend(["--", path.as_str()]);
245        }
246        match Git::at(&self.root).run(&args) {
247            Ok(out) => {
248                let commits: Vec<Value> = String::from_utf8_lossy(&out)
249                    .lines()
250                    .filter_map(|l| {
251                        let f: Vec<&str> = l.split('\u{1f}').collect();
252                        (f.len() == 4).then(|| json!({ "commit": f[0], "date": f[1], "author": f[2], "subject": f[3] }))
253                    })
254                    .collect();
255                json!({ "ok": true, "commits": commits })
256            }
257            Err(e) => fail(e.0),
258        }
259    }
260
261    fn git_show(&self, a: &GitShow) -> Value {
262        // A revision, never an option: letters, digits and `._/~^-`, not first `-`.
263        let rev = a.rev.trim();
264        if rev.is_empty()
265            || rev.starts_with('-')
266            || !rev
267                .chars()
268                .all(|c| c.is_ascii_alphanumeric() || "._/~^-".contains(c))
269        {
270            return fail(format!("`{rev}` is not a revision"));
271        }
272        let out = match &a.path {
273            Some(p) => match self.path(p) {
274                Ok(p) => Git::at(&self.root).run(&[
275                    "-c",
276                    "core.pager=cat",
277                    "show",
278                    "--no-color",
279                    &format!("{rev}:{p}"),
280                ]),
281                Err(e) => return fail(e),
282            },
283            None => Git::at(&self.root).run(&[
284                "-c",
285                "core.pager=cat",
286                "show",
287                "--no-color",
288                "--stat",
289                "--format=%H%n%ad%n%an%n%n%B",
290                "--date=short",
291                rev,
292            ]),
293        };
294        match out {
295            Ok(out) => {
296                let mut text = crate::secrets::mask(&String::from_utf8_lossy(&out));
297                let truncated = text.len() > crate::tools::READ_CAP;
298                if truncated {
299                    let mut cut = crate::tools::READ_CAP;
300                    while !text.is_char_boundary(cut) {
301                        cut -= 1;
302                    }
303                    text.truncate(cut);
304                }
305                json!({ "ok": true, "content": text, "truncated": truncated })
306            }
307            Err(e) => fail(e.0),
308        }
309    }
310
311    async fn journal_account(&self, a: &JournalAccount) -> Value {
312        match crate::substrate::account(&self.journal, &a.run).await {
313            Ok(turns) => {
314                let from = a.from.unwrap_or(1).max(1) as usize;
315                let to = a.to.map_or(usize::MAX, |t| t as usize);
316                let turns: Vec<Value> = turns
317                    .iter()
318                    .enumerate()
319                    .filter(|(i, _)| (from..=to).contains(&(i + 1)))
320                    .map(|(i, t)| json!({ "turn": i + 1, "account": t }))
321                    .collect();
322                json!({ "ok": true, "run": a.run, "note": "the implementing model's words: a claim, never evidence", "turns": turns })
323            }
324            Err(e) => fail(e.0),
325        }
326    }
327
328    fn task_evidence(&self, a: &TaskEvidence) -> Value {
329        let report = crate::status::report(&self.root, a.feature.as_deref());
330        let Some(speccoder_core::CommandData::Report(r)) = report.data() else {
331            return fail(report.message().to_owned());
332        };
333        let Some(t) = r.tasks.iter().find(|t| t.id == a.task) else {
334            return fail(format!("no task {} in {}", a.task, r.feature));
335        };
336        let statements = t
337            .commit
338            .as_deref()
339            .map(|c| crate::evidence::statements(&self.root, c))
340            .unwrap_or_default();
341        json!({
342            "ok": true,
343            "feature": r.feature,
344            "task": { "id": t.id, "title": t.title, "state": t.state, "commit": t.commit },
345            "rows": crate::evidence::rows(t, &statements, None),
346        })
347    }
348
349    fn report(&self, feature: Option<&str>) -> Value {
350        let report = crate::status::report(&self.root, feature);
351        let Some(speccoder_core::CommandData::Report(r)) = report.data() else {
352            return fail(report.message().to_owned());
353        };
354        let tasks: Vec<Value> = r
355            .tasks
356            .iter()
357            .map(|t| json!({ "id": t.id, "title": t.title, "state": t.state, "phase": t.phase }))
358            .collect();
359        json!({ "ok": true, "feature": r.feature, "tasks": tasks })
360    }
361
362    fn inbox(&self) -> Value {
363        let inbox = crate::inbox::inbox(&self.root, None);
364        match inbox.data() {
365            Some(speccoder_core::CommandData::Inbox(i)) => {
366                json!({ "ok": true, "inbox": serde_json::to_value(i).unwrap_or(Value::Null) })
367            }
368            _ => fail(inbox.message().to_owned()),
369        }
370    }
371}
372
373fn explain(code: &str) -> Value {
374    match speccoder_core::codes::lookup(code.trim()) {
375        Some(e) => {
376            json!({ "ok": true, "code": e.name, "title": e.title, "explain": e.explain, "fix": e.fix, "docs": e.docs() })
377        }
378        None => fail(format!("no code `{code}`")),
379    }
380}
381
382/// `f` on a thread of its own: a reader that blocks on a runtime of its
383/// own cannot run inside the plane's.
384fn off_runtime(f: impl FnOnce() -> Value + Send) -> Value {
385    std::thread::scope(|s| {
386        s.spawn(f)
387            .join()
388            .unwrap_or_else(|_| fail("the reader failed"))
389    })
390}
391
392fn fail(error: impl ToString) -> Value {
393    json!({ "ok": false, "error": error.to_string() })
394}
395
396#[async_trait]
397impl ToolClient for AskTools {
398    async fn call(
399        &self,
400        tool: &ToolId,
401        arguments: &Value,
402        _provenance: Option<&agentplane::core::Provenance>,
403    ) -> Result<Value, ToolError> {
404        if tool.server != SERVER || !names().contains(&tool.tool) {
405            return Err(ToolError::Refused {
406                tool: tool.clone(),
407                detail: "not an Ask tool".to_owned(),
408            });
409        }
410        Ok(self.run(&tool.tool, arguments).await)
411    }
412
413    fn destination(&self, _tool: &ToolId) -> Destination {
414        Destination::Local
415    }
416}