1use crate::llm::actions::{
5 client_trait::{Client, ClientActionResult},
6 protocol_trait::Protocol,
7 ActionDefinition, ParameterDefinition, StartupExamples,
8};
9use crate::protocol::{ConnectContext, EventType};
10use crate::server::libp2p::{
11 actions::{action, p},
12 host, wire,
13};
14use crate::state::app_state::AppState;
15use anyhow::{bail, ensure, Context, Result};
16use serde_json::{json, Value};
17use std::sync::LazyLock;
18
19#[derive(Default)]
20pub struct Libp2pClientProtocol;
21impl Libp2pClientProtocol {
22 pub fn new() -> Self {
23 Self
24 }
25}
26
27fn data(required: bool) -> Vec<crate::llm::actions::Parameter> {
28 vec![
29 p(
30 "data",
31 "string",
32 "The message: text, or hex with encoding hex",
33 required,
34 ),
35 p("encoding", "string", "utf8 (default) or hex", false),
36 ]
37}
38
39pub fn actions() -> Vec<ActionDefinition> {
40 let mut open = vec![p(
41 "protocol",
42 "string",
43 "The protocol id to open, e.g. /netget/chat/1.0.0",
44 true,
45 )];
46 open.extend(data(false));
47 let mut send = vec![p(
48 "stream_id",
49 "number",
50 "The stream to write on (from libp2p_response or libp2p_message)",
51 true,
52 )];
53 send.extend(data(true));
54 vec![
55 action(
56 "libp2p_open_stream",
57 "Open a stream on a protocol the remote supports, optionally with a first message.",
58 open,
59 json!({"type": "libp2p_open_stream", "protocol": "/netget/chat/1.0.0", "data": "hello"}),
60 ),
61 action(
62 "libp2p_send",
63 "Send one length-prefixed message on an open stream.",
64 send,
65 json!({"type": "libp2p_send", "stream_id": 1, "data": "hello again"}),
66 ),
67 action(
68 "libp2p_close_stream",
69 "Close our half of a stream; the remote reads end-of-stream.",
70 vec![p(
71 "stream_id",
72 "number",
73 "The stream to close, by its id",
74 true,
75 )],
76 json!({"type": "libp2p_close_stream", "stream_id": 1}),
77 ),
78 action(
79 "libp2p_ping",
80 "Ping the remote (/ipfs/ping/1.0.0) and learn the round-trip time.",
81 vec![],
82 json!({"type": "libp2p_ping"}),
83 ),
84 action(
85 "libp2p_identify",
86 "Ask the remote's identify again: agent, protocols and addresses.",
87 vec![],
88 json!({"type": "libp2p_identify"}),
89 ),
90 action(
91 "ipfs_bitswap_want",
92 "Ask the remote for blocks over Bitswap (needs the ipfs startup parameter). Each block that comes back is hashed and checked against the CID you asked for before it reaches you as ipfs_block_received; anything else is refused. DONT_HAVE and HAVE arrive as ipfs_bitswap_presence.",
93 vec![
94 p("cids", "array", "CIDs to fetch (Qm… or b…), raw or dag-pb", true),
95 p("want", "string", "block (default: send the block) or have (only say whether you have it)", false),
96 ],
97 json!({"type": "ipfs_bitswap_want", "cids": ["bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"]}),
98 ),
99 action(
100 "ipfs_bitswap_cancel",
101 "Withdraw wants sent with ipfs_bitswap_want.",
102 vec![p("cids", "array", "CIDs no longer wanted", true)],
103 json!({"type": "ipfs_bitswap_cancel", "cids": ["bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"]}),
104 ),
105 action(
106 "ipfs_kad_find_node",
107 "Ask the remote's Kademlia DHT for the peers closest to a peer id (FIND_NODE). The answer arrives as libp2p_response with closer_peers.",
108 vec![
109 p("peer_id", "string", "The peer id to look up", true),
110 p("protocol", "string", "/ipfs/kad/1.0.0 (default) or the LAN DHT's /ipfs/lan/kad/1.0.0", false),
111 ],
112 json!({"type": "ipfs_kad_find_node", "peer_id": "12D3KooWD3eckifWpRn9wQpMG9R9hX3sD158z7EqHWmweQAJU5SA"}),
113 ),
114 action(
115 "ipfs_kad_get_providers",
116 "Ask the remote's Kademlia DHT who provides a CID (GET_PROVIDERS). The answer arrives as libp2p_response with provider_peers and closer_peers.",
117 vec![
118 p("cid", "string", "The CID whose providers to find", true),
119 p("protocol", "string", "/ipfs/kad/1.0.0 (default) or the LAN DHT's /ipfs/lan/kad/1.0.0", false),
120 ],
121 json!({"type": "ipfs_kad_get_providers", "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}),
122 ),
123 action(
124 "disconnect",
125 "Close the connection.",
126 vec![],
127 json!({"type": "disconnect"}),
128 ),
129 ]
130}
131
132fn event(id: &str, description: &str, params: Vec<crate::llm::actions::Parameter>) -> EventType {
133 EventType::new(
134 id,
135 description,
136 json!({"type": "libp2p_open_stream", "protocol": "/netget/chat/1.0.0", "data": "hello"}),
137 )
138 .with_parameters(params)
139 .with_actions(actions())
140}
141
142pub static CONNECTED_EVENT: LazyLock<EventType> =
143 LazyLock::new(|| {
144 event(
145 "libp2p_connected",
146 "Connected: the remote proved its peer id in the Noise handshake and answered identify.",
147 vec![
148 p("peer_id", "string", "The remote's peer id", true),
149 p("agent_version", "string", "What the remote says it is, e.g. go-libp2p", true),
150 p("protocols", "array", "The protocols the remote supports", true),
151 p("listen_addrs", "array", "Where the remote says it listens", true),
152 p("observed_addr", "string", "This client's address as the remote sees it", false),
153 ],
154 )
155 });
156
157pub static MESSAGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
158 event(
159 "libp2p_message",
160 "The remote sent a message, on a stream either side opened.",
161 vec![
162 p(
163 "stream_id",
164 "number",
165 "The stream it arrived on (use it with libp2p_send)",
166 true,
167 ),
168 p("protocol", "string", "The stream's protocol id", true),
169 p(
170 "data",
171 "string",
172 "The message (text, or hex when encoding is hex)",
173 true,
174 ),
175 p(
176 "encoding",
177 "string",
178 "How data is written: utf8 text, or hex for binary",
179 true,
180 ),
181 ],
182 )
183});
184
185pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
186 event(
187 "libp2p_response",
188 "The outcome of an action that asks something of the remote (open_stream, ping, identify).",
189 vec![
190 p("operation", "string", "The action that was performed", true),
191 p("ok", "boolean", "Whether it succeeded", true),
192 p(
193 "stream_id",
194 "number",
195 "The new stream, for open_stream",
196 false,
197 ),
198 p("rtt_ms", "number", "The round trip, for ping", false),
199 p(
200 "result",
201 "object",
202 "The remote's identify, for identify",
203 false,
204 ),
205 p(
206 "closer_peers",
207 "array",
208 "For a DHT request: [{peer_id, addrs}] the remote says are closer",
209 false,
210 ),
211 p(
212 "provider_peers",
213 "array",
214 "For ipfs_kad_get_providers: [{peer_id, addrs}] that have the content",
215 false,
216 ),
217 p(
218 "error",
219 "string",
220 "Why it failed, e.g. the remote refused the protocol",
221 false,
222 ),
223 ],
224 )
225});
226
227pub static BLOCK_EVENT: LazyLock<EventType> = LazyLock::new(|| {
228 event(
229 "ipfs_block_received",
230 "A block you wanted arrived over Bitswap, and its bytes hash to the CID you asked for. A dag-pb block's links are listed; want them to fetch the rest of a file or directory.",
231 vec![
232 p("cid", "string", "The CID you asked for", true),
233 p("codec", "string", "The block's codec: raw (the bytes are the content) or dag-pb (a UnixFS node)", true),
234 p("size", "number", "The block's size in bytes", true),
235 p("data", "string", "The content: the block for raw, the UnixFS data for dag-pb (text, or hex when encoding is hex)", true),
236 p("encoding", "string", "How data is written: utf8 text, or hex for binary", true),
237 p("truncated", "boolean", "Whether data was cut short for display (the block was verified whole)", true),
238 p("unixfs_type", "string", "For dag-pb: file, directory, raw, symlink, …", false),
239 p("file_size", "number", "For a UnixFS file: the whole file's size", false),
240 p("links", "array", "For dag-pb: [{cid, name, size}] of the children", true),
241 ],
242 )
243});
244
245pub static PRESENCE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
246 event(
247 "ipfs_bitswap_presence",
248 "The remote said whether it has a block you wanted: HAVE (have true) or DONT_HAVE.",
249 vec![
250 p("cid", "string", "The CID you asked about", true),
251 p(
252 "have",
253 "boolean",
254 "true for HAVE, false for DONT_HAVE",
255 true,
256 ),
257 ],
258 )
259});
260
261pub fn check(v: &Value) -> Result<()> {
262 let stream = || {
263 v["stream_id"]
264 .as_u64()
265 .context("stream_id must be a number")
266 };
267 match v["type"].as_str().unwrap_or_default() {
268 "libp2p_open_stream" => {
269 let proto = v["protocol"].as_str().context("protocol is required")?;
270 ensure!(
271 proto.starts_with('/') && proto.len() <= 256 && !proto.contains('\n'),
272 "protocol must be a protocol id such as /netget/chat/1.0.0"
273 );
274 if !v["data"].is_null() {
275 wire::data_bytes(v)?;
276 }
277 }
278 "libp2p_send" => {
279 stream()?;
280 ensure!(
281 wire::data_bytes(v)?.len() <= host::MAX_MESSAGE,
282 "message too long"
283 );
284 }
285 "libp2p_close_stream" => {
286 stream()?;
287 }
288 "libp2p_ping" | "libp2p_identify" | "disconnect" => {}
289 "ipfs_bitswap_want" => {
290 super::ipfs::cids(v)?;
291 super::ipfs::want_type(v)?;
292 }
293 "ipfs_bitswap_cancel" => {
294 super::ipfs::cids(v)?;
295 }
296 "ipfs_kad_find_node" | "ipfs_kad_get_providers" => {
297 super::ipfs::kad_request(v)?;
298 super::ipfs::kad_protocol(v)?;
299 }
300 t => bail!("Unknown libp2p client action {t:?}"),
301 }
302 Ok(())
303}
304
305impl Protocol for Libp2pClientProtocol {
306 fn protocol_name(&self) -> &'static str {
307 "libp2p"
308 }
309 fn stack_name(&self) -> &'static str {
310 "ETH>IP>TCP>libp2p"
311 }
312 fn keywords(&self) -> Vec<&'static str> {
313 vec![
314 "libp2p",
315 "ipfs",
316 "p2p",
317 "dial peer",
318 "multiaddr",
319 "bitswap",
320 "cid",
321 "kademlia",
322 "kubo",
323 ]
324 }
325 fn description(&self) -> &'static str {
326 "libp2p dialler over TCP (Noise, yamux): identifies and pings the remote, talks length-prefixed messages on its protocols' streams, and with ipfs on fetches blocks by CID over Bitswap (each verified) and asks the remote's Kademlia DHT"
327 }
328 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
329 actions()
330 }
331 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
332 vec![]
333 }
334 fn get_event_types(&self) -> Vec<EventType> {
335 vec![
336 CONNECTED_EVENT.clone(),
337 MESSAGE_EVENT.clone(),
338 RESPONSE_EVENT.clone(),
339 BLOCK_EVENT.clone(),
340 PRESENCE_EVENT.clone(),
341 ]
342 }
343 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
344 vec![
345 ParameterDefinition {
346 name: "peer_id".into(),
347 type_hint: "string".into(),
348 description: "The remote's expected peer id (12D3KooW…); the handshake fails if it proves another. Also accepted inside a /p2p/ multiaddr.".into(),
349 required: false,
350 example: json!("12D3KooWD3eckifWpRn9wQpMG9R9hX3sD158z7EqHWmweQAJU5SA"),
351 default: None,
352 },
353 ParameterDefinition {
354 name: "protocols".into(),
355 type_hint: "array".into(),
356 description: "Application protocol ids the remote may open streams to this client on".into(),
357 required: false,
358 example: json!(["/netget/chat/1.0.0"]),
359 default: Some(json!(host::DEFAULT_PROTOCOLS)),
360 },
361 ParameterDefinition {
362 name: "private_key_seed".into(),
363 type_hint: "string".into(),
364 description: "Any text; its SHA-256 is the Ed25519 identity key, so the same text gives the same peer id (default: a new key)".into(),
365 required: false,
366 example: json!("my-stable-node"),
367 default: None,
368 },
369 ParameterDefinition {
370 name: "ipfs".into(),
371 type_hint: "boolean".into(),
372 description: "Also speak IPFS: accept Bitswap so blocks wanted with ipfs_bitswap_want can arrive (each verified against its CID)".into(),
373 required: false,
374 example: json!(true),
375 default: Some(json!(crate::server::libp2p::actions::IPFS_DEFAULT)),
376 },
377 ]
378 }
379 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
380 use crate::protocol::metadata::*;
381 ProtocolMetadataV2::builder()
382 .state(DevelopmentState::Experimental)
383 .privilege_requirement(PrivilegeRequirement::None)
384 .implementation("The server's hand-rolled stack in the dialler role: multistream-select, Noise XX as initiator (checking the remote's peer id when one is named), yamux, identify and ping; the remote's own identify and ping are answered. With ipfs: Bitswap wants on a stream of its own and blocks accepted on the remote's streams, each hashed against the outstanding wants; DHT FIND_NODE and GET_PROVIDERS, one question per stream")
385 .llm_control("Which protocols to open streams on, what to send, when to ping, identify, close and disconnect, and how to answer the remote's messages. With ipfs: which CIDs to want or cancel (a dag-pb block's links let it walk a chunked file or directory), and what to ask the remote's DHT")
386 .e2e_testing("tests/client/libp2p: go-libp2p listening (TCP, Noise, yamux), which identifies NetGet, answers its chat stream and opens one of its own to NetGet; Kubo 0.43.1 (loopback only) serves files it added — a CIDv0 UnixFS file, a CIDv1 raw block, and a chunked file whose leaves NetGet wants from the root's links — and answers FIND_NODE and GET_PROVIDERS; a go-libp2p peer answering wants with tampered bytes is refused before the model hears of it")
387 .notes("TCP only; Ed25519 identities only; no gossipsub, relay or hole punching. remote_addr is host:port or /ip4|ip6|dns/…/tcp/…[/p2p/<id>]. A handler chain stops after 8 follow-ups. IPFS limits: sha2-256 and the raw and dag-pb codecs only (other CIDs are refused before anything is sent); at most 1024 outstanding wants; block content shown to the model is cut at 64 KiB (the block is verified whole); the client serves no blocks and answers no DHT requests; Kubo keeps its own provider records in its LAN DHT while its WAN DHT is inactive, so ask /ipfs/lan/kad/1.0.0 on a private network.")
388 .build()
389 }
390 fn example_prompt(&self) -> &'static str {
391 "Dial the libp2p peer /ip4/127.0.0.1/tcp/4001/p2p/12D3KooW... and say hello on /netget/chat/1.0.0"
392 }
393 fn get_startup_examples(&self) -> StartupExamples {
394 let llm = json!({"type":"open_client","protocol":"libp2p","remote_addr":"127.0.0.1:4001",
395 "instruction":"Say hello on /netget/chat/1.0.0 and answer whatever comes back"});
396 let mut static_example = llm.clone();
397 static_example["event_handlers"] = json!([{"event_pattern":"libp2p_connected","handler":{"type":"static","actions":[{"type":"libp2p_open_stream","protocol":"/netget/chat/1.0.0","data":"hello"}]}}]);
398 let mut scripted = llm.clone();
399 scripted["event_handlers"] = json!([{"event_pattern":"*","handler":{"type":"script","language":"python","code":"import json,sys\ni=json.load(sys.stdin); t=i['event_type_id']; e=i['event']\na=[]\nif t=='libp2p_connected': a=[{'type':'libp2p_open_stream','protocol':'/netget/chat/1.0.0','data':'hello'}]\nprint(json.dumps({'actions':a}))"}}]);
400 StartupExamples::new(llm, scripted, static_example)
401 }
402 fn group_name(&self) -> &'static str {
403 "Peer-to-peer"
404 }
405}
406
407impl Client for Libp2pClientProtocol {
408 fn connect(
409 &self,
410 ctx: ConnectContext,
411 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
412 {
413 Box::pin(super::connect(ctx))
414 }
415 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
416 let name = v["type"].as_str().unwrap_or_default().to_string();
417 if name == "disconnect" {
418 return Ok(ClientActionResult::Disconnect);
419 }
420 check(&v)?;
421 Ok(ClientActionResult::Custom { name, data: v })
422 }
423}