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}