Skip to main content

agentd/runtime/
human.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! **Human-in-the-loop**: the `ask_human` internal tool and the workflow
3//! `human` node, wired to the interface.
4//!
5//! The flow: an ask flips (or creates) an A2A task to `input-required` with
6//! the question as its status message — every attached display client renders
7//! an answerable gate — and the asking unit suspends as a
8//! [`PendingKind::Human`]. A `SendMessage` carrying that `taskId` resolves the
9//! pending with the reply text: a turn's tool call returns it to the model, a
10//! workflow `human` step completes with it as output. Tasks are durable, so a
11//! run's gate survives a restart (rebuilt from the suspended step). A turn's
12//! gate degrades to conversation continuation instead: the asking child does
13//! not outlive the process, so there is no tool call left to return into, and
14//! the answer starts a fresh turn carrying it.
15//!
16//! **Fallback** (`agent.ask_human_fallback`) when NO human channel exists
17//! (`interface.enabled` off): `fail` (default — error immediately), `wait`
18//! (park until the ask timeout), or `auto` — an LLM judge answers on the
19//! operator's behalf (also fired when an interface-served gate times out
20//! unanswered). Auto answers are marked as auto in the task, the log and the
21//! audit stream — never mistakable for a human decision.
22
23use 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
31/// The default patience for a human answer.
32const ASK_TIMEOUT: Duration = Duration::from_secs(24 * 3600);
33/// The same default in milliseconds, for callers that already work in ms
34/// (a `security.policies` gate with no explicit `timeout`).
35#[cfg(feature = "a2a")]
36pub(crate) const ASK_TIMEOUT_MS: u64 = 24 * 3600 * 1000;
37/// How long the auto judge gets once fired.
38const AUTO_GRACE_MS: u64 = 10 * 60 * 1000;
39/// The judge's "cannot decide" sentinel.
40const UNDECIDED: &str = "UNDECIDED";
41
42impl Runtime {
43    /// The `ask_human` internal tool.
44    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        // The declared answer shape, carried so the reply can be checked
74        // against it rather than merely advertised to clients.
75        let schema = args.get("schema").cloned().filter(|v| !v.is_null());
76        // Who must answer. A malformed `to` is refused rather than dropped: a
77        // gate that looks routed and is not is worse than one that never
78        // claimed to be.
79        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        // The approval policy decides whether to ask AT ALL. It is checked
90        // before availability, because "do not interrupt me" is a decision the
91        // operator made and should hold whether or not a channel happens to
92        // exist.
93        match self.settings.agent.approval {
94            crate::config::v2::Approval::Ask => {}
95            // An ADDRESSED gate is never auto-answered, whatever the approval
96            // policy says. The point of naming a decider is that the record is
97            // true; a model judge standing in for the finance lead makes it a
98            // lie, and the operator who set `approval: auto` was making a
99            // statement about the agent's own asks, not about a gate that
100            // names someone.
101            _ if addressee.is_some() => {}
102            crate::config::v2::Approval::Accept => {
103                // Accept what the ask RECOMMENDS. With nothing recommended
104                // there is nothing to accept, and inventing an answer to a
105                // question a person wanted asked is worse than asking it — so
106                // fall through to the judge instead of guessing.
107                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        // A human can answer only through the interface surface.
162        #[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        // No channel to ask on: take the configured fallback.
173        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                    // The `fail` branch above names the cause; this one used to
187                    // say only "no human channel", and it is the branch people
188                    // actually configure. An integrator lost an hour to the
189                    // asymmetry — the condition is one config key, and the log
190                    // that fires is the one place they look.
191                    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    /// The interface-served gate: flip (or create) the owning A2A task to
225    /// `input-required` and suspend the asker.
226    #[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        // The task this ask belongs to: the A2A task behind the asking turn,
240        // or the task tracking the asking run.
241        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        // One live gate per task (asks within one unit are sequential anyway).
259        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                // No A2A caller owns this unit (a scheduled turn, a subagent,
275                // a run started by a timer): create the gate task so attached
276                // operators see and answer it.
277                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                    // The run's completion drives this task terminal.
294                    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            // The schema travels WITH the gate: the question says what is being
309            // asked, the schema says how to ask it. Without it a client can
310            // only offer a text box and hope the person types one of the words
311            // the schema would have listed.
312            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    /// Put a rejected gate back, with the reason appended to the question.
341    ///
342    /// The alternative is failing the step, which throws away a human who is
343    /// still sitting there — and the usual cause is a typo, not a refusal.
344    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    /// Resolve pending ask `i` with an answer. `via` marks who decided
363    /// (`"human"` / `"auto"`) in the task, the log and the audit stream.
364    /// `answered_by` is the principal who actually replied. The audit line
365    /// used to carry `via` — "human" or "auto" — in the principal field, which
366    /// says HOW a gate was answered and not by WHOM. An addressed gate makes
367    /// that load-bearing: "the finance lead approved" is only a record if the
368    /// record names them.
369    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        // The declared answer shape was advertised to clients and never applied
393        // to what came back, so a gate could ask for
394        // `{decision: "file"|"hold"}` and the run would proceed on "maybe
395        // later". Check it here — and re-ask rather than fail, because the
396        // person is still there and a second try is cheaper than a dead run.
397        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                // Cancel/Decline: the person declined to answer in the declared
401                // shape, which is a real answer, not a validation failure.
402                _ => 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                // An `auto` judge that cannot produce the shape must not spin.
411                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        // The asker has to still BE there to receive it. A child that died with
426        // its gate open took its `ToolResult` slot with it, so `reply` would
427        // write into a closed pipe and a human's decision would vanish leaving
428        // nothing but a debug line — while the task read as answered.
429        // `poll_pending_human` reaps orphaned gates every tick; this is the race
430        // where the answer lands in the same tick the child is reaped, and the
431        // honest outcome is a failed gate, not a silent one.
432        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                    // The Q&A was this task's whole purpose.
462                    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        // The record names WHO, not just how: an addressed gate is only worth
476        // declaring if the audit line can be read back as "this person decided
477        // this".
478        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        // A tool result must match `ask_human`'s DECLARED output shape,
487        // `{reply, timed_out}` (see `registry/internal.rs`). Consumers read
488        // that contract literally: the MCP elicitation bridge pulls `reply` out
489        // of the tool result to build the `accept` content, and a bare string
490        // would leave it with nothing, turning every `elicitation/create` into
491        // a `cancel`. `via` rides along so the asker can tell an auto judge's
492        // guess from a human decision.
493        //
494        // A workflow `human` step is NOT a tool result: the answer itself is
495        // the step's output, and later steps template on
496        // `steps.<gate>.output`, so a step keeps the bare reply.
497        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    /// Fail pending ask `i` (timeout / cancel / judge failure).
505    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    /// Fail the gate task itself (a no-op when no A2A task backs the ask).
518    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    /// The per-tick pass over pending asks: prune gates whose step already
533    /// resolved (the durable wait-record timeout owns step timeouts), fire the
534    /// `auto` judge on an unanswered deadline, and fail what remains.
535    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            /// The asker is gone: drop the gate and fail its task with `why`.
540            Prune(String, &'static str),
541            Timeout,
542        }
543        // Addressed by TARGET, never by index — the reentrancy that panicked
544        // `poll_pending`: ending one gate calls `reply`, which re-enters the
545        // reactor (a step outcome cascades through `finish_step` into
546        // `cancel_scoped_children`, which prunes `pending` itself), so an index
547        // remembered across an end addresses a different entry by the time we
548        // use it — or one past the end, panicking the reactor thread and taking
549        // the daemon with it.
550        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                // The step resolved some other way (its wait-record timed out,
564                // the run was cancelled): drop the dangling gate.
565                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                // The asking CHILD is gone (it crashed, was killed, its turn was
580                // torn down). Nothing can receive the answer any more — the
581                // `ToolResult` slot died with the process — so leaving the gate
582                // open would park an operator in front of an answerable question
583                // whose answer goes nowhere, for the rest of the 24 h ask
584                // timeout. Fail it explicitly: the task leaves `input-required`,
585                // and a later reply on it continues the conversation as a fresh
586                // turn — the documented degrade for a turn's gate.
587                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            // Re-find the entry by target on every iteration: ending an earlier
639            // gate can reenter and remove entries, so any index captured before
640            // the loop would be stale. A missing entry means that gate is
641            // already settled, so skip it.
642            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    /// Spawn the `auto` judge on a background thread — same shape as the goal
662    /// judge: an intel dial folded back through [`super::events::Event::Background`].
663    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    /// The judge came back: answer the ask on the operator's behalf, or fail it.
690    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; // answered by a human / cancelled while the judge ran
697        };
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    /// Rebuild run-linked gates after a restore: a durable task in
712    /// `input-required` whose run has a suspended `human` step re-arms the
713    /// pending ask, so the answer path works across restarts. Turn-linked
714    /// gates are NOT re-armed — no child survives the restart to receive the
715    /// answer, so an answer simply continues the conversation as a fresh turn.
716    #[cfg(feature = "a2a")]
717    pub(crate) fn rebuild_human_asks(&mut self) {
718        use crate::a2a::tasks::{Link, State};
719        // (task, run, step, question, deadline, schema, addressee)
720        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                    // The gate's enforcement, read back from the durable wait
747                    // record: a restart must not weaken a gate.
748                    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
789/// The auto-judge intel dial: answer `question` on the operator's behalf.
790fn 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}