1use crate::llm::actions::{
4 client_trait::{Client, ClientActionResult},
5 protocol_trait::Protocol,
6 ActionDefinition, Parameter,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14pub static ETCD_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16 EventType::new(
17 "etcd_connected",
18 "etcd client successfully connected to server",
19 json!({
20 "type": "etcd_get",
21 "key": "/config/database"
22 }),
23 )
24 .with_parameters(vec![Parameter {
25 name: "remote_addr".to_string(),
26 type_hint: "string".to_string(),
27 description: "etcd server address".to_string(),
28 required: true,
29 }])
30});
31
32pub static ETCD_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
34 EventType::new(
35 "etcd_response_received",
36 "Response received from etcd server",
37 json!({
38 "type": "etcd_put",
39 "key": "/config/database",
40 "value": "postgresql://localhost:5432/mydb"
41 }),
42 )
43 .with_parameters(vec![
44 Parameter {
45 name: "operation".to_string(),
46 type_hint: "string".to_string(),
47 description: "The operation type (get, put, delete)".to_string(),
48 required: true,
49 },
50 Parameter {
51 name: "key".to_string(),
52 type_hint: "string".to_string(),
53 description: "The key that was operated on".to_string(),
54 required: true,
55 },
56 ])
57});
58
59pub struct EtcdClientProtocol;
61
62impl EtcdClientProtocol {
63 pub fn new() -> Self {
64 Self
65 }
66}
67
68impl Protocol for EtcdClientProtocol {
70 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
71 vec![
72 ActionDefinition {
73 name: "etcd_get".to_string(),
74 description: "Get a key-value pair from etcd".to_string(),
75 parameters: vec![Parameter {
76 name: "key".to_string(),
77 type_hint: "string".to_string(),
78 description: "Key to retrieve".to_string(),
79 required: true,
80 }],
81 example: json!({
82 "type": "etcd_get",
83 "key": "/config/database"
84 }),
85 log_template: None,
86 },
87 ActionDefinition {
88 name: "etcd_put".to_string(),
89 description: "Put a key-value pair into etcd".to_string(),
90 parameters: vec![
91 Parameter {
92 name: "key".to_string(),
93 type_hint: "string".to_string(),
94 description: "Key to set".to_string(),
95 required: true,
96 },
97 Parameter {
98 name: "value".to_string(),
99 type_hint: "string".to_string(),
100 description: "Value to set".to_string(),
101 required: true,
102 },
103 ],
104 example: json!({
105 "type": "etcd_put",
106 "key": "/config/database",
107 "value": "postgresql://localhost:5432/mydb"
108 }),
109 log_template: None,
110 },
111 ActionDefinition {
112 name: "etcd_delete".to_string(),
113 description: "Delete a key from etcd".to_string(),
114 parameters: vec![Parameter {
115 name: "key".to_string(),
116 type_hint: "string".to_string(),
117 description: "Key to delete".to_string(),
118 required: true,
119 }],
120 example: json!({
121 "type": "etcd_delete",
122 "key": "/config/database"
123 }),
124 log_template: None,
125 },
126 ActionDefinition {
127 name: "disconnect".to_string(),
128 description: "Disconnect from the etcd server".to_string(),
129 parameters: vec![],
130 example: json!({
131 "type": "disconnect"
132 }),
133 log_template: None,
134 },
135 ]
136 }
137 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
138 vec![
139 ActionDefinition {
140 name: "etcd_get".to_string(),
141 description: "Get a key in response to received data".to_string(),
142 parameters: vec![Parameter {
143 name: "key".to_string(),
144 type_hint: "string".to_string(),
145 description: "Key to retrieve".to_string(),
146 required: true,
147 }],
148 example: json!({
149 "type": "etcd_get",
150 "key": "/config/database"
151 }),
152 log_template: None,
153 },
154 ActionDefinition {
155 name: "etcd_put".to_string(),
156 description: "Put a key in response to received data".to_string(),
157 parameters: vec![
158 Parameter {
159 name: "key".to_string(),
160 type_hint: "string".to_string(),
161 description: "Key to set".to_string(),
162 required: true,
163 },
164 Parameter {
165 name: "value".to_string(),
166 type_hint: "string".to_string(),
167 description: "Value to set".to_string(),
168 required: true,
169 },
170 ],
171 example: json!({
172 "type": "etcd_put",
173 "key": "/config/database",
174 "value": "postgresql://localhost:5432/mydb"
175 }),
176 log_template: None,
177 },
178 ]
179 }
180 fn protocol_name(&self) -> &'static str {
181 "etcd"
182 }
183 fn get_event_types(&self) -> Vec<EventType> {
184 vec![
185 EventType::new(
186 "etcd_connected",
187 "Triggered when etcd client connects to server",
188 json!({"type": "placeholder", "event_id": "etcd_connected"}),
189 ),
190 EventType::new(
191 "etcd_response_received",
192 "Triggered when etcd client receives a response",
193 json!({"type": "placeholder", "event_id": "etcd_response_received"}),
194 ),
195 ]
196 }
197 fn stack_name(&self) -> &'static str {
198 "ETH>IP>TCP>gRPC>etcd"
199 }
200 fn keywords(&self) -> Vec<&'static str> {
201 vec!["etcd", "etcd client", "connect to etcd"]
202 }
203 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
204 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
205
206 ProtocolMetadataV2::builder()
207 .state(DevelopmentState::Beta)
208 .implementation("etcd-client crate for gRPC-based KV operations")
209 .llm_control("Full control over get/put/delete operations")
210 .e2e_testing(
211 "tests/client/etcd/real_server_test.rs, 6 LLM calls, against the official Go \
212 etcd (grpc-go; NetGet's client is etcd-client on tonic, so no code is shared) \
213 read back with etcdctl. The model puts a key, gets it, puts a second value \
214 built from the kvs entry etcd returned, and deletes the first; etcdctl must \
215 find exactly that value and the deleted key gone. Checked against etcd 3.5.17 \
216 and 3.7.1. Not #[ignore]d, and a missing etcd or etcdctl fails the test rather \
217 than skipping it.",
218 )
219 .notes(
220 "Single-key get, put and delete only: no range or prefix reads, transactions, \
221 watches, leases, authentication or TLS.",
222 )
223 .build()
224 }
225 fn description(&self) -> &'static str {
226 "etcd client for distributed key-value operations"
227 }
228 fn example_prompt(&self) -> &'static str {
229 "Connect to etcd at localhost:2379 and get the value of '/config/database'"
230 }
231 fn group_name(&self) -> &'static str {
232 "Database"
233 }
234 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
235 use crate::llm::actions::StartupExamples;
236 use serde_json::json;
237
238 StartupExamples::new(
239 json!({
241 "type": "open_client",
242 "remote_addr": "localhost:2379",
243 "base_stack": "etcd",
244 "instruction": "Store a configuration key and retrieve it back"
245 }),
246 json!({
248 "type": "open_client",
249 "remote_addr": "localhost:2379",
250 "base_stack": "etcd",
251 "event_handlers": [{
252 "event_pattern": "etcd_response_received",
253 "handler": {
254 "type": "script",
255 "language": "python",
256 "code": "<etcd_client_handler>"
257 }
258 }]
259 }),
260 json!({
262 "type": "open_client",
263 "remote_addr": "localhost:2379",
264 "base_stack": "etcd",
265 "event_handlers": [
266 {
267 "event_pattern": "etcd_connected",
268 "handler": {
269 "type": "static",
270 "actions": [{
271 "type": "etcd_put",
272 "key": "/config/app",
273 "value": "production"
274 }]
275 }
276 },
277 {
278 "event_pattern": "etcd_response_received",
279 "handler": {
280 "type": "static",
281 "actions": [{
282 "type": "disconnect"
283 }]
284 }
285 }
286 ]
287 }),
288 )
289 }
290}
291
292impl Client for EtcdClientProtocol {
294 fn connect(
295 &self,
296 ctx: crate::protocol::ConnectContext,
297 ) -> std::pin::Pin<
298 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
299 > {
300 Box::pin(async move {
301 use crate::client::etcd::EtcdClient;
302 EtcdClient::connect_with_llm_actions(
303 ctx.remote_addr,
304 ctx.llm_client,
305 ctx.state,
306 ctx.status_tx,
307 ctx.client_id,
308 )
309 .await
310 })
311 }
312 fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
313 let action_type = action
314 .get("type")
315 .and_then(|v| v.as_str())
316 .context("Missing 'type' field in action")?;
317
318 match action_type {
319 "etcd_get" => {
320 let key = action
321 .get("key")
322 .and_then(|v| v.as_str())
323 .context("Missing 'key' field")?
324 .to_string();
325
326 Ok(ClientActionResult::Custom {
327 name: "etcd_get".to_string(),
328 data: json!({
329 "key": key,
330 }),
331 })
332 }
333 "etcd_put" => {
334 let key = action
335 .get("key")
336 .and_then(|v| v.as_str())
337 .context("Missing 'key' field")?
338 .to_string();
339
340 let value = action
341 .get("value")
342 .and_then(|v| v.as_str())
343 .context("Missing 'value' field")?
344 .to_string();
345
346 Ok(ClientActionResult::Custom {
347 name: "etcd_put".to_string(),
348 data: json!({
349 "key": key,
350 "value": value,
351 }),
352 })
353 }
354 "etcd_delete" => {
355 let key = action
356 .get("key")
357 .and_then(|v| v.as_str())
358 .context("Missing 'key' field")?
359 .to_string();
360
361 Ok(ClientActionResult::Custom {
362 name: "etcd_delete".to_string(),
363 data: json!({
364 "key": key,
365 }),
366 })
367 }
368 "disconnect" => Ok(ClientActionResult::Disconnect),
369 _ => Err(anyhow::anyhow!(
370 "Unknown etcd client action: {}",
371 action_type
372 )),
373 }
374 }
375}