a3s-code-core 8.0.3

A3S Code Core - Embeddable AI agent library with tool execution
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! Typed error enum for A3S Code Core
//!
//! Provides categorized errors that SDK consumers can match on programmatically,
//! instead of receiving opaque `anyhow::Error` strings.
//!
//! ## Migration Strategy
//!
//! The `Internal` variant wraps `anyhow::Error` via `#[from]`, allowing
//! gradual migration: call sites that haven't been updated yet auto-convert
//! through `?`. Over time, each call site replaces `anyhow::anyhow!(...)`
//! with a specific variant like `CodeError::Config(...)`.

use thiserror::Error;

/// Async resource whose initialization is part of building a session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionBuildResource {
    Capability,
    MemoryStore,
    SessionStore,
    Queue,
    Mcp,
    RlTrajectory,
    WorkspaceRetrieval,
}

impl std::fmt::Display for SessionBuildResource {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            Self::Capability => "capability runtime",
            Self::MemoryStore => "memory store",
            Self::SessionStore => "session store",
            Self::Queue => "session queue",
            Self::Mcp => "MCP",
            Self::RlTrajectory => "RL trajectory recorder",
            Self::WorkspaceRetrieval => "workspace retrieval",
        })
    }
}

/// Crate-wide result type alias.
pub type Result<T> = std::result::Result<T, CodeError>;

/// Categorized error type for A3S Code Core.
///
/// SDK bindings (Python/Node) can match on the variant to expose typed
/// exceptions (e.g., `CodeConfigError`, `CodeLlmError`).
#[derive(Debug, Error)]
pub enum CodeError {
    /// Configuration loading or parsing error
    #[error("Config error: {0}")]
    Config(String),

    /// LLM provider communication error
    #[error("LLM error: {0}")]
    Llm(String),

    /// Tool execution error
    #[error("Tool error: {tool}: {message}")]
    Tool { tool: String, message: String },

    /// Session management error
    #[error("Session error: {0}")]
    Session(String),

    /// A session option is missing, malformed, or conflicts with another option.
    #[error("Invalid session configuration for '{field}': {message}")]
    SessionConfiguration {
        field: &'static str,
        message: String,
    },

    /// A session resource could not be initialized.
    #[error("Failed to initialize {resource}: {message}")]
    SessionInitialization {
        resource: SessionBuildResource,
        message: String,
    },

