speccoder_runtime/substrate/attempt/
person.rs1use super::*;
5
6#[derive(Default)]
9pub(crate) struct StopGate {
10 pub spec: String,
12 pub frozen: Vec<String>,
14 pub rejected: bool,
16}
17
18#[derive(Debug, PartialEq, Eq)]
20pub(super) enum Stop {
21 Refused(String),
23 Asked(String),
25 Stopped(String),
27}
28
29pub(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 let said = match reason {
59 "" => checked,
60 r if r == evidence => r.to_owned(),
61 r => format!("{r} — {checked}"),
62 };
63 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
72pub const ASK_PERSON: &str = "ask_person";
74
75#[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#[derive(Debug, Clone)]
89pub struct Asker {
90 pub task: String,
92 pub task_title: String,
94 pub context: PresentContext,
96 pub wait_secs: u64,
98}
99
100impl Asker {
101 #[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
119pub(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
141pub(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
168pub(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
193pub(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
207pub(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 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}