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