agents_kit/workspace.rs
1//! Workspace contexts, durable snapshots, revision-checked requests, and live handles.
2//!
3//! Context paths describe the backend host, which may differ from the client.
4//! Construction validates lexical confinement without probing that filesystem.
5//! [`WorkspaceBackend`] owns its associated Conversation; close releases runtime
6//! ownership while preserving reopenable records. Use [`crate::AgentBackend`]
7//! for atomic replacement/deletion, supplying the last committed revision.
8
9use std::ffi::OsString;
10use std::fmt;
11use std::future::Future;
12use std::path::{Path, PathBuf};
13use tools_kit::ToolsetBackend;
14
15use providers_kit::ProviderBackend;
16use storage_kit::{DatabaseBackend, StateStoreBackend};
17
18use crate::backends::{codex, custom, opencode};
19use crate::{
20 Conversation, ConversationId, ConversationInfo, IngestKnowledgeRequest, KitError,
21 KnowledgePolicy, ModelSelector, NewConversation, PersonalitySelector, ReasoningEffort,
22 ToolInfo, WorkspaceId,
23};
24
25/// Filesystem access declared for one workspace root.
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub enum WorkspaceRootAccess {
28 /// Model-visible actions may read but not write beneath the root.
29 ReadOnly,
30 /// Model-visible actions may read and write beneath the root when policy permits.
31 ReadWrite,
32}
33
34/// One normalized absolute backend-host workspace root.
35#[derive(Clone, Debug, Eq, PartialEq)]
36///
37/// ```
38/// use agents_kit::workspace::{WorkspaceRoot, WorkspaceRootAccess};
39/// let value = WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?;
40/// assert_eq!(value.path(), std::path::Path::new("/workspace"));
41/// # Ok::<(), agents_kit::KitError>(())
42/// ```
43pub struct WorkspaceRoot {
44 path: BackendHostPath,
45 access: WorkspaceRootAccess,
46}
47
48#[derive(Clone)]
49struct BackendHostPath {
50 original: PathBuf,
51 anchor: BackendHostPathAnchor,
52 components: Vec<BackendHostPathSegment>,
53}
54
55impl PartialEq for BackendHostPath {
56 fn eq(&self, other: &Self) -> bool {
57 self.anchor == other.anchor && self.components == other.components
58 }
59}
60
61impl Eq for BackendHostPath {}
62
63#[derive(Clone, Debug, Eq, PartialEq)]
64enum BackendHostPathAnchor {
65 Posix,
66 WindowsDrive(u8),
67 WindowsUnc {
68 server: BackendHostPathSegment,
69 share: BackendHostPathSegment,
70 },
71}
72
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub(crate) enum BackendHostPathFlavor {
75 Posix,
76 Windows,
77}
78
79#[derive(Clone, Debug, Eq, PartialEq)]
80struct BackendHostPathSegment(Box<[u8]>);
81
82impl fmt::Debug for BackendHostPath {
83 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
84 formatter
85 .debug_tuple("BackendHostPath")
86 .field(&self.original)
87 .finish()
88 }
89}
90
91impl BackendHostPathSegment {
92 fn new(value: &[u8], label: &str) -> Result<Self, KitError> {
93 if value.is_empty() || value == b"." || value == b".." {
94 return Err(KitError::invalid_input(format!(
95 "{label} must already be lexically normalized"
96 )));
97 }
98 Ok(Self(value.into()))
99 }
100}
101
102impl BackendHostPathAnchor {
103 fn is_windows(&self) -> bool {
104 !matches!(self, Self::Posix)
105 }
106
107 fn flavor(&self) -> BackendHostPathFlavor {
108 if self.is_windows() {
109 BackendHostPathFlavor::Windows
110 } else {
111 BackendHostPathFlavor::Posix
112 }
113 }
114}
115
116impl BackendHostPath {
117 fn parse_absolute(label: &str, original: PathBuf) -> Result<Self, KitError> {
118 let bytes = original.as_os_str().as_encoded_bytes();
119 if bytes.is_empty() || bytes.contains(&0) {
120 return Err(KitError::invalid_input(format!(
121 "{label} must be a nonempty absolute backend-host path"
122 )));
123 }
124
125 let (anchor, components) = if bytes.starts_with(b"\\\\") {
126 let mut components = Self::parse_components(&bytes[2..], true, label)?;
127 if components.len() < 2 || components[0].0.as_ref() == b"?" {
128 return Err(KitError::invalid_input(format!(
129 "{label} must use a normalized UNC share path"
130 )));
131 }
132 let share = components.remove(1);
133 let server = components.remove(0);
134 (
135 BackendHostPathAnchor::WindowsUnc { server, share },
136 components,
137 )
138 } else if bytes.len() >= 3
139 && bytes[0].is_ascii_alphabetic()
140 && bytes[1] == b':'
141 && matches!(bytes[2], b'/' | b'\\')
142 {
143 (
144 BackendHostPathAnchor::WindowsDrive(bytes[0].to_ascii_lowercase()),
145 Self::parse_components(&bytes[3..], true, label)?,
146 )
147 } else if bytes[0] == b'/' && !bytes.starts_with(b"//") {
148 (
149 BackendHostPathAnchor::Posix,
150 Self::parse_components(&bytes[1..], false, label)?,
151 )
152 } else {
153 return Err(KitError::invalid_input(format!(
154 "{label} must be a nonempty absolute backend-host path"
155 )));
156 };
157
158 Ok(Self {
159 original,
160 anchor,
161 components,
162 })
163 }
164
165 fn parse_components(
166 bytes: &[u8],
167 windows: bool,
168 label: &str,
169 ) -> Result<Vec<BackendHostPathSegment>, KitError> {
170 if bytes.is_empty() {
171 return Ok(Vec::new());
172 }
173 let mut components = Vec::new();
174 let mut start = 0;
175 for (index, byte) in bytes.iter().copied().enumerate() {
176 if byte == b'/' || (windows && byte == b'\\') {
177 components.push(BackendHostPathSegment::new(&bytes[start..index], label)?);
178 start = index + 1;
179 }
180 }
181 components.push(BackendHostPathSegment::new(&bytes[start..], label)?);
182 Ok(components)
183 }
184
185 fn validate_portable_relative(path: &Path) -> Result<PathBuf, KitError> {
186 let bytes = path.as_os_str().as_encoded_bytes();
187 if bytes.is_empty()
188 || bytes.contains(&0)
189 || matches!(bytes[0], b'/' | b'\\')
190 || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
191 {
192 return Err(KitError::invalid_input(
193 "workspace path must be nonempty and relative",
194 ));
195 }
196 if bytes
197 .split(|byte| matches!(*byte, b'/' | b'\\'))
198 .any(|component| component.is_empty() || component == b"." || component == b"..")
199 {
200 return Err(KitError::invalid_input(
201 "workspace path must already be lexically normalized",
202 ));
203 }
204 Ok(path.to_path_buf())
205 }
206
207 fn resolve(&self, relative: &Path) -> Result<Self, KitError> {
208 let relative_components = Self::parse_components(
209 relative.as_os_str().as_encoded_bytes(),
210 self.anchor.is_windows(),
211 "workspace path",
212 )?;
213 let mut raw = OsString::from(self.original.as_os_str());
214 let terminated = raw
215 .as_encoded_bytes()
216 .last()
217 .is_some_and(|byte| *byte == b'/' || (self.anchor.is_windows() && *byte == b'\\'));
218 if !terminated {
219 raw.push(if self.anchor.is_windows() { "\\" } else { "/" });
220 }
221 raw.push(relative.as_os_str());
222 let resolved = Self::parse_absolute("resolved workspace path", PathBuf::from(raw))?;
223 let mut expected = self.components.clone();
224 expected.extend(relative_components);
225 if resolved.anchor != self.anchor || resolved.components != expected {
226 return Err(KitError::invalid_input(
227 "workspace path must remain beneath its backend-host cwd",
228 ));
229 }
230 Ok(resolved)
231 }
232
233 fn relative_native_file(&self, native_path: &Path) -> Result<PathBuf, KitError> {
234 let native = native_path
235 .to_str()
236 .filter(|path| !path.is_empty() && !path.contains('\0'))
237 .ok_or_else(|| KitError::invalid_input("native file path must be nonempty UTF-8"))?;
238 let windows = self.anchor.is_windows();
239 let native = if windows {
240 native.replace('\\', "/")
241 } else {
242 native.to_owned()
243 };
244 let mut parts = native
245 .split('/')
246 .filter(|part| !part.is_empty())
247 .collect::<Vec<_>>();
248 let root = if windows && native.starts_with("//") {
249 if parts.len() < 2 {
250 return Err(KitError::invalid_input("native file path has no UNC share"));
251 }
252 let root = Self::parse_absolute(
253 "native file root",
254 PathBuf::from(format!(r"\\{}\{}", parts[0], parts[1])),
255 )?;
256 parts.drain(..2);
257 Some(root)
258 } else if native.as_bytes().get(1) == Some(&b':')
259 && native.as_bytes()[0].is_ascii_alphabetic()
260 {
261 if !windows || native.as_bytes().get(2) != Some(&b'/') {
262 return Err(KitError::invalid_input(
263 "native file path has an incompatible drive",
264 ));
265 }
266 let root = Self::parse_absolute("native file root", PathBuf::from(&native[..3]))?;
267 parts.remove(0);
268 Some(root)
269 } else if native.starts_with('/') {
270 if windows {
271 return Err(KitError::invalid_input(
272 "native file path requires an explicit Windows root",
273 ));
274 }
275 Some(Self::parse_absolute(
276 "native file root",
277 PathBuf::from("/"),
278 )?)
279 } else {
280 if native.starts_with('\\') {
281 return Err(KitError::invalid_input(
282 "native file path has an incompatible root",
283 ));
284 }
285 None
286 };
287 let mut components = match root {
288 Some(root) if root.anchor == self.anchor => Vec::new(),
289 Some(_) => {
290 return Err(KitError::invalid_input(
291 "native file path has a different root",
292 ));
293 }
294 None => self.components.clone(),
295 };
296 for part in parts {
297 match part {
298 "." => {}
299 ".." => {
300 if components.pop().is_none() {
301 return Err(KitError::invalid_input("native file path escapes its root"));
302 }
303 }
304 part => components.push(BackendHostPathSegment::new(
305 part.as_bytes(),
306 "native file path",
307 )?),
308 }
309 }
310 if !components.starts_with(&self.components) || components.len() == self.components.len() {
311 return Err(KitError::invalid_input(
312 "native file path does not identify a workspace file",
313 ));
314 }
315 let relative = components[self.components.len()..]
316 .iter()
317 .map(|part| {
318 std::str::from_utf8(&part.0)
319 .map_err(|_| KitError::invalid_input("native file path is not UTF-8"))
320 })
321 .collect::<Result<Vec<_>, _>>()?
322 .join("/");
323 Self::validate_portable_relative(Path::new(&relative))
324 }
325
326 fn same_flavor(&self, other: &Self) -> bool {
327 self.anchor.is_windows() == other.anchor.is_windows()
328 }
329
330 fn contains(&self, candidate: &Self) -> bool {
331 self.anchor == candidate.anchor && candidate.components.starts_with(&self.components)
332 }
333
334 fn overlaps(&self, other: &Self) -> bool {
335 self.contains(other) || other.contains(self)
336 }
337
338 fn as_path(&self) -> &Path {
339 &self.original
340 }
341
342 fn into_path_buf(self) -> PathBuf {
343 self.original
344 }
345}
346
347impl WorkspaceRoot {
348 /// Validates and constructs a declared backend-host root.
349 ///
350 /// # Errors
351 /// Returns InvalidInput for a nonabsolute path or invalid backend-host path components.
352 ///
353 /// See [`WorkspaceRoot`] for construction and inspection.
354 pub fn new(path: PathBuf, access: WorkspaceRootAccess) -> Result<Self, KitError> {
355 Ok(Self {
356 path: BackendHostPath::parse_absolute("workspace root", path)?,
357 access,
358 })
359 }
360
361 pub(crate) fn relative_native_file(&self, path: &Path) -> Result<PathBuf, KitError> {
362 self.path.relative_native_file(path)
363 }
364
365 /// Returns the normalized absolute backend-host path.
366 #[must_use]
367 ///
368 /// See [`WorkspaceRoot`] for construction and inspection.
369 pub fn path(&self) -> &Path {
370 self.path.as_path()
371 }
372
373 /// Returns the declared access level.
374 #[must_use]
375 ///
376 /// ```
377 /// use agents_kit::workspace::{WorkspaceRoot, WorkspaceRootAccess};
378 /// let value = WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?;
379 /// assert_eq!(value.access(), WorkspaceRootAccess::ReadOnly);
380 /// # Ok::<(), agents_kit::KitError>(())
381 /// ```
382 pub fn access(&self) -> WorkspaceRootAccess {
383 self.access
384 }
385}
386
387/// Filesystem confinement selected for every model-visible workspace action.
388#[derive(Clone, Copy, Debug, Eq, PartialEq)]
389pub enum WorkspaceSandboxMode {
390 /// Allows reads only through every installed action path.
391 ReadOnly,
392 /// Allows writes only beneath roots declared read-write.
393 WorkspaceWrite,
394 /// Allows the backend's unrestricted filesystem behavior.
395 FullAccess,
396}
397
398/// Arbitrary network access selected for model-visible workspace actions.
399#[derive(Clone, Copy, Debug, Eq, PartialEq)]
400pub enum WorkspaceNetworkAccess {
401 /// Forbids model-visible arbitrary network access.
402 Disabled,
403 /// Permits model-visible network access.
404 Enabled,
405}
406
407/// Complete execution policy for one workspace.
408#[derive(Clone, Copy, Debug, Eq, PartialEq)]
409///
410/// ```
411/// use agents_kit::workspace::{WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
412/// let value = WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled);
413/// assert_eq!(value.sandbox_mode(), WorkspaceSandboxMode::ReadOnly);
414/// # Ok::<(), agents_kit::KitError>(())
415/// ```
416pub struct WorkspaceExecutionPolicy {
417 sandbox_mode: WorkspaceSandboxMode,
418 network_access: WorkspaceNetworkAccess,
419}
420
421impl WorkspaceExecutionPolicy {
422 /// Constructs an explicit workspace execution policy.
423 #[must_use]
424 ///
425 /// See [`WorkspaceExecutionPolicy`] for construction and inspection.
426 pub fn new(sandbox_mode: WorkspaceSandboxMode, network_access: WorkspaceNetworkAccess) -> Self {
427 Self {
428 sandbox_mode,
429 network_access,
430 }
431 }
432
433 /// Returns the selected filesystem sandbox mode.
434 #[must_use]
435 ///
436 /// See [`WorkspaceExecutionPolicy`] for construction and inspection.
437 pub fn sandbox_mode(&self) -> WorkspaceSandboxMode {
438 self.sandbox_mode
439 }
440
441 /// Returns the selected network access policy.
442 #[must_use]
443 ///
444 /// ```
445 /// use agents_kit::workspace::{WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
446 /// let value = WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled);
447 /// assert_eq!(value.network_access(), WorkspaceNetworkAccess::Disabled);
448 /// # Ok::<(), agents_kit::KitError>(())
449 /// ```
450 pub fn network_access(&self) -> WorkspaceNetworkAccess {
451 self.network_access
452 }
453}
454
455#[derive(Clone, Debug, Eq, PartialEq)]
456/// Declares the complete portable context for a workspace.
457///
458/// ```
459/// use agents_kit::workspace::{
460/// WorkspaceContext, WorkspaceExecutionPolicy, WorkspaceNetworkAccess,
461/// WorkspaceRoot, WorkspaceRootAccess, WorkspaceSandboxMode,
462/// };
463///
464/// let cwd = std::env::current_dir().expect("current directory");
465/// let root = WorkspaceRoot::new(cwd.clone(), WorkspaceRootAccess::ReadWrite)?;
466/// let policy = WorkspaceExecutionPolicy::new(
467/// WorkspaceSandboxMode::FullAccess,
468/// WorkspaceNetworkAccess::Enabled,
469/// );
470/// let context = WorkspaceContext::new(cwd.clone(), vec![root], policy)?;
471/// assert_eq!(context.cwd(), cwd);
472/// # Ok::<(), agents_kit::KitError>(())
473/// ```
474pub struct WorkspaceContext {
475 cwd: BackendHostPath,
476 roots: Vec<WorkspaceRoot>,
477 execution_policy: WorkspaceExecutionPolicy,
478}
479
480impl WorkspaceContext {
481 /// Validates and constructs a complete passive workspace context.
482 ///
483 /// # Errors
484 /// Returns InvalidInput for invalid absolute paths, mixed path flavors, no roots, contradictory overlapping access, a cwd outside the roots, or root access inconsistent with the sandbox policy.
485 ///
486 /// ```
487 /// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
488 /// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
489 /// let context = WorkspaceContext::new("/workspace".into(),
490 /// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
491 /// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
492 /// assert_eq!(context.roots().len(), 1);
493 /// # Ok::<(), agents_kit::KitError>(())
494 /// ```
495 pub fn new(
496 cwd: PathBuf,
497 roots: Vec<WorkspaceRoot>,
498 execution_policy: WorkspaceExecutionPolicy,
499 ) -> Result<Self, KitError> {
500 let cwd = BackendHostPath::parse_absolute("workspace cwd", cwd)?;
501 if roots.is_empty() {
502 return Err(KitError::invalid_input(
503 "workspace context requires at least one root",
504 ));
505 }
506
507 let mut deduplicated = Vec::<WorkspaceRoot>::new();
508 for root in roots {
509 if !cwd.same_flavor(&root.path) {
510 return Err(KitError::invalid_input(
511 "workspace cwd and roots must use one backend-host path flavor",
512 ));
513 }
514 if let Some(existing) = deduplicated
515 .iter()
516 .find(|existing| existing.path == root.path)
517 {
518 if existing.access != root.access {
519 return Err(KitError::invalid_input(
520 "duplicate workspace roots must not contradict access",
521 ));
522 }
523 continue;
524 }
525 if deduplicated.iter().any(|existing| {
526 existing.path.overlaps(&root.path) && existing.access != root.access
527 }) {
528 return Err(KitError::invalid_input(
529 "overlapping workspace roots must not contradict access",
530 ));
531 }
532 deduplicated.push(root);
533 }
534
535 let containing = deduplicated
536 .iter()
537 .filter(|root| root.path.contains(&cwd))
538 .collect::<Vec<_>>();
539 if containing.is_empty() {
540 return Err(KitError::invalid_input(
541 "workspace cwd must be contained by a declared root",
542 ));
543 }
544
545 match execution_policy.sandbox_mode {
546 WorkspaceSandboxMode::ReadOnly
547 if deduplicated
548 .iter()
549 .any(|root| root.access != WorkspaceRootAccess::ReadOnly) =>
550 {
551 return Err(KitError::invalid_input(
552 "read-only workspace policy requires every root to be read-only",
553 ));
554 }
555 WorkspaceSandboxMode::WorkspaceWrite
556 if containing
557 .iter()
558 .any(|root| root.access != WorkspaceRootAccess::ReadWrite) =>
559 {
560 return Err(KitError::invalid_input(
561 "workspace-write policy requires the cwd root to be read-write",
562 ));
563 }
564 WorkspaceSandboxMode::FullAccess
565 if deduplicated
566 .iter()
567 .any(|root| root.access != WorkspaceRootAccess::ReadWrite) =>
568 {
569 return Err(KitError::invalid_input(
570 "full-access workspace policy requires every root to be read-write",
571 ));
572 }
573 _ => {}
574 }
575
576 Ok(Self {
577 cwd,
578 roots: deduplicated,
579 execution_policy,
580 })
581 }
582
583 /// Returns the absolute backend-host cwd.
584 ///
585 /// ```
586 /// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
587 /// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
588 /// let context = WorkspaceContext::new("/workspace".into(),
589 /// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
590 /// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
591 /// assert_eq!(context.cwd(), std::path::Path::new("/workspace"));
592 /// # Ok::<(), agents_kit::KitError>(())
593 /// ```
594 pub fn cwd(&self) -> &Path {
595 self.cwd.as_path()
596 }
597
598 /// Returns the deduplicated declared workspace roots.
599 #[must_use]
600 ///
601 /// ```
602 /// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
603 /// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
604 /// let context = WorkspaceContext::new("/workspace".into(),
605 /// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
606 /// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
607 /// assert_eq!(context.roots()[0].access(), WorkspaceRootAccess::ReadOnly);
608 /// # Ok::<(), agents_kit::KitError>(())
609 /// ```
610 pub fn roots(&self) -> &[WorkspaceRoot] {
611 &self.roots
612 }
613
614 /// Returns the complete execution policy.
615 #[must_use]
616 ///
617 /// ```
618 /// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
619 /// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
620 /// let context = WorkspaceContext::new("/workspace".into(),
621 /// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
622 /// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
623 /// assert_eq!(context.execution_policy().network_access(), WorkspaceNetworkAccess::Disabled);
624 /// # Ok::<(), agents_kit::KitError>(())
625 /// ```
626 pub fn execution_policy(&self) -> &WorkspaceExecutionPolicy {
627 &self.execution_policy
628 }
629
630 pub(crate) fn normalized_relative(path: &Path) -> Result<PathBuf, KitError> {
631 BackendHostPath::validate_portable_relative(path)
632 }
633
634 pub(crate) fn path_flavor(&self) -> BackendHostPathFlavor {
635 self.cwd.anchor.flavor()
636 }
637
638 pub(crate) fn resolve(&self, path: &Path) -> Result<(PathBuf, PathBuf), KitError> {
639 let relative = Self::normalized_relative(path)?;
640 let absolute = self.cwd.resolve(&relative)?;
641 if !self.roots.iter().any(|root| root.path.contains(&absolute)) {
642 return Err(KitError::invalid_input(
643 "workspace path must remain beneath a declared root",
644 ));
645 }
646 Ok((relative, absolute.into_path_buf()))
647 }
648}
649
650#[derive(Clone, Debug, Eq, PartialEq)]
651/// Supplies optional model, reasoning, and knowledge defaults for a workspace.
652///
653/// ```
654/// use agents_kit::workspace::WorkspaceDefaults;
655/// let value = WorkspaceDefaults::new(None, None, None)?;
656/// assert_eq!(value.model(), None);
657/// # Ok::<(), agents_kit::KitError>(())
658/// ```
659pub struct WorkspaceDefaults {
660 model: Option<ModelSelector>,
661 personality: Option<PersonalitySelector>,
662 reasoning_effort: Option<ReasoningEffort>,
663 knowledge_policy: Option<KnowledgePolicy>,
664}
665
666impl WorkspaceDefaults {
667 /// Constructs validated workspace defaults.
668 ///
669 /// # Errors
670 /// Returns InvalidInput for an invalid explicit knowledge scope selection. Model and personality availability is checked by the receiving backend.
671 ///
672 /// See [`WorkspaceDefaults`] for construction and inspection.
673 pub fn new(
674 model: Option<ModelSelector>,
675 reasoning_effort: Option<ReasoningEffort>,
676 knowledge_policy: Option<KnowledgePolicy>,
677 ) -> Result<Self, KitError> {
678 if let Some(policy) = &knowledge_policy {
679 policy.validate()?;
680 }
681 Ok(Self {
682 model,
683 personality: None,
684 reasoning_effort,
685 knowledge_policy,
686 })
687 }
688
689 /// Constructs defaults with every optional value absent.
690 ///
691 /// ```
692 /// use agents_kit::workspace::WorkspaceDefaults;
693 /// let value = WorkspaceDefaults::new(None, None, None)?;
694 /// let value = WorkspaceDefaults::empty();
695 /// assert_eq!(value.knowledge_policy(), None);
696 /// # Ok::<(), agents_kit::KitError>(())
697 /// ```
698 pub fn empty() -> Self {
699 Self {
700 model: None,
701 personality: None,
702 reasoning_effort: None,
703 knowledge_policy: None,
704 }
705 }
706
707 /// Returns the optional model default.
708 ///
709 /// See [`WorkspaceDefaults`] for construction and inspection.
710 pub fn model(&self) -> Option<&ModelSelector> {
711 self.model.as_ref()
712 }
713
714 /// Sets the optional personality default.
715 #[must_use]
716 ///
717 /// ```
718 /// use agents_kit::workspace::WorkspaceDefaults;
719 /// let value = WorkspaceDefaults::new(None, None, None)?;
720 /// let value = value.with_personality(agents_kit::model::PersonalitySelector::new("concise")?);
721 /// assert_eq!(value.personality().unwrap().as_str(), "concise");
722 /// # Ok::<(), agents_kit::KitError>(())
723 /// ```
724 pub fn with_personality(mut self, personality: PersonalitySelector) -> Self {
725 self.personality = Some(personality);
726 self
727 }
728
729 /// Returns the optional personality default.
730 #[must_use]
731 ///
732 /// ```
733 /// use agents_kit::workspace::WorkspaceDefaults;
734 /// let value = WorkspaceDefaults::new(None, None, None)?;
735 /// assert_eq!(value.personality(), None);
736 /// # Ok::<(), agents_kit::KitError>(())
737 /// ```
738 pub fn personality(&self) -> Option<&PersonalitySelector> {
739 self.personality.as_ref()
740 }
741
742 /// Returns the optional reasoning-effort default.
743 ///
744 /// ```
745 /// use agents_kit::workspace::WorkspaceDefaults;
746 /// let value = WorkspaceDefaults::new(None, None, None)?;
747 /// assert_eq!(value.reasoning_effort(), None);
748 /// # Ok::<(), agents_kit::KitError>(())
749 /// ```
750 pub fn reasoning_effort(&self) -> Option<&ReasoningEffort> {
751 self.reasoning_effort.as_ref()
752 }
753
754 /// Returns the optional managed-knowledge policy default.
755 ///
756 /// ```
757 /// use agents_kit::workspace::WorkspaceDefaults;
758 /// let value = WorkspaceDefaults::new(None, None, None)?;
759 /// assert_eq!(value.knowledge_policy(), None);
760 /// # Ok::<(), agents_kit::KitError>(())
761 /// ```
762 pub fn knowledge_policy(&self) -> Option<&KnowledgePolicy> {
763 self.knowledge_policy.as_ref()
764 }
765}
766
767#[derive(Clone, Debug, Eq, PartialEq)]
768/// Describes one workspace to create.
769///
770/// ```
771/// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
772/// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
773/// let context = WorkspaceContext::new("/workspace".into(),
774/// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
775/// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
776/// use agents_kit::workspace::{NewWorkspace, WorkspaceDefaults};
777/// let value = NewWorkspace::new("review", context, WorkspaceDefaults::empty())?;
778/// assert_eq!(value.name(), "review");
779/// # Ok::<(), agents_kit::KitError>(())
780/// ```
781pub struct NewWorkspace {
782 name: String,
783 context: WorkspaceContext,
784 defaults: WorkspaceDefaults,
785}
786
787impl NewWorkspace {
788 /// Validates and constructs a new-workspace request.
789 ///
790 /// # Errors
791 /// Returns InvalidInput for an empty or padded name.
792 ///
793 /// See [`NewWorkspace`] for construction and inspection.
794 pub fn new(
795 name: impl Into<String>,
796 context: WorkspaceContext,
797 defaults: WorkspaceDefaults,
798 ) -> Result<Self, KitError> {
799 let name = name.into();
800 Self::validate_name(&name)?;
801 Ok(Self {
802 name,
803 context,
804 defaults,
805 })
806 }
807
808 /// Returns the workspace name.
809 ///
810 /// See [`NewWorkspace`] for construction and inspection.
811 pub fn name(&self) -> &str {
812 &self.name
813 }
814
815 /// Returns the complete workspace context.
816 ///
817 /// ```
818 /// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
819 /// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
820 /// let context = WorkspaceContext::new("/workspace".into(),
821 /// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
822 /// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
823 /// use agents_kit::workspace::{NewWorkspace, WorkspaceDefaults};
824 /// let value = NewWorkspace::new("review", context, WorkspaceDefaults::empty())?;
825 /// assert_eq!(value.context().cwd(), std::path::Path::new("/workspace"));
826 /// # Ok::<(), agents_kit::KitError>(())
827 /// ```
828 pub fn context(&self) -> &WorkspaceContext {
829 &self.context
830 }
831
832 /// Returns the complete workspace defaults.
833 ///
834 /// ```
835 /// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
836 /// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
837 /// let context = WorkspaceContext::new("/workspace".into(),
838 /// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
839 /// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
840 /// use agents_kit::workspace::{NewWorkspace, WorkspaceDefaults};
841 /// let value = NewWorkspace::new("review", context, WorkspaceDefaults::empty())?;
842 /// assert_eq!(value.defaults().model(), None);
843 /// # Ok::<(), agents_kit::KitError>(())
844 /// ```
845 pub fn defaults(&self) -> &WorkspaceDefaults {
846 &self.defaults
847 }
848
849 fn validate_name(name: &str) -> Result<(), KitError> {
850 if name.is_empty() || name.trim() != name {
851 return Err(KitError::invalid_input(
852 "workspace name must be nonempty and unpadded",
853 ));
854 }
855 Ok(())
856 }
857}
858
859#[derive(Clone, Debug, Eq, PartialEq)]
860/// Contains one committed workspace snapshot.
861///
862/// ```
863/// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
864/// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
865/// let context = WorkspaceContext::new("/workspace".into(),
866/// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
867/// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
868/// use agents_kit::workspace::{WorkspaceInfo, WorkspaceDefaults};
869/// let value = WorkspaceInfo::new("workspace_00000000000000000000000000000000".parse()?, "review".into(), context, WorkspaceDefaults::empty(), 1)?;
870/// assert_eq!(value.id().as_str(), "workspace_00000000000000000000000000000000");
871/// # Ok::<(), agents_kit::KitError>(())
872/// ```
873pub struct WorkspaceInfo {
874 id: WorkspaceId,
875 name: String,
876 context: WorkspaceContext,
877 defaults: WorkspaceDefaults,
878 revision: u64,
879}
880
881impl WorkspaceInfo {
882 /// Validates and constructs one committed workspace snapshot.
883 ///
884 /// The name must be nonempty and unpadded, and `revision` must be
885 /// nonzero.
886 ///
887 /// # Errors
888 ///
889 /// Returns [`crate::error::ErrorKind::InvalidInput`] for an invalid name or
890 /// [`crate::error::ErrorKind::InvalidState`] for a zero revision.
891 ///
892 /// # Examples
893 ///
894 /// ```
895 /// use agents_kit::{
896 /// identity::WorkspaceId,
897 /// workspace::{
898 /// WorkspaceContext, WorkspaceDefaults, WorkspaceExecutionPolicy,
899 /// WorkspaceInfo, WorkspaceNetworkAccess, WorkspaceRoot,
900 /// WorkspaceRootAccess, WorkspaceSandboxMode,
901 /// },
902 /// };
903 ///
904 /// let cwd = std::env::current_dir()?;
905 /// let context = WorkspaceContext::new(
906 /// cwd.clone(),
907 /// vec![WorkspaceRoot::new(cwd, WorkspaceRootAccess::ReadWrite)?],
908 /// WorkspaceExecutionPolicy::new(
909 /// WorkspaceSandboxMode::FullAccess,
910 /// WorkspaceNetworkAccess::Enabled,
911 /// ),
912 /// )?;
913 /// let id: WorkspaceId =
914 /// "workspace_00000000000000000000000000000000".parse()?;
915 /// let info = WorkspaceInfo::new(
916 /// id,
917 /// "release".to_owned(),
918 /// context,
919 /// WorkspaceDefaults::empty(),
920 /// 1,
921 /// )?;
922 /// assert_eq!(info.revision(), 1);
923 /// # Ok::<(), Box<dyn std::error::Error>>(())
924 /// ```
925 pub fn new(
926 id: WorkspaceId,
927 name: String,
928 context: WorkspaceContext,
929 defaults: WorkspaceDefaults,
930 revision: u64,
931 ) -> Result<Self, KitError> {
932 NewWorkspace::validate_name(&name)?;
933 if revision == 0 {
934 return Err(KitError::invalid_state(
935 "workspace revision must be nonzero",
936 ));
937 }
938 Ok(Self {
939 id,
940 name,
941 context,
942 defaults,
943 revision,
944 })
945 }
946
947 /// Returns the portable workspace identity.
948 ///
949 /// See [`WorkspaceInfo`] for construction and inspection.
950 pub fn id(&self) -> &WorkspaceId {
951 &self.id
952 }
953
954 /// Returns the committed workspace name.
955 ///
956 /// ```
957 /// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
958 /// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
959 /// let context = WorkspaceContext::new("/workspace".into(),
960 /// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
961 /// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
962 /// use agents_kit::workspace::{WorkspaceInfo, WorkspaceDefaults};
963 /// let value = WorkspaceInfo::new("workspace_00000000000000000000000000000000".parse()?, "review".into(), context, WorkspaceDefaults::empty(), 1)?;
964 /// assert_eq!(value.name(), "review");
965 /// # Ok::<(), agents_kit::KitError>(())
966 /// ```
967 pub fn name(&self) -> &str {
968 &self.name
969 }
970
971 /// Returns the committed workspace context.
972 ///
973 /// ```
974 /// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
975 /// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
976 /// let context = WorkspaceContext::new("/workspace".into(),
977 /// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
978 /// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
979 /// use agents_kit::workspace::{WorkspaceInfo, WorkspaceDefaults};
980 /// let value = WorkspaceInfo::new("workspace_00000000000000000000000000000000".parse()?, "review".into(), context, WorkspaceDefaults::empty(), 1)?;
981 /// assert_eq!(value.context().cwd(), std::path::Path::new("/workspace"));
982 /// # Ok::<(), agents_kit::KitError>(())
983 /// ```
984 pub fn context(&self) -> &WorkspaceContext {
985 &self.context
986 }
987
988 /// Returns the committed workspace defaults.
989 ///
990 /// ```
991 /// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
992 /// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
993 /// let context = WorkspaceContext::new("/workspace".into(),
994 /// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
995 /// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
996 /// use agents_kit::workspace::{WorkspaceInfo, WorkspaceDefaults};
997 /// let value = WorkspaceInfo::new("workspace_00000000000000000000000000000000".parse()?, "review".into(), context, WorkspaceDefaults::empty(), 1)?;
998 /// assert_eq!(value.defaults().model(), None);
999 /// # Ok::<(), agents_kit::KitError>(())
1000 /// ```
1001 pub fn defaults(&self) -> &WorkspaceDefaults {
1002 &self.defaults
1003 }
1004
1005 /// Returns the nonzero committed revision.
1006 ///
1007 /// ```
1008 /// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
1009 /// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
1010 /// let context = WorkspaceContext::new("/workspace".into(),
1011 /// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
1012 /// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
1013 /// use agents_kit::workspace::{WorkspaceInfo, WorkspaceDefaults};
1014 /// let value = WorkspaceInfo::new("workspace_00000000000000000000000000000000".parse()?, "review".into(), context, WorkspaceDefaults::empty(), 1)?;
1015 /// assert_eq!(value.revision(), 1);
1016 /// # Ok::<(), agents_kit::KitError>(())
1017 /// ```
1018 pub fn revision(&self) -> u64 {
1019 self.revision
1020 }
1021}
1022
1023#[derive(Clone, Debug, Eq, PartialEq)]
1024/// Proposes a revision-checked complete workspace replacement.
1025///
1026/// ```
1027/// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
1028/// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
1029/// let context = WorkspaceContext::new("/workspace".into(),
1030/// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
1031/// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
1032/// use agents_kit::workspace::{UpdateWorkspaceRequest, WorkspaceDefaults};
1033/// let value = UpdateWorkspaceRequest::new("workspace_00000000000000000000000000000000".parse()?, 1, "review", context, WorkspaceDefaults::empty())?;
1034/// assert_eq!(value.expected_revision(), 1);
1035/// # Ok::<(), agents_kit::KitError>(())
1036/// ```
1037pub struct UpdateWorkspaceRequest {
1038 id: WorkspaceId,
1039 expected_revision: u64,
1040 name: String,
1041 context: WorkspaceContext,
1042 defaults: WorkspaceDefaults,
1043}
1044
1045impl UpdateWorkspaceRequest {
1046 /// Validates and constructs a workspace update request.
1047 ///
1048 /// # Errors
1049 /// Returns InvalidInput for an empty/padded name or zero expected revision. Constructing a request does not reserve or change that revision.
1050 ///
1051 /// See [`UpdateWorkspaceRequest`] for construction and inspection.
1052 pub fn new(
1053 id: WorkspaceId,
1054 expected_revision: u64,
1055 name: impl Into<String>,
1056 context: WorkspaceContext,
1057 defaults: WorkspaceDefaults,
1058 ) -> Result<Self, KitError> {
1059 let name = name.into();
1060 NewWorkspace::validate_name(&name)?;
1061 if expected_revision == 0 {
1062 return Err(KitError::invalid_input(
1063 "expected workspace revision must be nonzero",
1064 ));
1065 }
1066 Ok(Self {
1067 id,
1068 expected_revision,
1069 name,
1070 context,
1071 defaults,
1072 })
1073 }
1074
1075 /// Returns the workspace identity to update.
1076 ///
1077 /// ```
1078 /// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
1079 /// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
1080 /// let context = WorkspaceContext::new("/workspace".into(),
1081 /// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
1082 /// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
1083 /// use agents_kit::workspace::{UpdateWorkspaceRequest, WorkspaceDefaults};
1084 /// let value = UpdateWorkspaceRequest::new("workspace_00000000000000000000000000000000".parse()?, 1, "review", context, WorkspaceDefaults::empty())?;
1085 /// assert_eq!(value.id().as_str(), "workspace_00000000000000000000000000000000");
1086 /// # Ok::<(), agents_kit::KitError>(())
1087 /// ```
1088 pub fn id(&self) -> &WorkspaceId {
1089 &self.id
1090 }
1091
1092 /// Returns the revision that must still be current.
1093 ///
1094 /// See [`UpdateWorkspaceRequest`] for construction and inspection.
1095 pub fn expected_revision(&self) -> u64 {
1096 self.expected_revision
1097 }
1098
1099 /// Returns the proposed replacement name.
1100 ///
1101 /// ```
1102 /// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
1103 /// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
1104 /// let context = WorkspaceContext::new("/workspace".into(),
1105 /// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
1106 /// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
1107 /// use agents_kit::workspace::{UpdateWorkspaceRequest, WorkspaceDefaults};
1108 /// let value = UpdateWorkspaceRequest::new("workspace_00000000000000000000000000000000".parse()?, 1, "review", context, WorkspaceDefaults::empty())?;
1109 /// assert_eq!(value.name(), "review");
1110 /// # Ok::<(), agents_kit::KitError>(())
1111 /// ```
1112 pub fn name(&self) -> &str {
1113 &self.name
1114 }
1115
1116 /// Returns the proposed replacement context.
1117 ///
1118 /// ```
1119 /// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
1120 /// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
1121 /// let context = WorkspaceContext::new("/workspace".into(),
1122 /// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
1123 /// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
1124 /// use agents_kit::workspace::{UpdateWorkspaceRequest, WorkspaceDefaults};
1125 /// let value = UpdateWorkspaceRequest::new("workspace_00000000000000000000000000000000".parse()?, 1, "review", context, WorkspaceDefaults::empty())?;
1126 /// assert_eq!(value.context().cwd(), std::path::Path::new("/workspace"));
1127 /// # Ok::<(), agents_kit::KitError>(())
1128 /// ```
1129 pub fn context(&self) -> &WorkspaceContext {
1130 &self.context
1131 }
1132
1133 /// Returns the proposed replacement defaults.
1134 ///
1135 /// ```
1136 /// use agents_kit::workspace::{WorkspaceContext, WorkspaceRoot, WorkspaceRootAccess,
1137 /// WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
1138 /// let context = WorkspaceContext::new("/workspace".into(),
1139 /// vec![WorkspaceRoot::new("/workspace".into(), WorkspaceRootAccess::ReadOnly)?],
1140 /// WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
1141 /// use agents_kit::workspace::{UpdateWorkspaceRequest, WorkspaceDefaults};
1142 /// let value = UpdateWorkspaceRequest::new("workspace_00000000000000000000000000000000".parse()?, 1, "review", context, WorkspaceDefaults::empty())?;
1143 /// assert_eq!(value.defaults().model(), None);
1144 /// # Ok::<(), agents_kit::KitError>(())
1145 /// ```
1146 pub fn defaults(&self) -> &WorkspaceDefaults {
1147 &self.defaults
1148 }
1149}
1150
1151#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1152/// Reports advisory readiness for a workspace update.
1153pub enum WorkspaceUpdateReadiness {
1154 /// The current snapshot has no known blocker.
1155 Ready,
1156 /// Current admitted or descendant work blocks mutation.
1157 Busy,
1158}
1159
1160#[derive(Clone, Debug, Eq, PartialEq)]
1161/// Requests revision-checked deletion of one quiescent workspace.
1162///
1163/// ```
1164/// use agents_kit::workspace::DeleteWorkspaceRequest;
1165/// let value = DeleteWorkspaceRequest::new("workspace_00000000000000000000000000000000".parse()?, 1)?;
1166/// assert_eq!(value.expected_revision(), 1);
1167/// # Ok::<(), agents_kit::KitError>(())
1168/// ```
1169pub struct DeleteWorkspaceRequest {
1170 id: WorkspaceId,
1171 expected_revision: u64,
1172}
1173
1174impl DeleteWorkspaceRequest {
1175 /// Validates and constructs a workspace deletion request.
1176 ///
1177 /// # Errors
1178 /// Returns InvalidInput for a zero expected revision. Actual deletion also requires current revision and quiescence.
1179 ///
1180 /// See [`DeleteWorkspaceRequest`] for construction and inspection.
1181 pub fn new(id: WorkspaceId, expected_revision: u64) -> Result<Self, KitError> {
1182 if expected_revision == 0 {
1183 return Err(KitError::invalid_input(
1184 "expected workspace revision must be nonzero",
1185 ));
1186 }
1187 Ok(Self {
1188 id,
1189 expected_revision,
1190 })
1191 }
1192
1193 /// Returns the workspace identity to delete.
1194 ///
1195 /// ```
1196 /// use agents_kit::workspace::DeleteWorkspaceRequest;
1197 /// let value = DeleteWorkspaceRequest::new("workspace_00000000000000000000000000000000".parse()?, 1)?;
1198 /// assert_eq!(value.id().as_str(), "workspace_00000000000000000000000000000000");
1199 /// # Ok::<(), agents_kit::KitError>(())
1200 /// ```
1201 pub fn id(&self) -> &WorkspaceId {
1202 &self.id
1203 }
1204
1205 /// Returns the revision that must still be current.
1206 ///
1207 /// See [`DeleteWorkspaceRequest`] for construction and inspection.
1208 pub fn expected_revision(&self) -> u64 {
1209 self.expected_revision
1210 }
1211}
1212
1213/// Public implementation contract for one revision-bound portable workspace.
1214///
1215/// A caller Agent returns its own concrete workspace through
1216/// [`AgentBackend::Workspace`](crate::AgentBackend::Workspace). Each workspace owns
1217/// its associated [`Conversation`](Self::Conversation); runtime composition may use
1218/// an exhaustive caller enum hierarchy. A stable workspace ID does not refresh a
1219/// stale runtime revision. Closing invalidates runtime descendants while preserving
1220/// quiescent durable records for direct-ID reopen.
1221///
1222/// See [`crate::AgentBackend::new_workspace`] for creation and
1223/// [`Self::new_conversation`] for the child lifecycle. The [executable caller hierarchy](https://github.com/jecsand838/ai-kit/blob/main/agents-kit/tests/mock_hierarchy/mod.rs)
1224/// implements these contracts directly without a provider, process or database.
1225pub trait WorkspaceBackend: Send + Sync + 'static {
1226 /// Concrete conversation implementation produced by this workspace.
1227 type Conversation: crate::ConversationBackend;
1228
1229 /// Returns the durable portable workspace identity.
1230 ///
1231 /// This is the durable workspace identity. A handle also carries a runtime
1232 /// revision binding, so a stable id does not make an old handle current.
1233 ///
1234 /// See [`Self::new_conversation`] for the complete child lifecycle and [`crate::AgentBackend::new_workspace`] for workspace creation.
1235 fn id(&self) -> WorkspaceId;
1236
1237 /// Reads the current committed workspace information.
1238 ///
1239 /// The result contains the complete committed context/defaults and revision.
1240 /// It does not refresh this handle after a concurrent revision change.
1241 ///
1242 /// # Errors
1243 ///
1244 /// Returns [`KitError`] for a closed parent/workspace, stale revision,
1245 /// state-read failure, inconsistent durable state, or deadline expiry.
1246 ///
1247 /// See [`Self::new_conversation`] for the complete child lifecycle and [`crate::AgentBackend::new_workspace`] for workspace creation.
1248 fn info(&self) -> impl Future<Output = Result<WorkspaceInfo, KitError>> + Send + '_;
1249
1250 /// Creates and publishes one conversation under this workspace.
1251 ///
1252 /// The returned associated child is published only after resolved defaults,
1253 /// initial knowledge, backend attachment, and durable ownership agree.
1254 ///
1255 /// # Errors
1256 ///
1257 /// Returns [`KitError`] for closed or stale runtime, busy admission, invalid
1258 /// input or unsupported policy/integrations, and dependency/deadline failures.
1259 /// Accepted native setup remains durably attached or recovery-owned on failure.
1260 ///
1261 /// # Examples
1262 ///
1263 /// This local Custom agent uses in-memory Lance and sends no network requests.
1264 ///
1265 /// ```standalone_crate
1266 /// #![recursion_limit = "256"] // Nested concrete async futures need this on Linux.
1267 /// # use std::{num::NonZeroUsize, time::Duration};
1268 /// # use agents_kit::{AgentBackend, AgentBuilder, AgentStorage, WorkspaceBackend};
1269 /// # use agents_kit::config::{AgentConfig, CustomAgentConfig, CustomLoopLimits, CustomToolApproval};
1270 /// # use agents_kit::{model::ModelSelector, tool::ToolSelection};
1271 /// # use providers_kit::{Provider, ProviderConfig, config::{OllamaConfig, ProviderKind, TransportConfig}};
1272 /// # use storage_kit::{StateStore, state::config::{LanceStateStoreConfig, StateStoreConfig}};
1273 /// # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
1274 /// # runtime.block_on(async {
1275 /// # let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
1276 /// # let custom = CustomAgentConfig::new(ModelSelector::new("exact-model")?, ToolSelection::None,
1277 /// # CustomLoopLimits::new(4, 0, 1)?, CustomToolApproval::Deny)?;
1278 /// # let config = AgentConfig::new(custom.into(), Duration::from_secs(10), NonZeroUsize::MIN)?;
1279 /// # let provider = Provider::from_config(ProviderConfig::new(
1280 /// # ProviderKind::Ollama(OllamaConfig::local()), TransportConfig::default()))?;
1281 /// # let agent = AgentBuilder::create(config, AgentStorage::new(store)).with_provider(provider).build().await?;
1282 /// # let outcome = async {
1283 /// # use agents_kit::workspace::{NewWorkspace, WorkspaceContext, WorkspaceDefaults,
1284 /// # WorkspaceRoot, WorkspaceRootAccess, WorkspaceExecutionPolicy, WorkspaceSandboxMode, WorkspaceNetworkAccess};
1285 /// # let cwd = std::env::current_dir()?;
1286 /// # let context = WorkspaceContext::new(cwd.clone(),
1287 /// # vec![WorkspaceRoot::new(cwd, WorkspaceRootAccess::ReadOnly)?],
1288 /// # WorkspaceExecutionPolicy::new(WorkspaceSandboxMode::ReadOnly, WorkspaceNetworkAccess::Disabled))?;
1289 /// # let workspace = agent.new_workspace(NewWorkspace::new("review", context, WorkspaceDefaults::empty())?).await?;
1290 /// use agents_kit::ConversationBackend;
1291 /// use agents_kit::conversation::NewConversation;
1292 /// let workspace_id = workspace.id();
1293 /// let result = async {
1294 /// let conversation = workspace.new_conversation(NewConversation::new(
1295 /// Some("Review".into()), None, Vec::new(),
1296 /// )?).await?;
1297 /// let id = conversation.id();
1298 /// let info = conversation.info().await?;
1299 /// assert_eq!(info.id(), &id);
1300 /// assert_eq!(info.workspace_id(), &workspace_id);
1301 /// assert_eq!(info.title(), Some("Review"));
1302 /// let inventory = workspace.list_conversations().await?;
1303 /// assert_eq!(inventory.len(), 1);
1304 /// assert_eq!(inventory[0].id(), &id);
1305 /// assert_eq!(workspace.conversation_info(id.clone()).await?.id(), &id);
1306 /// assert!(workspace.list_tools().await?.is_empty());
1307 /// Ok::<_, agents_kit::KitError>(id)
1308 /// }.await;
1309 /// // Parent close settles children and preserves quiescent records for reopen.
1310 /// let close = workspace.close().await;
1311 /// let conversation_id = match (result, close) {
1312 /// (Err(primary), Err(cleanup)) => Err(primary.with_supplementary(cleanup)),
1313 /// (Err(error), _) | (_, Err(error)) => Err(error),
1314 /// (Ok(id), Ok(())) => Ok(id),
1315 /// }?;
1316 /// let workspace = agent.get_workspace(workspace_id.clone()).await?;
1317 /// let result = async {
1318 /// assert_eq!(workspace.id(), workspace_id);
1319 /// assert_eq!(workspace.info().await?.id(), &workspace_id);
1320 /// let conversation = workspace.get_conversation(conversation_id.clone()).await?;
1321 /// let observed = conversation.info().await;
1322 /// // Explicit child close is terminal; unlike parent close, it ends reopenability.
1323 /// let close = conversation.close().await;
1324 /// let info = match (observed, close) {
1325 /// (Err(primary), Err(cleanup)) => Err(primary.with_supplementary(cleanup)),
1326 /// (Err(error), _) | (_, Err(error)) => Err(error),
1327 /// (Ok(info), Ok(())) => Ok(info),
1328 /// }?;
1329 /// assert_eq!(info.id(), &conversation_id);
1330 /// assert_eq!(info.workspace_id(), &workspace_id);
1331 /// assert_eq!(info.title(), Some("Review"));
1332 /// assert!(workspace.list_conversations().await?.is_empty());
1333 /// Ok::<(), agents_kit::KitError>(())
1334 /// }.await;
1335 /// let close = workspace.close().await;
1336 /// match (result, close) {
1337 /// (Err(primary), Err(cleanup)) => Err(primary.with_supplementary(cleanup)),
1338 /// (Err(error), _) | (_, Err(error)) => Err(error),
1339 /// (Ok(()), Ok(())) => Ok(()),
1340 /// }?;
1341 /// # Ok::<(), agents_kit::KitError>(())
1342 /// # }.await;
1343 /// # let cleanup = agent.shutdown().await;
1344 /// # match (outcome, cleanup) {
1345 /// # (Err(primary), Err(cleanup)) => Err(primary.with_supplementary(cleanup)),
1346 /// # (Err(error), _) | (_, Err(error)) => Err(error),
1347 /// # (Ok(()), Ok(())) => Ok(()),
1348 /// # }?;
1349 /// # Ok::<(), Box<dyn std::error::Error>>(())
1350 /// # })?;
1351 /// # Ok::<(), Box<dyn std::error::Error>>(())
1352 /// ```
1353 fn new_conversation(
1354 &self,
1355 request: NewConversation,
1356 ) -> impl Future<Output = Result<Self::Conversation, KitError>> + Send + '_;
1357
1358 /// Lists live or reopenable conversations in deterministic identity order.
1359 ///
1360 /// Inventory comes from ownership-valid durable records; it is not limited
1361 /// to handles currently open in this process. Terminally closed conversations
1362 /// are excluded. Listing does not claim live handles.
1363 ///
1364 /// # Errors
1365 ///
1366 /// Returns [`KitError`] for closed or stale runtime, state/dependency read
1367 /// failures, inconsistent inventory, or deadline expiry.
1368 ///
1369 /// See [`Self::new_conversation`] for the complete child lifecycle and [`crate::AgentBackend::new_workspace`] for workspace creation.
1370 fn list_conversations(
1371 &self,
1372 ) -> impl Future<Output = Result<Vec<ConversationInfo>, KitError>> + Send + '_;
1373
1374 /// Reads committed information for one conversation identity.
1375 ///
1376 /// This reads committed metadata without claiming a live child handle.
1377 /// Missing and foreign identities do not reveal a foreign owner.
1378 ///
1379 /// # Errors
1380 ///
1381 /// Returns [`crate::error::ErrorKind::NotFound`] for an unavailable or foreign
1382 /// conversation, or the owning closed/stale runtime, state-read, integrity, or
1383 /// deadline error.
1384 ///
1385 /// See [`Self::new_conversation`] for the complete child lifecycle and [`crate::AgentBackend::new_workspace`] for workspace creation.
1386 fn conversation_info(
1387 &self,
1388 id: ConversationId,
1389 ) -> impl Future<Output = Result<ConversationInfo, KitError>> + Send + '_;
1390
1391 /// Opens the current live handle for one conversation identity.
1392 ///
1393 /// A current conversation has at most one live public handle; repeated lookup
1394 /// is busy until its claim is released. Reopen verifies the current workspace
1395 /// revision, defaults, attachments, tools, knowledge, and recovery state. It
1396 /// does not create a replacement session for a missing identity.
1397 ///
1398 /// # Errors
1399 ///
1400 /// Returns [`KitError`] for a missing/foreign or terminal conversation, an
1401 /// existing live claim, closed or stale runtime, incompatible reopen state,
1402 /// or dependency/deadline failure.
1403 ///
1404 /// See [`Self::new_conversation`] for the complete child lifecycle and [`crate::AgentBackend::new_workspace`] for workspace creation.
1405 fn get_conversation(
1406 &self,
1407 id: ConversationId,
1408 ) -> impl Future<Output = Result<Self::Conversation, KitError>> + Send + '_;
1409
1410 /// Lists agents-kit-managed tools installed for this workspace.
1411 ///
1412 /// Returns the complete source-neutral catalog the Agent owns and can expose
1413 /// in this workspace, subject to its execution policy. An unavailable source
1414 /// must not be silently omitted from a purportedly complete result.
1415 ///
1416 /// # Errors
1417 ///
1418 /// Returns [`KitError`] for closed or stale runtime, unsupported or incomplete
1419 /// catalog exposure, malformed source data, or dependency/deadline failure.
1420 ///
1421 /// See [`Self::new_conversation`] for the complete child lifecycle and [`crate::AgentBackend::new_workspace`] for workspace creation.
1422 fn list_tools(&self) -> impl Future<Output = Result<Vec<ToolInfo>, KitError>> + Send + '_;
1423
1424 /// Atomically replaces managed knowledge from this workspace.
1425 ///
1426 /// An omitted scope selects this Workspace. Explicit Agent or Database scope
1427 /// is validated under the shared knowledge rules; Conversation scope is not
1428 /// accepted here. Empty sources are a no-op after scope validation, not deletion.
1429 /// Updating workspace context/defaults does not move sources. See [`crate::knowledge`]
1430 /// for local ingestion and policy examples.
1431 ///
1432 /// # Errors
1433 ///
1434 /// Returns [`KitError`] for invalid sources or scope, missing/incompatible
1435 /// knowledge integrations, closed or stale runtime, busy admission, or
1436 /// dependency/deadline failure. Unsupported semantics fail explicitly.
1437 ///
1438 /// Caller implementations need no service. See [`IngestKnowledgeRequest`] for request construction and the [executable caller hierarchy](https://github.com/jecsand838/ai-kit/blob/main/agents-kit/tests/mock_hierarchy/mod.rs) for direct-trait ingestion.
1439 fn ingest_knowledge(
1440 &self,
1441 request: IngestKnowledgeRequest,
1442 ) -> impl Future<Output = Result<(), KitError>> + Send + '_;
1443
1444 /// Consumes the implementation, seals its runtime, and settles accepted work.
1445 ///
1446 /// Close cascade-invalidates live descendants after accepted work and
1447 /// interactions settle. Quiescent durable conversations, notifications, and
1448 /// knowledge remain for reopen. This does not terminally close conversations
1449 /// or acknowledge caller events. Drop alone provides no async cleanup guarantee.
1450 ///
1451 /// # Errors
1452 ///
1453 /// Returns [`KitError`] when an owned cleanup responsibility fails or cannot
1454 /// settle within the lifecycle deadline. The consumed handle is not restored;
1455 /// durable recovery owns later reconciliation and supplementary failures do
1456 /// not replace the primary error.
1457 ///
1458 /// See [`Self::new_conversation`] for the complete child lifecycle and [`crate::AgentBackend::new_workspace`] for workspace creation.
1459 fn close(self) -> impl Future<Output = Result<(), KitError>> + Send
1460 where
1461 Self: Sized;
1462}
1463
1464/// Owns one live workspace runtime and its conversation handles.
1465///
1466/// Obtain this unique, non-Clone handle from [`crate::AgentBackend`]. Import
1467/// [`WorkspaceBackend`] for behavior. A committed workspace update invalidates
1468/// prior-revision handles; reopen by typed id instead of expecting in-place
1469/// refresh. Explicit close settles the runtime while preserving quiescent
1470/// durable children for later reopen.
1471///
1472/// See [`WorkspaceBackend`] and [`WorkspaceBackend::new_conversation`] for usage.
1473pub struct Workspace<
1474 S: StateStoreBackend = storage_kit::StateStore,
1475 D: DatabaseBackend = storage_kit::Database,
1476 P: ProviderBackend = providers_kit::Provider,
1477 T: ToolsetBackend = tools_kit::Toolset,
1478> {
1479 inner: WorkspaceInner<S, D, P, T>,
1480}
1481
1482enum WorkspaceInner<S: StateStoreBackend, D: DatabaseBackend, P: ProviderBackend, T: ToolsetBackend>
1483{
1484 Codex(codex::workspace::CodexWorkspace<S, D, T>),
1485 OpenCode(opencode::workspace::OpenCodeWorkspace<S, D>),
1486 Custom(custom::CustomWorkspace<S, D, P, T>),
1487}
1488
1489impl<S, D, P, T> Workspace<S, D, P, T>
1490where
1491 S: StateStoreBackend,
1492 D: DatabaseBackend,
1493 P: ProviderBackend,
1494 T: ToolsetBackend,
1495{
1496 pub(crate) fn codex(backend: codex::workspace::CodexWorkspace<S, D, T>) -> Self {
1497 Self {
1498 inner: WorkspaceInner::Codex(backend),
1499 }
1500 }
1501
1502 pub(crate) fn opencode(backend: opencode::workspace::OpenCodeWorkspace<S, D>) -> Self {
1503 Self {
1504 inner: WorkspaceInner::OpenCode(backend),
1505 }
1506 }
1507
1508 pub(crate) fn custom(backend: custom::CustomWorkspace<S, D, P, T>) -> Self {
1509 Self {
1510 inner: WorkspaceInner::Custom(backend),
1511 }
1512 }
1513}
1514
1515impl<S, D, P, T> WorkspaceBackend for WorkspaceInner<S, D, P, T>
1516where
1517 S: StateStoreBackend,
1518 D: DatabaseBackend,
1519 P: ProviderBackend,
1520 T: ToolsetBackend,
1521{
1522 type Conversation = Conversation<S, D, P, T>;
1523
1524 fn id(&self) -> WorkspaceId {
1525 match self {
1526 WorkspaceInner::Codex(backend) => backend.id(),
1527 WorkspaceInner::OpenCode(backend) => backend.id(),
1528 WorkspaceInner::Custom(backend) => backend.id(),
1529 }
1530 }
1531
1532 async fn info(&self) -> Result<WorkspaceInfo, KitError> {
1533 match self {
1534 WorkspaceInner::Codex(backend) => backend.info().await,
1535 WorkspaceInner::OpenCode(backend) => backend.info().await,
1536 WorkspaceInner::Custom(backend) => backend.info().await,
1537 }
1538 }
1539
1540 async fn new_conversation(
1541 &self,
1542 request: NewConversation,
1543 ) -> Result<Conversation<S, D, P, T>, KitError> {
1544 match self {
1545 WorkspaceInner::Codex(backend) => backend
1546 .new_conversation(request)
1547 .await
1548 .map(Conversation::codex),
1549 WorkspaceInner::OpenCode(backend) => backend
1550 .new_conversation(request)
1551 .await
1552 .map(Conversation::opencode),
1553 WorkspaceInner::Custom(backend) => backend
1554 .new_conversation(request)
1555 .await
1556 .map(Conversation::custom),
1557 }
1558 }
1559
1560 async fn list_conversations(&self) -> Result<Vec<ConversationInfo>, KitError> {
1561 match self {
1562 WorkspaceInner::Codex(backend) => backend.list_conversations().await,
1563 WorkspaceInner::OpenCode(backend) => backend.list_conversations().await,
1564 WorkspaceInner::Custom(backend) => backend.list_conversations().await,
1565 }
1566 }
1567
1568 async fn conversation_info(&self, id: ConversationId) -> Result<ConversationInfo, KitError> {
1569 match self {
1570 WorkspaceInner::Codex(backend) => backend.conversation_info(id).await,
1571 WorkspaceInner::OpenCode(backend) => backend.conversation_info(id).await,
1572 WorkspaceInner::Custom(backend) => backend.conversation_info(id).await,
1573 }
1574 }
1575
1576 async fn get_conversation(
1577 &self,
1578 id: ConversationId,
1579 ) -> Result<Conversation<S, D, P, T>, KitError> {
1580 match self {
1581 WorkspaceInner::Codex(backend) => {
1582 backend.get_conversation(id).await.map(Conversation::codex)
1583 }
1584 WorkspaceInner::OpenCode(backend) => backend
1585 .get_conversation(id)
1586 .await
1587 .map(Conversation::opencode),
1588 WorkspaceInner::Custom(backend) => {
1589 backend.get_conversation(id).await.map(Conversation::custom)
1590 }
1591 }
1592 }
1593
1594 async fn list_tools(&self) -> Result<Vec<ToolInfo>, KitError> {
1595 match self {
1596 WorkspaceInner::Codex(backend) => backend.list_tools().await,
1597 WorkspaceInner::OpenCode(backend) => backend.list_tools().await,
1598 WorkspaceInner::Custom(backend) => backend.list_tools().await,
1599 }
1600 }
1601
1602 async fn ingest_knowledge(&self, request: IngestKnowledgeRequest) -> Result<(), KitError> {
1603 match self {
1604 WorkspaceInner::Codex(backend) => backend.ingest_knowledge(request).await,
1605 WorkspaceInner::OpenCode(backend) => backend.ingest_knowledge(request).await,
1606 WorkspaceInner::Custom(backend) => backend.ingest_knowledge(request).await,
1607 }
1608 }
1609
1610 async fn close(self) -> Result<(), KitError> {
1611 match self {
1612 WorkspaceInner::Codex(backend) => backend.close().await,
1613 WorkspaceInner::OpenCode(backend) => backend.close().await,
1614 WorkspaceInner::Custom(backend) => backend.close().await,
1615 }
1616 }
1617}
1618
1619impl<S, D, P, T> WorkspaceBackend for Workspace<S, D, P, T>
1620where
1621 S: StateStoreBackend,
1622 D: DatabaseBackend,
1623 P: ProviderBackend,
1624 T: ToolsetBackend,
1625{
1626 type Conversation = Conversation<S, D, P, T>;
1627
1628 fn id(&self) -> WorkspaceId {
1629 self.inner.id()
1630 }
1631
1632 async fn info(&self) -> Result<WorkspaceInfo, KitError> {
1633 self.inner.info().await.map_err(|error| {
1634 error
1635 .at_operation(crate::error::Operation::WorkspaceInfo)
1636 .in_phase(crate::error::ErrorPhase::StateRead)
1637 })
1638 }
1639
1640 async fn new_conversation(
1641 &self,
1642 request: NewConversation,
1643 ) -> Result<Conversation<S, D, P, T>, KitError> {
1644 self.inner.new_conversation(request).await.map_err(|error| {
1645 error
1646 .at_operation(crate::error::Operation::NewConversation)
1647 .in_phase(crate::error::ErrorPhase::Admission)
1648 })
1649 }
1650
1651 async fn list_conversations(&self) -> Result<Vec<ConversationInfo>, KitError> {
1652 self.inner.list_conversations().await.map_err(|error| {
1653 error
1654 .at_operation(crate::error::Operation::ListConversations)
1655 .in_phase(crate::error::ErrorPhase::StateRead)
1656 })
1657 }
1658
1659 async fn conversation_info(&self, id: ConversationId) -> Result<ConversationInfo, KitError> {
1660 self.inner.conversation_info(id).await.map_err(|error| {
1661 error
1662 .at_operation(crate::error::Operation::ConversationInfo)
1663 .in_phase(crate::error::ErrorPhase::StateRead)
1664 })
1665 }
1666
1667 async fn get_conversation(
1668 &self,
1669 id: ConversationId,
1670 ) -> Result<Conversation<S, D, P, T>, KitError> {
1671 self.inner.get_conversation(id).await.map_err(|error| {
1672 error
1673 .at_operation(crate::error::Operation::GetConversation)
1674 .in_phase(crate::error::ErrorPhase::Admission)
1675 })
1676 }
1677
1678 async fn list_tools(&self) -> Result<Vec<ToolInfo>, KitError> {
1679 self.inner.list_tools().await.map_err(|error| {
1680 error
1681 .at_operation(crate::error::Operation::ListTools)
1682 .in_phase(crate::error::ErrorPhase::InventoryVerification)
1683 })
1684 }
1685
1686 async fn ingest_knowledge(&self, request: IngestKnowledgeRequest) -> Result<(), KitError> {
1687 self.inner.ingest_knowledge(request).await.map_err(|error| {
1688 error
1689 .at_operation(crate::error::Operation::IngestKnowledge)
1690 .in_phase(crate::error::ErrorPhase::KnowledgeWrite)
1691 })
1692 }
1693
1694 async fn close(self) -> Result<(), KitError> {
1695 self.inner.close().await.map_err(|error| {
1696 error
1697 .at_operation(crate::error::Operation::CloseWorkspace)
1698 .in_phase(crate::error::ErrorPhase::Lifecycle)
1699 })
1700 }
1701}