1use 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#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ProviderToolUse {
37 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct NoVisionChoice {
61 pub choice: String,
63 pub model: String,
64 pub driver: String,
66}
67
68#[derive(Debug, Clone, Default, PartialEq, Eq)]
71pub(crate) struct ProviderToolTerms {
72 pub(crate) declared_floor: ContainmentClass,
75 pub(crate) host_can_back: ContainmentClass,
77 pub(crate) no_vision: Vec<NoVisionChoice>,
80 pub(crate) declared: Vec<ProviderToolUse>,
83}
84
85impl ProviderToolTerms {
86 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
98fn may_declare(manifest_yaml: &str) -> bool {
101 manifest_yaml.contains(murmur_artifact::PROVIDER_TOOL_KEY)
102}
103
104pub(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
136pub(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
174pub(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
191pub(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
212pub(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
234pub(crate) const SAFETY_CHECKS_KEY: &str = "safety_checks";
236
237pub(crate) const ACKNOWLEDGED_SAFETY_CHECKS_KEY: &str = "acknowledged_safety_checks";
240
241pub(crate) const ACKNOWLEDGING_REPLIES: [&str; 6] = [
244 "yes",
245 "y",
246 "approve",
247 "approved",
248 "acknowledge",
249 "acknowledged",
250];
251
252#[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 fn line(&self) -> String {
262 format!("{}: {}", self.code.as_deref().unwrap_or("-"), self.message)
263 }
264}
265
266#[derive(Debug, Clone, PartialEq)]
268pub(crate) enum SafetyChecks {
269 None,
271 Checks {
273 verbatim: Value,
274 checks: Vec<SafetyCheck>,
275 },
276 Malformed { verbatim: Value },
278}
279
280pub(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
327pub(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
338pub(crate) fn acknowledges(reply: &str) -> bool {
340 ACKNOWLEDGING_REPLIES.contains(&reply.trim().to_ascii_lowercase().as_str())
341}
342
343#[derive(Debug, Clone, PartialEq, Eq)]
345pub(crate) enum SafetyOutcome {
346 Acknowledged,
347 Declined {
350 reply: String,
351 },
352 TimedOut,
353 Canceled,
354 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 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
404pub(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}