Skip to main content

speccoder_runtime/approvals/
tasks.rs

1//! A person's decision as an agentplane human task.
2//!
3//! Where a call needs a person, the attempt waits on a task whose
4//! justification is the call exactly as the prompt shows it. Whoever answers
5//! — at the terminal, in an editor, or later with `speccoder task approve` —
6//! decides that task with an operator; the decision is journal history,
7//! bound to the digest of what was shown.
8
9use agentplane::core::{Decided, Decision, Justification, Operator, Tainted, TaskSpec};
10use serde_json::{Value, json};
11
12use super::{Answer, Decision as Said, PresentContext, Presentation, Request, Scope, Who};
13
14/// The obligation every wait for a person is bounded by.
15pub const DEADLINE: &str = "approval";
16
17/// The task kind of a call waiting for approval.
18pub const APPROVAL: &str = "approval";
19
20/// The task kind of a question to a person.
21pub const QUESTION: &str = "question";
22
23/// The waiting task for a call: the exact call, the question as the
24/// journal holds it, and the class an approval for the task or run would
25/// create. Only journaled facts go in — the approval is bound to this
26/// task's digest, so replay must rebuild it byte for byte; the presentation
27/// (the file as it is, the sandbox) is built live by whoever asks.
28#[must_use]
29pub fn approval_task(
30    request: &Request,
31    grant: Option<&crate::tools::policy::Grant>,
32    log: &[Value],
33) -> TaskSpec {
34    TaskSpec::new(
35        APPROVAL,
36        Justification::new(
37            Tainted::trusted(format!("{} — {}", request.task, request.covers)),
38            json!({
39                "tool": request.tool,
40                "arguments": request.arguments,
41                "task": request.task,
42                "grant": grant,
43                "request": request,
44                // What the attempt did before it stopped here, as journaled.
45                "log": log,
46            }),
47        ),
48        DEADLINE,
49    )
50}
51
52/// The waiting task for a question from `task` (`skill/<name>`).
53#[must_use]
54pub fn question_task(task: &str, question: &str) -> TaskSpec {
55    TaskSpec::new(
56        QUESTION,
57        Justification::new(
58            Tainted::trusted(format!("{task} asks")),
59            json!({ "task": task, "question": question }),
60        ),
61        DEADLINE,
62    )
63}
64
65/// The question a waiting approval task carries.
66#[must_use]
67pub fn request_of(proposed_action: &Value) -> Option<Request> {
68    serde_json::from_value(proposed_action["request"].clone()).ok()
69}
70
71/// The waiting call as the person sees it here, now.
72#[must_use]
73pub fn presentation_of(proposed_action: &Value, context: &PresentContext) -> Option<Presentation> {
74    request_of(proposed_action).map(|r| super::present(&r, context))
75}
76
77/// A person's answer as the decision recorded for its task: the scope, note,
78/// channel and any answer text travel in the amendment.
79#[must_use]
80pub fn decision_for(answer: &Answer, by: &Who, text: Option<&str>) -> Option<Decision> {
81    let operator: Operator = by.operator()?;
82    let mut amendment = json!({ "scope": answer.scope.name(), "via": by.via });
83    if let Some(note) = &answer.note {
84        amendment["note"] = json!(note);
85    }
86    if let Some(text) = text {
87        amendment["answer"] = json!(text);
88    }
89    Some(
90        match answer.decision {
91            Said::Approve => Decision::approve(operator, answer.note.clone().unwrap_or_default()),
92            Said::Reject | Said::Stop => Decision::reject(
93                operator,
94                answer
95                    .reason
96                    .clone()
97                    .unwrap_or_else(|| "no reason given".to_owned()),
98            ),
99        }
100        .amend(amendment),
101    )
102}
103
104/// Asks `approver` about a waiting `task` now. `None` when the person
105/// answers "later" (the call keeps waiting) or the task is not one of ours.
106#[must_use]
107pub fn ask(
108    approver: &dyn super::Approver,
109    task: &agentplane::core::Task,
110    context: &PresentContext,
111) -> Option<Decision> {
112    let action = &task.justification.proposed_action;
113    match task.kind.as_str() {
114        APPROVAL => {
115            let answer = approver.ask(&presentation_of(action, context)?);
116            if answer.decision == Said::Stop {
117                return None;
118            }
119            decision_for(&answer, &named(answer.by.clone(), approver, context), None)
120        }
121        QUESTION => {
122            let by = named(None, approver, context);
123            match approver.answer(action["question"].as_str()?) {
124                Some(text) => decision_for(
125                    &Answer::approve(Scope::Once, None, Some(by.clone())),
126                    &by,
127                    Some(&text),
128                ),
129                None => decision_for(
130                    &Answer::reject("declined to answer", Some(by.clone())),
131                    &by,
132                    None,
133                ),
134            }
135        }
136        _ => None,
137    }
138}
139
140/// Who answered, named as git names them in the project they decide for.
141fn named(by: Option<Who>, approver: &dyn super::Approver, context: &PresentContext) -> Who {
142    let mut by = by.unwrap_or_else(|| Who::local(&approver.via()));
143    if let Some(root) = &context.root {
144        by.name = super::who_in(root);
145    }
146    by
147}
148
149/// What the attempt reads from a recorded decision. An expiry is a refusal
150/// nobody made: `by` is `None` and `expired` is set.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct Read {
153    /// The answer as the tool loop applies it.
154    pub answer: Answer,
155    /// Who decided; `None` for an expiry.
156    pub by: Option<Who>,
157    /// The deadline passed and the call was denied.
158    pub expired: bool,
159    /// A question's answer text.
160    pub text: Option<String>,
161}
162
163/// Reads a recorded decision back.
164#[must_use]
165pub fn read(d: &Decision) -> Read {
166    let a = &d.amendment;
167    let scope = match a["scope"].as_str() {
168        Some("once") => Scope::Once,
169        Some("run") => Scope::Run,
170        _ => Scope::Task,
171    };
172    let by = d.decided.operator().map(|op| Who {
173        name: op.actor().to_owned(),
174        via: a["via"].as_str().unwrap_or("cli").to_owned(),
175        basis: op.basis().to_string(),
176    });
177    let expired = matches!(d.decided, Decided::OnExpiry(_));
178    let answer = if d.approved {
179        Answer::approve(scope, a["note"].as_str().map(str::to_owned), by.clone())
180    } else {
181        Answer::reject(
182            if expired {
183                "not approved before its deadline"
184            } else {
185                d.reason.as_str()
186            },
187            by.clone(),
188        )
189    };
190    Read {
191        answer,
192        by,
193        expired,
194        text: a["answer"].as_str().map(str::to_owned),
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn who() -> Who {
203        Who {
204            name: "dana".to_owned(),
205            via: "terminal".to_owned(),
206            basis: "asserted".to_owned(),
207        }
208    }
209
210    #[test]
211    fn every_scope_and_note_survive_the_record() {
212        for scope in [Scope::Once, Scope::Task, Scope::Run] {
213            let answer = Answer::approve(scope, Some("keep it small".to_owned()), Some(who()));
214            let d = decision_for(&answer, &who(), None).unwrap();
215            let back = read(&d);
216            assert_eq!(back.answer.scope, scope);
217            assert_eq!(back.answer.note.as_deref(), Some("keep it small"));
218            assert_eq!(back.by, Some(who()));
219            assert!(!back.expired);
220        }
221        let rejected = Answer::reject("not that file", Some(who()));
222        let back = read(&decision_for(&rejected, &who(), None).unwrap());
223        assert_eq!(back.answer.decision, Said::Reject);
224        assert_eq!(back.answer.reason.as_deref(), Some("not that file"));
225    }
226
227    #[test]
228    fn an_expiry_is_a_refusal_nobody_made() {
229        let d = Decision::expired(agentplane::core::OnExpiry::Deny);
230        let back = read(&d);
231        assert!(back.expired && back.by.is_none());
232        assert_eq!(back.answer.decision, Said::Reject);
233    }
234
235    #[test]
236    fn the_task_carries_the_exact_call() {
237        let request = super::super::Request {
238            task: "001-x/T001".to_owned(),
239            task_title: "t".to_owned(),
240            turn: 1,
241            tool: "write_file".to_owned(),
242            arguments: json!({ "path": "src/a.rs", "content": "fn a() {}\n" }),
243            kind: "write".to_owned(),
244            reason: "r".to_owned(),
245            covers: "write `src/a.rs`".to_owned(),
246            risk: "low".to_owned(),
247            claim: None,
248            options: vec!["once".to_owned()],
249        };
250        let spec = approval_task(&request, None, &[]);
251        assert_eq!(spec.kind, APPROVAL);
252        let action = &spec.justification.proposed_action;
253        assert_eq!(action["arguments"]["content"], "fn a() {}\n");
254        assert_eq!(request_of(action), Some(request.clone()));
255        // Only journaled facts: the same request gives the same digest.
256        assert_eq!(
257            spec.justification.digest(),
258            approval_task(&request, None, &[]).justification.digest()
259        );
260    }
261}