Skip to main content

everruns_integrations_parallel/
payments.rs

1//! Parallel machine-payment capability.
2//!
3//! Decision: expose only Parallel-specific tools. Payment mechanics stay inside
4//! `PaymentAuthority`, so the model cannot initiate arbitrary paid HTTP calls.
5//! Decision: this lives in the Parallel integration crate, not core. Core owns the
6//! payment trust boundary (`PaymentAuthority`, payment DTOs, `ToolContext`); the
7//! vendor-specific paid adapter is a plugin gated by the `machine_payments`
8//! internal feature flag.
9
10use 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        // The schema arg `urls` is an array; fall back to its first element
205        // (also accept a scalar `url` alias). Rendered via url_display so any
206        // embedded credentials/query strings are stripped.
207        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        // Bare: the run id is not user-friendly.
414        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        // No payment authority wired -> the paid tools must refuse, not spend.
547        let result = ParallelSearchTool
548            .execute(json!({ "query": "hello" }))
549            .await;
550        assert!(result.is_error());
551    }
552
553    // ========================================================================
554    // Tool narration
555    // ========================================================================
556
557    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        // Long query is truncated to 48 chars + ellipsis.
600        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        // No query arg -> bare verb fallback.
606        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        // No urls -> bare verb fallback.
625        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}