Skip to main content

capsule_runtime/
provider_tool.rs

1//! Tools that are a provider's own tools, and the safety checks a driver attaches to a call.
2//!
3//! A `runtime: tool` artifact whose bundled `murmur.yaml` declares `provider_tool:`
4//! ([`murmur_artifact::ProviderTool`]) is one the model's provider defines itself, of a kind in
5//! [`ProviderToolKind::ALL`]. The runtime reads the block, checks it, and passes it to the driver
6//! on the tool's tool-array entry. It names no provider: which tool a driver sends for the block is
7//! the driver's to decide.
8//!
9//! Declaring one constrains the launch:
10//!
11//! | Term | Refusal |
12//! |---|---|
13//! | the effective declared containment floor is `sealed` | `E-CAP-003`, [`RuntimeError::ProviderToolRequiresSealed`] |
14//! | no `transport: http` driver choice runs a model its driver marks `vision: false` | `E-RUN-057`, [`RuntimeError::ProviderToolNeedsVision`] |
15//! | the block parses | `E-RUN-058`, [`RuntimeError::ProviderToolMalformed`] |
16//!
17//! A runtime pull of such a tool is refused on the first two terms ([`ProviderToolTerms`]). When
18//! a `sealed` capsule is refused because the host cannot back it (`E-CAP-003`) or cannot give its
19//! subprocess tree a network namespace (`E-CAP-005`), the refusal names each declared provider
20//! tool with a `required by:` line ([`required_by_lines`]).
21//!
22//! A driver may attach `safety_checks` to a `tool_call` block ([`read_safety_checks`]). The agent
23//! loop then asks the person before the call runs ([`ask_safety_checks`]); only a reply that is
24//! one of [`ACKNOWLEDGING_REPLIES`] runs it.
25
26use murmur_artifact::{
27    ArtifactName, ArtifactRuntime, ContainmentClass, ProviderTool, ProviderToolKind,
28};
29use serde_json::Value;
30
31use crate::{errors::RuntimeError, types::ArtifactRequest};
32
33/// One declared tool that is a provider tool: its tool name and its kind.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ProviderToolUse {
36    /// The tool name, as the model's tool array names it.
37    pub tool: String,
38    pub kind: ProviderToolKind,
39}
40
41impl std::fmt::Display for ProviderToolUse {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        write!(f, "tool '{}' (provider_tool: {})", self.tool, self.kind)
44    }
45}
46
47/// One `required by:` line per tool, each on its own line under a refusal's message, or `""` for
48/// none: the suffix `E-CAP-003` and `E-CAP-005` append to their message.
49#[must_use]
50pub fn required_by_lines(tools: &[ProviderToolUse]) -> String {
51    tools
52        .iter()
53        .map(|tool| format!("\n  required by: {tool}"))
54        .collect()
55}
56
57/// A driver choice whose driver marks its model `vision: false`.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct NoVisionChoice {
60    /// [`murmur_artifact::PRIMARY_DRIVER_CHOICE`] or the alternate's `name`.
61    pub choice: String,
62    pub model: String,
63    /// The driver artifact as `name@version`.
64    pub driver: String,
65}
66
67/// What a runtime pull of a provider tool is checked against: the session's effective declared
68/// floor and the driver choices whose model has no vision.
69#[derive(Debug, Clone, Default, PartialEq, Eq)]
70pub(crate) struct ProviderToolTerms {
71    /// The effective declared containment floor. [`ContainmentClass::Advisory`] by default, which
72    /// refuses every provider tool pull.
73    pub(crate) declared_floor: ContainmentClass,
74    /// What the host can back, named by the sealed refusal's hint.
75    pub(crate) host_can_back: ContainmentClass,
76    /// Every `transport: http` driver choice whose model its driver marks `vision: false`,
77    /// primary first.
78    pub(crate) no_vision: Vec<NoVisionChoice>,
79    /// The provider tools the session staged with, for the `required by:` line of a refusal made
80    /// after staging.
81    pub(crate) declared: Vec<ProviderToolUse>,
82}
83
84impl ProviderToolTerms {
85    /// Whether a tool declaring `provider_tool` may join this session. `Err` is the refusal, the
86    /// same one staging makes: the floor first, then vision.
87    pub(crate) fn admit(&self, tool: &ProviderToolUse) -> Result<(), RuntimeError> {
88        check_sealed(
89            std::slice::from_ref(tool),
90            self.declared_floor,
91            self.host_can_back,
92        )?;
93        check_vision(std::slice::from_ref(tool), &self.no_vision)
94    }
95}
96
97/// Whether `manifest_yaml` can declare a provider tool at all: a manifest whose text never names
98/// the key is not parsed.
99fn may_declare(manifest_yaml: &str) -> bool {
100    manifest_yaml.contains(murmur_artifact::PROVIDER_TOOL_KEY)
101}
102
103/// The provider tools among `manifests`, `(tool name, manifest YAML)` in staging order, each
104/// labelled for a refusal by `label` (`name@version`).
105///
106/// A manifest whose text does not contain `provider_tool` is skipped unparsed. A block that does
107/// not parse, or sits on an artifact that is not a tool, is refused with
108/// [`RuntimeError::ProviderToolMalformed`].
109pub(crate) fn declared_provider_tools<'a>(
110    manifests: impl IntoIterator<Item = (&'a str, &'a str)>,
111    label: impl Fn(&str) -> String,
112) -> Result<Vec<ProviderToolUse>, RuntimeError> {
113    let mut tools = Vec::new();
114    for (tool, manifest_yaml) in manifests {
115        if !may_declare(manifest_yaml) {
116            continue;
117        }
118        match murmur_artifact::parse_provider_tool(manifest_yaml) {
119            Ok(Some(declared)) => tools.push(ProviderToolUse {
120                tool: tool.to_string(),
121                kind: declared.kind,
122            }),
123            Ok(None) => {}
124            Err(error) => {
125                return Err(RuntimeError::ProviderToolMalformed {
126                    artifact: label(tool),
127                    reason: error.to_string(),
128                })
129            }
130        }
131    }
132    Ok(tools)
133}
134
135/// The provider tools among `artifacts` as the local store holds them, for the `required by:`
136/// line of a refusal made before staging pulls anything.
137///
138/// Best-effort and read-only: every declared tool that `registry` resolves, whose manifest reads
139/// and whose block parses, is named. A tool that fails any step is left out, and nothing is
140/// fetched (staging's registry reads the project and global stores only).
141pub(crate) fn provider_tools_in_store(
142    registry: &dyn murmur_artifact::Registry,
143    artifacts: &[ArtifactRequest],
144) -> Vec<ProviderToolUse> {
145    artifacts
146        .iter()
147        .filter(|artifact| {
148            matches!(artifact.runtime, ArtifactRuntime::Tool) && artifact.source.is_none()
149        })
150        .filter_map(|artifact| {
151            let name = ArtifactName::parse(&artifact.name).ok()?;
152            let resolved = registry.resolve(&name, &artifact.version).ok()?;
153            let manifest_yaml = crate::artifact::extract_manifest_yaml(
154                &artifact.name,
155                &artifact.version,
156                &resolved.bytes,
157            )
158            .ok()?;
159            if !may_declare(&manifest_yaml) {
160                return None;
161            }
162            let declared = murmur_artifact::parse_provider_tool(&manifest_yaml).ok()??;
163            Some(ProviderToolUse {
164                tool: murmur_artifact::tool_name_of(&artifact.name).to_string(),
165                kind: declared.kind,
166            })
167        })
168        .collect()
169}
170
171/// Refuses `tools` under an effective declared floor below `sealed`. Decided against the declared
172/// floor alone: the floor is never raised for them.
173pub(crate) fn check_sealed(
174    tools: &[ProviderToolUse],
175    declared: ContainmentClass,
176    host_can_back: ContainmentClass,
177) -> Result<(), RuntimeError> {
178    if tools.is_empty() || declared >= ContainmentClass::Sealed {
179        return Ok(());
180    }
181    Err(RuntimeError::ProviderToolRequiresSealed {
182        tools: tools.to_vec(),
183        declared,
184        host_can_back,
185    })
186}
187
188/// Refuses a computer tool when a driver choice runs a model its driver marks `vision: false`,
189/// naming the first such tool and the first such choice.
190pub(crate) fn check_vision(
191    tools: &[ProviderToolUse],
192    no_vision: &[NoVisionChoice],
193) -> Result<(), RuntimeError> {
194    let computer = tools
195        .iter()
196        .find(|tool| tool.kind == ProviderToolKind::Computer);
197    match (computer, no_vision.first()) {
198        (Some(tool), Some(choice)) => Err(RuntimeError::ProviderToolNeedsVision {
199            tool: tool.tool.clone(),
200            kind: tool.kind,
201            model: choice.model.clone(),
202            choice: choice.choice.clone(),
203            driver: choice.driver.clone(),
204        }),
205        _ => Ok(()),
206    }
207}
208
209/// Reads a pulled payload's manifest as [`ProviderToolTerms::admit`] needs it: `None` when it
210/// declares no provider tool.
211pub(crate) fn pulled_provider_tool(
212    tool: &str,
213    label: &str,
214    manifest_yaml: &str,
215) -> Result<Option<ProviderToolUse>, RuntimeError> {
216    if !may_declare(manifest_yaml) {
217        return Ok(None);
218    }
219    match murmur_artifact::parse_provider_tool(manifest_yaml) {
220        Ok(declared) => Ok(declared.map(|declared: ProviderTool| ProviderToolUse {
221            tool: tool.to_string(),
222            kind: declared.kind,
223        })),
224        Err(error) => Err(RuntimeError::ProviderToolMalformed {
225            artifact: label.to_string(),
226            reason: error.to_string(),
227        }),
228    }
229}
230
231/// The key of a `tool_call` block that carries its safety checks.
232pub(crate) const SAFETY_CHECKS_KEY: &str = "safety_checks";
233
234/// The key of a tool message that carries the checks the person acknowledged, as the driver sent
235/// them.
236pub(crate) const ACKNOWLEDGED_SAFETY_CHECKS_KEY: &str = "acknowledged_safety_checks";
237
238/// The replies, trimmed and ASCII-lowercased, that acknowledge a safety check. Any other reply
239/// declines it.
240pub(crate) const ACKNOWLEDGING_REPLIES: [&str; 6] = [
241    "yes",
242    "y",
243    "approve",
244    "approved",
245    "acknowledge",
246    "acknowledged",
247];
248
249/// One check as the prompt and the not-run texts name it.
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub(crate) struct SafetyCheck {
252    pub(crate) code: Option<String>,
253    pub(crate) message: String,
254}
255
256impl SafetyCheck {
257    /// `<code or ->: <message>`.
258    fn line(&self) -> String {
259        format!("{}: {}", self.code.as_deref().unwrap_or("-"), self.message)
260    }
261}
262
263/// What a `tool_call` block's `safety_checks` holds.
264#[derive(Debug, Clone, PartialEq)]
265pub(crate) enum SafetyChecks {
266    /// Absent, or `[]`.
267    None,
268    /// At least one well-formed check. `verbatim` is the array as the driver sent it.
269    Checks {
270        verbatim: Value,
271        checks: Vec<SafetyCheck>,
272    },
273    /// Anything else. `verbatim` is the value as the driver sent it.
274    Malformed { verbatim: Value },
275}
276
277/// Reads `block`'s `safety_checks`: an array of `{"id"?: string, "code"?: string, "message":
278/// non-empty string}` objects. An absent key and `[]` are no checks; any other shape, `null`
279/// included, is malformed.
280pub(crate) fn read_safety_checks(block: &Value) -> SafetyChecks {
281    let Some(verbatim) = block.get(SAFETY_CHECKS_KEY) else {
282        return SafetyChecks::None;
283    };
284    let malformed = || SafetyChecks::Malformed {
285        verbatim: verbatim.clone(),
286    };
287    let Some(entries) = verbatim.as_array() else {
288        return malformed();
289    };
290    if entries.is_empty() {
291        return SafetyChecks::None;
292    }
293    let mut checks = Vec::with_capacity(entries.len());
294    for entry in entries {
295        let Some(object) = entry.as_object() else {
296            return malformed();
297        };
298        let optional = |key: &str| match object.get(key) {
299            None => Ok(None),
300            Some(Value::String(text)) => Ok(Some(text.clone())),
301            Some(_) => Err(()),
302        };
303        let (Ok(_id), Ok(code)) = (optional("id"), optional("code")) else {
304            return malformed();
305        };
306        let Some(message) = object
307            .get("message")
308            .and_then(Value::as_str)
309            .filter(|message| !message.trim().is_empty())
310        else {
311            return malformed();
312        };
313        checks.push(SafetyCheck {
314            code,
315            message: message.to_string(),
316        });
317    }
318    SafetyChecks::Checks {
319        verbatim: verbatim.clone(),
320        checks,
321    }
322}
323
324/// The `input-required` prompt for one call's checks.
325pub(crate) fn safety_prompt(tool: &str, call_id: &str, checks: &[SafetyCheck]) -> String {
326    let mut prompt = format!("safety check on tool call '{tool}' ({call_id}):\n");
327    for check in checks {
328        prompt.push_str(&check.line());
329        prompt.push('\n');
330    }
331    prompt.push_str("reply yes to acknowledge and run the action; any other reply declines it");
332    prompt
333}
334
335/// Whether `reply` acknowledges the checks.
336pub(crate) fn acknowledges(reply: &str) -> bool {
337    ACKNOWLEDGING_REPLIES.contains(&reply.trim().to_ascii_lowercase().as_str())
338}
339
340/// How one call's safety checks ended, as `safety_check.outcome` names it.
341#[derive(Debug, Clone, PartialEq, Eq)]
342pub(crate) enum SafetyOutcome {
343    Acknowledged,
344    /// Any reply that does not acknowledge. The reply goes into the model's text, never the
345    /// trace.
346    Declined {
347        reply: String,
348    },
349    TimedOut,
350    Canceled,
351    /// No task in scope, so no person to ask.
352    NoInputChannel,
353    Malformed,
354}
355
356impl SafetyOutcome {
357    pub(crate) fn wire_name(&self) -> &'static str {
358        match self {
359            Self::Acknowledged => "acknowledged",
360            Self::Declined { .. } => "declined",
361            Self::TimedOut => "timed_out",
362            Self::Canceled => "canceled",
363            Self::NoInputChannel => "no_input_channel",
364            Self::Malformed => "malformed",
365        }
366    }
367
368    /// The tool message text of a call that did not run, or `None` for an acknowledged one.
369    pub(crate) fn not_run_text(&self, checks: &[SafetyCheck]) -> Option<String> {
370        let named = checks
371            .iter()
372            .map(SafetyCheck::line)
373            .collect::<Vec<_>>()
374            .join("; ");
375        match self {
376            Self::Acknowledged => None,
377            Self::Declined { reply } => Some(format!(
378                "the person did not acknowledge the safety check on this call, so it was not \
379                 run: {named}; their reply: {reply}"
380            )),
381            Self::TimedOut => Some(format!(
382                "the person did not answer the safety check on this call within \
383                 lifecycle.input_timeout_secs, so it was not run: {named}"
384            )),
385            Self::Canceled => Some(format!(
386                "the task was canceled while the safety check on this call waited for an answer, \
387                 so it was not run: {named}"
388            )),
389            Self::NoInputChannel => Some(format!(
390                "this call carries a safety check and this task has no way to ask a person, so it \
391                 was not run: {named}"
392            )),
393            Self::Malformed => Some(
394                "the driver returned a malformed safety check with this call, so it was not run"
395                    .to_string(),
396            ),
397        }
398    }
399}
400
401/// Asks the person in scope about one call's `checks` and waits for the answer, on the same wait
402/// `request-input` uses ([`crate::runtime::wait_for_input`]).
403///
404/// [`SafetyOutcome::NoInputChannel`] without asking when no task is in scope. A wait that cannot
405/// start (the task is not running) is treated the same way: the call does not run.
406pub(crate) async fn ask_safety_checks(
407    state: &crate::runtime::CapsuleStoreState,
408    tool: &str,
409    call_id: &str,
410    checks: &[SafetyCheck],
411) -> SafetyOutcome {
412    let (Some(task), Some(registry)) = (state.a2a_task.clone(), state.a2a_task_registry.clone())
413    else {
414        return SafetyOutcome::NoInputChannel;
415    };
416    let prompt = safety_prompt(tool, call_id, checks);
417    match crate::runtime::wait_for_input(
418        task,
419        prompt,
420        registry,
421        state.a2a_sse.clone(),
422        state.input_timeout_secs,
423    )
424    .await
425    {
426        Ok(reply) if acknowledges(&reply) => SafetyOutcome::Acknowledged,
427        Ok(reply) => SafetyOutcome::Declined { reply },
428        Err(crate::runtime::InputWaitEnd::Canceled) => SafetyOutcome::Canceled,
429        Err(crate::runtime::InputWaitEnd::TimedOut) => SafetyOutcome::TimedOut,
430        Err(crate::runtime::InputWaitEnd::NotWaiting(_)) => SafetyOutcome::NoInputChannel,
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use serde_json::json;
438
439    const COMPUTER: &str = "name: computer\nversion: 0.1.0\nruntime: tool\nprovider_tool:\n  \
440                            kind: computer\n  display_width: 1280\n  display_height: 800\n";
441    const PLAIN: &str = "name: plain\nversion: 0.1.0\nruntime: tool\n";
442
443    fn computer(tool: &str) -> ProviderToolUse {
444        ProviderToolUse {
445            tool: tool.to_string(),
446            kind: ProviderToolKind::Computer,
447        }
448    }
449
450    #[test]
451    fn required_by_lines_name_each_computer_tool_from_the_manifests() {
452        let tools = declared_provider_tools(
453            [
454                ("plain", PLAIN),
455                ("computer", COMPUTER),
456                ("second", COMPUTER),
457            ],
458            |tool| format!("local/{tool}@0.1.0"),
459        )
460        .unwrap();
461        assert_eq!(tools, vec![computer("computer"), computer("second")]);
462        assert_eq!(
463            required_by_lines(&tools),
464            "\n  required by: tool 'computer' (provider_tool: computer)\n  required by: tool \
465             'second' (provider_tool: computer)"
466        );
467    }
468
469    #[test]
470    fn required_by_lines_are_empty_without_a_provider_tool() {
471        let tools = declared_provider_tools([("plain", PLAIN)], |tool| tool.to_string()).unwrap();
472        assert!(tools.is_empty());
473        assert_eq!(required_by_lines(&tools), "");
474    }
475
476    #[test]
477    fn required_by_a_failed_store_read_yields_no_line() {
478        struct EmptyStore;
479        impl murmur_artifact::Registry for EmptyStore {
480            fn resolve(
481                &self,
482                name: &ArtifactName,
483                version: &str,
484            ) -> Result<murmur_artifact::ResolvedArtifact, murmur_artifact::RegistryError>
485            {
486                Err(murmur_artifact::RegistryError::NotFound {
487                    name: name.to_string(),
488                    version: version.to_string(),
489                })
490            }
491            fn publish(
492                &self,
493                _: murmur_artifact::ArtifactMeta,
494                _: &[u8],
495            ) -> Result<murmur_artifact::PublishResult, murmur_artifact::RegistryError>
496            {
497                unreachable!("never published")
498            }
499            fn list_index(
500                &self,
501            ) -> Result<Vec<murmur_artifact::ArtifactMeta>, murmur_artifact::RegistryError>
502            {
503                Ok(Vec::new())
504            }
505        }
506        let artifacts = vec![ArtifactRequest {
507            name: "local/computer".to_string(),
508            version: "0.1.0".to_string(),
509            runtime: ArtifactRuntime::Tool,
510            source: None,
511            on_overflow: Default::default(),
512            config: None,
513            gateway: None,
514            capabilities: None,
515        }];
516        let tools = provider_tools_in_store(&EmptyStore, &artifacts);
517        assert!(tools.is_empty());
518        assert_eq!(required_by_lines(&tools), "");
519    }
520
521    #[test]
522    fn required_by_a_malformed_block_is_refused_naming_the_artifact_and_field() {
523        let error = declared_provider_tools(
524            [(
525                "computer",
526                "name: computer\nversion: 0.1.0\nruntime: tool\nprovider_tool:\n  kind: \
527                 computer\n  display_width: 0\n  display_height: 800\n",
528            )],
529            |tool| format!("local/{tool}@0.1.0"),
530        )
531        .unwrap_err();
532        let message = error.to_string();
533        assert!(
534            message.contains("local/computer@0.1.0")
535                && message.contains("provider_tool.display_width"),
536            "{message}"
537        );
538    }
539
540    #[test]
541    fn provider_tool_sealed_is_decided_on_the_declared_floor() {
542        let tools = [computer("a"), computer("b")];
543        for floor in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
544            let error = check_sealed(&tools, floor, ContainmentClass::Sealed).unwrap_err();
545            let message = error.to_string();
546            assert!(
547                message.contains(
548                    "tool 'a' (provider_tool: computer), tool 'b' (provider_tool: \
549                                  computer)"
550                ) && message.contains(&format!("'{floor}'")),
551                "{message}"
552            );
553        }
554        assert!(check_sealed(&tools, ContainmentClass::Sealed, ContainmentClass::Scoped).is_ok());
555        assert!(check_sealed(&[], ContainmentClass::Advisory, ContainmentClass::Sealed).is_ok());
556    }
557
558    #[test]
559    fn provider_tool_vision_refuses_only_a_declared_false() {
560        let blind = NoVisionChoice {
561            choice: "primary".to_string(),
562            model: "probe-model".to_string(),
563            driver: "local/drv@0.1.0".to_string(),
564        };
565        let message = check_vision(&[computer("computer")], std::slice::from_ref(&blind))
566            .unwrap_err()
567            .to_string();
568        assert_eq!(
569            message,
570            "tool 'computer' is a computer (provider_tool: computer) but model 'probe-model' of \
571             driver choice 'primary' has no vision: driver local/drv@0.1.0 declares vision: \
572             false for it"
573        );
574        assert!(check_vision(&[computer("computer")], &[]).is_ok());
575        assert!(check_vision(&[], &[blind]).is_ok());
576    }
577
578    fn checks_of(block: Value) -> Vec<SafetyCheck> {
579        match read_safety_checks(&block) {
580            SafetyChecks::Checks { checks, .. } => checks,
581            other => panic!("{block}: {other:?}"),
582        }
583    }
584
585    #[test]
586    fn safety_checks_malformed_shapes_are_refused() {
587        for checks in [
588            json!("check this"),
589            json!([1]),
590            json!([{"code": "c"}]),
591            json!([{"code": "c", "message": ""}]),
592            json!([{"code": "c", "message": "   "}]),
593            json!([{"code": 3, "message": "m"}]),
594            json!([{"id": false, "message": "m"}]),
595            json!({"message": "m"}),
596            json!(null),
597        ] {
598            let block = json!({"type": "tool_call", "safety_checks": checks});
599            assert_eq!(
600                read_safety_checks(&block),
601                SafetyChecks::Malformed { verbatim: checks },
602            );
603        }
604        assert_eq!(
605            SafetyOutcome::Malformed.not_run_text(&[]).as_deref(),
606            Some("the driver returned a malformed safety check with this call, so it was not run")
607        );
608    }
609
610    #[test]
611    fn safety_checks_absent_or_empty_are_none() {
612        assert_eq!(
613            read_safety_checks(&json!({"type": "tool_call"})),
614            SafetyChecks::None
615        );
616        assert_eq!(
617            read_safety_checks(&json!({"type": "tool_call", "safety_checks": []})),
618            SafetyChecks::None
619        );
620    }
621
622    #[test]
623    fn safety_checks_are_kept_verbatim() {
624        let checks = json!([
625            {"id": "sc_1", "code": "irrelevant_domain", "message": "The page is not the one the task named.", "extra": 1}
626        ]);
627        let block = json!({"type": "tool_call", "safety_checks": checks.clone()});
628        match read_safety_checks(&block) {
629            SafetyChecks::Checks { verbatim, .. } => assert_eq!(verbatim, checks),
630            other => panic!("{other:?}"),
631        }
632    }
633
634    #[test]
635    fn safety_prompt_for_two_checks_matches_line_by_line() {
636        let checks = checks_of(json!({"safety_checks": [
637            {"id": "sc_1", "code": "irrelevant_domain", "message": "The page is not the one the task named."},
638            {"id": "sc_2", "message": "A payment form is open."}
639        ]}));
640        let prompt = safety_prompt("computer", "call_0_0", &checks);
641        assert_eq!(
642            prompt.lines().collect::<Vec<_>>(),
643            vec![
644                "safety check on tool call 'computer' (call_0_0):",
645                "irrelevant_domain: The page is not the one the task named.",
646                "-: A payment form is open.",
647                "reply yes to acknowledge and run the action; any other reply declines it",
648            ]
649        );
650    }
651
652    #[test]
653    fn safety_replies_acknowledge_only_the_yes_words() {
654        for reply in [
655            "yes",
656            " YES ",
657            "y",
658            "Approve",
659            "approved",
660            "acknowledge",
661            "acknowledged",
662        ] {
663            assert!(acknowledges(reply), "{reply:?}");
664        }
665        for reply in ["yes please", "no", "", "ok", "sure", "no, wrong site"] {
666            assert!(!acknowledges(reply), "{reply:?}");
667        }
668    }
669
670    #[test]
671    fn safety_not_run_texts_are_as_documented() {
672        let checks = checks_of(json!({"safety_checks": [
673            {"code": "irrelevant_domain", "message": "The page is not the one the task named."}
674        ]}));
675        assert_eq!(SafetyOutcome::Acknowledged.not_run_text(&checks), None);
676        assert_eq!(
677            SafetyOutcome::Declined {
678                reply: "no, wrong site".to_string()
679            }
680            .not_run_text(&checks)
681            .unwrap(),
682            "the person did not acknowledge the safety check on this call, so it was not run: \
683             irrelevant_domain: The page is not the one the task named.; their reply: no, wrong \
684             site"
685        );
686        assert_eq!(
687            SafetyOutcome::NoInputChannel.not_run_text(&checks).unwrap(),
688            "this call carries a safety check and this task has no way to ask a person, so it was \
689             not run: irrelevant_domain: The page is not the one the task named."
690        );
691        for (outcome, wire) in [
692            (SafetyOutcome::Acknowledged, "acknowledged"),
693            (
694                SafetyOutcome::Declined {
695                    reply: String::new(),
696                },
697                "declined",
698            ),
699            (SafetyOutcome::TimedOut, "timed_out"),
700            (SafetyOutcome::Canceled, "canceled"),
701            (SafetyOutcome::NoInputChannel, "no_input_channel"),
702            (SafetyOutcome::Malformed, "malformed"),
703        ] {
704            assert_eq!(outcome.wire_name(), wire);
705        }
706    }
707
708    #[test]
709    fn safety_without_a_task_in_scope_is_no_input_channel() {
710        let rt = tokio::runtime::Builder::new_current_thread()
711            .enable_all()
712            .build()
713            .unwrap();
714        let dir = tempfile::tempdir().unwrap();
715        let state = crate::runtime::build_test_state(
716            std::sync::Arc::new(murmur_artifact::LocalRegistry::new(
717                dir.path().join("registry"),
718            )),
719            dir.path().to_path_buf(),
720            dir.path().join("murmur.lock"),
721        );
722        assert!(state.a2a_task.is_none());
723        let checks = checks_of(json!({"safety_checks": [{"message": "m"}]}));
724        let outcome = rt.block_on(ask_safety_checks(&state, "computer", "call_0_0", &checks));
725        assert_eq!(outcome, SafetyOutcome::NoInputChannel);
726    }
727}