1use super::reactor::{PendingKind, Runtime, Target};
24use crate::config::v2::AskHumanFallback;
25use crate::intel::client::IntelClient;
26use crate::state::now_ms;
27use crate::wire::intel::{Message, Request};
28use serde_json::{Value, json};
29use std::time::Duration;
30
31const ASK_TIMEOUT: Duration = Duration::from_secs(24 * 3600);
33#[cfg(feature = "a2a")]
36pub(crate) const ASK_TIMEOUT_MS: u64 = 24 * 3600 * 1000;
37const AUTO_GRACE_MS: u64 = 10 * 60 * 1000;
39const UNDECIDED: &str = "UNDECIDED";
41
42impl Runtime {
43 pub(crate) fn ask_human_tool(
45 &mut self,
46 caller: &super::tools::ToolCaller,
47 args: Value,
48 ) -> super::tools::ToolOutcome {
49 use super::tools::ToolOutcome;
50 let question = {
51 let q = args["question"].as_str().unwrap_or("").trim().to_string();
52 let mut q = if q.is_empty() {
53 "The agent needs your input.".to_string()
54 } else {
55 q
56 };
57 if q.len() > 2000 {
58 let mut cut = 2000;
59 while cut > 0 && !q.is_char_boundary(cut) {
60 cut -= 1;
61 }
62 q.truncate(cut);
63 q.push('…');
64 }
65 q
66 };
67 let timeout = args
68 .get("timeout")
69 .and_then(Value::as_str)
70 .and_then(|t| crate::config::parse_duration(t).ok())
71 .unwrap_or(ASK_TIMEOUT);
72 let deadline_ms = now_ms() + timeout.as_millis() as u64;
73 let schema = args.get("schema").cloned().filter(|v| !v.is_null());
76 let addressee = match args.get("to").filter(|v| !v.is_null()) {
80 None => None,
81 Some(v) => match crate::a2a::principals::Addressee::parse(v) {
82 Ok(a) => Some(a),
83 Err(e) => {
84 return ToolOutcome::Ready(Value::String(format!("ask_human: {e}")), true);
85 }
86 },
87 };
88
89 match self.settings.agent.approval {
94 crate::config::v2::Approval::Ask => {}
95 _ if addressee.is_some() => {}
102 crate::config::v2::Approval::Accept => {
103 let recommended = args
108 .get("recommend")
109 .cloned()
110 .filter(|v| !v.is_null())
111 .or_else(|| schema.as_ref().and_then(|s| s.get("default").cloned()));
112 if let Some(v) = recommended {
113 let text = match &v {
114 Value::String(s) => s.clone(),
115 other => other.to_string(),
116 };
117 self.log.info(
118 "human.auto_accepted",
119 json!({"question": question, "answer": text, "policy": "accept"}),
120 );
121 self.audit(super::audit::AuditEvent {
122 action: "ask_human.accepted",
123 target: json!({"question": question}),
124 outcome: "accept",
125 principal: Some("policy"),
126 role: None,
127 request_id: None,
128 });
129 return ToolOutcome::Ready(
130 json!({"reply": text, "timed_out": false, "via": "accept"}),
131 false,
132 );
133 }
134 let ask = self.next_id("ask");
135 self.spawn_human_judge(&ask, &question);
136 return ToolOutcome::Deferred(PendingKind::Human {
137 task: ask,
138 question,
139 deadline_ms: now_ms() + AUTO_GRACE_MS,
140 standalone: false,
141 auto_fired: true,
142 schema,
143 addressee: None,
144 });
145 }
146 crate::config::v2::Approval::Auto => {
147 let ask = self.next_id("ask");
148 self.spawn_human_judge(&ask, &question);
149 return ToolOutcome::Deferred(PendingKind::Human {
150 task: ask,
151 question,
152 deadline_ms: now_ms() + AUTO_GRACE_MS,
153 standalone: false,
154 auto_fired: true,
155 schema,
156 addressee: None,
157 });
158 }
159 }
160
161 #[cfg(feature = "a2a")]
163 let available = self.settings.interface.enabled && self.a2a_sink.is_some();
164 #[cfg(not(feature = "a2a"))]
165 let available = false;
166
167 if available {
168 #[cfg(feature = "a2a")]
169 return self.human_gate(caller, question, deadline_ms, schema, addressee);
170 }
171 let _ = caller;
172 match self.settings.agent.ask_human_fallback {
174 AskHumanFallback::Fail => ToolOutcome::Ready(
175 Value::String(
176 "ask_human: no human channel (interface.enabled is off) and \
177 agent.ask_human_fallback = fail"
178 .into(),
179 ),
180 true,
181 ),
182 AskHumanFallback::Wait => {
183 let ask = self.next_id("ask");
184 self.log.info(
185 "human.ask.parked",
186 json!({
192 "ask": ask,
193 "deadline_ms": deadline_ms,
194 "note": "no human channel (interface.enabled is off); \
195 ask_human_fallback = wait — this gate will park until its timeout"
196 }),
197 );
198 ToolOutcome::Deferred(PendingKind::Human {
199 task: ask,
200 question,
201 deadline_ms,
202 standalone: false,
203 auto_fired: false,
204 schema: schema.clone(),
205 addressee: addressee.clone(),
206 })
207 }
208 AskHumanFallback::Auto => {
209 let ask = self.next_id("ask");
210 self.spawn_human_judge(&ask, &question);
211 ToolOutcome::Deferred(PendingKind::Human {
212 task: ask,
213 question,
214 deadline_ms: now_ms() + AUTO_GRACE_MS,
215 standalone: false,
216 auto_fired: true,
217 schema: schema.clone(),
218 addressee: None,
219 })
220 }
221 }
222 }
223
224 #[cfg(feature = "a2a")]
227 pub(crate) fn human_gate(
228 &mut self,
229 caller: &super::tools::ToolCaller,
230 question: String,
231 deadline_ms: u64,
232 schema: Option<Value>,
233 addressee: Option<crate::a2a::principals::Addressee>,
234 ) -> super::tools::ToolOutcome {
235 use super::children::ChildKind;
236 use super::tools::ToolOutcome;
237 use crate::a2a::tasks::{Link, State};
238
239 let linked: Option<String> = if let Some(node) = caller.node {
242 match self.children.get(node).map(|c| c.kind.clone()) {
243 Some(ChildKind::RootTurn {
244 event: Some(ev), ..
245 }) => self.event_to_task.get(&ev).cloned(),
246 Some(ChildKind::StepTurn { run, .. }) => {
247 self.runs.get(&run).and_then(|r| r.task.clone())
248 }
249 _ => None,
250 }
251 } else if let Some(run) = &caller.run {
252 self.runs.get(run).and_then(|r| r.task.clone())
253 } else {
254 None
255 };
256 let linked = linked.filter(|t| self.tasks.get(t).is_some_and(|t| !t.state.is_terminal()));
257
258 if let Some(t) = &linked
260 && self
261 .pending
262 .iter()
263 .any(|p| matches!(&p.kind, PendingKind::Human { task, .. } if task == t))
264 {
265 return ToolOutcome::Ready(
266 Value::String("ask_human: an ask is already pending on this task".into()),
267 true,
268 );
269 }
270
271 let (task_id, standalone) = match linked {
272 Some(t) => (t, false),
273 None => {
274 let principal_id = caller
278 .principal
279 .clone()
280 .unwrap_or_else(|| "operator".to_string());
281 let principal = crate::a2a::Principal {
282 id: principal_id,
283 role: crate::config::v2::Role::Operator,
284 grants: Vec::new(),
285 rate: None,
286 budget: None,
287 labels: Default::default(),
288 };
289 if let Some(run) = &caller.run {
290 let run = run.clone();
291 let ctx = format!("run-{run}");
292 let tid = self.task_create(&ctx, &principal, Link::Run { id: run.clone() });
293 if let Some(r) = self.runs.get_mut(&run) {
295 r.task = Some(tid.clone());
296 r.touch();
297 }
298 (tid, false)
299 } else {
300 let ctx = caller.context_id();
301 let tid = self.task_create(&ctx, &principal, Link::Turn { ctx: ctx.clone() });
302 (tid, true)
303 }
304 }
305 };
306
307 if let Some(t) = self.tasks.get_mut(&task_id) {
308 t.ask_schema = schema.clone();
313 t.transition(State::InputRequired, Some(question.clone()));
314 }
315 self.task_persist(&task_id);
316 self.task_sync(&task_id);
317 self.log.info(
318 "human.ask",
319 json!({"task": task_id, "question": question, "deadline_ms": deadline_ms}),
320 );
321 self.audit(super::audit::AuditEvent {
322 action: "ask_human",
323 target: json!({"task": task_id}),
324 outcome: "asked",
325 principal: caller.principal.as_deref(),
326 role: None,
327 request_id: None,
328 });
329 ToolOutcome::Deferred(PendingKind::Human {
330 task: task_id,
331 question,
332 deadline_ms,
333 standalone,
334 auto_fired: false,
335 schema,
336 addressee,
337 })
338 }
339
340 fn reask_human(&mut self, mut p: super::reactor::PendingTool, question: &str, why: &str) {
345 let amended = format!("{question}\n\n(previous answer rejected: {why})");
346 if let PendingKind::Human { question: q, .. } = &mut p.kind {
347 *q = amended.clone();
348 }
349 #[cfg(feature = "a2a")]
350 if let PendingKind::Human { task, .. } = &p.kind {
351 use crate::a2a::tasks::State;
352 let task = task.clone();
353 if let Some(t) = self.tasks.get_mut(&task) {
354 t.transition(State::InputRequired, Some(amended));
355 }
356 self.task_persist(&task);
357 self.task_sync(&task);
358 }
359 self.pending.push(p);
360 }
361
362 pub(crate) fn human_answer(
370 &mut self,
371 i: usize,
372 text: &str,
373 via: &str,
374 answered_by: Option<&str>,
375 ) {
376 let p = self.pending.remove(i);
377 let PendingKind::Human {
378 task,
379 standalone,
380 schema,
381 question,
382 ..
383 } = &p.kind
384 else {
385 return;
386 };
387 let (task, standalone) = (task.clone(), *standalone);
388 self.fire_event_starts(
389 "human.answered",
390 &serde_json::json!({"task": task, "via": via}),
391 );
392 if let Some(schema) = schema.clone() {
398 let value = match crate::mcp::elicit::shape_reply(&json!(text), &schema) {
399 ::mcp::inbound::Answer::Accept(v) => v,
400 _ => json!(text),
403 };
404 if let Err(errs) = crate::jsonschema::validate(&schema, &value) {
405 let q = question.clone();
406 self.log.info(
407 "human.answer.rejected",
408 json!({"task": task, "errors": errs, "via": via}),
409 );
410 if via == "auto" {
412 self.human_task_fail(
413 &task,
414 &format!(
415 "auto-answer does not match the declared schema: {}",
416 errs.join("; ")
417 ),
418 );
419 return;
420 }
421 self.reask_human(p, &q, &errs.join("; "));
422 return;
423 }
424 }
425 if let Target::Child(node, _) = &p.target
433 && self.children.get(*node).is_none()
434 {
435 const LATE: &str = "ask_human: the asking turn ended before the answer arrived";
436 self.human_task_fail(&task, LATE);
437 self.log.warn(
438 "human.answer.undelivered",
439 json!({"task": task, "via": via}),
440 );
441 self.audit(super::audit::AuditEvent {
442 action: "ask_human.answered",
443 target: json!({"task": task}),
444 outcome: "undelivered",
445 principal: answered_by.or(Some(via)),
446 role: None,
447 request_id: None,
448 });
449 return;
450 }
451 #[cfg(feature = "a2a")]
452 if self.tasks.contains_key(&task) {
453 use crate::a2a::tasks::State;
454 let note = if via == "auto" {
455 "auto-answered (no human reply)"
456 } else {
457 "answered"
458 };
459 if let Some(t) = self.tasks.get_mut(&task) {
460 if standalone {
461 t.transition(State::Completed, Some(note.to_string()));
463 } else {
464 t.transition(State::Working, Some(note.to_string()));
465 }
466 }
467 self.task_persist(&task);
468 self.task_sync(&task);
469 }
470 let _ = standalone;
471 self.log.info(
472 "human.answered",
473 json!({"task": task, "via": via, "by": answered_by}),
474 );
475 self.audit(super::audit::AuditEvent {
479 action: "ask_human.answered",
480 target: json!({"task": task}),
481 outcome: via,
482 principal: answered_by.or(Some(via)),
483 role: None,
484 request_id: None,
485 });
486 let result = match &p.target {
498 Target::Child(..) => json!({"reply": text, "timed_out": false, "via": via}),
499 Target::Step(..) => Value::String(text.to_string()),
500 };
501 self.reply(&p.target, result, false);
502 }
503
504 pub(crate) fn human_fail(&mut self, i: usize, msg: &str) {
506 let p = self.pending.remove(i);
507 let PendingKind::Human { task, .. } = &p.kind else {
508 return;
509 };
510 let task = task.clone();
511 self.human_task_fail(&task, msg);
512 self.log
513 .warn("human.ask.failed", json!({"task": task, "err": msg}));
514 self.reply(&p.target, Value::String(msg.to_string()), true);
515 }
516
517 fn human_task_fail(&mut self, task: &str, msg: &str) {
519 #[cfg(feature = "a2a")]
520 if self.tasks.contains_key(task) {
521 use crate::a2a::tasks::State;
522 if let Some(t) = self.tasks.get_mut(task) {
523 t.transition(State::Failed, Some(msg.to_string()));
524 }
525 self.task_persist(task);
526 self.task_sync(task);
527 }
528 #[cfg(not(feature = "a2a"))]
529 let _ = (task, msg);
530 }
531
532 pub(crate) fn poll_pending_human(&mut self) {
536 let now = now_ms();
537 let auto = self.settings.agent.ask_human_fallback == AskHumanFallback::Auto;
538 enum End {
539 Prune(String, &'static str),
541 Timeout,
542 }
543 let mut fire_auto: Vec<Target> = Vec::new();
551 let mut ends: Vec<(Target, End)> = Vec::new();
552 for p in self.pending.iter() {
553 let PendingKind::Human {
554 task,
555 deadline_ms,
556 auto_fired,
557 ..
558 } = &p.kind
559 else {
560 continue;
561 };
562 match &p.target {
563 Target::Step(run, step) => {
566 let suspended = self
567 .runs
568 .get(run)
569 .and_then(|r| r.steps.get(step))
570 .is_some_and(|s| s.status == crate::engine::run::StepStatus::Suspended);
571 if !suspended {
572 ends.push((
573 p.target.clone(),
574 End::Prune(task.clone(), "the asking step resolved without an answer"),
575 ));
576 continue;
577 }
578 }
579 Target::Child(node, _) if self.children.get(*node).is_none() => {
588 ends.push((
589 p.target.clone(),
590 End::Prune(
591 task.clone(),
592 "the asking turn ended before the gate was answered",
593 ),
594 ));
595 continue;
596 }
597 Target::Child(..) => {}
598 }
599 if now >= *deadline_ms {
600 if auto && !auto_fired {
601 fire_auto.push(p.target.clone());
602 } else {
603 ends.push((p.target.clone(), End::Timeout));
604 }
605 }
606 }
607 for target in fire_auto {
608 let Some(p) = self.pending.iter_mut().find(|p| p.target == target) else {
609 continue;
610 };
611 let PendingKind::Human {
612 task,
613 question,
614 deadline_ms,
615 auto_fired,
616 ..
617 } = &mut p.kind
618 else {
619 continue;
620 };
621 *auto_fired = true;
622 *deadline_ms = now + AUTO_GRACE_MS;
623 let (task, question) = (task.clone(), question.clone());
624 #[cfg(feature = "a2a")]
625 {
626 use crate::a2a::tasks::State;
627 if let Some(t) = self.tasks.get_mut(&task) {
628 t.transition(
629 State::InputRequired,
630 Some("auto-answering (no human reply in time)…".to_string()),
631 );
632 }
633 self.task_sync(&task);
634 }
635 self.spawn_human_judge(&task, &question);
636 }
637 for (target, end) in ends {
638 let Some(i) = self
643 .pending
644 .iter()
645 .position(|p| p.target == target && matches!(&p.kind, PendingKind::Human { .. }))
646 else {
647 continue;
648 };
649 match end {
650 End::Prune(task, why) => {
651 self.pending.remove(i);
652 self.log
653 .warn("human.ask.pruned", json!({"task": task, "err": why}));
654 self.human_task_fail(&task, why);
655 }
656 End::Timeout => self.human_fail(i, "ask_human: no answer within the timeout"),
657 }
658 }
659 }
660
661 pub(crate) fn spawn_human_judge(&mut self, ask: &str, question: &str) {
664 let uri = self.intel_uri.clone();
665 let token = self.current_intel_bearer();
666 let headers = self.intel_headers.clone();
667 let aws_auth = self.intel_aws_auth();
668 let dialect = self.intel_dialect();
669 let model = self.model.clone();
670 let tx = self.events_tx.clone();
671 let (ask, question) = (ask.to_string(), question.to_string());
672 self.log.info(
673 "human.judge.start",
674 json!({"ask": ask, "question": question}),
675 );
676 std::thread::Builder::new()
677 .name("human-judge".into())
678 .spawn(move || {
679 let result =
680 human_judge_call(&uri, token, &headers, aws_auth, dialect, &model, &question);
681 let _ = tx.send(super::events::Event::Background {
682 id: format!("human.judge:{ask}"),
683 result,
684 });
685 })
686 .ok();
687 }
688
689 pub(crate) fn on_human_judge(&mut self, ask: &str, result: &Value) {
691 let Some(i) = self
692 .pending
693 .iter()
694 .position(|p| matches!(&p.kind, PendingKind::Human { task, .. } if task == ask))
695 else {
696 return; };
698 match result["answer"].as_str() {
699 Some(a) if !a.trim().is_empty() && a.trim() != UNDECIDED => {
700 let answer = a.trim().to_string();
701 self.human_answer(i, &answer, "auto", None);
702 }
703 Some(_) => self.human_fail(i, "ask_human: the auto judge could not decide (UNDECIDED)"),
704 None => {
705 let err = result["error"].as_str().unwrap_or("no answer").to_string();
706 self.human_fail(i, &format!("ask_human: auto judge failed: {err}"));
707 }
708 }
709 }
710
711 #[cfg(feature = "a2a")]
717 pub(crate) fn rebuild_human_asks(&mut self) {
718 use crate::a2a::tasks::{Link, State};
719 type RestoredGate = (
721 String,
722 String,
723 String,
724 String,
725 u64,
726 Option<Value>,
727 Option<crate::a2a::principals::Addressee>,
728 );
729 let gates: Vec<RestoredGate> = self
730 .tasks
731 .values()
732 .filter(|t| t.state == State::InputRequired)
733 .filter_map(|t| match &t.link {
734 Link::Run { id } => {
735 let r = self.runs.get(id)?;
736 let (step_id, wait) = r.steps.iter().find_map(|(sid, s)| {
737 (s.status == crate::engine::run::StepStatus::Suspended
738 && s.wait.as_ref()?.get("kind")?.as_str()? == "human")
739 .then(|| (sid.clone(), s.wait.clone().unwrap_or(Value::Null)))
740 })?;
741 let question = t.message.clone().unwrap_or_default();
742 let deadline_ms = wait
743 .get("deadline_ms")
744 .and_then(Value::as_u64)
745 .unwrap_or_else(|| now_ms() + ASK_TIMEOUT.as_millis() as u64);
746 let schema = wait.get("schema").cloned().filter(|v| !v.is_null());
749 let addressee = wait
750 .get("to")
751 .filter(|v| !v.is_null())
752 .and_then(|v| crate::a2a::principals::Addressee::parse(v).ok());
753 Some((
754 t.id.clone(),
755 id.clone(),
756 step_id,
757 question,
758 deadline_ms,
759 schema,
760 addressee,
761 ))
762 }
763 _ => None,
764 })
765 .collect();
766 for (task, run, step, question, deadline_ms, schema, addressee) in gates {
767 self.log.info(
768 "human.ask.restored",
769 json!({"task": task, "run": run, "step": step}),
770 );
771 self.push_pending(super::reactor::PendingTool {
772 target: Target::Step(run, step),
773 name: "human".into(),
774 kind: PendingKind::Human {
775 task,
776 question,
777 deadline_ms,
778 standalone: false,
779 auto_fired: false,
780 schema,
781 addressee,
782 },
783 started_ms: now_ms(),
784 });
785 }
786 }
787}
788
789fn human_judge_call(
791 uri: &str,
792 token: Option<String>,
793 headers: &[(String, String)],
794 aws_auth: Option<crate::config::AuthSpec>,
795 dialect: Option<String>,
796 model: &str,
797 question: &str,
798) -> Value {
799 let client = match IntelClient::from_parts(uri, token) {
800 Ok(c) => {
801 #[allow(unused_mut)]
802 let mut c = c
803 .with_headers(headers.to_vec())
804 .with_dialect(dialect.as_deref());
805 #[cfg(feature = "oauth")]
806 if let Some(aws) = &aws_auth
807 && let Ok(s) = crate::auth::aws::SigV4Signer::from_spec(aws, "intelligence")
808 {
809 c = c.with_signer(Some(s as std::sync::Arc<dyn ::mcp::http::RequestSigner>));
810 }
811 #[cfg(not(feature = "oauth"))]
812 let _ = &aws_auth;
813 c
814 }
815 Err(e) => return json!({"error": format!("intel: {e}")}),
816 };
817 let system = "You are answering ON BEHALF OF the unavailable human operator of an \
818autonomous agent. The agent asked the operator a question. Decide pragmatically and \
819conservatively: prefer the safe, reversible choice; never approve destructive or \
820irreversible actions on the operator's behalf. Reply with ONLY the answer text the \
821operator would give — no preamble. If you genuinely cannot decide, reply exactly \
822UNDECIDED.";
823 let req = Request {
824 model: model.to_string(),
825 messages: vec![
826 Message::System(system.to_string()),
827 Message::User(format!("QUESTION FOR THE OPERATOR:\n{question}")),
828 ],
829 tools: vec![],
830 max_tokens: 400,
831 temperature: Some(0.0),
832 };
833 match client.complete(&req) {
834 Ok(resp) => json!({"answer": resp.text.unwrap_or_default()}),
835 Err(e) => json!({"error": format!("intel: {e}")}),
836 }
837}