    /// The synchronous compatibility factory was asked to initialize an
    /// async-only resource. Call `Agent::session_builder(...).build().await`.
    #[error(
        "{resource} requires asynchronous session construction; use Agent::session_builder(...).build().await"
    )]
    AsyncSessionBuildRequired { resource: SessionBuildResource },

    /// Session has been closed; further operations are rejected.
    ///
    /// Returned by `send`/`stream` (and their variants) after
    /// [`AgentSession::close`](crate::agent_api::AgentSession::close)
    /// — or [`Agent::close`](crate::agent_api::Agent::close) — has been called.
    #[error("Session '{session_id}' is closed")]
    SessionClosed { session_id: String },

    /// Another conversation operation is already active on this session.
    ///
    /// Sessions serialize conversation state, so callers must wait for the
    /// active operation's returned future or stream handle to finish before
    /// starting another one.
    #[error("Session '{session_id}' already has an active operation")]
    SessionBusy { session_id: String },

    /// Global task admission was cancelled before an execution slot opened.
    #[error("Task admission for session '{session_id}' was cancelled")]
    TaskAdmissionCancelled { session_id: String },

    /// The owning agent's global task scheduler no longer accepts work.
    #[error("Task scheduler is closed")]
    TaskSchedulerClosed,

    /// A host replayed a run id with different immutable session or input
    /// identity. The existing run is preserved and no work is started.
    #[error("Run '{run_id}' is already bound to different immutable input")]
    RunIdentityConflict { run_id: String },

    /// A host-supplied [`BudgetGuard`](crate::budget::BudgetGuard) denied
    /// the operation. The session is not closed — callers can re-try
    /// after the host has re-allocated budget.
    #[error("Budget exhausted on '{resource}': {reason}")]
    BudgetExhausted { resource: String, reason: String },

    /// Security subsystem error
    #[error("Security error: {0}")]
    Security(String),

    /// Context provider or context store error
    #[error("Context error: {0}")]
    Context(String),

    /// MCP (Model Context Protocol) error
    #[error("MCP error: {0}")]
    Mcp(String),

    /// Queue or lane error
    #[error("Queue error: {0}")]
    Queue(String),

    /// Atomic Session capability publication or Run admission failure.
    #[error("Capability runtime error: {0}")]
    Capability(#[from] crate::capability::CapabilityRuntimeError),

    /// A3S Flow definition, replay, store, or runtime failure.
    #[error("Flow error: {0}")]
    Flow(#[from] a3s_flow::FlowError),

    /// I/O error
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    /// JSON serialization/deserialization error
    #[error("Serialization error: {0}")]
    Serialization(#[from] serde_json::Error),

    /// Catch-all for errors not yet migrated to a specific variant.
    ///
    /// The `#[from] anyhow::Error` conversion enables gradual migration:
    /// any function returning `anyhow::Result` can be called with `?` from
    /// a function returning `crate::error::Result` without changes.
    #[error("{0:#}")]
    Internal(#[from] anyhow::Error),
}

impl CodeError {
    /// Stable machine-readable code for SDK and service boundaries.
    pub const fn code(&self) -> &'static str {
        match self {
            Self::Config(_) => "CONFIG_ERROR",
            Self::Llm(_) => "LLM_ERROR",
            Self::Tool { .. } => "TOOL_ERROR",
            Self::Session(_) => "SESSION_ERROR",
            Self::SessionConfiguration { .. } => "SESSION_CONFIGURATION_ERROR",
            Self::SessionInitialization { .. } => "SESSION_INITIALIZATION_ERROR",
            Self::AsyncSessionBuildRequired { .. } => "ASYNC_SESSION_BUILD_REQUIRED",
            Self::SessionClosed { .. } => "SESSION_CLOSED",
            Self::SessionBusy { .. } => "SESSION_BUSY",
            Self::TaskAdmissionCancelled { .. } => "TASK_ADMISSION_CANCELLED",
            Self::TaskSchedulerClosed => "TASK_SCHEDULER_CLOSED",
            Self::RunIdentityConflict { .. } => "RUN_IDENTITY_CONFLICT",
            Self::BudgetExhausted { .. } => "BUDGET_EXHAUSTED",
            Self::Security(_) => "SECURITY_ERROR",
            Self::Context(_) => "CONTEXT_ERROR",
            Self::Mcp(_) => "MCP_ERROR",
            Self::Queue(_) => "QUEUE_ERROR",
            Self::Capability(_) => "CAPABILITY_RUNTIME_ERROR",
            Self::Flow(_) => "FLOW_ERROR",
            Self::Io(_) => "IO_ERROR",
            Self::Serialization(_) => "SERIALIZATION_ERROR",
            Self::Internal(_) => "INTERNAL_ERROR",
        }
    }
}

// ============================================================================
// Lock Poisoning Helpers (Phase 3b)
// ============================================================================

/// Acquire a read guard, recovering from poison if the lock was poisoned.
///
/// Non-security code should never panic on a poisoned lock. The data may
/// be in an inconsistent state, but crashing the entire process is worse
/// than serving stale data in a coding agent context.
pub(crate) fn read_or_recover<T>(lock: &std::sync::RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
    lock.read().unwrap_or_else(|p| p.into_inner())
}

/// Acquire a write guard, recovering from poison if the lock was poisoned.
///
/// See [`read_or_recover`] for rationale.
pub(crate) fn write_or_recover<T>(
    lock: &std::sync::RwLock<T>,
) -> std::sync::RwLockWriteGuard<'_, T> {
    lock.write().unwrap_or_else(|p| p.into_inner())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_code_error_config() {
        let err = CodeError::Config("missing API key".to_string());
        assert!(err.to_string().contains("Config error"));
        assert!(err.to_string().contains("missing API key"));
    }

    #[test]
    fn test_code_error_llm() {
        let err = CodeError::Llm("rate limited".to_string());
        assert!(err.to_string().contains("LLM error"));
    }

    #[test]
    fn test_code_error_tool() {
        let err = CodeError::Tool {
            tool: "bash".to_string(),
            message: "command not found".to_string(),
        };
        let msg = err.to_string();
        assert!(msg.contains("bash"));
        assert!(msg.contains("command not found"));
    }

    #[test]
    fn test_code_error_session() {
        let err = CodeError::Session("not found".to_string());
        assert!(err.to_string().contains("Session error"));
    }

    #[test]
    fn test_code_error_session_configuration_keeps_field_identity() {
        let err = CodeError::SessionConfiguration {
            field: "session_id",
            message: "must not be empty".to_string(),
        };
        assert!(err.to_string().contains("session_id"));
        assert!(err.to_string().contains("must not be empty"));
    }

    #[test]
    fn test_code_error_session_busy() {
        let err = CodeError::SessionBusy {
            session_id: "session-1".to_string(),
        };
        assert!(err.to_string().contains("session-1"));
        assert!(err.to_string().contains("active operation"));
    }

    #[test]
    fn test_code_error_security() {
        let err = CodeError::Security("taint detected".to_string());
        assert!(err.to_string().contains("Security error"));
    }

    #[test]
    fn test_code_error_context() {
        let err = CodeError::Context("provider failed".to_string());
        assert!(err.to_string().contains("Context error"));
    }

    #[test]
    fn test_code_error_mcp() {
        let err = CodeError::Mcp("connection refused".to_string());
        assert!(err.to_string().contains("MCP error"));
    }

    #[test]
    fn test_code_error_queue() {
        let err = CodeError::Queue("lane full".to_string());
        assert!(err.to_string().contains("Queue error"));
    }

    #[test]
    fn test_code_error_from_io() {
        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
        let err: CodeError = io_err.into();
        assert!(matches!(err, CodeError::Io(_)));
        assert!(err.to_string().contains("file missing"));
    }

    #[test]
    fn test_code_error_from_serde_json() {
        let json_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
        let err: CodeError = json_err.into();
        assert!(matches!(err, CodeError::Serialization(_)));
    }

    #[test]
    fn test_code_error_from_anyhow() {
        let anyhow_err = anyhow::anyhow!("something went wrong");
        let err: CodeError = anyhow_err.into();
        assert!(matches!(err, CodeError::Internal(_)));
        assert!(err.to_string().contains("something went wrong"));
    }

    #[test]
    fn stable_error_codes_cover_control_flow_variants() {
        assert_eq!(
            CodeError::SessionBusy {
                session_id: "session-1".to_string(),
            }
            .code(),
            "SESSION_BUSY"
        );
        assert_eq!(
            CodeError::SessionClosed {
                session_id: "session-1".to_string(),
            }
            .code(),
            "SESSION_CLOSED"
        );
        assert_eq!(
            CodeError::RunIdentityConflict {
                run_id: "run-1".to_string(),
            }
            .code(),
            "RUN_IDENTITY_CONFLICT"
        );
        assert_eq!(
            CodeError::BudgetExhausted {
                resource: "tokens".to_string(),
                reason: "limit".to_string(),
            }
            .code(),
            "BUDGET_EXHAUSTED"
        );
        assert_eq!(
            CodeError::TaskAdmissionCancelled {
                session_id: "session-1".to_string(),
            }
            .code(),
            "TASK_ADMISSION_CANCELLED"
        );
        assert_eq!(
            CodeError::TaskSchedulerClosed.code(),
            "TASK_SCHEDULER_CLOSED"
        );
    }

    #[test]
    fn test_code_error_question_mark_from_anyhow() {
        fn inner() -> anyhow::Result<()> {
            anyhow::bail!("inner error")
        }

        fn outer() -> Result<()> {
            inner()?; // anyhow::Error -> CodeError::Internal via #[from]
            Ok(())
        }

        let result = outer();
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(matches!(err, CodeError::Internal(_)));
    }

    #[test]
    fn test_read_or_recover_normal() {
        let lock = std::sync::RwLock::new(42);
        let guard = read_or_recover(&lock);
        assert_eq!(*guard, 42);
    }

    #[test]
    fn test_write_or_recover_normal() {
        let lock = std::sync::RwLock::new(42);
        let mut guard = write_or_recover(&lock);
        *guard = 99;
        drop(guard);
        assert_eq!(*read_or_recover(&lock), 99);
    }

    #[test]
    fn test_read_or_recover_poisoned() {
        let lock = std::sync::RwLock::new(42);
        // Poison the lock by panicking while holding a write guard
        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            let _guard = lock.write().unwrap();
            panic!("intentional poison");
        }));
        // Should recover without panicking
        let guard = read_or_recover(&lock);
        assert_eq!(*guard, 42);
    }

    #[test]
    fn test_write_or_recover_poisoned() {
        let lock = std::sync::RwLock::new(42);
        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            let _guard = lock.write().unwrap();
            panic!("intentional poison");
        }));
        let mut guard = write_or_recover(&lock);
        *guard = 100;
        assert_eq!(*guard, 100);
    }
}