Skip to content

Commit be61032

Browse files
committed
feat(acp-nats-agent): add server-side ACP agent framework
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent c8e1839 commit be61032

8 files changed

Lines changed: 762 additions & 0 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
[package]
2+
name = "acp-nats-agent"
3+
version = "0.0.1"
4+
edition = "2024"
5+
6+
[lints]
7+
workspace = true
8+
9+
[dependencies]
10+
acp-nats = { workspace = true }
11+
agent-client-protocol = { workspace = true, features = [
12+
"unstable_auth_methods",
13+
"unstable_boolean_config",
14+
"unstable_cancel_request",
15+
"unstable_message_id",
16+
"unstable_session_close",
17+
"unstable_session_fork",
18+
"unstable_session_model",
19+
"unstable_session_resume",
20+
"unstable_session_usage",
21+
] }
22+
async-nats = { workspace = true }
23+
async-trait = { workspace = true }
24+
bytes = { workspace = true }
25+
futures = { workspace = true }
26+
serde = { workspace = true }
27+
serde_json = { workspace = true }
28+
tokio = { workspace = true, features = ["rt", "macros", "sync"] }
29+
tracing = { workspace = true }
30+
trogon-nats = { workspace = true }
31+
trogon-std = { workspace = true }
32+
33+
[dev-dependencies]
34+
tokio = { workspace = true, features = ["test-util"] }
35+
trogon-nats = { workspace = true, features = ["test-support"] }
36+
trogon-std = { workspace = true, features = ["test-support"] }
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
use acp_nats::AcpPrefix;
2+
3+
pub struct AgentConfig {
4+
acp_prefix: AcpPrefix,
5+
}
6+
7+
impl AgentConfig {
8+
pub fn new(acp_prefix: AcpPrefix) -> Self {
9+
Self { acp_prefix }
10+
}
11+
12+
pub fn acp_prefix(&self) -> &AcpPrefix {
13+
&self.acp_prefix
14+
}
15+
}
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
use acp_nats::AcpSessionId;
2+
3+
const AGENT_MARKER: &str = ".agent.";
4+
const EXT_PREFIX: &str = "agent.ext.";
5+
6+
#[derive(Debug, Clone, PartialEq, Eq)]
7+
pub enum AgentMethod {
8+
Initialize,
9+
Authenticate,
10+
SessionNew,
11+
SessionList,
12+
SessionLoad,
13+
SessionPrompt,
14+
SessionCancel,
15+
SessionSetMode,
16+
SessionSetConfigOption,
17+
SessionSetModel,
18+
SessionFork,
19+
SessionResume,
20+
SessionClose,
21+
Ext(String),
22+
}
23+
24+
impl AgentMethod {
25+
pub fn is_session_scoped(&self) -> bool {
26+
!matches!(
27+
self,
28+
Self::Initialize
29+
| Self::Authenticate
30+
| Self::SessionNew
31+
| Self::SessionList
32+
| Self::Ext(_)
33+
)
34+
}
35+
36+
fn from_suffix(suffix: &str) -> Option<Self> {
37+
match suffix {
38+
"agent.initialize" => Some(Self::Initialize),
39+
"agent.authenticate" => Some(Self::Authenticate),
40+
"agent.session.new" => Some(Self::SessionNew),
41+
"agent.session.list" => Some(Self::SessionList),
42+
"agent.session.load" => Some(Self::SessionLoad),
43+
"agent.session.prompt" => Some(Self::SessionPrompt),
44+
"agent.session.cancel" => Some(Self::SessionCancel),
45+
"agent.session.set_mode" => Some(Self::SessionSetMode),
46+
"agent.session.set_config_option" => Some(Self::SessionSetConfigOption),
47+
"agent.session.set_model" => Some(Self::SessionSetModel),
48+
"agent.session.fork" => Some(Self::SessionFork),
49+
"agent.session.resume" => Some(Self::SessionResume),
50+
"agent.session.close" => Some(Self::SessionClose),
51+
other => {
52+
let ext_name = other.strip_prefix(EXT_PREFIX)?;
53+
if ext_name.is_empty()
54+
|| ext_name.contains('*')
55+
|| ext_name.contains('>')
56+
|| ext_name.contains(' ')
57+
{
58+
return None;
59+
}
60+
Some(Self::Ext(ext_name.to_string()))
61+
}
62+
}
63+
}
64+
}
65+
66+
#[derive(Debug)]
67+
pub struct ParsedAgentSubject {
68+
pub session_id: Option<AcpSessionId>,
69+
pub method: AgentMethod,
70+
}
71+
72+
pub fn parse_agent_subject(subject: &str) -> Option<ParsedAgentSubject> {
73+
let agent_byte_pos = subject.rmatch_indices(AGENT_MARKER).next()?.0;
74+
75+
let suffix = &subject[agent_byte_pos + 1..];
76+
let method = AgentMethod::from_suffix(suffix)?;
77+
78+
let session_id = if method.is_session_scoped() {
79+
let before_agent = &subject[..agent_byte_pos];
80+
let session_dot = before_agent.rfind('.')?;
81+
let raw = &before_agent[session_dot + 1..];
82+
Some(AcpSessionId::new(raw).ok()?)
83+
} else {
84+
None
85+
};
86+
87+
Some(ParsedAgentSubject { session_id, method })
88+
}
89+
90+
#[cfg(test)]
91+
mod tests {
92+
use super::*;
93+
94+
#[test]
95+
fn parse_initialize() {
96+
let parsed = parse_agent_subject("acp.agent.initialize").unwrap();
97+
assert!(parsed.session_id.is_none());
98+
assert_eq!(parsed.method, AgentMethod::Initialize);
99+
}
100+
101+
#[test]
102+
fn parse_authenticate() {
103+
let parsed = parse_agent_subject("acp.agent.authenticate").unwrap();
104+
assert!(parsed.session_id.is_none());
105+
assert_eq!(parsed.method, AgentMethod::Authenticate);
106+
}
107+
108+
#[test]
109+
fn parse_session_new() {
110+
let parsed = parse_agent_subject("acp.agent.session.new").unwrap();
111+
assert!(parsed.session_id.is_none());
112+
assert_eq!(parsed.method, AgentMethod::SessionNew);
113+
}
114+
115+
#[test]
116+
fn parse_session_list() {
117+
let parsed = parse_agent_subject("acp.agent.session.list").unwrap();
118+
assert!(parsed.session_id.is_none());
119+
assert_eq!(parsed.method, AgentMethod::SessionList);
120+
}
121+
122+
#[test]
123+
fn parse_session_load() {
124+
let parsed = parse_agent_subject("acp.s1.agent.session.load").unwrap();
125+
assert_eq!(parsed.session_id.unwrap().as_str(), "s1");
126+
assert_eq!(parsed.method, AgentMethod::SessionLoad);
127+
}
128+
129+
#[test]
130+
fn parse_session_prompt() {
131+
let parsed = parse_agent_subject("acp.s1.agent.session.prompt").unwrap();
132+
assert_eq!(parsed.session_id.unwrap().as_str(), "s1");
133+
assert_eq!(parsed.method, AgentMethod::SessionPrompt);
134+
}
135+
136+
#[test]
137+
fn parse_session_cancel() {
138+
let parsed = parse_agent_subject("acp.s1.agent.session.cancel").unwrap();
139+
assert_eq!(parsed.session_id.unwrap().as_str(), "s1");
140+
assert_eq!(parsed.method, AgentMethod::SessionCancel);
141+
}
142+
143+
#[test]
144+
fn parse_session_set_mode() {
145+
let parsed = parse_agent_subject("acp.s1.agent.session.set_mode").unwrap();
146+
assert_eq!(parsed.session_id.unwrap().as_str(), "s1");
147+
assert_eq!(parsed.method, AgentMethod::SessionSetMode);
148+
}
149+
150+
#[test]
151+
fn parse_session_set_config_option() {
152+
let parsed = parse_agent_subject("acp.s1.agent.session.set_config_option").unwrap();
153+
assert_eq!(parsed.session_id.unwrap().as_str(), "s1");
154+
assert_eq!(parsed.method, AgentMethod::SessionSetConfigOption);
155+
}
156+
157+
#[test]
158+
fn parse_session_set_model() {
159+
let parsed = parse_agent_subject("acp.s1.agent.session.set_model").unwrap();
160+
assert_eq!(parsed.session_id.unwrap().as_str(), "s1");
161+
assert_eq!(parsed.method, AgentMethod::SessionSetModel);
162+
}
163+
164+
#[test]
165+
fn parse_session_fork() {
166+
let parsed = parse_agent_subject("acp.s1.agent.session.fork").unwrap();
167+
assert_eq!(parsed.session_id.unwrap().as_str(), "s1");
168+
assert_eq!(parsed.method, AgentMethod::SessionFork);
169+
}
170+
171+
#[test]
172+
fn parse_session_resume() {
173+
let parsed = parse_agent_subject("acp.s1.agent.session.resume").unwrap();
174+
assert_eq!(parsed.session_id.unwrap().as_str(), "s1");
175+
assert_eq!(parsed.method, AgentMethod::SessionResume);
176+
}
177+
178+
#[test]
179+
fn parse_session_close() {
180+
let parsed = parse_agent_subject("acp.s1.agent.session.close").unwrap();
181+
assert_eq!(parsed.session_id.unwrap().as_str(), "s1");
182+
assert_eq!(parsed.method, AgentMethod::SessionClose);
183+
}
184+
185+
#[test]
186+
fn parse_ext_method() {
187+
let parsed = parse_agent_subject("acp.agent.ext.my_tool").unwrap();
188+
assert!(parsed.session_id.is_none());
189+
assert_eq!(parsed.method, AgentMethod::Ext("my_tool".to_string()));
190+
}
191+
192+
#[test]
193+
fn parse_ext_dotted_namespace() {
194+
let parsed = parse_agent_subject("acp.agent.ext.vendor.operation").unwrap();
195+
assert!(parsed.session_id.is_none());
196+
assert_eq!(
197+
parsed.method,
198+
AgentMethod::Ext("vendor.operation".to_string())
199+
);
200+
}
201+
202+
#[test]
203+
fn parse_custom_prefix() {
204+
let parsed = parse_agent_subject("myapp.agent.initialize").unwrap();
205+
assert!(parsed.session_id.is_none());
206+
assert_eq!(parsed.method, AgentMethod::Initialize);
207+
}
208+
209+
#[test]
210+
fn parse_multi_part_prefix() {
211+
let parsed = parse_agent_subject("my.multi.prefix.s1.agent.session.load").unwrap();
212+
assert_eq!(parsed.session_id.unwrap().as_str(), "s1");
213+
assert_eq!(parsed.method, AgentMethod::SessionLoad);
214+
}
215+
216+
#[test]
217+
fn parse_empty_returns_none() {
218+
assert!(parse_agent_subject("").is_none());
219+
}
220+
221+
#[test]
222+
fn parse_no_agent_marker_returns_none() {
223+
assert!(parse_agent_subject("acp.client.session.update").is_none());
224+
}
225+
226+
#[test]
227+
fn parse_unknown_method_returns_none() {
228+
assert!(parse_agent_subject("acp.agent.unknown.method").is_none());
229+
}
230+
231+
#[test]
232+
fn parse_invalid_session_id_returns_none() {
233+
assert!(parse_agent_subject("acp.sess*ion.agent.session.load").is_none());
234+
}
235+
236+
#[test]
237+
fn parse_ext_empty_name_returns_none() {
238+
assert!(parse_agent_subject("acp.agent.ext.").is_none());
239+
}
240+
241+
#[test]
242+
fn parse_ext_wildcard_returns_none() {
243+
assert!(parse_agent_subject("acp.agent.ext.*").is_none());
244+
}
245+
246+
#[test]
247+
fn parse_multi_dot_prefix_global_method_has_no_session() {
248+
let parsed = parse_agent_subject("my.multi.agent.initialize").unwrap();
249+
assert!(parsed.session_id.is_none());
250+
assert_eq!(parsed.method, AgentMethod::Initialize);
251+
}
252+
253+
#[test]
254+
fn parse_prefix_containing_agent_word() {
255+
let parsed = parse_agent_subject("org.agent.app.agent.initialize").unwrap();
256+
assert!(parsed.session_id.is_none());
257+
assert_eq!(parsed.method, AgentMethod::Initialize);
258+
}
259+
260+
#[test]
261+
fn parse_multi_dot_prefix_session_scoped() {
262+
let parsed = parse_agent_subject("my.multi.s1.agent.session.prompt").unwrap();
263+
assert_eq!(parsed.session_id.unwrap().as_str(), "s1");
264+
assert_eq!(parsed.method, AgentMethod::SessionPrompt);
265+
}
266+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
mod config;
2+
mod dispatch;
3+
mod serve;
4+
5+
pub use config::AgentConfig;
6+
pub use serve::AgentServer;

0 commit comments

Comments
 (0)