Skip to main content

glass/browser/session/
agent.rs

1//! Bounded task-oriented operations built on the guarded session runtime.
2
3use super::*;
4use crate::protocol::{RetryClassification, RetryGuidance};
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value};
7use std::time::Duration;
8
9const MAX_EXTRACTION_FIELDS: usize = 32;
10const MAX_EXTRACTION_ITEMS: usize = 256;
11const MAX_EXTRACTION_BYTES: usize = 256 * 1024;
12
13/// Compact page inspection result for an agent turn.
14#[derive(Debug, Clone, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct InspectPageResult {
17    pub page: SemanticPage,
18    pub revision: u64,
19    pub regions: Vec<SemanticRegion>,
20    pub limits: SemanticObservationLimits,
21    pub focused_target: Option<SemanticTarget>,
22    pub alerts: Vec<String>,
23}
24
25/// Candidate-only target lookup result. It never dispatches an action.
26#[derive(Debug, Clone, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct FindTargetResult {
29    pub normalized_intent: String,
30    pub revision: Option<u64>,
31    pub candidates: Vec<SemanticIntentCandidate>,
32    pub ambiguity: String,
33    pub suggested_constraints: Vec<IntentConstraintSuggestion>,
34}
35
36/// Bounded action plus optional postcondition verification result.
37#[derive(Debug, Clone, Serialize)]
38#[serde(rename_all = "camelCase")]
39pub struct ActAndVerifyResult {
40    pub status: String,
41    pub phase: String,
42    pub mutation_possible: bool,
43    pub execution: SemanticIntentExecutionResult,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub verification: Option<VerificationOutcome>,
46    pub retry: RetryGuidance,
47}
48
49/// Structured extraction field declaration.
50#[derive(Debug, Clone, Deserialize, Serialize)]
51#[serde(rename_all = "camelCase")]
52pub struct ExtractionField {
53    pub name: String,
54    pub path: String,
55    pub kind: ExtractionKind,
56}
57
58/// Supported bounded extraction shapes.
59#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
60#[serde(rename_all = "camelCase")]
61pub enum ExtractionKind {
62    Scalar,
63    OptionalScalar,
64    List,
65    Object,
66    Table,
67    RepeatedItems,
68}
69
70/// Request for typed extraction from one fresh semantic region.
71#[derive(Debug, Clone, Deserialize, Serialize)]
72#[serde(rename_all = "camelCase")]
73pub struct StructuredExtractionRequest {
74    pub fields: Vec<ExtractionField>,
75    #[serde(default)]
76    pub region_id: Option<String>,
77    #[serde(default = "default_extraction_items")]
78    pub max_items: usize,
79    #[serde(default = "default_extraction_bytes")]
80    pub max_bytes: usize,
81}
82
83/// Bounded typed extraction output with revision provenance.
84#[derive(Debug, Clone, Serialize)]
85#[serde(rename_all = "camelCase")]
86pub struct StructuredExtractionResult {
87    pub source_revision: u64,
88    pub source_route: SemanticRouteIdentity,
89    pub records: Vec<Value>,
90    pub truncated: bool,
91    pub provenance: Vec<String>,
92}
93
94/// Recovery information for a run identifier that may outlive a session.
95#[derive(Debug, Clone, Serialize)]
96#[serde(rename_all = "camelCase")]
97pub struct RecoverRunResult {
98    pub execution_id: String,
99    pub known: bool,
100    pub phase: String,
101    pub dispatch_happened: bool,
102    pub mutation_possible: bool,
103    pub reconciliation: String,
104    pub retry: RetryGuidance,
105}
106
107impl BrowserSession {
108    /// Capture one bounded semantic observation without changing browser state.
109    pub async fn inspect_page(&self) -> BrowserResult<InspectPageResult> {
110        let observation = self
111            .semantic_observe(SemanticObservationLevel::Structured)
112            .await?;
113        Ok(InspectPageResult {
114            page: observation.page,
115            revision: observation.revision,
116            regions: observation.regions,
117            limits: observation.limits,
118            focused_target: None,
119            alerts: Vec::new(),
120        })
121    }
122
123    /// Resolve candidates from a fresh observation without acting.
124    pub async fn find_target(
125        &self,
126        request: &SemanticIntentRequest,
127    ) -> BrowserResult<FindTargetResult> {
128        let result = self.resolve_intent(request).await?;
129        let ambiguity = match result.resolution {
130            SemanticResolution::Exact
131            | SemanticResolution::UniqueHighConfidence
132            | SemanticResolution::UniqueLowConfidence => "none",
133            SemanticResolution::Ambiguous => "ambiguous",
134            SemanticResolution::NotFound => "not_found",
135            SemanticResolution::StaleRevision => "stale_revision",
136            SemanticResolution::PolicyRejected => "policy_rejected",
137            SemanticResolution::UnsupportedIntent => "unsupported_intent",
138        };
139        Ok(FindTargetResult {
140            normalized_intent: result.normalized_intent,
141            revision: result.revision,
142            candidates: result.candidates,
143            ambiguity: ambiguity.into(),
144            suggested_constraints: result.suggested_constraints,
145        })
146    }
147
148    /// Execute one explicit intent through the guarded boundary and verify it.
149    pub async fn act_and_verify(
150        &self,
151        execution: &SemanticIntentExecutionRequest,
152        predicate: Option<VerificationPredicate>,
153        timeout: Duration,
154    ) -> BrowserResult<ActAndVerifyResult> {
155        let execution_result = self.execute_intent(execution).await?;
156        let Some(_) = execution_result.action.as_ref() else {
157            return Ok(ActAndVerifyResult {
158                status: "not_executed".into(),
159                phase: "preflight".into(),
160                mutation_possible: false,
161                execution: execution_result,
162                verification: None,
163                retry: RetryGuidance {
164                    classification: RetryClassification::SafeAfterReobserve,
165                    recommended_operation: "find_target".into(),
166                },
167            });
168        };
169        let Some(predicate) = predicate else {
170            return Ok(ActAndVerifyResult {
171                status: "dispatched_unverified".into(),
172                phase: "post_dispatch".into(),
173                mutation_possible: true,
174                execution: execution_result,
175                verification: None,
176                retry: RetryGuidance {
177                    classification: RetryClassification::RequiresUserDecision,
178                    recommended_operation: "inspect_page".into(),
179                },
180            });
181        };
182        let verification = self.verify(predicate, timeout).await;
183        match verification {
184            Ok(verification) => Ok(ActAndVerifyResult {
185                status: "verified".into(),
186                phase: "verification".into(),
187                mutation_possible: false,
188                execution: execution_result,
189                verification: Some(verification),
190                retry: RetryGuidance {
191                    classification: RetryClassification::SafeImmediate,
192                    recommended_operation: "inspect_page".into(),
193                },
194            }),
195            Err(_error) => Ok(ActAndVerifyResult {
196                status: "indeterminate".into(),
197                phase: "verification".into(),
198                mutation_possible: true,
199                execution: execution_result,
200                verification: None,
201                retry: RetryGuidance {
202                    classification: RetryClassification::UnsafeUntilReconciled,
203                    recommended_operation: "recover_run".into(),
204                },
205            }),
206        }
207    }
208
209    /// Extract typed, bounded records from a fresh semantic region.
210    pub async fn extract_structured(
211        &self,
212        request: &StructuredExtractionRequest,
213    ) -> BrowserResult<StructuredExtractionResult> {
214        validate_extraction_request(request)?;
215        let observation = self
216            .semantic_observe(SemanticObservationLevel::Structured)
217            .await?;
218        let source = if let Some(region_id) = &request.region_id {
219            observation
220                .regions
221                .iter()
222                .find(|region| region.id == *region_id)
223                .map(serde_json::to_value)
224                .transpose()?
225                .ok_or_else(|| format!("region not found: {region_id}"))?
226        } else {
227            serde_json::to_value(&observation)?
228        };
229        let mut record = Map::new();
230        let mut truncated = false;
231        for field in &request.fields {
232            let value = value_at_path(&source, &field.path).cloned();
233            let mut value = validate_extracted_value(value, field)?;
234            if let Value::Array(items) = &mut value
235                && items.len() > request.max_items
236            {
237                items.truncate(request.max_items);
238                truncated = true;
239            }
240            record.insert(field.name.clone(), value);
241        }
242        let serialized = serde_json::to_vec(&record)?;
243        if serialized.len() > request.max_bytes {
244            return Err(format!(
245                "extraction exceeds maxBytes ({} > {})",
246                serialized.len(),
247                request.max_bytes
248            )
249            .into());
250        }
251        Ok(StructuredExtractionResult {
252            source_revision: observation.revision,
253            source_route: observation.route,
254            records: vec![record.into()],
255            truncated,
256            provenance: request
257                .fields
258                .iter()
259                .map(|field| field.path.clone())
260                .collect(),
261        })
262    }
263
264    /// Reconcile a run ID conservatively when the original session is gone.
265    pub fn recover_run(&self, execution_id: &str) -> BrowserResult<RecoverRunResult> {
266        if execution_id.is_empty() || execution_id.len() > 128 {
267            return Err("execution ID must be 1..=128 bytes".into());
268        }
269        Ok(RecoverRunResult {
270            execution_id: execution_id.into(),
271            known: false,
272            phase: "reconciliation".into(),
273            dispatch_happened: false,
274            mutation_possible: true,
275            reconciliation: "session-local execution evidence is unavailable; observe before retry"
276                .into(),
277            retry: RetryGuidance {
278                classification: RetryClassification::UnsafeUntilReconciled,
279                recommended_operation: "inspect_page".into(),
280            },
281        })
282    }
283}
284
285fn validate_extraction_request(request: &StructuredExtractionRequest) -> BrowserResult<()> {
286    if request.fields.is_empty() || request.fields.len() > MAX_EXTRACTION_FIELDS {
287        return Err(format!("fields must contain 1..={MAX_EXTRACTION_FIELDS} entries").into());
288    }
289    if !(1..=MAX_EXTRACTION_ITEMS).contains(&request.max_items) {
290        return Err(format!("maxItems must be 1..={MAX_EXTRACTION_ITEMS}").into());
291    }
292    if !(1..=MAX_EXTRACTION_BYTES).contains(&request.max_bytes) {
293        return Err(format!("maxBytes must be 1..={MAX_EXTRACTION_BYTES}").into());
294    }
295    for field in &request.fields {
296        if field.name.is_empty() || field.name.len() > 128 || field.path.len() > 512 {
297            return Err("extraction field names and paths must be bounded".into());
298        }
299    }
300    Ok(())
301}
302
303fn value_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
304    if path.is_empty() || path == "$" {
305        return Some(value);
306    }
307    path.trim_start_matches("$.")
308        .split('.')
309        .try_fold(value, |current, segment| current.get(segment))
310}
311
312fn validate_extracted_value(value: Option<Value>, field: &ExtractionField) -> BrowserResult<Value> {
313    let value = value.unwrap_or(Value::Null);
314    let valid = match field.kind {
315        ExtractionKind::Scalar => value.is_string() || value.is_number() || value.is_boolean(),
316        ExtractionKind::OptionalScalar => {
317            value.is_null() || value.is_string() || value.is_number() || value.is_boolean()
318        }
319        ExtractionKind::List | ExtractionKind::RepeatedItems | ExtractionKind::Table => {
320            value.is_array()
321        }
322        ExtractionKind::Object => value.is_object(),
323    };
324    if !valid {
325        return Err(format!("field {} does not match its declared type", field.name).into());
326    }
327    Ok(value)
328}
329
330fn default_extraction_items() -> usize {
331    64
332}
333
334fn default_extraction_bytes() -> usize {
335    64 * 1024
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn extraction_bounds_and_types_are_checked() {
344        let request = StructuredExtractionRequest {
345            fields: vec![ExtractionField {
346                name: "title".into(),
347                path: "page.title".into(),
348                kind: ExtractionKind::Scalar,
349            }],
350            region_id: None,
351            max_items: 1,
352            max_bytes: 1024,
353        };
354        validate_extraction_request(&request).unwrap();
355        assert_eq!(
356            validate_extracted_value(Some(Value::String("Glass".into())), &request.fields[0])
357                .unwrap(),
358            Value::String("Glass".into())
359        );
360    }
361}