Skip to main content

netget/client/libp2p/
actions.rs

1//! What the model does as a libp2p dialler: open streams on the remote's protocols, send and
2//! close them, ping and re-identify the remote, disconnect. Messages the remote sends (on
3//! streams either side opened) come back as events.
4use 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}