Skip to main content

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    /// Hot-sync the shared global MCP manager to match `servers`.
108    ///
109    /// Enabled entries are registered and best-effort connected; disabled or
110    /// omitted entries are removed. The agent tool cache is refreshed so new
111    /// sessions pick up the catalog. Live sessions must call
112    /// [`AgentSession::republish_inherited_mcp_tools`] to update executors
113    /// without rebuilding the session.
114    pub async fn sync_global_mcp_servers(
115        &self,
116        servers: Vec<crate::mcp::McpServerConfig>,
117    ) -> Result<()> {
118        agent_sessions::sync_global_mcp_servers(self, servers).await
119    }
120
121    /// Live status of servers registered on this agent's shared global MCP manager.
122    ///
123    /// Empty when the agent has no global manager. Hosts use this to project
124    /// connection truth into settings UIs (enabled ≠ connected).
125    pub async fn global_mcp_status(
126        &self,
127    ) -> std::collections::HashMap<String, crate::mcp::McpServerStatus> {
128        match self.global_mcp.as_ref() {
129            Some(manager) => manager.get_status().await,
130            None => std::collections::HashMap::new(),
131        }
132    }
133
134    /// Start async-first construction of a workspace-bound session.
135    pub fn session_builder(&self, workspace: impl Into<String>) -> SessionBuilder<'_> {
136        SessionBuilder::new(self, workspace)
137    }
138
139    /// Build a workspace-bound session asynchronously.
140    ///
141    /// A session ID may have only one live or in-progress session per `Agent`.
142    /// Reusing an occupied ID returns a typed session-configuration error.
143    pub async fn session_async(
144        &self,
145        workspace: impl Into<String>,
146        options: Option<SessionOptions>,
147    ) -> Result<AgentSession> {
148        agent_sessions::create_session_async(self, workspace, options).await
149    }
150
151    /// Open a protocol-owned session, resuming its complete persisted snapshot
152    /// when one exists and optionally creating a fresh session otherwise.
153    ///
154    /// This remains crate-private because public SDK callers should choose
155    /// explicitly between `session_async` and `resume_session_async`. The
156    /// native headless Harness needs the atomic policy so process restarts do
157    /// not bypass Code's existing session/run store.
158    pub(crate) async fn open_protocol_session_async(
159        &self,
160        workspace: impl Into<String>,
161        options: SessionOptions,
162        create_if_missing: bool,
163    ) -> Result<Option<AgentSession>> {
164        agent_sessions::open_protocol_session_async(self, workspace, options, create_if_missing)
165            .await
166    }
167
168    /// Build one unpublished protocol Session directly from a validated
169    /// portable checkpoint snapshot. The Harness owns publication and the
170    /// matching logical-resume admission.
171    pub(crate) async fn restore_protocol_checkpoint_session_async(
172        &self,
173        snapshot: crate::store::SessionSnapshotV1,
174        workspace: impl Into<String>,
175        options: SessionOptions,
176    ) -> Result<AgentSession> {
177        agent_sessions::restore_protocol_checkpoint_session_async(
178            self,
179            snapshot,
180            workspace.into(),
181            options,
182        )
183        .await
184    }
185
186    pub(crate) async fn load_protocol_session_snapshot_async(
187        &self,
188        session_id: &str,
189        options: &SessionOptions,
190    ) -> Result<Option<crate::store::SessionSnapshotV1>> {
191        agent_sessions::load_protocol_session_snapshot_async(self, session_id, options).await
192    }
193
194    /// Bind to a workspace directory, returning an [`AgentSession`].
195    ///
196    /// This compatibility entry point never starts or blocks an async runtime.
197    /// It requires an explicit, pre-initialized `memory_store`; an optional
198    /// pre-initialized session store and cached global MCP tools are accepted.
199    /// File-store specs/defaults, queues, RL trajectory files, and host-supplied
200    /// MCP sources require [`session_builder`](Self::session_builder).
201    /// A session ID may have only one live or in-progress session per `Agent`.
202    pub fn session(
203        &self,
204        workspace: impl Into<String>,
205        options: Option<SessionOptions>,
206    ) -> Result<AgentSession> {
207        agent_sessions::create_session(self, workspace, options)
208    }
209
210    /// Create a session pre-configured from an
211    /// [`AgentDefinition`](crate::subagent::AgentDefinition).
212    ///
213    /// Maps the definition's `permissions`, `prompt`, `model`, and `max_steps`
214    /// directly into [`SessionOptions`], so markdown/YAML-defined subagents can
215    /// be used by delegation and advanced control-plane flows without manual wiring.
216    ///
217    /// The mapping follows the same logic as the built-in `task` tool:
218    /// - `permissions` → `permission_checker`
219    /// - `prompt`      → `prompt_slots.extra`
220    /// - `max_steps`   → `max_tool_rounds`
221    /// - `model`       → `model` (as `"provider/model"` string)
222    ///
223    /// `extra` can supply additional overrides (e.g. `planning_enabled`) that
224    /// take precedence over the definition's values.
225    pub fn session_for_agent(
226        &self,
227        workspace: impl Into<String>,
228        def: &crate::subagent::AgentDefinition,
229        extra: Option<SessionOptions>,
230    ) -> Result<AgentSession> {
231        agent_sessions::create_session_for_agent(self, workspace, def, extra)
232    }
233
234    /// Async-first variant of [`session_for_agent`](Self::session_for_agent).
235    pub async fn session_for_agent_async(
236        &self,
237        workspace: impl Into<String>,
238        def: &crate::subagent::AgentDefinition,
239        extra: Option<SessionOptions>,
240    ) -> Result<AgentSession> {
241        agent_sessions::create_session_for_agent_async(self, workspace, def, extra).await
242    }
243
244    /// Create a session from a reproducible disposable worker recipe.
245    ///
246    /// This is the cattle-mode companion to [`Agent::session_for_agent`]: callers
247    /// provide a small [`WorkerAgentSpec`](crate::subagent::WorkerAgentSpec), and
248    /// A3S Code compiles it into the same runtime definition used by delegated agents.
249    pub fn session_for_worker(
250        &self,
251        workspace: impl Into<String>,
252        spec: crate::subagent::WorkerAgentSpec,
253        extra: Option<SessionOptions>,
254    ) -> Result<AgentSession> {
255        let def = spec.into_agent_definition();
256        self.session_for_agent(workspace, &def, extra)
257    }
258
259    /// Async-first variant of [`session_for_worker`](Self::session_for_worker).
260    pub async fn session_for_worker_async(
261        &self,
262        workspace: impl Into<String>,
263        spec: crate::subagent::WorkerAgentSpec,
264        extra: Option<SessionOptions>,
265    ) -> Result<AgentSession> {
266        let def = spec.into_agent_definition();
267        self.session_for_agent_async(workspace, &def, extra).await
268    }
269
270    /// Resume a previously saved session by ID.
271    ///
272    /// Loads the session data from the store, rebuilds the `AgentSession` with
273    /// the saved conversation history, and returns it ready for continued use.
274    ///
275    /// The `options` must include a `session_store` (or `with_file_session_store`)
276    /// that contains the saved session.
277    ///
278    /// The resumed session uses the **workspace stored in the snapshot**, not a
279    /// workspace from `options`. The store is therefore a trust boundary: its
280    /// contents drive the resumed workspace and the persisted runtime policies.
281    /// The requested ID must not already be live or under construction on this
282    /// `Agent`.
283    ///
284    /// This synchronous compatibility entry point returns
285    /// [`CodeError::AsyncSessionBuildRequired`](crate::error::CodeError::AsyncSessionBuildRequired);
286    /// use [`resume_session_async`](Self::resume_session_async).
287    pub fn resume_session(
288        &self,
289        session_id: &str,
290        options: SessionOptions,
291    ) -> Result<AgentSession> {
292        agent_sessions::resume_session(self, session_id, options)
293    }
294
295    /// Resume a persisted session without blocking the async runtime.
296    ///
297    /// The requested ID must not already be live or under construction on this
298    /// `Agent`.
299    pub async fn resume_session_async(
300        &self,
301        session_id: &str,
302        options: SessionOptions,
303    ) -> Result<AgentSession> {
304        agent_sessions::resume_session_async(self, session_id, options).await
305    }
306
307    /// Rebuild a live persisted session with new options without exposing a
308    /// closed-session gap to the caller.
309    ///
310    /// The current session is saved first and remains live while the
311    /// replacement is constructed and restored. If construction fails, the
312    /// current session stays registered and usable. On success, the registry
313    /// is switched to the replacement before the old session is closed.
314    ///
315    /// Callers must serialize this operation with conversation work on
316    /// `current` (for example, only reconfigure an idle interactive session).
317    /// The replacement keeps the same session ID and persisted history.
318    pub async fn replace_session_async(
319        &self,
320        current: &AgentSession,
321        options: SessionOptions,
322    ) -> Result<AgentSession> {
323        agent_sessions::replace_session_async(self, current, options).await
324    }
325
326    /// Return the IDs of every live session created from this agent.
327    ///
328    /// "Live" means the caller still holds an [`AgentSession`] — sessions
329    /// that have been dropped are pruned lazily on each call. The list is
330    /// sorted to make output stable for tests/UIs.
331    pub async fn list_sessions(&self) -> Vec<String> {
332        agent_sessions::list_sessions(self).await
333    }
334
335    /// Close a specific live session by its session ID.
336    ///
337    /// Returns `true` when a live session with the given id was found and
338    /// transitioned from open to closed by this call; `false` when no live
339    /// session has that id, or when the session was already closed.
340    ///
341    /// This is the out-of-band counterpart to [`AgentSession::close`]: it
342    /// performs exactly the same cleanup but can be invoked without holding
343    /// a reference to the session itself — useful for control-plane code
344    /// that only knows the session ID.
345    pub async fn close_session(&self, session_id: &str) -> bool {
346        agent_sessions::close_session(self, session_id).await
347    }
348
349    /// Close every live session created from this agent and tear down
350    /// background resources owned by the agent (global MCP connections).
351    ///
352    /// After this call:
353    /// - Every live `AgentSession` is closed (same effect as calling
354    ///   [`AgentSession::close`] on each).
355    /// - Subsequent [`Agent::session`] / [`Agent::resume_session`] calls
356    ///   fail fast with [`CodeError::SessionClosed`](crate::error::CodeError::SessionClosed).
357    /// - Session builds admitted before close but not yet finalized are rejected
358    ///   at finalization and cannot return an open session.
359    ///
360    /// Idempotent: subsequent calls are no-ops and are guaranteed not to
361    /// panic.
362    pub async fn close(&self) {
363        agent_sessions::close_agent(self).await
364    }
365
366    /// Return current cross-session priority scheduler occupancy.
367    pub async fn task_scheduler_stats(
368        &self,
369    ) -> std::result::Result<
370        crate::task_scheduler::TaskSchedulerStats,
371        crate::task_scheduler::TaskSchedulerError,
372    > {
373        self.task_scheduler.stats().await
374    }
375
376    /// Return occupancy and bounded cumulative admission/fairness diagnostics
377    /// for the scheduler shared by every session created from this agent.
378    pub async fn task_scheduler_health(
379        &self,
380    ) -> std::result::Result<
381        crate::task_scheduler::TaskSchedulerHealthSnapshot,
382        crate::task_scheduler::TaskSchedulerError,
383    > {
384        self.task_scheduler.health().await
385    }
386
387    /// Return whether [`close`](Self::close) has been called on this agent.
388    pub fn is_closed(&self) -> bool {
389        self.closed.load(std::sync::atomic::Ordering::Acquire)
390    }
391
392    /// Disconnect every global MCP server whose last activity is older
393    /// than `idle_threshold_ms`. Returns the names of disconnected
394    /// servers (empty when there is no global MCP manager or when
395    /// nothing is idle).
396    ///
397    /// Hosts running thousands of long-lived sessions should call this
398    /// periodically (e.g. every 60s with a 5-min threshold) to release
399    /// file descriptors and background workers from quiet MCP servers
400    /// without losing the server's configuration. A subsequent tool
401    /// call on the same server will require an explicit reconnect.
402    pub async fn disconnect_idle_mcp(&self, idle_threshold_ms: u64) -> Vec<String> {
403        match &self.global_mcp {
404            Some(mcp) => mcp.disconnect_idle(idle_threshold_ms).await,
405            None => Vec::new(),
406        }
407    }
408
409    #[cfg(test)]
410    pub(super) fn build_session(
411        &self,
412        workspace: String,
413        llm_client: Arc<dyn LlmClient>,
414        opts: &SessionOptions,
415    ) -> Result<AgentSession> {
416        let mut opts = opts.clone().with_llm_client(llm_client);
417        if opts.memory_store.is_none() && opts.file_memory_dir.is_none() {
418            opts = opts.with_memory(Arc::new(a3s_memory::InMemoryStore::new()));
419        }
420        agent_sessions::create_session(self, workspace, Some(opts))
421    }
422}