1use murmur_artifact::{
27 ArtifactName, ArtifactRuntime, ContainmentClass, ProviderTool, ProviderToolKind,
28};
29use serde_json::Value;
30
31use crate::{errors::RuntimeError, types::ArtifactRequest};
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ProviderToolUse {
36 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct NoVisionChoice {
60 pub choice: String,
62 pub model: String,
63 pub driver: String,
65}
66
67#[derive(Debug, Clone, Default, PartialEq, Eq)]
70pub(crate) struct ProviderToolTerms {
71 pub(crate) declared_floor: ContainmentClass,
74 pub(crate) host_can_back: ContainmentClass,
76 pub(crate) no_vision: Vec<NoVisionChoice>,
79 pub(crate) declared: Vec<ProviderToolUse>,
82}
83
84impl ProviderToolTerms {
85 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
97fn may_declare(manifest_yaml: &str) -> bool {
100 manifest_yaml.contains(murmur_artifact::PROVIDER_TOOL_KEY)
101}
102
103pub(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
135pub(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
171pub(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
188pub(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
209pub(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
231pub(crate) const SAFETY_CHECKS_KEY: &str = "safety_checks";
233
234pub(crate) const ACKNOWLEDGED_SAFETY_CHECKS_KEY: &str = "acknowledged_safety_checks";
237
238pub(crate) const ACKNOWLEDGING_REPLIES: [&str; 6] = [
241 "yes",
242 "y",
243 "approve",
244 "approved",
245 "acknowledge",
246 "acknowledged",
247];
248
249#[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 fn line(&self) -> String {
259 format!("{}: {}", self.code.as_deref().unwrap_or("-"), self.message)
260 }
261}
262
263#[derive(Debug, Clone, PartialEq)]
265pub(crate) enum SafetyChecks {
266 None,
268 Checks {
270 verbatim: Value,
271 checks: Vec<SafetyCheck>,
272 },
273 Malformed { verbatim: Value },
275}
276
277pub(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
324pub(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
335pub(crate) fn acknowledges(reply: &str) -> bool {
337 ACKNOWLEDGING_REPLIES.contains(&reply.trim().to_ascii_lowercase().as_str())
338}
339
340#[derive(Debug, Clone, PartialEq, Eq)]
342pub(crate) enum SafetyOutcome {
343 Acknowledged,
344 Declined {
347 reply: String,
348 },
349 TimedOut,
350 Canceled,
351 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 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
401pub(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}