use std::path::{Path, PathBuf};
use agentplane::model::ToolDeclaration;
use agentplane::tools::{Destination, ToolClient, ToolError, ToolId};
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::git::Git;
use crate::tools::paths::normalise;
use crate::tools::policy::Policy;
pub const SERVER: &str = "ask";
pub const WORKSPACE: [&str; 5] = crate::substrate::converge::READ_ONLY;
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GitLog {
pub path: Option<String>,
pub n: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GitShow {
pub rev: String,
pub path: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct JournalAccount {
pub run: String,
pub from: Option<u32>,
pub to: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TaskEvidence {
pub task: String,
pub feature: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Report {
pub feature: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Inbox {}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Explain {
pub code: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Propose {
pub kind: String,
pub title: String,
pub target: Option<String>,
pub request: Option<String>,
pub checks: Option<Vec<String>>,
pub note: Option<String>,
pub answer: Option<String>,
pub description: Option<String>,
pub symptom: Option<String>,
pub expected: Option<Vec<String>>,
pub unchanged: Option<Vec<String>>,
}
fn declaration<T: JsonSchema>(name: &str, description: &str) -> ToolDeclaration {
ToolDeclaration::new(name, description, agentplane::tools::strict_schema::<T>())
}
#[must_use]
pub fn declarations() -> Vec<ToolDeclaration> {
vec![
declaration::<GitLog>(
"git_log",
"Commits on a path, newest first: hash, date, author, subject.",
),
declaration::<GitShow>(
"git_show",
"A file as it was at a revision, or a commit's summary and changed files.",
),
declaration::<JournalAccount>(
"journal_account",
"What the implementing model said in a run, turn by turn — its words, a claim, never evidence.",
),
declaration::<TaskEvidence>(
"task_evidence",
"A task's state and the evidence speccoder recorded for it.",
),
declaration::<Report>("report", "A feature's tasks and their states."),
declaration::<Inbox>(
"inbox",
"What waits for a person's decision, across features.",
),
declaration::<Explain>(
"explain",
"What a speccoder code (e.g. BUDGET-EXHAUSTED) means and how to resolve it.",
),
declaration::<Propose>(
crate::ask::PROPOSE,
"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.",
),
]
}
#[must_use]
pub fn names() -> Vec<String> {
declarations()
.into_iter()
.map(|d| d.name)
.filter(|n| n != crate::ask::PROPOSE)
.collect()
}
#[derive(Debug, Clone)]
pub struct AskTools {
root: PathBuf,
journal: PathBuf,
policy: Policy,
}
impl AskTools {
#[must_use]
pub fn new(root: &Path, journal: &Path) -> Self {
Self {
root: root.to_path_buf(),
journal: journal.to_path_buf(),
policy: Policy::new(&[], &[]),
}
}
fn path(&self, p: &str) -> Result<String, String> {
let p = normalise(p).map_err(|e| e.0)?;
if self.policy.protected().is_secret(&p) {
return Err(format!("`{p}` looks like a credential"));
}
Ok(p)
}
async fn run(&self, tool: &str, args: &Value) -> Value {
let parsed = |v: &Value| v.clone();
match tool {
"git_log" => match serde_json::from_value::<GitLog>(parsed(args)) {
Ok(a) => self.git_log(&a),
Err(e) => fail(e),
},
"git_show" => match serde_json::from_value::<GitShow>(parsed(args)) {
Ok(a) => self.git_show(&a),
Err(e) => fail(e),
},
"journal_account" => match serde_json::from_value::<JournalAccount>(parsed(args)) {
Ok(a) => self.journal_account(&a).await,
Err(e) => fail(e),
},
"task_evidence" => match serde_json::from_value::<TaskEvidence>(parsed(args)) {
Ok(a) => off_runtime(|| self.task_evidence(&a)),
Err(e) => fail(e),
},
"report" => match serde_json::from_value::<Report>(parsed(args)) {
Ok(a) => off_runtime(|| self.report(a.feature.as_deref())),
Err(e) => fail(e),
},
"inbox" => off_runtime(|| self.inbox()),
"explain" => match serde_json::from_value::<Explain>(parsed(args)) {
Ok(a) => explain(&a.code),
Err(e) => fail(e),
},
other => fail(format!("no Ask tool `{other}`")),
}
}
fn git_log(&self, a: &GitLog) -> Value {
let n = a.n.unwrap_or(20).clamp(1, 50).to_string();
let mut args = vec![
"-c",
"core.pager=cat",
"log",
"--no-color",
"--date=short",
"--format=%h%x1f%ad%x1f%an%x1f%s",
"-n",
&n,
];
let path;
if let Some(p) = &a.path {
path = match self.path(p) {
Ok(p) => p,
Err(e) => return fail(e),
};
args.extend(["--", path.as_str()]);
}
match Git::at(&self.root).run(&args) {
Ok(out) => {
let commits: Vec<Value> = String::from_utf8_lossy(&out)
.lines()
.filter_map(|l| {
let f: Vec<&str> = l.split('\u{1f}').collect();
(f.len() == 4).then(|| json!({ "commit": f[0], "date": f[1], "author": f[2], "subject": f[3] }))
})
.collect();
json!({ "ok": true, "commits": commits })
}
Err(e) => fail(e.0),
}
}
fn git_show(&self, a: &GitShow) -> Value {
let rev = a.rev.trim();
if rev.is_empty()
|| rev.starts_with('-')
|| !rev
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._/~^-".contains(c))
{
return fail(format!("`{rev}` is not a revision"));
}
let out = match &a.path {
Some(p) => match self.path(p) {
Ok(p) => Git::at(&self.root).run(&[
"-c",
"core.pager=cat",
"show",
"--no-color",
&format!("{rev}:{p}"),
]),
Err(e) => return fail(e),
},
None => Git::at(&self.root).run(&[
"-c",
"core.pager=cat",
"show",
"--no-color",
"--stat",
"--format=%H%n%ad%n%an%n%n%B",
"--date=short",
rev,
]),
};
match out {
Ok(out) => {
let mut text = crate::secrets::mask(&String::from_utf8_lossy(&out));
let truncated = text.len() > crate::tools::READ_CAP;
if truncated {
let mut cut = crate::tools::READ_CAP;
while !text.is_char_boundary(cut) {
cut -= 1;
}
text.truncate(cut);
}
json!({ "ok": true, "content": text, "truncated": truncated })
}
Err(e) => fail(e.0),
}
}
async fn journal_account(&self, a: &JournalAccount) -> Value {
match crate::substrate::account(&self.journal, &a.run).await {
Ok(turns) => {
let from = a.from.unwrap_or(1).max(1) as usize;
let to = a.to.map_or(usize::MAX, |t| t as usize);
let turns: Vec<Value> = turns
.iter()
.enumerate()
.filter(|(i, _)| (from..=to).contains(&(i + 1)))
.map(|(i, t)| json!({ "turn": i + 1, "account": t }))
.collect();
json!({ "ok": true, "run": a.run, "note": "the implementing model's words: a claim, never evidence", "turns": turns })
}
Err(e) => fail(e.0),
}
}
fn task_evidence(&self, a: &TaskEvidence) -> Value {
let report = crate::status::report(&self.root, a.feature.as_deref());
let Some(speccoder_core::CommandData::Report(r)) = report.data() else {
return fail(report.message().to_owned());
};
let Some(t) = r.tasks.iter().find(|t| t.id == a.task) else {
return fail(format!("no task {} in {}", a.task, r.feature));
};
let statements = t
.commit
.as_deref()
.map(|c| crate::evidence::statements(&self.root, c))
.unwrap_or_default();
json!({
"ok": true,
"feature": r.feature,
"task": { "id": t.id, "title": t.title, "state": t.state, "commit": t.commit },
"rows": crate::evidence::rows(t, &statements, None),
})
}
fn report(&self, feature: Option<&str>) -> Value {
let report = crate::status::report(&self.root, feature);
let Some(speccoder_core::CommandData::Report(r)) = report.data() else {
return fail(report.message().to_owned());
};
let tasks: Vec<Value> = r
.tasks
.iter()
.map(|t| json!({ "id": t.id, "title": t.title, "state": t.state, "phase": t.phase }))
.collect();
json!({ "ok": true, "feature": r.feature, "tasks": tasks })
}
fn inbox(&self) -> Value {
let inbox = crate::inbox::inbox(&self.root, None);
match inbox.data() {
Some(speccoder_core::CommandData::Inbox(i)) => {
json!({ "ok": true, "inbox": serde_json::to_value(i).unwrap_or(Value::Null) })
}
_ => fail(inbox.message().to_owned()),
}
}
}
fn explain(code: &str) -> Value {
match speccoder_core::codes::lookup(code.trim()) {
Some(e) => {
json!({ "ok": true, "code": e.name, "title": e.title, "explain": e.explain, "fix": e.fix, "docs": e.docs() })
}
None => fail(format!("no code `{code}`")),
}
}
fn off_runtime(f: impl FnOnce() -> Value + Send) -> Value {
std::thread::scope(|s| {
s.spawn(f)
.join()
.unwrap_or_else(|_| fail("the reader failed"))
})
}
fn fail(error: impl ToString) -> Value {
json!({ "ok": false, "error": error.to_string() })
}
#[async_trait]
impl ToolClient for AskTools {
async fn call(
&self,
tool: &ToolId,
arguments: &Value,
_provenance: Option<&agentplane::core::Provenance>,
) -> Result<Value, ToolError> {
if tool.server != SERVER || !names().contains(&tool.tool) {
return Err(ToolError::Refused {
tool: tool.clone(),
detail: "not an Ask tool".to_owned(),
});
}
Ok(self.run(&tool.tool, arguments).await)
}
fn destination(&self, _tool: &ToolId) -> Destination {
Destination::Local
}
}