1use 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
18pub const SERVER: &str = "ask";
20
21pub const WORKSPACE: [&str; 5] = crate::substrate::converge::READ_ONLY;
23
24#[derive(Debug, Deserialize, JsonSchema)]
26#[serde(deny_unknown_fields)]
27pub struct GitLog {
28 pub path: Option<String>,
30 pub n: Option<u32>,
32}
33
34#[derive(Debug, Deserialize, JsonSchema)]
36#[serde(deny_unknown_fields)]
37pub struct GitShow {
38 pub rev: String,
40 pub path: Option<String>,
42}
43
44#[derive(Debug, Deserialize, JsonSchema)]
46#[serde(deny_unknown_fields)]
47pub struct JournalAccount {
48 pub run: String,
50 pub from: Option<u32>,
52 pub to: Option<u32>,
54}
55
56#[derive(Debug, Deserialize, JsonSchema)]
58#[serde(deny_unknown_fields)]
59pub struct TaskEvidence {
60 pub task: String,
62 pub feature: Option<String>,
64}
65
66#[derive(Debug, Deserialize, JsonSchema)]
68#[serde(deny_unknown_fields)]
69pub struct Report {
70 pub feature: Option<String>,
72}
73
74#[derive(Debug, Deserialize, JsonSchema)]
76#[serde(deny_unknown_fields)]
77pub struct Inbox {}
78
79#[derive(Debug, Deserialize, JsonSchema)]
81#[serde(deny_unknown_fields)]
82pub struct Explain {
83 pub code: String,
85}
86
87#[derive(Debug, Deserialize, JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct Propose {
91 pub kind: String,
93 pub title: String,
95 pub target: Option<String>,
97 pub request: Option<String>,
99 pub checks: Option<Vec<String>>,
101 pub note: Option<String>,
103 pub answer: Option<String>,
105 pub description: Option<String>,
107 pub symptom: Option<String>,
109 pub expected: Option<Vec<String>>,
111 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#[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#[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#[derive(Debug, Clone)]
167pub struct AskTools {
168 root: PathBuf,
169 journal: PathBuf,
171 policy: Policy,
172}
173
174impl AskTools {
175 #[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 "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 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
382fn 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}