Skip to main content

superdoc_sdk/
agent.rs

1use crate::{
2    generated::types::{
3        DocCapabilitiesGetBoundParams, DocCapabilitiesGetResult, DocPresetDispatchBoundParams,
4        DocPresetDispatchResult, DocPresetGetSystemPromptParams, DocPresetGetSystemPromptResult,
5        DocPresetGetToolsParams, DocPresetGetToolsResult,
6    },
7    wire::Presence,
8    Document, Error, InvokeOptions, Result,
9};
10use serde::Deserialize;
11use serde_json::Value;
12use std::sync::OnceLock;
13
14pub(crate) fn prepare_core_dispatch(
15    tool_name: &str,
16    args: &mut Value,
17    options: &mut InvokeOptions,
18    configured: Option<&DocPresetGetToolsParams>,
19) -> Result<()> {
20    if !args.is_object() {
21        let mut error = Error::new("INVALID_ARGUMENT", "tool arguments must be an object");
22        error.details = Some(Box::new(serde_json::json!({"toolName": tool_name})));
23        return Err(error);
24    }
25    let authority = action_policy()?;
26    validate_core_policy(tool_name, args, configured, authority)?;
27    let Some(tool) = authority
28        .preflight
29        .tools
30        .iter()
31        .find(|tool| tool.name == tool_name)
32    else {
33        let mut error = Error::new("TOOL_DISPATCH_NOT_FOUND", "Unknown Core tool");
34        error.host_message = Some(format!("Unknown tool: {tool_name}").into());
35        error.details = Some(Box::new(
36            serde_json::json!({"toolName":tool_name,"preset":"core"}),
37        ));
38        return Err(error);
39    };
40    validate_core_arguments(tool, args, &authority.preflight)?;
41    if options.dry_run == Some(true) {
42        let mut error = Error::new(
43            "CAPABILITY_UNSUPPORTED",
44            "the core preset dispatcher does not support dryRun previews",
45        );
46        error.operation_id = Some("doc.preset.dispatch".into());
47        return Err(error);
48    }
49    if options
50        .change_mode
51        .as_deref()
52        .is_some_and(|mode| !["direct", "tracked"].contains(&mode))
53    {
54        return Err(Error::new(
55            "INVALID_ARGUMENT",
56            "core dispatch change mode must be direct or tracked",
57        ));
58    }
59    let explicit = (tool_name == "superdoc_perform_action")
60        .then(|| args.get("expectedRevision"))
61        .flatten();
62    let revision = match explicit {
63        Some(value) => value.as_str(),
64        None => options.expected_revision.as_deref(),
65    };
66    if revision.is_some() || explicit.is_some() {
67        let revision = revision.filter(|revision| !revision.is_empty());
68        let Some(revision) = revision else {
69            let mut error = Error::new(
70                "INVALID_ARGUMENT",
71                "expectedRevision must be a non-empty live revision or a non-negative safe integer.",
72            );
73            error.details = Some(Box::new(
74                serde_json::json!({"preset":"core","option":"expectedRevision"}),
75            ));
76            return Err(error);
77        };
78        options.expected_revision = Some(revision.to_owned());
79    }
80    Ok(())
81}
82
83pub(crate) fn stale_revision_message(action: &str) -> String {
84    format!("{action}: expectedRevision is stale. Inspect this live document again before editing.")
85}
86
87pub(crate) fn stale_action_revision_receipt(
88    args: &Value,
89    live_revision: &str,
90    configured: Option<&DocPresetGetToolsParams>,
91) -> Result<Value> {
92    let action = args.get("action").and_then(Value::as_str);
93    let intent = action.unwrap_or("superdoc_perform_action");
94    let mut receipt = serde_json::json!({
95        "status": "failed",
96        "intent": intent,
97        "preSnapshot": { "revision": live_revision },
98        "selectedTargets": [],
99        "executedOperations": [],
100        "verification": [],
101        "errors": [{
102            "code": "REVISION_CONFLICT",
103            "message": stale_revision_message(intent),
104            "recovery": { "kind": "reinspect" },
105        }],
106    });
107    let enforcing = configured.is_some_and(
108        |configured| matches!(&configured.enforce_change_mode, Presence::Present(mode) if mode == "tracked"),
109    );
110    if enforcing {
111        let requested_tracked = args.get("changeMode").and_then(Value::as_str) == Some("tracked");
112        let actions = &action_policy()?.actions;
113        let disposition = action.and_then(|action| {
114            actions
115                .iter()
116                .find(|entry| entry.name == action)
117                .map(|entry| entry.disposition.as_str())
118        });
119        let (change_mode, forced) = match disposition {
120            Some("tracked") => ("tracked", !requested_tracked),
121            Some(_) => ("direct", requested_tracked),
122            None => (
123                if requested_tracked {
124                    "tracked"
125                } else {
126                    "direct"
127                },
128                false,
129            ),
130        };
131        receipt["changeMode"] = Value::from(change_mode);
132        receipt["changeModeForced"] = Value::from(forced);
133    }
134    Ok(receipt)
135}
136
137fn invalid_core_arguments(message: String, details: Value) -> Error {
138    let mut error = Error::new("INVALID_ARGUMENT", "Core tool arguments are invalid");
139    error.host_message = Some(message.into());
140    error.details = Some(Box::new(details));
141    error
142}
143
144fn trim_core_csv_name<'a>(name: &'a str, authority: &CorePreflight) -> &'a str {
145    name.trim_matches(|character: char| authority.trim_code_points.contains(&(character as u32)))
146}
147
148fn csv_names<'a>(value: &'a Presence<String>, authority: &CorePreflight) -> Vec<&'a str> {
149    match value {
150        Presence::Missing => Vec::new(),
151        Presence::Present(value) => value
152            .split(',')
153            .map(|name| trim_core_csv_name(name, authority))
154            .filter(|name| !name.is_empty())
155            .collect(),
156    }
157}
158
159fn validate_core_policy(
160    tool_name: &str,
161    args: &Value,
162    configured: Option<&DocPresetGetToolsParams>,
163    authority: &ActionPolicy,
164) -> Result<()> {
165    let Some(configured) = configured else {
166        return Ok(());
167    };
168    let enforcing = match &configured.enforce_change_mode {
169        Presence::Missing => false,
170        Presence::Present(mode) if mode == "tracked" => true,
171        Presence::Present(mode) => {
172            return Err(invalid_core_arguments(
173                format!(
174                    "enforceChangeMode: only \"tracked\" is supported; got {}.",
175                    serde_json::json!(mode)
176                ),
177                serde_json::json!({"enforceChangeMode":mode,"supported":["tracked"]}),
178            ))
179        }
180    };
181    let allow = csv_names(&configured.allow_direct_actions, &authority.preflight);
182    if !configured.allow_direct_actions.is_missing() && !enforcing {
183        return Err(invalid_core_arguments(
184            "allowDirectActions requires enforceChangeMode: \"tracked\"; without enforcement every action already runs as requested.".into(),
185            serde_json::json!({"allowDirectActions":allow})));
186    }
187    if enforcing
188        && authority
189            .preflight
190            .enforcement_side_doors
191            .iter()
192            .any(|name| name == tool_name)
193    {
194        return Err(invalid_core_arguments(
195            format!("{tool_name} bypasses the tracked-changes policy and is refused under enforceChangeMode: \"tracked\"."),
196            serde_json::json!({"toolName":tool_name,"reason":"enforce-change-mode"})));
197    }
198    if tool_name == "superdoc_perform_action" {
199        if let Some(action) = args.get("action").and_then(Value::as_str) {
200            if csv_names(&configured.exclude_actions, &authority.preflight).contains(&action) {
201                return Err(invalid_core_arguments(
202                    format!("Action {action} is excluded by configuration."),
203                    serde_json::json!({"toolName":tool_name,"action":action,"excluded":true,"reason":"excluded"}),
204                ));
205            }
206            if enforcing
207                && !allow.contains(&"*")
208                && !allow.contains(&action)
209                && authority
210                    .actions
211                    .iter()
212                    .any(|entry| entry.name == action && entry.disposition == "direct")
213            {
214                return Err(invalid_core_arguments(
215                    format!("Action {action} edits the document directly and is refused under enforceChangeMode: \"tracked\". Pass it in allowDirectActions to permit it."),
216                    serde_json::json!({"toolName":tool_name,"action":action,"excluded":true,"reason":"direct-only"})));
217            }
218        }
219    }
220    Ok(())
221}
222
223fn meaningful_argument_key(key: &str, authority: &CorePreflight) -> bool {
224    key.chars().any(|character| {
225        let point = character as u32;
226        let at = authority
227            .letter_number_ranges
228            .partition_point(|range| range[1] < point);
229        authority
230            .letter_number_ranges
231            .get(at)
232            .is_some_and(|range| range[0] <= point)
233    })
234}
235
236fn validate_core_arguments(tool: &CoreTool, args: &Value, authority: &CorePreflight) -> Result<()> {
237    let unknown = args
238        .as_object()
239        .expect("Core arguments were checked")
240        .keys()
241        .filter(|name| !tool.known_keys.contains(name) && meaningful_argument_key(name, authority))
242        .cloned()
243        .collect::<Vec<_>>();
244    if !unknown.is_empty() {
245        let retired = unknown
246            .iter()
247            .filter(|name| authority.retired_arguments.contains_key(*name))
248            .cloned()
249            .collect::<Vec<_>>();
250        let message = if retired.is_empty() {
251            format!(
252                "Unknown argument(s) for {}: {}",
253                tool.name,
254                unknown.join(", ")
255            )
256        } else {
257            retired
258                .iter()
259                .map(|name| authority.retired_arguments[name].as_str())
260                .collect::<Vec<_>>()
261                .join(" ")
262        };
263        let mut details = serde_json::json!({"toolName":tool.name,"unknownKeys":unknown,"knownKeys":tool.known_keys});
264        if !retired.is_empty() {
265            details["retiredKeys"] = serde_json::json!(retired);
266        }
267        return Err(invalid_core_arguments(message, details));
268    }
269    let missing = tool
270        .required_keys
271        .iter()
272        .filter(|name| args.get(name.as_str()).is_none_or(Value::is_null))
273        .collect::<Vec<_>>();
274    if !missing.is_empty() {
275        return Err(invalid_core_arguments(
276            format!(
277                "Missing required argument(s) for {}: {}",
278                tool.name,
279                missing
280                    .iter()
281                    .map(|name| name.as_str())
282                    .collect::<Vec<_>>()
283                    .join(", ")
284            ),
285            serde_json::json!({"toolName":tool.name,"missingKeys":missing}),
286        ));
287    }
288    Ok(())
289}
290
291#[derive(Deserialize)]
292#[serde(rename_all = "camelCase", deny_unknown_fields)]
293struct CoreTool {
294    name: String,
295    known_keys: Vec<String>,
296    required_keys: Vec<String>,
297}
298
299#[derive(Deserialize)]
300#[serde(rename_all = "camelCase", deny_unknown_fields)]
301struct CorePreflight {
302    catalog_sha256: String,
303    dispatch_sha256: String,
304    action_source_sha256: String,
305    tools: Vec<CoreTool>,
306    enforcement_side_doors: Vec<String>,
307    retired_arguments: std::collections::BTreeMap<String, String>,
308    unicode_version: String,
309    character_data_sha256: String,
310    letter_number_ranges: Vec<[u32; 2]>,
311    trim_code_points: Vec<u32>,
312}
313
314#[derive(Deserialize)]
315#[serde(rename_all = "camelCase", deny_unknown_fields)]
316struct ActionPolicy {
317    version: u32,
318    descriptor_sha256: String,
319    contract_hash: String,
320    actions: Vec<Action>,
321    preflight: CorePreflight,
322}
323
324#[derive(Deserialize)]
325#[serde(deny_unknown_fields)]
326struct Action {
327    name: String,
328    operations: Vec<String>,
329    disposition: String,
330}
331
332fn action_policy() -> Result<&'static ActionPolicy> {
333    static POLICY: OnceLock<std::result::Result<ActionPolicy, String>> = OnceLock::new();
334    let policy = POLICY.get_or_init(|| {
335        serde_json::from_str(include_str!("../assets/action-policy.json"))
336            .map_err(|error| error.to_string())
337    });
338    let policy = policy
339        .as_ref()
340        .map_err(|_| Error::new("HOST_PROTOCOL_ERROR", "generated action policy is invalid"))?;
341    if policy.version != 2
342        || policy.descriptor_sha256.len() != 64
343        || policy.contract_hash != crate::generated::CONTRACT_HASH
344        || policy.preflight.catalog_sha256.len() != 64
345        || policy.preflight.dispatch_sha256.len() != 64
346        || policy.preflight.action_source_sha256.len() != 64
347        || policy.preflight.tools.len() != 6
348        || policy.preflight.unicode_version.is_empty()
349        || policy.preflight.character_data_sha256.len() != 64
350        || policy.preflight.letter_number_ranges.is_empty()
351    {
352        return Err(Error::new(
353            "HOST_PROTOCOL_ERROR",
354            "generated action policy does not match the SDK contract",
355        ));
356    }
357    Ok(policy)
358}
359
360fn unavailable_from_operations(operations: &Value) -> Result<Vec<String>> {
361    Ok(action_policy()?
362        .actions
363        .iter()
364        .filter(|action| {
365            action.disposition == "tracked"
366                && action.operations.iter().any(|id| {
367                    let capability = &operations[id];
368                    capability["available"] != true
369                        || (capability["tracked"] != true
370                            && capability["trackedSupport"] != "conditional")
371                })
372        })
373        .map(|action| action.name.clone())
374        .collect())
375}
376
377fn unavailable_tracked_actions(capabilities: &DocCapabilitiesGetResult) -> Result<Vec<String>> {
378    unavailable_from_operations(
379        &serde_json::to_value(&capabilities.operations).map_err(Error::decode)?,
380    )
381}
382
383fn merge_exclusions(
384    configured: &Presence<String>,
385    unavailable: &[String],
386    authority: &CorePreflight,
387) -> Presence<String> {
388    if unavailable.is_empty() {
389        return configured.clone();
390    }
391    let mut names = Vec::<String>::new();
392    let configured = match configured {
393        Presence::Present(value) => value
394            .split(',')
395            .map(|name| trim_core_csv_name(name, authority))
396            .filter(|value| !value.is_empty())
397            .map(str::to_owned)
398            .collect::<Vec<_>>(),
399        Presence::Missing => Vec::new(),
400    };
401    for name in configured.into_iter().chain(unavailable.iter().cloned()) {
402        if !names.contains(&name) {
403            names.push(name);
404        }
405    }
406    Presence::Present(names.join(","))
407}
408
409#[derive(Clone)]
410pub struct CoreToolkit {
411    document: Document,
412    policy: DocPresetGetToolsParams,
413    tools: DocPresetGetToolsResult,
414    system_prompt: DocPresetGetSystemPromptResult,
415    capabilities: Option<Box<DocCapabilitiesGetResult>>,
416}
417
418impl CoreToolkit {
419    pub fn tools(&self) -> &DocPresetGetToolsResult {
420        &self.tools
421    }
422
423    pub fn system_prompt(&self) -> &DocPresetGetSystemPromptResult {
424        &self.system_prompt
425    }
426
427    pub fn prompt(&self) -> &str {
428        &self.system_prompt.prompt
429    }
430
431    pub fn policy(&self) -> &DocPresetGetToolsParams {
432        &self.policy
433    }
434
435    pub fn capabilities_snapshot(&self) -> Option<&DocCapabilitiesGetResult> {
436        self.capabilities.as_deref()
437    }
438
439    pub async fn dispatch(
440        &self,
441        params: &DocPresetDispatchBoundParams,
442    ) -> Result<DocPresetDispatchResult> {
443        self.dispatch_with_options(params, InvokeOptions::default())
444            .await
445    }
446
447    pub async fn dispatch_with_options(
448        &self,
449        params: &DocPresetDispatchBoundParams,
450        mut options: InvokeOptions,
451    ) -> Result<DocPresetDispatchResult> {
452        let mut params = params.clone();
453        let mut args = match &params.args {
454            Presence::Missing => serde_json::json!({}),
455            Presence::Present(args) => args.clone(),
456        };
457        if params.tool_name == "superdoc_perform_action" && args.get("expectedRevision").is_some() {
458            params.expected_revision = Presence::Missing;
459        } else if !params.expected_revision.is_missing() {
460            options.expected_revision = None;
461        }
462        prepare_core_dispatch(
463            &params.tool_name,
464            &mut args,
465            &mut options,
466            Some(&self.policy),
467        )?;
468        if let Presence::Present(revision) = &params.expected_revision {
469            let invalid = || {
470                Error::new("INVALID_ARGUMENT", "expectedRevision must be a non-empty live revision or a non-negative safe integer.")
471            };
472            let revision = match serde_json::to_value(revision).map_err(Error::decode)? {
473                Value::String(value) if !value.is_empty() => value,
474                Value::Number(value) => {
475                    let value = value.as_f64().ok_or_else(invalid)?;
476                    if value < 0.0
477                        || value.fract() != 0.0
478                        || value > crate::wire::Integer::MAX as f64
479                    {
480                        return Err(invalid());
481                    }
482                    if value == 0.0 {
483                        "0".into()
484                    } else {
485                        value.to_string()
486                    }
487                }
488                _ => return Err(invalid()),
489            };
490            options.expected_revision = Some(revision);
491        }
492        if let Some(receipt) = self
493            .document
494            .check_core_revision(&params.tool_name, &args, &options, Some(&self.policy))
495            .await?
496        {
497            return Ok(DocPresetDispatchResult::new().with_result(receipt));
498        }
499        params.args = Presence::Present(args);
500        params.preset = self.policy.preset.clone();
501        params.exclude_actions = self.policy.exclude_actions.clone();
502        params.enforce_change_mode = self.policy.enforce_change_mode.clone();
503        params.allow_direct_actions = self.policy.allow_direct_actions.clone();
504        self.document
505            .api()
506            .preset()
507            .dispatch_with_options(&params, options)
508            .await
509    }
510}
511
512#[derive(Clone)]
513pub struct FullToolkit {
514    document: Document,
515    policy: DocPresetGetToolsParams,
516    tools: DocPresetGetToolsResult,
517    system_prompt: DocPresetGetSystemPromptResult,
518}
519
520impl FullToolkit {
521    pub fn tools(&self) -> &DocPresetGetToolsResult {
522        &self.tools
523    }
524
525    pub fn system_prompt(&self) -> &DocPresetGetSystemPromptResult {
526        &self.system_prompt
527    }
528
529    pub fn prompt(&self) -> &str {
530        &self.system_prompt.prompt
531    }
532
533    pub fn policy(&self) -> &DocPresetGetToolsParams {
534        &self.policy
535    }
536
537    pub async fn dispatch(
538        &self,
539        params: &DocPresetDispatchBoundParams,
540    ) -> Result<DocPresetDispatchResult> {
541        self.dispatch_with_options(params, InvokeOptions::default())
542            .await
543    }
544
545    pub async fn dispatch_with_options(
546        &self,
547        params: &DocPresetDispatchBoundParams,
548        options: InvokeOptions,
549    ) -> Result<DocPresetDispatchResult> {
550        let mut params = params.clone();
551        params.preset = self.policy.preset.clone();
552        params.exclude_actions = self.policy.exclude_actions.clone();
553        params.enforce_change_mode = self.policy.enforce_change_mode.clone();
554        params.allow_direct_actions = self.policy.allow_direct_actions.clone();
555        self.document
556            .api()
557            .preset()
558            .dispatch_with_options(&params, options)
559            .await
560    }
561}
562
563impl Document {
564    pub async fn full_toolkit(&self, input: &DocPresetGetToolsParams) -> Result<FullToolkit> {
565        let mut policy = input.clone();
566        if let Presence::Present(preset) = &policy.preset {
567            if preset != "full" {
568                return Err(Error::new(
569                    "INVALID_ARGUMENT",
570                    "full_toolkit requires the full preset",
571                ));
572            }
573        }
574        policy.preset = Presence::Present("full".to_owned());
575        let tools = self.api().preset().get_tools(&policy).await?;
576        let mut prompt_params = DocPresetGetSystemPromptParams::new();
577        prompt_params.preset = policy.preset.clone();
578        prompt_params.exclude_actions = policy.exclude_actions.clone();
579        prompt_params.enforce_change_mode = policy.enforce_change_mode.clone();
580        prompt_params.allow_direct_actions = policy.allow_direct_actions.clone();
581        let system_prompt = self
582            .api()
583            .preset()
584            .get_system_prompt(&prompt_params)
585            .await?;
586        Ok(FullToolkit {
587            document: self.clone(),
588            policy,
589            tools,
590            system_prompt,
591        })
592    }
593}
594
595impl Document {
596    pub async fn core_toolkit(&self, input: &DocPresetGetToolsParams) -> Result<CoreToolkit> {
597        let mut policy = input.clone();
598        if let Presence::Present(preset) = &policy.preset {
599            if preset != "core" {
600                return Err(Error::new(
601                    "INVALID_ARGUMENT",
602                    "core_toolkit requires the core preset",
603                ));
604            }
605        }
606        policy.preset = Presence::Present("core".to_owned());
607        let capabilities = if matches!(&policy.enforce_change_mode, Presence::Present(mode) if mode == "tracked")
608        {
609            Some(Box::new(
610                self.api()
611                    .capabilities()
612                    .get(&DocCapabilitiesGetBoundParams::new())
613                    .await?,
614            ))
615        } else {
616            None
617        };
618        if let Some(capabilities) = &capabilities {
619            policy.exclude_actions = merge_exclusions(
620                &policy.exclude_actions,
621                &unavailable_tracked_actions(capabilities)?,
622                &action_policy()?.preflight,
623            );
624        }
625        let tools = self.api().preset().get_tools(&policy).await?;
626        let mut prompt_params = DocPresetGetSystemPromptParams::new();
627        prompt_params.preset = policy.preset.clone();
628        prompt_params.exclude_actions = policy.exclude_actions.clone();
629        prompt_params.enforce_change_mode = policy.enforce_change_mode.clone();
630        prompt_params.allow_direct_actions = policy.allow_direct_actions.clone();
631        let system_prompt = self
632            .api()
633            .preset()
634            .get_system_prompt(&prompt_params)
635            .await?;
636        Ok(CoreToolkit {
637            document: self.clone(),
638            policy,
639            tools,
640            system_prompt,
641            capabilities,
642        })
643    }
644}
645
646#[cfg(test)]
647#[path = "agent_tests.rs"]
648mod tests;