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 ¶ms.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 ¶ms.tool_name,
464 &mut args,
465 &mut options,
466 Some(&self.policy),
467 )?;
468 if let Presence::Present(revision) = ¶ms.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(¶ms.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(¶ms, 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(¶ms, 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;