use super::*;
#[derive(Default)]
pub(crate) struct StopGate {
pub spec: String,
pub frozen: Vec<String>,
pub rejected: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Stop {
Refused(String),
Asked(String),
Stopped(String),
}
pub(super) fn judge_stop(
gate: &StopGate,
arguments: &Value,
refused: bool,
asked: bool,
turns_left: u32,
) -> Stop {
let text = |k: &str| arguments[k].as_str().unwrap_or_default().trim();
let (kind, evidence, reason) = (text("kind"), text("evidence"), text("reason"));
let words = |t: &str| t.split_whitespace().collect::<Vec<_>>().join(" ");
let checked = match kind {
"refused" if refused => format!("refused call `{evidence}`"),
"refused" => return Stop::Refused("not stopped: no call in this attempt was refused or rejected".to_owned()),
"spec_conflict" if evidence.chars().count() >= 12 && words(&gate.spec).contains(&words(evidence)) => {
format!("the spec says “{evidence}”")
}
"spec_conflict" => {
return Stop::Refused("not stopped: the evidence must be the spec's exact words, at least a phrase; these are not in it".to_owned());
}
"wrong_test" if gate.frozen.iter().any(|f| f == evidence.trim_start_matches("./")) => {
format!("frozen test `{evidence}`")
}
"wrong_test" => return Stop::Refused("not stopped: the evidence must be the path of a test that existed when the attempt started".to_owned()),
"needs_information" if !evidence.is_empty() => format!("question: {evidence}"),
_ => return Stop::Refused("not stopped: `kind` is one of refused, spec_conflict, wrong_test, needs_information, with its `evidence`".to_owned()),
};
let said = match reason {
"" => checked,
r if r == evidence => r.to_owned(),
r => format!("{r} — {checked}"),
};
if kind == "refused" || asked || turns_left == 0 {
return Stop::Stopped(said);
}
Stop::Asked(format!(
"Not stopped yet: speccoder asks once before a stop. {turns_left} turns are left. The task's paths are the plan's guess: wiring an entry point, caller or import the tests run to the task's module is part of the task, and a failing check is a reason to keep going. If the task still cannot be done as written, call report_blocked again."
))
}
pub const ASK_PERSON: &str = "ask_person";
#[must_use]
pub fn ask_person() -> ToolDeclaration {
ToolDeclaration::new(
ASK_PERSON,
"Ask the person one question and wait for their answer. If nobody can answer, the reply says so.",
crate::substrate::answers::schema::<crate::substrate::answers::Question>(),
)
}
#[derive(Debug, Clone)]
pub struct Asker {
pub task: String,
pub task_title: String,
pub context: PresentContext,
pub wait_secs: u64,
}
impl Asker {
#[must_use]
pub fn from_settings(
settings: &Value,
task: String,
task_title: String,
context: PresentContext,
) -> Option<Self> {
(settings["asks"] == "tasks").then(|| Self {
task,
task_title,
context,
wait_secs: settings["wait_secs"].as_u64().unwrap_or(7 * 86_400),
})
}
}
pub(super) async fn wait_for_person(
cx: &mut StepCtx<'_>,
asker: &Asker,
obligation: &mut bool,
spec: &agentplane::core::TaskSpec,
) -> Result<agentplane::core::Decision, SkillError> {
if !*obligation {
let minutes = u32::try_from(asker.wait_secs.div_ceil(60).max(1)).unwrap_or(u32::MAX);
cx.deadline(
crate::approvals::tasks::DEADLINE,
&agentplane::core::DeadlineSpec::minutes(minutes),
None,
)
.await?;
*obligation = true;
}
Ok(cx.task(spec).await?)
}
pub(super) fn needs_person(
observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
asked: &agentplane::model::ToolCall,
object: Option<&Object>,
) {
if let Some((task, p)) = observer {
let (tool, arguments) = (asked.name.as_str(), &asked.arguments);
let target = crate::progress::target(tool, arguments);
let what: String = format!("{tool} {target}")
.chars()
.filter(|c| !c.is_control())
.take(60)
.collect();
p.event(&crate::progress::Event::ApprovalRequested {
task: task.clone(),
call: asked.id.clone(),
tool: tool.to_owned(),
what,
class: object
.map(|o| o.class().as_str().to_owned())
.unwrap_or_default(),
object: object.map(Object::short).unwrap_or_default(),
});
}
}
pub(super) fn person_decided(
observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
asked: &agentplane::model::ToolCall,
decision: &str,
scope: Option<&str>,
who: Option<&Who>,
object: Option<&Object>,
) {
if let Some((task, p)) = observer {
p.event(&crate::progress::Event::ApprovalDecided {
task: task.clone(),
call: asked.id.clone(),
decision: decision.to_owned(),
scope: scope.map(str::to_owned),
by: who.map_or_else(|| "nobody (expired)".to_owned(), |w| w.name.clone()),
how: who.map_or_else(|| "expired".to_owned(), |w| w.basis.clone()),
class: object
.map(|o| o.class().as_str().to_owned())
.unwrap_or_default(),
object: object.map(Object::short).unwrap_or_default(),
});
}
}
pub(super) struct Question<'a> {
pub(super) asked: &'a agentplane::model::ToolCall,
pub(super) reason: &'a str,
pub(super) turn: u32,
pub(super) said: &'a str,
pub(super) asker: Option<&'a Asker>,
pub(super) policy: &'a Policy,
pub(super) object: Option<&'a Object>,
pub(super) remembered: &'a [(Object, &'static str)],
pub(super) parts: &'a [crate::approvals::derived::Part],
pub(super) run: Option<&'a str>,
}
pub(super) fn question(q: &Question<'_>) -> Request {
let (tool, arguments) = (q.asked.name.as_str(), &q.asked.arguments);
let kind = crate::approvals::kind_of(tool, arguments);
let risk = crate::approvals::risk(tool, q.reason);
let always_risk = if kind == "network" {
let mut bare = arguments.clone();
if let Some(o) = bare.as_object_mut() {
o.remove("network");
}
match q.policy.base(tool, &bare) {
Decision::Allow => "low",
Decision::Approval(r) => crate::approvals::risk(tool, &r),
Decision::Deny(_) => "high",
}
} else {
risk
};
let (sources, more) = crate::approvals::derived::sources(tool, arguments, q.parts);
let changed = q.object.and_then(|o| {
q.remembered
.iter()
.map(|(r, _)| r)
.chain(q.policy.approved())
.find_map(|earlier| o.changed_from(earlier))
});
Request {
task: q.asker.map(|a| a.task.clone()).unwrap_or_default(),
task_title: q.asker.map(|a| a.task_title.clone()).unwrap_or_default(),
turn: q.turn,
tool: tool.to_owned(),
arguments: arguments.clone(),
kind: kind.to_owned(),
reason: q.reason.to_owned(),
object: q.object.cloned(),
changed,
risk: risk.to_owned(),
claim: crate::approvals::claim(q.said),
options: crate::approvals::options(
risk,
q.object.is_some(),
!sources.is_empty(),
kind,
always_risk,
),
sources,
more,
run: q.run.map(str::to_owned),
asked: 0,
budget: 0,
batched: false,
}
}