1use async_trait::async_trait;
11use everruns_core::ToolHints;
12use everruns_core::capabilities::{
13 Capability, CapabilityLocalization, CapabilityStatus, RiskLevel,
14};
15use everruns_core::payment::{MachinePaymentRequest, PaymentMethod, PaymentRail};
16use everruns_core::tool_narration::{
17 generic_phrase, labeled_phrase, safe_arg_str, truncate, url_display,
18};
19use everruns_core::tools::{Tool, ToolExecutionResult};
20use everruns_core::traits::ToolContext;
21use serde::Deserialize;
22use serde_json::{Value, json};
23
24const PARALLEL_BASE_URL: &str = "https://parallelmpp.dev";
25
26pub struct ParallelPaymentsCapability;
27
28#[async_trait]
29impl Capability for ParallelPaymentsCapability {
30 fn id(&self) -> &str {
31 "parallel"
32 }
33
34 fn name(&self) -> &str {
35 "Parallel (Machine Payments)"
36 }
37
38 fn description(&self) -> &str {
39 "Paid Parallel search, extract, and async task tools backed by Everruns machine payments."
40 }
41
42 fn status(&self) -> CapabilityStatus {
43 CapabilityStatus::Available
44 }
45
46 fn risk_level(&self) -> RiskLevel {
47 RiskLevel::High
48 }
49
50 fn icon(&self) -> Option<&str> {
51 Some("wallet")
52 }
53
54 fn category(&self) -> Option<&str> {
55 Some("Machine Payments")
56 }
57
58 fn system_prompt_addition(&self) -> Option<&str> {
59 Some(
60 "When web research is needed and Parallel tools are available, prefer the Parallel tools for structured paid search/extract/task work. Use `parallel_task_status` to poll async task run IDs until complete.",
61 )
62 }
63
64 fn tools(&self) -> Vec<Box<dyn Tool>> {
65 vec![
66 Box::new(ParallelSearchTool),
67 Box::new(ParallelExtractTool),
68 Box::new(ParallelTaskTool),
69 Box::new(ParallelTaskStatusTool),
70 ]
71 }
72
73 fn features(&self) -> Vec<&'static str> {
74 vec!["machine_payments"]
75 }
76
77 fn localizations(&self) -> Vec<CapabilityLocalization> {
78 vec![CapabilityLocalization::text(
79 "uk",
80 "Parallel (машинні платежі)",
81 "Платні інструменти Parallel для пошуку, вилучення даних та асинхронних завдань \
82 на основі машинних платежів Everruns.",
83 )]
84 }
85}
86
87#[derive(Debug, Deserialize)]
88struct SearchArgs {
89 query: String,
90 #[serde(default = "default_search_mode")]
91 mode: String,
92}
93
94fn default_search_mode() -> String {
95 "one-shot".to_string()
96}
97
98pub struct ParallelSearchTool;
99
100#[async_trait]
101impl Tool for ParallelSearchTool {
102 fn narrate(
103 &self,
104 tool_call: &everruns_core::tool_types::ToolCall,
105 phase: everruns_core::tool_narration::ToolNarrationPhase,
106 _locale: Option<&str>,
107 _ctx: everruns_core::tool_narration::ToolNarrationContext<'_>,
108 ) -> Option<String> {
109 let query = safe_arg_str(&tool_call.arguments, &["query", "q", "objective"])
110 .map(|value| truncate(value, 48));
111 Some(labeled_phrase(
112 "Searching Parallel",
113 "Searched Parallel",
114 "Could not search Parallel",
115 query,
116 phase,
117 ))
118 }
119
120 fn name(&self) -> &str {
121 "parallel_search"
122 }
123
124 fn display_name(&self) -> Option<&str> {
125 Some("Parallel Search")
126 }
127
128 fn description(&self) -> &str {
129 "Search the web through Parallel's paid API. Costs up to $0.01 per call."
130 }
131
132 fn parameters_schema(&self) -> Value {
133 json!({
134 "type": "object",
135 "properties": {
136 "query": { "type": "string", "description": "Search query." },
137 "mode": {
138 "type": "string",
139 "enum": ["one-shot", "fast"],
140 "description": "Use one-shot for comprehensive results or fast for lower latency.",
141 "default": "one-shot"
142 }
143 },
144 "required": ["query"],
145 "additionalProperties": false
146 })
147 }
148
149 fn requires_context(&self) -> bool {
150 true
151 }
152
153 fn hints(&self) -> ToolHints {
154 ToolHints::default()
155 .with_readonly(true)
156 .with_open_world(true)
157 .with_requires_secrets(true)
158 }
159
160 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
161 missing_payment_authority()
162 }
163
164 async fn execute_with_context(
165 &self,
166 arguments: Value,
167 context: &ToolContext,
168 ) -> ToolExecutionResult {
169 let args: SearchArgs = match serde_json::from_value(arguments) {
170 Ok(args) => args,
171 Err(error) => return invalid_args(error),
172 };
173 if !matches!(args.mode.as_str(), "one-shot" | "fast") {
174 return ToolExecutionResult::tool_error("mode must be one of: one-shot, fast");
175 }
176 execute_paid_parallel(
177 context,
178 "search",
179 "/api/search",
180 json!({ "query": args.query, "mode": args.mode }),
181 0.01,
182 )
183 .await
184 }
185}
186
187#[derive(Debug, Deserialize)]
188struct ExtractArgs {
189 urls: Vec<String>,
190 objective: String,
191}
192
193pub struct ParallelExtractTool;
194
195#[async_trait]
196impl Tool for ParallelExtractTool {
197 fn narrate(
198 &self,
199 tool_call: &everruns_core::tool_types::ToolCall,
200 phase: everruns_core::tool_narration::ToolNarrationPhase,
201 _locale: Option<&str>,
202 _ctx: everruns_core::tool_narration::ToolNarrationContext<'_>,
203 ) -> Option<String> {
204 let url = safe_arg_str(&tool_call.arguments, &["url"])
208 .map(str::to_string)
209 .or_else(|| {
210 tool_call
211 .arguments
212 .get("urls")
213 .and_then(Value::as_array)
214 .and_then(|items| items.first())
215 .and_then(Value::as_str)
216 .map(str::to_string)
217 })
218 .map(|value| url_display(&value))
219 .filter(|value| !value.is_empty());
220 Some(labeled_phrase(
221 "Extracting URL",
222 "Extracted URL",
223 "Could not extract URL",
224 url,
225 phase,
226 ))
227 }
228
229 fn name(&self) -> &str {
230 "parallel_extract"
231 }
232
233 fn display_name(&self) -> Option<&str> {
234 Some("Parallel Extract")
235 }
236
237 fn description(&self) -> &str {
238 "Extract structured facts from URLs through Parallel's paid API. Costs up to $0.01 per URL, minimum $0.01."
239 }
240
241 fn parameters_schema(&self) -> Value {
242 json!({
243 "type": "object",
244 "properties": {
245 "urls": {
246 "type": "array",
247 "items": { "type": "string" },
248 "minItems": 1,
249 "description": "URLs to extract from."
250 },
251 "objective": { "type": "string", "description": "What facts to extract." }
252 },
253 "required": ["urls", "objective"],
254 "additionalProperties": false
255 })
256 }
257
258 fn requires_context(&self) -> bool {
259 true
260 }
261
262 fn hints(&self) -> ToolHints {
263 ToolHints::default()
264 .with_readonly(true)
265 .with_open_world(true)
266 .with_requires_secrets(true)
267 }
268
269 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
270 missing_payment_authority()
271 }
272
273 async fn execute_with_context(
274 &self,
275 arguments: Value,
276 context: &ToolContext,
277 ) -> ToolExecutionResult {
278 let args: ExtractArgs = match serde_json::from_value(arguments) {
279 Ok(args) => args,
280 Err(error) => return invalid_args(error),
281 };
282 if args.urls.is_empty() {
283 return ToolExecutionResult::tool_error("urls must contain at least one URL");
284 }
285 let max_amount_usd = (args.urls.len() as f64 * 0.01).max(0.01);
286 execute_paid_parallel(
287 context,
288 "extract",
289 "/api/extract",
290 json!({ "urls": args.urls, "objective": args.objective }),
291 max_amount_usd,
292 )
293 .await
294 }
295}
296
297#[derive(Debug, Deserialize)]
298struct TaskArgs {
299 input: String,
300 #[serde(default = "default_processor")]
301 processor: String,
302}
303
304fn default_processor() -> String {
305 "ultra".to_string()
306}
307
308pub struct ParallelTaskTool;
309
310#[async_trait]
311impl Tool for ParallelTaskTool {
312 fn narrate(
313 &self,
314 _tool_call: &everruns_core::tool_types::ToolCall,
315 phase: everruns_core::tool_narration::ToolNarrationPhase,
316 _locale: Option<&str>,
317 _ctx: everruns_core::tool_narration::ToolNarrationContext<'_>,
318 ) -> Option<String> {
319 Some(generic_phrase(
320 "Running Parallel task",
321 "Ran Parallel task",
322 "Failed to run Parallel task",
323 None,
324 phase,
325 ))
326 }
327
328 fn name(&self) -> &str {
329 "parallel_task"
330 }
331
332 fn display_name(&self) -> Option<&str> {
333 Some("Parallel Task")
334 }
335
336 fn description(&self) -> &str {
337 "Start a deep async Parallel task. Costs up to $0.10 for pro or $0.30 for ultra; poll with parallel_task_status."
338 }
339
340 fn parameters_schema(&self) -> Value {
341 json!({
342 "type": "object",
343 "properties": {
344 "input": { "type": "string", "description": "Task input." },
345 "processor": {
346 "type": "string",
347 "enum": ["pro", "ultra"],
348 "default": "ultra"
349 }
350 },
351 "required": ["input"],
352 "additionalProperties": false
353 })
354 }
355
356 fn requires_context(&self) -> bool {
357 true
358 }
359
360 fn hints(&self) -> ToolHints {
361 ToolHints::default()
362 .with_readonly(true)
363 .with_open_world(true)
364 .with_requires_secrets(true)
365 .with_long_running(true)
366 }
367
368 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
369 missing_payment_authority()
370 }
371
372 async fn execute_with_context(
373 &self,
374 arguments: Value,
375 context: &ToolContext,
376 ) -> ToolExecutionResult {
377 let args: TaskArgs = match serde_json::from_value(arguments) {
378 Ok(args) => args,
379 Err(error) => return invalid_args(error),
380 };
381 let max_amount_usd = match args.processor.as_str() {
382 "pro" => 0.10,
383 "ultra" => 0.30,
384 _ => return ToolExecutionResult::tool_error("processor must be one of: pro, ultra"),
385 };
386 execute_paid_parallel(
387 context,
388 "task",
389 "/api/task",
390 json!({ "input": args.input, "processor": args.processor }),
391 max_amount_usd,
392 )
393 .await
394 }
395}
396
397#[derive(Debug, Deserialize)]
398struct TaskStatusArgs {
399 run_id: String,
400}
401
402pub struct ParallelTaskStatusTool;
403
404#[async_trait]
405impl Tool for ParallelTaskStatusTool {
406 fn narrate(
407 &self,
408 _tool_call: &everruns_core::tool_types::ToolCall,
409 phase: everruns_core::tool_narration::ToolNarrationPhase,
410 _locale: Option<&str>,
411 _ctx: everruns_core::tool_narration::ToolNarrationContext<'_>,
412 ) -> Option<String> {
413 Some(generic_phrase(
415 "Checking task status",
416 "Checked task status",
417 "Failed to check task status",
418 None,
419 phase,
420 ))
421 }
422
423 fn name(&self) -> &str {
424 "parallel_task_status"
425 }
426
427 fn display_name(&self) -> Option<&str> {
428 Some("Parallel Task Status")
429 }
430
431 fn description(&self) -> &str {
432 "Poll a Parallel task run. This endpoint is free and does not require payment."
433 }
434
435 fn parameters_schema(&self) -> Value {
436 json!({
437 "type": "object",
438 "properties": {
439 "run_id": { "type": "string", "description": "Run ID returned by parallel_task." }
440 },
441 "required": ["run_id"],
442 "additionalProperties": false
443 })
444 }
445
446 fn hints(&self) -> ToolHints {
447 ToolHints::default()
448 .with_readonly(true)
449 .with_idempotent(true)
450 .with_open_world(true)
451 }
452
453 async fn execute(&self, arguments: Value) -> ToolExecutionResult {
454 let args: TaskStatusArgs = match serde_json::from_value(arguments) {
455 Ok(args) => args,
456 Err(error) => return invalid_args(error),
457 };
458 let run_id = args.run_id.trim();
459 if run_id.is_empty() || run_id.contains('/') {
460 return ToolExecutionResult::tool_error("run_id is invalid");
461 }
462 let url = format!("{PARALLEL_BASE_URL}/api/task/{run_id}");
463 match reqwest::Client::new().get(url).send().await {
464 Ok(response) => match response.json::<Value>().await {
465 Ok(value) => ToolExecutionResult::success(value),
466 Err(error) => ToolExecutionResult::tool_error(format!(
467 "Parallel task status response was not valid JSON: {error}"
468 )),
469 },
470 Err(error) => ToolExecutionResult::tool_error(format!(
471 "Failed to poll Parallel task status: {error}"
472 )),
473 }
474 }
475}
476
477async fn execute_paid_parallel(
478 context: &ToolContext,
479 operation: &str,
480 path: &str,
481 body: Value,
482 max_amount_usd: f64,
483) -> ToolExecutionResult {
484 let Some(authority) = context.payment_authority.as_ref() else {
485 return missing_payment_authority();
486 };
487
488 let request = MachinePaymentRequest {
489 capability: "parallel".to_string(),
490 operation: operation.to_string(),
491 method: PaymentMethod::Post,
492 url: format!("{PARALLEL_BASE_URL}{path}"),
493 body: Some(body),
494 max_amount_usd,
495 rail_preference: vec![PaymentRail::X402Base],
496 metadata: json!({
497 "provider": "parallel",
498 "host": "parallelmpp.dev",
499 "path": path,
500 }),
501 };
502
503 match authority
504 .execute_machine_payment(context.session_id, request)
505 .await
506 {
507 Ok(response) => ToolExecutionResult::success(json!({
508 "result": response.response,
509 "payment": {
510 "attempt_id": response.attempt_id.map(|id| id.to_string()),
511 "amount_usd": response.amount_usd,
512 "rail": response.rail.map(|rail| rail.to_string()),
513 "receipt": response.receipt,
514 }
515 })),
516 Err(error) => ToolExecutionResult::tool_error(format!("Machine payment failed: {error}")),
517 }
518}
519
520fn missing_payment_authority() -> ToolExecutionResult {
521 ToolExecutionResult::tool_error(
522 "Machine payments are not configured for this session. Configure a payment wallet and policy before using Parallel paid tools.",
523 )
524}
525
526fn invalid_args(error: serde_json::Error) -> ToolExecutionResult {
527 ToolExecutionResult::tool_error(format!("Invalid arguments: {error}"))
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 #[test]
535 fn metadata() {
536 let cap = ParallelPaymentsCapability;
537 assert_eq!(cap.id(), "parallel");
538 assert_eq!(cap.category(), Some("Machine Payments"));
539 assert_eq!(cap.risk_level(), RiskLevel::High);
540 assert_eq!(cap.tools().len(), 4);
541 assert_eq!(cap.features(), vec!["machine_payments"]);
542 }
543
544 #[tokio::test]
545 async fn paid_tools_fail_closed_without_authority() {
546 let result = ParallelSearchTool
548 .execute(json!({ "query": "hello" }))
549 .await;
550 assert!(result.is_error());
551 }
552
553 use everruns_core::tool_narration::{ToolNarrationContext, ToolNarrationPhase};
558 use everruns_core::tool_types::ToolCall;
559
560 fn narrate(tool: &dyn Tool, arguments: Value, phase: ToolNarrationPhase) -> Option<String> {
561 let call = ToolCall {
562 id: "call-1".to_string(),
563 name: tool.name().to_string(),
564 arguments,
565 };
566 tool.narrate(&call, phase, None, ToolNarrationContext::default())
567 }
568
569 #[test]
570 fn narrate_search_all_phases_and_truncation() {
571 let tool = ParallelSearchTool;
572 assert_eq!(
573 narrate(
574 &tool,
575 json!({"query": "rust async"}),
576 ToolNarrationPhase::Started
577 )
578 .as_deref(),
579 Some("Searching Parallel: rust async")
580 );
581 assert_eq!(
582 narrate(
583 &tool,
584 json!({"query": "rust async"}),
585 ToolNarrationPhase::Completed
586 )
587 .as_deref(),
588 Some("Searched Parallel: rust async")
589 );
590 assert_eq!(
591 narrate(
592 &tool,
593 json!({"query": "rust async"}),
594 ToolNarrationPhase::Failed
595 )
596 .as_deref(),
597 Some("Could not search Parallel: rust async")
598 );
599 let long = "a".repeat(80);
601 let narration =
602 narrate(&tool, json!({ "query": long }), ToolNarrationPhase::Started).unwrap();
603 assert!(narration.starts_with("Searching Parallel: "));
604 assert!(narration.ends_with("..."));
605 assert_eq!(
607 narrate(&tool, json!({}), ToolNarrationPhase::Started).as_deref(),
608 Some("Searching Parallel")
609 );
610 }
611
612 #[test]
613 fn narrate_extract_uses_first_url_and_strips_scheme() {
614 let tool = ParallelExtractTool;
615 assert_eq!(
616 narrate(
617 &tool,
618 json!({"urls": ["https://example.com/a?x=1", "https://example.com/b"], "objective": "facts"}),
619 ToolNarrationPhase::Started
620 )
621 .as_deref(),
622 Some("Extracting URL: example.com/a")
623 );
624 assert_eq!(
626 narrate(&tool, json!({}), ToolNarrationPhase::Failed).as_deref(),
627 Some("Could not extract URL")
628 );
629 }
630
631 #[test]
632 fn narrate_task_status_is_bare() {
633 let tool = ParallelTaskStatusTool;
634 assert_eq!(
635 narrate(
636 &tool,
637 json!({"run_id": "abc123"}),
638 ToolNarrationPhase::Started
639 )
640 .as_deref(),
641 Some("Checking task status")
642 );
643 assert_eq!(
644 narrate(
645 &tool,
646 json!({"run_id": "abc123"}),
647 ToolNarrationPhase::Completed
648 )
649 .as_deref(),
650 Some("Checked task status")
651 );
652 }
653
654 #[test]
655 fn narrate_task_omits_private_input() {
656 let tool = ParallelTaskTool;
657 assert_eq!(
658 narrate(
659 &tool,
660 json!({"input": "Private analysis with customer token SECRET42"}),
661 ToolNarrationPhase::Started
662 )
663 .as_deref(),
664 Some("Running Parallel task")
665 );
666 }
667}