Skip to main content

speccoder_runtime/substrate/attempt/
person.rs

1//! The person in an attempt: the honest exit checked against its evidence,
2//! the questions a call or a skill puts to them, and their answers.
3
4use super::*;
5
6/// What a `report_blocked` call is checked against: its evidence must be
7/// there, never the model's word alone.
8#[derive(Default)]
9pub(crate) struct StopGate {
10    /// The spec text the prompt carried: what a `spec_conflict` quotes.
11    pub spec: String,
12    /// The tests frozen when the attempt started: what a `wrong_test` names.
13    pub frozen: Vec<String>,
14    /// A person rejected a call for this task before.
15    pub rejected: bool,
16}
17
18/// What speccoder does with a `report_blocked` call.
19#[derive(Debug, PartialEq, Eq)]
20pub(super) enum Stop {
21    /// The evidence is not there: the attempt goes on, told why.
22    Refused(String),
23    /// Asked once before stopping, with the turns left.
24    Asked(String),
25    /// The attempt stops; the reason the person reads.
26    Stopped(String),
27}
28
29/// Judges a stop by its evidence: a refused call stops at once; a quote,
30/// a frozen test or a question is asked about once first.
31pub(super) fn judge_stop(
32    gate: &StopGate,
33    arguments: &Value,
34    refused: bool,
35    asked: bool,
36    turns_left: u32,
37) -> Stop {
38    let text = |k: &str| arguments[k].as_str().unwrap_or_default().trim();
39    let (kind, evidence, reason) = (text("kind"), text("evidence"), text("reason"));
40    let words = |t: &str| t.split_whitespace().collect::<Vec<_>>().join(" ");
41    let checked = match kind {
42        "refused" if refused => format!("refused call `{evidence}`"),
43        "refused" => return Stop::Refused("not stopped: no call in this attempt was refused or rejected".to_owned()),
44        "spec_conflict" if evidence.chars().count() >= 12 && words(&gate.spec).contains(&words(evidence)) => {
45            format!("the spec says “{evidence}”")
46        }
47        "spec_conflict" => {
48            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());
49        }
50        "wrong_test" if gate.frozen.iter().any(|f| f == evidence.trim_start_matches("./")) => {
51            format!("frozen test `{evidence}`")
52        }
53        "wrong_test" => return Stop::Refused("not stopped: the evidence must be the path of a test that existed when the attempt started".to_owned()),
54        "needs_information" if !evidence.is_empty() => format!("question: {evidence}"),
55        _ => return Stop::Refused("not stopped: `kind` is one of refused, spec_conflict, wrong_test, needs_information, with its `evidence`".to_owned()),
56    };
57    // The model's reason first, then what speccoder checked.
58    let said = match reason {
59        "" => checked,
60        r if r == evidence => r.to_owned(),
61        r => format!("{r} — {checked}"),
62    };
63    // Asked once only, and never on the last turn: no answer could follow.
64    if kind == "refused" || asked || turns_left == 0 {
65        return Stop::Stopped(said);
66    }
67    Stop::Asked(format!(
68        "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."
69    ))
70}
71
72/// The question tool a hosted skill may use.
73pub const ASK_PERSON: &str = "ask_person";
74
75/// The `ask_person` declaration.
76#[must_use]
77pub fn ask_person() -> ToolDeclaration {
78    ToolDeclaration::new(
79        ASK_PERSON,
80        "Ask the person one question and wait for their answer. If nobody can answer, the reply says so.",
81        crate::substrate::answers::schema::<crate::substrate::answers::Question>(),
82    )
83}
84
85/// How an attempt waits for a person when a call needs one: on an
86/// agentplane human task the person decides — inline, from an editor, or
87/// later with `speccoder task approve` — while the run is suspended.
88#[derive(Debug, Clone)]
89pub struct Asker {
90    /// `feature/task`, shown to the person.
91    pub task: String,
92    /// The task's title.
93    pub task_title: String,
94    /// What the live run knows for the presentation.
95    pub context: PresentContext,
96    /// How long a call waits before it is denied as expired.
97    pub wait_secs: u64,
98}
99
100impl Asker {
101    /// The asker the journaled settings describe: `asks = "tasks"` waits on
102    /// a task; anything else (`"stop"`, a lane) stops the attempt instead.
103    #[must_use]
104    pub fn from_settings(
105        settings: &Value,
106        task: String,
107        task_title: String,
108        context: PresentContext,
109    ) -> Option<Self> {
110        (settings["asks"] == "tasks").then(|| Self {
111            task,
112            task_title,
113            context,
114            wait_secs: settings["wait_secs"].as_u64().unwrap_or(7 * 86_400),
115        })
116    }
117}
118
119/// Waits for a person's decision on `spec`, registering the `approval`
120/// obligation the first time: the run suspends until someone decides or the
121/// deadline passes. Journaled — replay reads the decision back.
122pub(super) async fn wait_for_person(
123    cx: &mut StepCtx<'_>,
124    asker: &Asker,
125    obligation: &mut bool,
126    spec: &agentplane::core::TaskSpec,
127) -> Result<agentplane::core::Decision, SkillError> {
128    if !*obligation {
129        let minutes = u32::try_from(asker.wait_secs.div_ceil(60).max(1)).unwrap_or(u32::MAX);
130        cx.deadline(
131            crate::approvals::tasks::DEADLINE,
132            &agentplane::core::DeadlineSpec::minutes(minutes),
133            None,
134        )
135        .await?;
136        *obligation = true;
137    }
138    Ok(cx.task(spec).await?)
139}
140
141/// A person is needed for `asked`: the run tells whoever watches.
142pub(super) fn needs_person(
143    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
144    asked: &agentplane::model::ToolCall,
145    object: Option<&Object>,
146) {
147    if let Some((task, p)) = observer {
148        let (tool, arguments) = (asked.name.as_str(), &asked.arguments);
149        let target = crate::progress::target(tool, arguments);
150        let what: String = format!("{tool} {target}")
151            .chars()
152            .filter(|c| !c.is_control())
153            .take(60)
154            .collect();
155        p.event(&crate::progress::Event::ApprovalRequested {
156            task: task.clone(),
157            call: asked.id.clone(),
158            tool: tool.to_owned(),
159            what,
160            class: object
161                .map(|o| o.class().as_str().to_owned())
162                .unwrap_or_default(),
163            object: object.map(Object::short).unwrap_or_default(),
164        });
165    }
166}
167
168/// A person decided `asked`: the run tells whoever watches.
169pub(super) fn person_decided(
170    observer: Option<&(String, std::sync::Arc<dyn crate::progress::Progress>)>,
171    asked: &agentplane::model::ToolCall,
172    decision: &str,
173    scope: Option<&str>,
174    who: Option<&Who>,
175    object: Option<&Object>,
176) {
177    if let Some((task, p)) = observer {
178        p.event(&crate::progress::Event::ApprovalDecided {
179            task: task.clone(),
180            call: asked.id.clone(),
181            decision: decision.to_owned(),
182            scope: scope.map(str::to_owned),
183            by: who.map_or_else(|| "nobody (expired)".to_owned(), |w| w.name.clone()),
184            how: who.map_or_else(|| "expired".to_owned(), |w| w.basis.clone()),
185            class: object
186                .map(|o| o.class().as_str().to_owned())
187                .unwrap_or_default(),
188            object: object.map(Object::short).unwrap_or_default(),
189        });
190    }
191}
192
193/// What a call needing a person is asked by.
194pub(super) struct Question<'a> {
195    pub(super) asked: &'a agentplane::model::ToolCall,
196    pub(super) reason: &'a str,
197    pub(super) turn: u32,
198    pub(super) said: &'a str,
199    pub(super) asker: Option<&'a Asker>,
200    pub(super) policy: &'a Policy,
201    pub(super) object: Option<&'a Object>,
202    pub(super) remembered: &'a [(Object, &'static str)],
203    pub(super) parts: &'a [crate::approvals::derived::Part],
204    pub(super) run: Option<&'a str>,
205}
206
207/// The journaled question for a call needing a person: its object, what
208/// changed since an approval of the same name, where its arguments were
209/// copied from, and the choices that leaves — all from journaled data.
210pub(super) fn question(q: &Question<'_>) -> Request {
211    let (tool, arguments) = (q.asked.name.as_str(), &q.asked.arguments);
212    let kind = crate::approvals::kind_of(tool, arguments);
213    let risk = crate::approvals::risk(tool, q.reason);
214    // Always Allow commits an exact entry: judged without the network.
215    let always_risk = if kind == "network" {
216        let mut bare = arguments.clone();
217        if let Some(o) = bare.as_object_mut() {
218            o.remove("network");
219        }
220        match q.policy.base(tool, &bare) {
221            Decision::Allow => "low",
222            Decision::Approval(r) => crate::approvals::risk(tool, &r),
223            Decision::Deny(_) => "high",
224        }
225    } else {
226        risk
227    };
228    let (sources, more) = crate::approvals::derived::sources(tool, arguments, q.parts);
229    let changed = q.object.and_then(|o| {
230        q.remembered
231            .iter()
232            .map(|(r, _)| r)
233            .chain(q.policy.approved())
234            .find_map(|earlier| o.changed_from(earlier))
235    });
236    Request {
237        task: q.asker.map(|a| a.task.clone()).unwrap_or_default(),
238        task_title: q.asker.map(|a| a.task_title.clone()).unwrap_or_default(),
239        turn: q.turn,
240        tool: tool.to_owned(),
241        arguments: arguments.clone(),
242        kind: kind.to_owned(),
243        reason: q.reason.to_owned(),
244        object: q.object.cloned(),
245        changed,
246        risk: risk.to_owned(),
247        claim: crate::approvals::claim(q.said),
248        options: crate::approvals::options(
249            risk,
250            q.object.is_some(),
251            !sources.is_empty(),
252            kind,
253            always_risk,
254        ),
255        sources,
256        more,
257        run: q.run.map(str::to_owned),
258        asked: 0,
259        budget: 0,
260        batched: false,
261    }
262}