-
Notifications
You must be signed in to change notification settings - Fork 328
feat(acp): expose Moltis as an ACP agent over stdio #1169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+12,265
−959
Merged
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
7d49782
feat(acp): expose Moltis as an ACP agent over stdio
claude 9f5725b
fix(acp): enforce the acp: session namespace at the protocol boundary
claude c66981a
fix(acp): declare tracing as a dev-dependency so tests build without …
claude c3b07d7
feat(acp): serve real Moltis turns over ACP
claude a762749
fix(acp): complete production agent runtime
penso 89f643f
test(acp): isolate real backend stdio test
penso 29e84f9
test(tools): bound HTTP fixture waits
penso fde06c7
fix(web): prevent session title toolbar overlap
penso 4403d55
Move ACP selection into the chat model picker (#1171)
penso 9baaee7
fix(scripts): target local validation tests
penso 799f477
fix(acp): harden inbound stdio runtime
penso 397a688
test: isolate resource-sensitive nextest groups
penso d4c7a7e
Merge remote-tracking branch 'origin/main' into claude/new-session-53…
penso 8cbb26b
fix(acp): tighten cancellation and resource bounds
penso 526462c
fix(acp): preserve cleanup guards under cancellation
penso 1b94343
fix(process): terminate complete child process trees
penso b6b6d44
fix(acp): bound transport resources without blocking reads
penso 0575c4e
chore(acp): ignore vendored test artifacts
penso ed50dda
fix(sessions): make storage paths injective
penso c60c550
perf(sessions): avoid duplicate key encoding allocation
penso 6335653
bench(sessions): measure versioned history paths
penso File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| [package] | ||
| edition.workspace = true | ||
| name = "moltis-acp" | ||
| repository.workspace = true | ||
| version.workspace = true | ||
|
|
||
| [features] | ||
| default = [] | ||
| metrics = ["dep:moltis-metrics"] | ||
| tracing = ["dep:tracing"] | ||
|
|
||
| [dependencies] | ||
| agent-client-protocol = { workspace = true } | ||
| anyhow = { workspace = true } | ||
| async-trait = { workspace = true } | ||
| futures = { workspace = true } | ||
| moltis-metrics = { optional = true, workspace = true } | ||
| serde_json = { workspace = true } | ||
| tokio = { workspace = true } | ||
| tokio-util = { features = ["compat"], workspace = true } | ||
| tracing = { optional = true, workspace = true } | ||
|
|
||
| [dev-dependencies] | ||
| tokio-test = { workspace = true } | ||
| tracing-subscriber = { workspace = true } | ||
|
|
||
| [lints] | ||
| workspace = true |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,228 @@ | ||
| //! `impl acp::Agent` — the agent side of the Agent Client Protocol. | ||
| //! | ||
| //! Everything here runs on the [`tokio::task::LocalSet`] that owns the | ||
| //! connection, so the types are `Rc`-based and never cross a thread boundary. | ||
| //! Real work is delegated to a [`AcpBackend`], which is `Send + Sync` and runs | ||
| //! on the ordinary multi-threaded runtime. | ||
|
|
||
| use std::{ | ||
| cell::RefCell, | ||
| rc::{Rc, Weak}, | ||
| sync::Arc, | ||
| }; | ||
|
|
||
| use { | ||
| agent_client_protocol::{self as acp, Client as _}, | ||
| async_trait::async_trait, | ||
| tokio::sync::mpsc, | ||
| }; | ||
|
|
||
| use crate::{ | ||
| backend::{AcpBackend, TurnUpdates}, | ||
| session::{SessionKey, SessionRegistry}, | ||
| }; | ||
|
|
||
| /// Protocol handler bridging an ACP client to a Moltis backend. | ||
| pub struct MoltisAgent { | ||
| backend: Arc<dyn AcpBackend>, | ||
| sessions: SessionRegistry, | ||
| /// Weak on purpose: the connection owns the handler, so a strong reference | ||
| /// here would form a cycle and leak both for the life of the process. | ||
| connection: RefCell<Weak<acp::AgentSideConnection>>, | ||
| } | ||
|
|
||
| impl MoltisAgent { | ||
| #[must_use] | ||
| pub fn new(backend: Arc<dyn AcpBackend>) -> Self { | ||
| Self { | ||
| backend, | ||
| sessions: SessionRegistry::new(), | ||
| connection: RefCell::new(Weak::new()), | ||
| } | ||
| } | ||
|
|
||
| /// Attaches the connection used to send `session/update` notifications. | ||
| /// | ||
| /// `AgentSideConnection::new` consumes the handler, so the handler cannot | ||
| /// hold the connection at construction time. Build the agent first, pass a | ||
| /// clone into the connection, then call this. The caller must keep the | ||
| /// `Rc` alive for as long as the connection is served. | ||
| pub fn set_connection(&self, connection: &Rc<acp::AgentSideConnection>) { | ||
| *self.connection.borrow_mut() = Rc::downgrade(connection); | ||
| } | ||
|
|
||
| #[must_use] | ||
| pub fn sessions(&self) -> &SessionRegistry { | ||
| &self.sessions | ||
| } | ||
|
|
||
| /// Clones the connection out of its `RefCell` so no borrow is held across | ||
| /// an await point. | ||
| fn connection(&self) -> acp::Result<Rc<acp::AgentSideConnection>> { | ||
| self.connection | ||
| .borrow() | ||
| .upgrade() | ||
| .ok_or_else(|| acp::Error::internal_error().data("ACP connection not attached")) | ||
| } | ||
|
|
||
| async fn notify( | ||
| connection: &Rc<acp::AgentSideConnection>, | ||
| session_id: &acp::SessionId, | ||
| update: acp::SessionUpdate, | ||
| ) -> bool { | ||
| connection | ||
| .session_notification(acp::SessionNotification::new(session_id.clone(), update)) | ||
| .await | ||
| .is_ok() | ||
| } | ||
| } | ||
|
|
||
| /// Flattens a prompt's content blocks into the plain text Moltis consumes. | ||
| /// | ||
| /// Non-text blocks are represented by a short placeholder rather than dropped, | ||
| /// so a turn that is entirely an image does not reach the model as an empty | ||
| /// message. | ||
| #[must_use] | ||
| pub fn prompt_text(blocks: &[acp::ContentBlock]) -> String { | ||
| blocks | ||
| .iter() | ||
| .map(|block| match block { | ||
| acp::ContentBlock::Text(text) => text.text.clone(), | ||
| acp::ContentBlock::Image(_) => "<image>".to_string(), | ||
| acp::ContentBlock::Audio(_) => "<audio>".to_string(), | ||
| acp::ContentBlock::ResourceLink(link) => link.uri.to_string(), | ||
| acp::ContentBlock::Resource(_) => "<resource>".to_string(), | ||
| _ => "<content>".to_string(), | ||
| }) | ||
| .collect::<Vec<_>>() | ||
| .join("\n") | ||
| } | ||
|
|
||
| #[async_trait(?Send)] | ||
| impl acp::Agent for MoltisAgent { | ||
| #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, args)))] | ||
| async fn initialize( | ||
| &self, | ||
| args: acp::InitializeRequest, | ||
| ) -> acp::Result<acp::InitializeResponse> { | ||
| // Negotiate down to whatever both sides understand. | ||
| let version = args.protocol_version.min(acp::ProtocolVersion::LATEST); | ||
| let capabilities = self.backend.capabilities(); | ||
| Ok(acp::InitializeResponse::new(version) | ||
| .agent_capabilities( | ||
| acp::AgentCapabilities::new().load_session(capabilities.load_session), | ||
| ) | ||
| .agent_info( | ||
| acp::Implementation::new("moltis", env!("CARGO_PKG_VERSION")).title("Moltis"), | ||
| )) | ||
| } | ||
|
|
||
| async fn authenticate( | ||
| &self, | ||
| _args: acp::AuthenticateRequest, | ||
| ) -> acp::Result<acp::AuthenticateResponse> { | ||
| // The client is the local parent process that spawned us; there is no | ||
| // separate identity to establish. | ||
| Ok(acp::AuthenticateResponse::new()) | ||
| } | ||
|
|
||
| #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, args)))] | ||
| async fn new_session( | ||
| &self, | ||
| args: acp::NewSessionRequest, | ||
| ) -> acp::Result<acp::NewSessionResponse> { | ||
| let key = self | ||
| .backend | ||
| .create_session(&args.cwd) | ||
| .await | ||
| .map_err(|error| { | ||
| acp::Error::internal_error().data(format!("failed to create session: {error}")) | ||
| })?; | ||
| self.sessions.insert(key.clone()); | ||
| Ok(acp::NewSessionResponse::new(acp::SessionId::from(key))) | ||
| } | ||
|
|
||
| #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, args)))] | ||
| async fn load_session( | ||
| &self, | ||
| args: acp::LoadSessionRequest, | ||
| ) -> acp::Result<acp::LoadSessionResponse> { | ||
| if !self.backend.capabilities().load_session { | ||
| return Err(acp::Error::method_not_found()); | ||
| } | ||
| let key = SessionKey::from(&args.session_id); | ||
| let history = self.backend.load_session(&key).await.map_err(|error| { | ||
| acp::Error::invalid_params().data(format!("failed to load session {key}: {error}")) | ||
| })?; | ||
| self.sessions.insert(key); | ||
|
|
||
| // The spec asks the agent to stream the whole conversation back before | ||
| // resolving the request. | ||
| let connection = self.connection()?; | ||
| for update in history { | ||
| if !Self::notify(&connection, &args.session_id, update).await { | ||
| break; | ||
| } | ||
| } | ||
| Ok(acp::LoadSessionResponse::new()) | ||
| } | ||
|
|
||
| #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, args)))] | ||
| async fn prompt(&self, args: acp::PromptRequest) -> acp::Result<acp::PromptResponse> { | ||
| let key = self.sessions.resolve(&args.session_id)?; | ||
| let connection = self.connection()?; | ||
| let text = prompt_text(&args.prompt); | ||
| self.sessions.clear_cancelled(&key); | ||
|
|
||
| let (tx, mut rx) = mpsc::unbounded_channel(); | ||
| let turn = self.backend.prompt(&key, text, TurnUpdates::new(tx)); | ||
| let mut turn = std::pin::pin!(turn); | ||
|
|
||
| // Forward updates while the turn runs, on this same task. Doing it here | ||
| // rather than in a spawned forwarder means a backend that leaks a | ||
| // `TurnUpdates` clone cannot wedge the response: we stop draining the | ||
| // moment the turn resolves. | ||
| let outcome = loop { | ||
| tokio::select! { | ||
| biased; | ||
| Some(update) = rx.recv() => { | ||
| if !Self::notify(&connection, &args.session_id, update).await { | ||
| break Ok(acp::StopReason::Cancelled); | ||
| } | ||
| }, | ||
| result = &mut turn => break result, | ||
| } | ||
| }; | ||
|
|
||
| // Flush anything already queued so no delta lands after the response. | ||
| while let Ok(update) = rx.try_recv() { | ||
| if !Self::notify(&connection, &args.session_id, update).await { | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| let cancelled = self.sessions.take_cancelled(&key); | ||
| let stop_reason = match outcome { | ||
| // A cancel that raced the turn's own completion still has to be | ||
| // reported as cancelled: the client is waiting for that signal. | ||
| Ok(_) if cancelled => acp::StopReason::Cancelled, | ||
| Ok(reason) => reason, | ||
| Err(_) if cancelled => acp::StopReason::Cancelled, | ||
| Err(error) => { | ||
| return Err(acp::Error::internal_error().data(format!("turn failed: {error}"))); | ||
| }, | ||
| }; | ||
| Ok(acp::PromptResponse::new(stop_reason)) | ||
| } | ||
|
|
||
| #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, args)))] | ||
| async fn cancel(&self, args: acp::CancelNotification) -> acp::Result<()> { | ||
| // Arrives out-of-band while `prompt` is still pending. Flag first so the | ||
| // pending turn reports `Cancelled` even if the backend finishes first. | ||
| let key = self.sessions.resolve(&args.session_id)?; | ||
| self.sessions.mark_cancelled(&key); | ||
| self.backend.cancel(&key).await.map_err(|error| { | ||
| acp::Error::internal_error().data(format!("failed to cancel turn: {error}")) | ||
| }) | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.