Skip to main content

netget/client/gemini/
actions.rs

1use super::wire::Request;
2use crate::llm::actions::{
3    client_trait::{Client, ClientActionResult},
4    protocol_trait::Protocol,
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::{ConnectContext, EventType};
9use crate::state::app_state::AppState;
10use anyhow::{bail, Result};
11use serde_json::{json, Value};
12use std::sync::LazyLock;
13#[derive(Default)]
14pub struct GeminiClientProtocol;
15impl GeminiClientProtocol {
16    pub fn new() -> Self {
17        Self
18    }
19}
20fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
21    Parameter {
22        name: name.into(),
23        type_hint: kind.into(),
24        description: description.into(),
25        required,
26    }
27}
28fn action(
29    name: &str,
30    description: &str,
31    parameters: Vec<Parameter>,
32    example: Value,
33    log_template: &str,
34) -> ActionDefinition {
35    ActionDefinition {
36        name: name.into(),
37        description: description.into(),
38        parameters,
39        example,
40        log_template: Some(LogTemplate::new().with_info(log_template)),
41    }
42}
43fn request_action() -> ActionDefinition {
44    action("gemini_request", "Fetch a Gemini URL on this client's endpoint; redirects and input prompts are returned for an explicit next action", vec![
45        parameter("url","string","Absolute gemini:// URL, maximum1024 bytes; host/port must match the configured endpoint",true),
46        parameter("input","string","Optional response to an input prompt; UTF-8 percent-encoded as the URL query",false),
47    ],json!({"type":"gemini_request","url":"gemini://localhost:1965/"}), "Request Gemini capsule response")
48}
49fn disconnect_action() -> ActionDefinition {
50    action(
51        "disconnect",
52        "Close the Gemini connection",
53        vec![],
54        json!({"type":"disconnect"}),
55        "Disconnect Gemini capsule",
56    )
57}
58fn actions() -> Vec<ActionDefinition> {
59    vec![request_action(), disconnect_action()]
60}
61pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
62    EventType::new(
63        "gemini_connected",
64        "Connected to Gemini; send a request",
65        request_action().example.clone(),
66    )
67    .with_parameters(vec![
68        parameter("remote_addr", "string", "Gemini server address", true),
69        parameter("server_name", "string", "Verified TLS server name", true),
70    ])
71    .with_actions(actions())
72});
73pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
74    EventType::new(
75        "gemini_response",
76        "Complete, validated Gemini response including protocol refusals.",
77        disconnect_action().example.clone(),
78    )
79    .with_parameters(vec![
80        parameter("request", "object", "Structured request", true),
81        parameter(
82            "response",
83            "object",
84            "status/meta/kind plus text, structured gemtext, redirect target or input prompt",
85            true,
86        ),
87    ])
88    .with_actions(actions())
89});
90impl Protocol for GeminiClientProtocol {
91    fn protocol_name(&self) -> &'static str {
92        "Gemini"
93    }
94    fn stack_name(&self) -> &'static str {
95        "ETH>IP>TCP>TLS>GEMINI"
96    }
97    fn keywords(&self) -> Vec<&'static str> {
98        vec!["gemini", "gemini://", "gemtext", "capsule"]
99    }
100    fn description(&self) -> &'static str {
101        "Gemini capsule client with verified TLS and structured gemtext"
102    }
103    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
104        actions()
105    }
106    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
107        vec![]
108    }
109    fn get_event_types(&self) -> Vec<EventType> {
110        vec![CONNECTED_EVENT.clone(), RESPONSE_EVENT.clone()]
111    }
112    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
113        use crate::protocol::metadata::*;
114        ProtocolMetadataV2::builder().state(DevelopmentState::Experimental).privilege_requirement(PrivilegeRequirement::None).well_known_port(1965)
115        .implementation("rustls TLS1.2/1.3; bounded Gemini framing and structured gemtext")
116        .llm_control("URLs, explicit input replies and redirect decisions; parsed status/meta/text/links")
117        .e2e_testing("tests/client/gemini: independent Agate server, NetGet pair, certificate rejection, wire bounds and lifecycle")
118        .notes("Text MIME bodies only, UTF-8/US-ASCII; no binary downloads or client certificate authentication. TLS verified with Mozilla roots or explicit custom CA, no hidden TOFU state. Fresh TLS connection per request;30s complete transaction deadline, body1MiB, header1029 bytes, gemtext8192 lines. Redirects never followed automatically.")
119        .max_inbound_bytes(super::wire::MAX_BODY_BYTES).build()
120    }
121    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
122        [("server_name", "TLS server name and required URL hostname; derived from remote_addr when omitted",json!("localhost")),
123         ("custom_ca_cert_pem", "PEM trust anchors instead of Mozilla roots; supply the capsule certificate for a self-signed server",json!("-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"))]
124         .into_iter().map(|(name,description,example)| crate::llm::actions::ParameterDefinition{name:name.into(),type_hint:"string".into(),description:description.into(),required:false,example,default:None}).collect()
125    }
126    fn example_prompt(&self) -> &'static str {
127        "Connect to Gemini at localhost:1965 and read the home capsule"
128    }
129    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
130        let llm = json!({"type":"open_client","protocol":"gemini","remote_addr":"localhost:1965","instruction":"Read the capsule home page"});
131        let mut static_example = llm.clone();
132        static_example["event_handlers"] = json!([{"event_pattern":"gemini_connected","handler":{"type":"static","actions":[{"type":"gemini_request","url":"gemini://localhost:1965/"}]}},{"event_pattern":"gemini_response","handler":{"type":"static","actions":[]}}]);
133        let mut scripted = static_example.clone();
134        scripted["event_handlers"][0]["handler"] = json!({"type":"script","language":"python","code":"respond([{'type':'gemini_request','url':'gemini://localhost:1965/'}])"});
135        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
136    }
137    fn group_name(&self) -> &'static str {
138        "Discovery"
139    }
140}
141impl Client for GeminiClientProtocol {
142    fn connect(
143        &self,
144        ctx: ConnectContext,
145    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
146    {
147        Box::pin(super::connect(ctx))
148    }
149    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
150        match v["type"].as_str() {
151            Some("gemini_request") => {
152                Request::from_action(&v)?;
153                Ok(ClientActionResult::Custom {
154                    name: "gemini_request".into(),
155                    data: v,
156                })
157            }
158            Some("disconnect") => Ok(ClientActionResult::Disconnect),
159            _ => bail!("Unknown Gemini client action"),
160        }
161    }
162}