a3s_code_core/agent_api/agent_facade.rs
1use super::*;
2
3// ============================================================================
4// Agent
5// ============================================================================
6
7/// High-level agent facade.
8///
9/// Holds the LLM client and agent config. Workspace-independent.
10/// Use [`Agent::session()`] to bind to a workspace.
11pub struct Agent {
12 pub(super) code_config: CodeConfig,
13 pub(super) config: AgentConfig,
14 /// Shared priority admission for every session and independent task.
15 pub(super) task_scheduler: Arc<crate::task_scheduler::TaskScheduler>,
16 /// Global MCP manager loaded from config.mcp_servers
17 pub(super) global_mcp: Option<Arc<crate::mcp::manager::McpManager>>,
18 /// Pre-fetched MCP tool definitions from global_mcp (cached at creation time).
19 /// Wrapped in Mutex so `refresh_mcp_tools()` can update the cache without `&mut self`.
20 pub(super) global_mcp_tools: std::sync::Mutex<Vec<(String, crate::mcp::McpTool)>>,
21 /// Tracks session IDs reserved by in-progress builds and every live session
22 /// created by this agent. Build reservations prevent duplicate IDs and make
23 /// construction finalization atomic with [`Agent::close`]. Live sessions
24 /// are held via `Weak` refs and pruned on registry access after drop.
25 ///
26 /// Uses a synchronous lock so the sync `Agent::session()` factory can
27 /// insert without nesting tokio runtimes. The lock is only held for
28 /// brief insert/scan operations — async close work happens after the
29 /// lock is released.
30 pub(super) sessions: Arc<std::sync::Mutex<agent_sessions::SessionRegistry>>,
31 /// Set once `Agent::close()` has been called. Subsequent `session()` /
32 /// `resume_session()` calls fail fast with `CodeError::SessionClosed`.
33 pub(super) closed: Arc<std::sync::atomic::AtomicBool>,
34}
35
36/// Async-first session construction API.
37///
38/// Builder methods only record typed configuration. Filesystem access, queue
39/// startup, and MCP discovery happen once in [`build`](Self::build).
40#[must_use = "a session builder does nothing until build() is awaited"]
41pub struct SessionBuilder<'a> {
42 agent: &'a Agent,
43 workspace: String,
44 options: SessionOptions,
45}
46
47impl<'a> SessionBuilder<'a> {
48 fn new(agent: &'a Agent, workspace: impl Into<String>) -> Self {
49 Self {
50 agent,
51 workspace: workspace.into(),
52 options: SessionOptions::default(),
53 }
54 }
55
56 /// Replace the per-session option patch.
57 pub fn options(mut self, options: SessionOptions) -> Self {
58 self.options = options;
59 self
60 }
61
62 /// Resolve configuration, initialize async resources, and build the session.
63 pub async fn build(self) -> Result<AgentSession> {
64 agent_sessions::create_session_async(self.agent, self.workspace, Some(self.options)).await
65 }
66}
67
68impl std::fmt::Debug for Agent {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 f.debug_struct("Agent").finish()
71 }
72}
73
74impl Agent {
75 /// Create from a config file path or inline ACL-compatible string.
76 ///
77 /// Auto-detects `.acl` file paths vs inline ACL-compatible config.
78 pub async fn new(config_source: impl Into<String>) -> Result<Self> {
79 let config = agent_bootstrap::load_code_config(config_source.into())?;
80 Self::from_config(config).await
81 }
82
83 /// Create from a config file path or inline ACL-compatible string.
84 ///
85 /// Alias for [`Agent::new()`] — provides a consistent API with
86 /// the Python and Node.js SDKs.
87 pub async fn create(config_source: impl Into<String>) -> Result<Self> {
88 Self::new(config_source).await
89 }
90
91 /// Create from a [`CodeConfig`] struct.
92 pub async fn from_config(config: CodeConfig) -> Result<Self> {
93 agent_bootstrap::build_agent_from_config(config).await
94 }
95
96 /// Re-fetch tool definitions from all connected global MCP servers and
97 /// update the internal cache.
98 ///
99 /// Call this when an MCP server has added or removed tools since the
100 /// agent was created. The refreshed tools will be visible to all
101 /// **new** sessions created after this call; existing sessions are
102 /// unaffected (their `ToolExecutor` snapshot is already built).
103 pub async fn refresh_mcp_tools(&self) -> Result<()> {
104 agent_sessions::refresh_mcp_tools(self).await
105 }
106
107 /// Start async-first construction of a workspace-bound session.
108 pub fn session_builder(&self, workspace: impl Into<String>) -> SessionBuilder<'_> {
109 SessionBuilder::new(self, workspace)
110 }
111
112 /// Build a workspace-bound session asynchronously.
113 ///
114 /// A session ID may have only one live or in-progress session per `Agent`.
115 /// Reusing an occupied ID returns a typed session-configuration error.
116 pub async fn session_async(
117 &self,
118 workspace: impl Into<String>,
119 options: Option<SessionOptions>,
120 ) -> Result<AgentSession> {
121 agent_sessions::create_session_async(self, workspace, options).await
122 }
123
124 /// Open a protocol-owned session, resuming its complete persisted snapshot
125 /// when one exists and optionally creating a fresh session otherwise.
126 ///
127 /// This remains crate-private because public SDK callers should choose
128 /// explicitly between `session_async` and `resume_session_async`. The
129 /// native headless Harness needs the atomic policy so process restarts do
130 /// not bypass Code's existing session/run store.
131 pub(crate) async fn open_protocol_session_async(
132 &self,
133 workspace: impl Into<String>,
134 options: SessionOptions,
135 create_if_missing: bool,
136 ) -> Result<Option<AgentSession>> {
137 agent_sessions::open_protocol_session_async(self, workspace, options, create_if_missing)
138 .await
139 }
140
141 /// Build one unpublished protocol Session directly from a validated
142 /// portable checkpoint snapshot. The Harness owns publication and the
143 /// matching logical-resume admission.
144 pub(crate) async fn restore_protocol_checkpoint_session_async(
145 &self,
146 snapshot: crate::store::SessionSnapshotV1,
147 workspace: impl Into<String>,
148 options: SessionOptions,
149 ) -> Result<AgentSession> {
150 agent_sessions::restore_protocol_checkpoint_session_async(
151 self,
152 snapshot,
153 workspace.into(),
154 options,
155 )
156 .await
157 }
158
159 pub(crate) async fn load_protocol_session_snapshot_async(
160 &self,
161 session_id: &str,
162 options: &SessionOptions,
163 ) -> Result<Option<crate::store::SessionSnapshotV1>> {
164 agent_sessions::load_protocol_session_snapshot_async(self, session_id, options).await
165 }
166
167 /// Bind to a workspace directory, returning an [`AgentSession`].
168 ///
169 /// This compatibility entry point never starts or blocks an async runtime.
170 /// It requires an explicit, pre-initialized `memory_store`; an optional
171 /// pre-initialized session store and cached global MCP tools are accepted.
172 /// File-store specs/defaults, queues, RL trajectory files, and host-supplied
173 /// MCP sources require [`session_builder`](Self::session_builder).
174 /// A session ID may have only one live or in-progress session per `Agent`.
175 pub fn session(
176 &self,
177 workspace: impl Into<String>,
178 options: Option<SessionOptions>,
179 ) -> Result<AgentSession> {
180 agent_sessions::create_session(self, workspace, options)
181 }
182
183 /// Create a session pre-configured from an
184 /// [`AgentDefinition`](crate::subagent::AgentDefinition).
185 ///
186 /// Maps the definition's `permissions`, `prompt`, `model`, and `max_steps`
187 /// directly into [`SessionOptions`], so markdown/YAML-defined subagents can
188 /// be used by delegation and advanced control-plane flows without manual wiring.
189 ///
190 /// The mapping follows the same logic as the built-in `task` tool:
191 /// - `permissions` → `permission_checker`
192 /// - `prompt` → `prompt_slots.extra`
193 /// - `max_steps` → `max_tool_rounds`
194 /// - `model` → `model` (as `"provider/model"` string)
195 ///
196 /// `extra` can supply additional overrides (e.g. `planning_enabled`) that
197 /// take precedence over the definition's values.
198 pub fn session_for_agent(
199 &self,
200 workspace: impl Into<String>,
201 def: &crate::subagent::AgentDefinition,
202 extra: Option<SessionOptions>,
203 ) -> Result<AgentSession> {
204 agent_sessions::create_session_for_agent(self, workspace, def, extra)
205 }
206
207 /// Async-first variant of [`session_for_agent`](Self::session_for_agent).
208 pub async fn session_for_agent_async(
209 &self,
210 workspace: impl Into<String>,
211 def: &crate::subagent::AgentDefinition,
212 extra: Option<SessionOptions>,
213 ) -> Result<AgentSession> {
214 agent_sessions::create_session_for_agent_async(self, workspace, def, extra).await
215 }
216
217 /// Create a session from a reproducible disposable worker recipe.
218 ///
219 /// This is the cattle-mode companion to [`Agent::session_for_agent`]: callers
220 /// provide a small [`WorkerAgentSpec`](crate::subagent::WorkerAgentSpec), and
221 /// A3S Code compiles it into the same runtime definition used by delegated agents.
222 pub fn session_for_worker(
223 &self,
224 workspace: impl Into<String>,
225 spec: crate::subagent::WorkerAgentSpec,
226 extra: Option<SessionOptions>,
227 ) -> Result<AgentSession> {
228 let def = spec.into_agent_definition();
229 self.session_for_agent(workspace, &def, extra)
230 }
231
232 /// Async-first variant of [`session_for_worker`](Self::session_for_worker).
233 pub async fn session_for_worker_async(
234 &self,
235 workspace: impl Into<String>,
236 spec: crate::subagent::WorkerAgentSpec,
237 extra: Option<SessionOptions>,
238 ) -> Result<AgentSession> {
239 let def = spec.into_agent_definition();
240 self.session_for_agent_async(workspace, &def, extra).await
241 }
242
243 /// Resume a previously saved session by ID.
244 ///
245 /// Loads the session data from the store, rebuilds the `AgentSession` with
246 /// the saved conversation history, and returns it ready for continued use.
247 ///
248 /// The `options` must include a `session_store` (or `with_file_session_store`)
249 /// that contains the saved session.
250 ///
251 /// The resumed session uses the **workspace stored in the snapshot**, not a
252 /// workspace from `options`. The store is therefore a trust boundary: its
253 /// contents drive the resumed workspace and the persisted runtime policies.
254 /// The requested ID must not already be live or under construction on this
255 /// `Agent`.
256 ///
257 /// This synchronous compatibility entry point returns
258 /// [`CodeError::AsyncSessionBuildRequired`](crate::error::CodeError::AsyncSessionBuildRequired);
259 /// use [`resume_session_async`](Self::resume_session_async).
260 pub fn resume_session(
261 &self,
262 session_id: &str,
263 options: SessionOptions,
264 ) -> Result<AgentSession> {
265 agent_sessions::resume_session(self, session_id, options)
266 }
267
268 /// Resume a persisted session without blocking the async runtime.
269 ///
270 /// The requested ID must not already be live or under construction on this
271 /// `Agent`.
272 pub async fn resume_session_async(
273 &self,
274 session_id: &str,
275 options: SessionOptions,
276 ) -> Result<AgentSession> {
277 agent_sessions::resume_session_async(self, session_id, options).await
278 }
279
280 /// Rebuild a live persisted session with new options without exposing a
281 /// closed-session gap to the caller.
282 ///
283 /// The current session is saved first and remains live while the
284 /// replacement is constructed and restored. If construction fails, the
285 /// current session stays registered and usable. On success, the registry
286 /// is switched to the replacement before the old session is closed.
287 ///
288 /// Callers must serialize this operation with conversation work on
289 /// `current` (for example, only reconfigure an idle interactive session).
290 /// The replacement keeps the same session ID and persisted history.
291 pub async fn replace_session_async(
292 &self,
293 current: &AgentSession,
294 options: SessionOptions,
295 ) -> Result<AgentSession> {
296 agent_sessions::replace_session_async(self, current, options).await
297 }
298
299 /// Return the IDs of every live session created from this agent.
300 ///
301 /// "Live" means the caller still holds an [`AgentSession`] — sessions
302 /// that have been dropped are pruned lazily on each call. The list is
303 /// sorted to make output stable for tests/UIs.
304 pub async fn list_sessions(&self) -> Vec<String> {
305 agent_sessions::list_sessions(self).await
306 }
307
308 /// Close a specific live session by its session ID.
309 ///
310 /// Returns `true` when a live session with the given id was found and
311 /// transitioned from open to closed by this call; `false` when no live
312 /// session has that id, or when the session was already closed.
313 ///
314 /// This is the out-of-band counterpart to [`AgentSession::close`]: it
315 /// performs exactly the same cleanup but can be invoked without holding
316 /// a reference to the session itself — useful for control-plane code
317 /// that only knows the session ID.
318 pub async fn close_session(&self, session_id: &str) -> bool {
319 agent_sessions::close_session(self, session_id).await
320 }
321
322 /// Close every live session created from this agent and tear down
323 /// background resources owned by the agent (global MCP connections).
324 ///
325 /// After this call:
326 /// - Every live `AgentSession` is closed (same effect as calling
327 /// [`AgentSession::close`] on each).
328 /// - Subsequent [`Agent::session`] / [`Agent::resume_session`] calls
329 /// fail fast with [`CodeError::SessionClosed`](crate::error::CodeError::SessionClosed).
330 /// - Session builds admitted before close but not yet finalized are rejected
331 /// at finalization and cannot return an open session.
332 ///
333 /// Idempotent: subsequent calls are no-ops and are guaranteed not to
334 /// panic.
335 pub async fn close(&self) {
336 agent_sessions::close_agent(self).await
337 }
338
339 /// Return current cross-session priority scheduler occupancy.
340 pub async fn task_scheduler_stats(
341 &self,
342 ) -> std::result::Result<
343 crate::task_scheduler::TaskSchedulerStats,
344 crate::task_scheduler::TaskSchedulerError,
345 > {
346 self.task_scheduler.stats().await
347 }
348
349 /// Return whether [`close`](Self::close) has been called on this agent.
350 pub fn is_closed(&self) -> bool {
351 self.closed.load(std::sync::atomic::Ordering::Acquire)
352 }
353
354 /// Disconnect every global MCP server whose last activity is older
355 /// than `idle_threshold_ms`. Returns the names of disconnected
356 /// servers (empty when there is no global MCP manager or when
357 /// nothing is idle).
358 ///
359 /// Hosts running thousands of long-lived sessions should call this
360 /// periodically (e.g. every 60s with a 5-min threshold) to release
361 /// file descriptors and background workers from quiet MCP servers
362 /// without losing the server's configuration. A subsequent tool
363 /// call on the same server will require an explicit reconnect.
364 pub async fn disconnect_idle_mcp(&self, idle_threshold_ms: u64) -> Vec<String> {
365 match &self.global_mcp {
366 Some(mcp) => mcp.disconnect_idle(idle_threshold_ms).await,
367 None => Vec::new(),
368 }
369 }
370
371 #[cfg(test)]
372 pub(super) fn build_session(
373 &self,
374 workspace: String,
375 llm_client: Arc<dyn LlmClient>,
376 opts: &SessionOptions,
377 ) -> Result<AgentSession> {
378 let mut opts = opts.clone().with_llm_client(llm_client);
379 if opts.memory_store.is_none() && opts.file_memory_dir.is_none() {
380 opts = opts.with_memory(Arc::new(a3s_memory::InMemoryStore::new()));
381 }
382 agent_sessions::create_session(self, workspace, Some(opts))
383 }
384}