Skip to main content

adk_computer_use/
graph.rs

1use crate::runtime::binding::{
2    validate_envelope_freshness, validate_lease, validate_receipt, validate_reservation,
3};
4use crate::{
5    ActionClass, ActionPreview, ComputerUseAuthContext, ComputerUseRuntime, ControlLease,
6    ExecutionMode, ExecutionReceipt, ScopeAuthorizer, TargetReservation, VerificationOutcome,
7};
8use adk_graph::{
9    Channel, Checkpointer, CompiledGraph, DeferredNodeConfig, END, GraphError, MergeStrategy,
10    NodeOutput, START, StateGraph, StateSchema,
11};
12use serde_json::{Value, json};
13use std::sync::Arc;
14
15fn node_error(node: &str, message: impl Into<String>) -> GraphError {
16    GraphError::NodeExecutionFailed { node: node.to_string(), message: message.into() }
17}
18
19fn preview_route(preview: &ActionPreview) -> &'static str {
20    if preview.executable {
21        "allowed"
22    } else if preview.blocker.as_deref() == Some("approval_required") {
23        "approval"
24    } else {
25        "blocked"
26    }
27}
28
29fn reservation_from_state(
30    value: Option<&Value>,
31    node: &str,
32) -> Result<Option<TargetReservation>, GraphError> {
33    match value {
34        None | Some(Value::Null) => Ok(None),
35        Some(value) => serde_json::from_value(value.clone())
36            .map(Some)
37            .map_err(|error| node_error(node, format!("invalid target reservation: {error}"))),
38    }
39}
40
41fn validate_preview_history(
42    preview: &ActionPreview,
43    history: Option<&Value>,
44    node: &str,
45) -> Result<(), GraphError> {
46    let history = history
47        .and_then(Value::as_array)
48        .ok_or_else(|| node_error(node, "missing append-only preview history"))?;
49    if history.len() != 1 {
50        return Err(node_error(
51            node,
52            format!(
53                "append-only preview history contains {} entries instead of exactly one; \
54                 resumed state may have attempted to replace the approved preview",
55                history.len()
56            ),
57        ));
58    }
59    let original: ActionPreview = serde_json::from_value(history[0].clone())
60        .map_err(|error| node_error(node, format!("invalid preview history: {error}")))?;
61    if original != *preview {
62        return Err(node_error(node, "preview changed after policy evaluation; refusing mutation"));
63    }
64    Ok(())
65}
66
67fn validate_execution_approval(
68    preview: &ActionPreview,
69    route: Option<&str>,
70    approval: Option<&Value>,
71    approval_grant_id: Option<&str>,
72    approved_action_digest: Option<&str>,
73    approved_policy_digest: Option<&str>,
74) -> Result<(), GraphError> {
75    let expected_route = preview_route(preview);
76    if route != Some(expected_route) {
77        return Err(node_error(
78            "execute",
79            "preview route changed after policy evaluation; refusing mutation",
80        ));
81    }
82
83    match expected_route {
84        "allowed" => {
85            if approval_grant_id.is_some()
86                || approved_action_digest.is_some()
87                || approved_policy_digest.is_some()
88            {
89                return Err(node_error(
90                    "execute",
91                    "an allowed preview carried stale approval authority",
92                ));
93            }
94        }
95        "approval" => {
96            let approval = approval.and_then(Value::as_object).ok_or_else(|| {
97                node_error("execute", "approval state is missing before mutation")
98            })?;
99            let action_digest = approval.get("actionDigest").and_then(Value::as_str);
100            let policy_digest = approval.get("policyDigest").and_then(Value::as_str);
101            if action_digest != Some(preview.envelope.args_digest.as_str())
102                || policy_digest != Some(preview.policy.policy_digest.as_str())
103                || approved_action_digest != action_digest
104                || approved_policy_digest != policy_digest
105            {
106                return Err(node_error(
107                    "execute",
108                    "approval no longer matches the exact preview action and policy digests",
109                ));
110            }
111
112            let supplied_grant = approval.get("grantId").and_then(Value::as_str);
113            let runtime_approved =
114                approval.get("runtimeApproved").and_then(Value::as_bool).unwrap_or(false);
115            let exact_grant = supplied_grant.is_some()
116                && supplied_grant == approval_grant_id
117                && !runtime_approved;
118            let runtime_holds_grant =
119                supplied_grant.is_none() && approval_grant_id.is_none() && runtime_approved;
120            if !exact_grant && !runtime_holds_grant {
121                return Err(node_error(
122                    "execute",
123                    "approval authority changed after the digest-bound approval step",
124                ));
125            }
126        }
127        _ => {
128            return Err(node_error("execute", "a blocked preview reached the mutation node"));
129        }
130    }
131
132    Ok(())
133}
134
135async fn release_on_error<T>(
136    runtime: &Arc<dyn ComputerUseRuntime>,
137    reservation: Option<&TargetReservation>,
138    node: &str,
139    result: Result<T, GraphError>,
140) -> Result<T, GraphError> {
141    let Err(primary) = result else {
142        return result;
143    };
144    let Some(reservation) = reservation else {
145        return Err(primary);
146    };
147    match runtime.release_target(reservation).await {
148        Ok(()) => Err(primary),
149        Err(cleanup) => Err(node_error(
150            node,
151            format!(
152                "primary failure: {primary}; target reservation cleanup also failed for {}: \
153                 {cleanup}",
154                reservation.reservation_id
155            ),
156        )),
157    }
158}
159
160async fn release_after_terminal<T>(
161    runtime: &Arc<dyn ComputerUseRuntime>,
162    reservation: Option<&TargetReservation>,
163    node: &str,
164    result: Result<T, GraphError>,
165) -> Result<T, GraphError> {
166    let Some(reservation) = reservation else {
167        return result;
168    };
169    match (result, runtime.release_target(reservation).await) {
170        (Ok(value), Ok(())) => Ok(value),
171        (Ok(_), Err(cleanup)) => Err(node_error(
172            node,
173            format!(
174                "target reservation cleanup failed for {}: {cleanup}",
175                reservation.reservation_id
176            ),
177        )),
178        (Err(primary), Ok(())) => Err(primary),
179        (Err(primary), Err(cleanup)) => Err(node_error(
180            node,
181            format!(
182                "primary failure: {primary}; target reservation cleanup also failed for {}: \
183                 {cleanup}",
184                reservation.reservation_id
185            ),
186        )),
187    }
188}
189
190/// Build the flagship deterministic ADK graph.
191///
192/// The graph fans capability, visual, and semantic observation out concurrently,
193/// joins them once, previews before mutation, interrupts for approval, and has
194/// exactly one node that can call `execute_action`.
195pub fn build_reference_graph(
196    runtime: Arc<dyn ComputerUseRuntime>,
197    authorizer: Arc<ScopeAuthorizer>,
198) -> Result<CompiledGraph, GraphError> {
199    build_reference_graph_with_checkpointer(runtime, authorizer, None)
200}
201
202/// Build the reference graph with a durable host-supplied checkpointer.
203pub fn build_reference_graph_with_checkpointer(
204    runtime: Arc<dyn ComputerUseRuntime>,
205    authorizer: Arc<ScopeAuthorizer>,
206    checkpointer: Option<Arc<dyn Checkpointer>>,
207) -> Result<CompiledGraph, GraphError> {
208    let capability_runtime = runtime.clone();
209    let visual_runtime = runtime.clone();
210    let semantic_runtime = runtime.clone();
211    let preview_runtime = runtime.clone();
212    let reservation_runtime = runtime.clone();
213    let lease_runtime = runtime.clone();
214    let execute_runtime = runtime.clone();
215    let verify_runtime = runtime;
216
217    StateGraph::new({
218        let mut schema = StateSchema::simple(&[
219            "proposed_action",
220            "capabilities",
221            "visual_evidence",
222            "semantic_evidence",
223            "observations_joined",
224            "preview",
225            "route",
226            "approval",
227            "approval_grant_id",
228            "approved_action_digest",
229            "approved_policy_digest",
230            "reservation",
231            "lease",
232            "receipt",
233            "verified",
234            "committed",
235            "result",
236        ]);
237        // A resumed caller cannot overwrite an append-only channel. Supplying another preview
238        // creates a second entry, and the approval/execute checks fail closed unless exactly
239        // the original runtime preview remains.
240        schema.channels.insert("preview_history".into(), Channel::list("preview_history"));
241        schema
242    })
243    .add_node_fn("discover", move |_| {
244        let runtime = capability_runtime.clone();
245        async move {
246            let value = runtime
247                .discover_capabilities()
248                .await
249                .map_err(|error| node_error("discover", error.to_string()))?;
250            Ok(NodeOutput::new().with_update("capabilities", value))
251        }
252    })
253    .add_node_fn("observe_visual", move |_| {
254        let runtime = visual_runtime.clone();
255        async move {
256            let value = runtime
257                .observe_visual()
258                .await
259                .map_err(|error| node_error("observe_visual", error.to_string()))?;
260            Ok(NodeOutput::new().with_update("visual_evidence", value))
261        }
262    })
263    .add_node_fn("observe_semantic", move |_| {
264        let runtime = semantic_runtime.clone();
265        async move {
266            let value = runtime
267                .observe_semantic()
268                .await
269                .map_err(|error| node_error("observe_semantic", error.to_string()))?;
270            Ok(NodeOutput::new().with_update("semantic_evidence", value))
271        }
272    })
273    .add_deferred_node_fn(
274        "join_observations",
275        |ctx| async move {
276            let complete = ctx.get("capabilities").is_some()
277                && ctx.get("visual_evidence").is_some()
278                && ctx.get("semantic_evidence").is_some();
279            if !complete {
280                return Err(node_error(
281                    "join_observations",
282                    "fan-in completed without all evidence",
283                ));
284            }
285            Ok(NodeOutput::new().with_update("observations_joined", json!(true)))
286        },
287        DeferredNodeConfig {
288            merge_strategy: MergeStrategy::Collect,
289            fan_in_timeout: None,
290            ..Default::default()
291        },
292    )
293    .add_node_fn("plan", |ctx| async move {
294        let proposed = ctx
295            .get("proposed_action")
296            .cloned()
297            .ok_or_else(|| node_error("plan", "missing proposed_action"))?;
298        Ok(NodeOutput::new().with_update("proposed_action", proposed))
299    })
300    .add_node_fn("preview", move |ctx| {
301        let runtime = preview_runtime.clone();
302        async move {
303            let proposed = ctx
304                .get("proposed_action")
305                .cloned()
306                .ok_or_else(|| node_error("preview", "missing proposed_action"))?;
307            let preview = runtime
308                .preview_action(proposed)
309                .await
310                .map_err(|error| node_error("preview", error.to_string()))?;
311            validate_envelope_freshness(&preview.envelope)
312                .map_err(|error| node_error("preview", error.to_string()))?;
313            let route = preview_route(&preview);
314            let preview = serde_json::to_value(preview)?;
315            Ok(NodeOutput::new()
316                .with_update("preview", preview.clone())
317                .with_update("preview_history", preview)
318                .with_update("route", json!(route)))
319        }
320    })
321    .add_node_fn("request_approval", |ctx| async move {
322        let preview: ActionPreview = serde_json::from_value(
323            ctx.get("preview")
324                .cloned()
325                .ok_or_else(|| node_error("request_approval", "missing preview"))?,
326        )?;
327        validate_preview_history(&preview, ctx.get("preview_history"), "request_approval")?;
328        let approval = ctx.get("approval").and_then(Value::as_object);
329        let approved_digest =
330            approval.and_then(|value| value.get("actionDigest")).and_then(Value::as_str);
331        let grant_id = approval
332            .and_then(|value| value.get("grantId"))
333            .and_then(Value::as_str)
334            .filter(|value| !value.is_empty());
335        let approved_policy =
336            approval.and_then(|value| value.get("policyDigest")).and_then(Value::as_str);
337        let runtime_approved = approval
338            .and_then(|value| value.get("runtimeApproved"))
339            .and_then(Value::as_bool)
340            .unwrap_or(false);
341        match (approved_digest, approved_policy, grant_id, runtime_approved) {
342            (None, None, None, false) => Ok(NodeOutput::interrupt_with_data(
343                "computer-use action requires scoped approval",
344                serde_json::to_value(&preview)?,
345            )),
346            (Some(digest), Some(policy), Some(grant_id), false)
347                if digest == preview.envelope.args_digest
348                    && policy == preview.policy.policy_digest =>
349            {
350                Ok(NodeOutput::new()
351                    .with_update("approval_grant_id", json!(grant_id))
352                    .with_update("approved_action_digest", json!(digest))
353                    .with_update("approved_policy_digest", json!(policy)))
354            }
355            (Some(digest), Some(policy), None, true)
356                if digest == preview.envelope.args_digest
357                    && policy == preview.policy.policy_digest =>
358            {
359                Ok(NodeOutput::new()
360                    .with_update("approved_action_digest", json!(digest))
361                    .with_update("approved_policy_digest", json!(policy)))
362            }
363            _ => Err(node_error(
364                "request_approval",
365                "approval does not match the interrupted action and policy digests",
366            )),
367        }
368    })
369    .add_node_fn("blocked", |ctx| async move {
370        let blocker = ctx
371            .get("preview")
372            .and_then(|value| value.get("blocker"))
373            .cloned()
374            .unwrap_or_else(|| json!("policy_denied"));
375        Ok(NodeOutput::new()
376            .with_update("result", json!({ "status": "blocked", "reason": blocker })))
377    })
378    .add_node_fn("reserve_target", move |ctx| {
379        let runtime = reservation_runtime.clone();
380        async move {
381            let preview: ActionPreview = serde_json::from_value(
382                ctx.get("preview")
383                    .cloned()
384                    .ok_or_else(|| node_error("reserve_target", "missing preview"))?,
385            )?;
386            validate_preview_history(&preview, ctx.get("preview_history"), "reserve_target")?;
387            let reservation = runtime
388                .reserve_target(&preview.envelope)
389                .await
390                .map_err(|error| node_error("reserve_target", error.to_string()))?;
391            if let Some(reservation) = reservation.as_ref() {
392                let validation = validate_reservation(reservation, &preview.envelope)
393                    .map_err(|error| node_error("reserve_target", error.to_string()));
394                release_on_error(&runtime, Some(reservation), "reserve_target", validation).await?;
395            }
396            let serialized = serde_json::to_value(&reservation)
397                .map_err(|error| node_error("reserve_target", error.to_string()));
398            let serialized =
399                release_on_error(&runtime, reservation.as_ref(), "reserve_target", serialized)
400                    .await?;
401            Ok(NodeOutput::new().with_update("reservation", serialized))
402        }
403    })
404    .add_node_fn("acquire_lease", move |ctx| {
405        let runtime = lease_runtime.clone();
406        async move {
407            let reservation = reservation_from_state(ctx.get("reservation"), "acquire_lease")?;
408            let result = async {
409                let preview: ActionPreview = serde_json::from_value(
410                    ctx.get("preview")
411                        .cloned()
412                        .ok_or_else(|| node_error("acquire_lease", "missing preview"))?,
413                )
414                .map_err(|error| node_error("acquire_lease", error.to_string()))?;
415                let lease = runtime
416                    .acquire_lease(&preview.envelope)
417                    .await
418                    .map_err(|error| node_error("acquire_lease", error.to_string()))?;
419                validate_lease(&lease, &preview.envelope)
420                    .map_err(|error| node_error("acquire_lease", error.to_string()))?;
421                let lease = serde_json::to_value(lease)
422                    .map_err(|error| node_error("acquire_lease", error.to_string()))?;
423                Ok(NodeOutput::new().with_update("lease", lease))
424            }
425            .await;
426            release_on_error(&runtime, reservation.as_ref(), "acquire_lease", result).await
427        }
428    })
429    .add_node_fn("execute", move |ctx| {
430        let runtime = execute_runtime.clone();
431        let authorizer = authorizer.clone();
432        async move {
433            let reservation = reservation_from_state(ctx.get("reservation"), "execute")?;
434            let result = async {
435                let preview: ActionPreview = serde_json::from_value(
436                    ctx.get("preview")
437                        .cloned()
438                        .ok_or_else(|| node_error("execute", "missing preview"))?,
439                )
440                .map_err(|error| node_error("execute", error.to_string()))?;
441                let lease: ControlLease = serde_json::from_value(
442                    ctx.get("lease")
443                        .cloned()
444                        .ok_or_else(|| node_error("execute", "missing lease"))?,
445                )
446                .map_err(|error| node_error("execute", error.to_string()))?;
447                let envelope = &preview.envelope;
448                validate_preview_history(&preview, ctx.get("preview_history"), "execute")?;
449                validate_envelope_freshness(envelope)
450                    .map_err(|error| node_error("execute", error.to_string()))?;
451                validate_lease(&lease, envelope)
452                    .map_err(|error| node_error("execute", error.to_string()))?;
453                if let Some(reservation) = reservation.as_ref() {
454                    validate_reservation(reservation, envelope)
455                        .map_err(|error| node_error("execute", error.to_string()))?;
456                }
457                validate_execution_approval(
458                    &preview,
459                    ctx.get("route").and_then(Value::as_str),
460                    ctx.get("approval"),
461                    ctx.get("approval_grant_id").and_then(Value::as_str),
462                    ctx.get("approved_action_digest").and_then(Value::as_str),
463                    ctx.get("approved_policy_digest").and_then(Value::as_str),
464                )?;
465                let auth = ComputerUseAuthContext {
466                    principal_id: envelope.principal_id.clone(),
467                    tenant_id: authorizer.verified_tenant_id().map(str::to_owned),
468                    session_id: envelope.session_id.clone(),
469                    execution_group_id: envelope.execution_group_id.clone().unwrap_or_default(),
470                    requested_mode: envelope.requested_mode,
471                    action_class: envelope.action_class,
472                    target_app: envelope.target.as_ref().map(|target| target.app_id.clone()),
473                    target_window: envelope
474                        .target
475                        .as_ref()
476                        .and_then(|target| target.window_id.as_ref())
477                        .map(|value| match value {
478                            Value::String(value) => value.clone(),
479                            value => value.to_string(),
480                        }),
481                    policy_digest: preview.policy.policy_digest.clone(),
482                };
483                authorizer
484                    .authorize(&auth)
485                    .map_err(|error| node_error("execute", error.to_string()))?;
486                let receipt = runtime
487                    .execute_action(
488                        envelope,
489                        &lease,
490                        ctx.get("approval_grant_id").and_then(Value::as_str),
491                    )
492                    .await
493                    .map_err(|error| node_error("execute", error.to_string()))?;
494                validate_receipt(&receipt, envelope, &envelope.args_digest)
495                    .map_err(|error| node_error("execute", error.to_string()))?;
496                let receipt = serde_json::to_value(receipt)
497                    .map_err(|error| node_error("execute", error.to_string()))?;
498                Ok(NodeOutput::new().with_update("receipt", receipt))
499            }
500            .await;
501            release_on_error(&runtime, reservation.as_ref(), "execute", result).await
502        }
503    })
504    .add_node_fn("verify", move |ctx| {
505        let runtime = verify_runtime.clone();
506        async move {
507            let reservation = reservation_from_state(ctx.get("reservation"), "verify")?;
508            let result = async {
509                let receipt: ExecutionReceipt = serde_json::from_value(
510                    ctx.get("receipt")
511                        .cloned()
512                        .ok_or_else(|| node_error("verify", "missing receipt"))?,
513                )
514                .map_err(|error| node_error("verify", error.to_string()))?;
515                let preview: ActionPreview = serde_json::from_value(
516                    ctx.get("preview")
517                        .cloned()
518                        .ok_or_else(|| node_error("verify", "missing preview"))?,
519                )
520                .map_err(|error| node_error("verify", error.to_string()))?;
521                let outcome = runtime
522                    .verify(&receipt, preview.envelope.postcondition.as_ref())
523                    .await
524                    .map_err(|error| node_error("verify", error.to_string()))?;
525                let detail = match &outcome {
526                    VerificationOutcome::Verified => None,
527                    VerificationOutcome::CommittedUnverified { reason }
528                    | VerificationOutcome::Failed { reason } => Some(reason.clone()),
529                };
530
531                Ok(NodeOutput::new()
532                    .with_update("verified", json!(outcome.is_verified()))
533                    .with_update("committed", json!(outcome.is_committed()))
534                    .with_update(
535                        "result",
536                        json!({
537                            "status": outcome.status(),
538                            "receiptId": receipt.receipt_id,
539                            "verificationDetail": detail,
540                        }),
541                    ))
542            }
543            .await;
544            release_after_terminal(&runtime, reservation.as_ref(), "verify", result).await
545        }
546    })
547    .add_edge(START, "discover")
548    .add_edge(START, "observe_visual")
549    .add_edge(START, "observe_semantic")
550    .add_edge("discover", "join_observations")
551    .add_edge("observe_visual", "join_observations")
552    .add_edge("observe_semantic", "join_observations")
553    .add_edge("join_observations", "plan")
554    .add_edge("plan", "preview")
555    .add_conditional_edges(
556        "preview",
557        |state| state.get("route").and_then(Value::as_str).unwrap_or("blocked").to_string(),
558        [("allowed", "reserve_target"), ("approval", "request_approval"), ("blocked", "blocked")],
559    )
560    .add_edge("request_approval", "reserve_target")
561    .add_edge("blocked", END)
562    .add_edge("reserve_target", "acquire_lease")
563    .add_edge("acquire_lease", "execute")
564    .add_edge("execute", "verify")
565    .add_edge("verify", END)
566    .compile()
567    // This graph is governed: a digest decides what may run. A node writing a
568    // channel name the schema does not hold would take overwrite semantics and
569    // report nothing, so the write is rejected instead.
570    .map(CompiledGraph::with_strict_channels)
571    .map(|graph| match checkpointer {
572        Some(checkpointer) => graph.with_checkpointer_arc(checkpointer),
573        None => graph,
574    })
575}
576
577// Keep the public contract variants referenced by rustdoc and exhaustive builds.
578const _: (ExecutionMode, ActionClass) = (ExecutionMode::Shadow, ActionClass::Observe);