/home/runner/work/lyquor/lyquor/lyquid-agent/src/service/descriptor.rs
Line | Count | Source |
1 | | //! Self-describing capability metadata for service-shaped Lyquids. |
2 | | //! |
3 | | //! A Lyquid that wants to be discoverable as a service exposes a |
4 | | //! `get_capabilities() -> LyquidResult<String>` ABI method returning a JSON-encoded |
5 | | //! array of [`ServiceDescriptor`]. Other Lyquids and off-chain clients read this to |
6 | | //! learn what capabilities the service offers, what input/output shapes to use, what |
7 | | //! risk tier it operates at, and what it typically costs. |
8 | | //! |
9 | | //! ## Forward compatibility |
10 | | //! |
11 | | //! `ServiceDescriptor` is a versioned data shape, not an on-chain ABI commitment. |
12 | | //! Optional fields use `#[serde(default)]` so older readers stay compatible. The |
13 | | //! `capability` string carries the contract — `"amazon.gift.research.v1"` and |
14 | | //! `"amazon.gift.research.v2"` are distinct capabilities. |
15 | | //! |
16 | | //! ## Why not commit schema hashes on chain? |
17 | | //! |
18 | | //! `input_schema` / `output_schema` are stored as full JSON Schema documents (not |
19 | | //! hashes) because the immediate consumer is an LLM coordinator that needs to *read* |
20 | | //! them. We deliberately skip on-chain hash commitment until a verifier exists — adding |
21 | | //! a `*_schema_hash` field today would be dead weight. |
22 | | |
23 | | use lyquor_primitives::LyquidID; |
24 | | use serde::{Deserialize, Serialize}; |
25 | | use serde_json::Value; |
26 | | |
27 | | use crate::core::RiskTier; |
28 | | |
29 | | /// One capability advertised by a Lyquid. |
30 | | #[derive(Clone, Debug, Serialize, Deserialize)] |
31 | | pub struct ServiceDescriptor { |
32 | | /// The LyquidID of the service exposing this capability. Self-describing so a |
33 | | /// coordinator can cache `(capability, lyquid_id)` pairs without needing a side |
34 | | /// channel to learn which Lyquid produced the descriptor. |
35 | | pub lyquid_id: LyquidID, |
36 | | /// Human-readable capability identifier (e.g. `"amazon.gift.research.v1"`). |
37 | | /// Version-suffixed by convention so distinct revisions of the same logical |
38 | | /// capability are distinguishable. |
39 | | pub capability: String, |
40 | | /// Semver string for the implementation. Lets a coordinator pick (or reject) a |
41 | | /// service based on bug fixes / behavior changes within the same capability id. |
42 | | pub version: String, |
43 | | /// Full JSON Schema describing the dispatch input the service accepts. |
44 | | pub input_schema: Value, |
45 | | /// Full JSON Schema describing the receipt output the service produces. |
46 | | pub output_schema: Value, |
47 | | /// Highest risk tier this capability operates at. Coordinators use this to drive |
48 | | /// approval policy before dispatching. |
49 | | pub max_risk: RiskTier, |
50 | | /// Optional cost estimate. Coordinators that budget across a plan need this to |
51 | | /// reject obvious blowups before dispatching. |
52 | | #[serde(default)] |
53 | | pub cost_hint: Option<CostHint>, |
54 | | /// Replay/retry semantics. |
55 | | pub idempotency: IdempotencyClass, |
56 | | /// `true` if a coordinator may call this capability without explicit per-deploy |
57 | | /// authorization (e.g. read-only catalog queries). Default `false` — callers |
58 | | /// should be allowlisted by the service. |
59 | | #[serde(default)] |
60 | | pub allowed_by_default: bool, |
61 | | } |
62 | | |
63 | | /// Cost hint for coordinator budgeting. `unit` names the cost unit |
64 | | /// (e.g. `"USDC"`); `typical_base_units` is a big-number string in that unit's smallest |
65 | | /// denomination (so a 1 USDC fee shows up as `"1000000"`). |
66 | | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] |
67 | | pub struct CostHint { |
68 | | pub unit: String, |
69 | | pub typical_base_units: String, |
70 | | } |
71 | | |
72 | | /// Replay/retry semantics of a capability. Lets a coordinator decide whether re-issuing |
73 | | /// the same call is safe and whether it needs to track an idempotency key. |
74 | | #[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] |
75 | | pub enum IdempotencyClass { |
76 | | /// Safe to retry; identical inputs produce the same effect (e.g. a `correlation_id`- |
77 | | /// keyed call where the service dedupes internally). |
78 | | Idempotent, |
79 | | /// Retrying may produce duplicate effects unless the caller dedupes. Coordinators |
80 | | /// should retry only with care. |
81 | | AtLeastOnce, |
82 | | /// Caller must supply an `idempotency_key`; the service refuses duplicates within a |
83 | | /// retention window. |
84 | | ExactlyOnce, |
85 | | } |
86 | | |
87 | | /// Encodes a slice of descriptors as a JSON array string, the format `get_capabilities()` |
88 | | /// methods return. Wraps `serde_json` errors with a small context prefix. |
89 | 4 | pub fn capabilities_json(descriptors: &[ServiceDescriptor]) -> Result<String, String> { |
90 | 4 | serde_json::to_string(descriptors).map_err(|e| format!0 ("encode capabilities: {e}")) |
91 | 4 | } |
92 | | |
93 | | impl ServiceDescriptor { |
94 | | /// Builder entry point. Required fields go up front; optionals (`cost`, |
95 | | /// `allowed_by_default`) are chainable setters with sensible defaults |
96 | | /// (`cost_hint = None`, `allowed_by_default = false`). |
97 | | /// |
98 | | /// Replaces 13-line struct literals in `get_capabilities()` ABI methods with: |
99 | | /// ```ignore |
100 | | /// ServiceDescriptor::build( |
101 | | /// ctx.lyquid_id, CAPABILITY, "1.0.0", |
102 | | /// my_input_schema(), my_output_schema(), |
103 | | /// RiskTier::SensitiveMutation, IdempotencyClass::AtLeastOnce, |
104 | | /// ).cost("USDC", "100000").allowed_by_default(true).to_capabilities_json() |
105 | | /// ``` |
106 | 2 | pub fn build( |
107 | 2 | lyquid_id: LyquidID, capability: impl Into<String>, version: impl Into<String>, input_schema: Value, |
108 | 2 | output_schema: Value, max_risk: RiskTier, idempotency: IdempotencyClass, |
109 | 2 | ) -> ServiceDescriptorBuilder { |
110 | 2 | ServiceDescriptorBuilder { |
111 | 2 | descriptor: ServiceDescriptor { |
112 | 2 | lyquid_id, |
113 | 2 | capability: capability.into(), |
114 | 2 | version: version.into(), |
115 | 2 | input_schema, |
116 | 2 | output_schema, |
117 | 2 | max_risk, |
118 | 2 | cost_hint: None, |
119 | 2 | idempotency, |
120 | 2 | allowed_by_default: false, |
121 | 2 | }, |
122 | 2 | } |
123 | 2 | } |
124 | | } |
125 | | |
126 | | /// Owns a partially-constructed `ServiceDescriptor`. See [`ServiceDescriptor::build`]. |
127 | | pub struct ServiceDescriptorBuilder { |
128 | | descriptor: ServiceDescriptor, |
129 | | } |
130 | | |
131 | | impl ServiceDescriptorBuilder { |
132 | | /// Set the cost hint (currency unit + typical amount in that unit's smallest |
133 | | /// denomination as a decimal string). |
134 | 1 | pub fn cost(mut self, unit: impl Into<String>, typical_base_units: impl Into<String>) -> Self { |
135 | 1 | self.descriptor.cost_hint = Some(CostHint { |
136 | 1 | unit: unit.into(), |
137 | 1 | typical_base_units: typical_base_units.into(), |
138 | 1 | }); |
139 | 1 | self |
140 | 1 | } |
141 | | |
142 | | /// Override `allowed_by_default` (defaults to `false`). |
143 | 1 | pub fn allowed_by_default(mut self, v: bool) -> Self { |
144 | 1 | self.descriptor.allowed_by_default = v; |
145 | 1 | self |
146 | 1 | } |
147 | | |
148 | | /// Consume the builder and return the descriptor. |
149 | 2 | pub fn finish(self) -> ServiceDescriptor { |
150 | 2 | self.descriptor |
151 | 2 | } |
152 | | |
153 | | /// Shortcut for `capabilities_json(&[self.finish()])` — the 1-descriptor case all |
154 | | /// current `get_capabilities()` ABI methods use. |
155 | 1 | pub fn to_capabilities_json(self) -> Result<String, String> { |
156 | 1 | capabilities_json(&[self.finish()]) |
157 | 1 | } |
158 | | } |
159 | | |
160 | | #[cfg(test)] |
161 | | mod tests { |
162 | | use super::*; |
163 | | use serde_json::json; |
164 | | |
165 | 3 | fn sample() -> ServiceDescriptor { |
166 | 3 | ServiceDescriptor { |
167 | 3 | lyquid_id: LyquidID([0xaa; 20]), |
168 | 3 | capability: "amazon.gift.research.v1".into(), |
169 | 3 | version: "1.0.0".into(), |
170 | 3 | input_schema: json!({"type": "object", "required": ["recipient_description"]}), |
171 | 3 | output_schema: json!({"type": "object", "required": ["recommendations"]}), |
172 | 3 | max_risk: RiskTier::ReadOnly, |
173 | 3 | cost_hint: Some(CostHint { |
174 | 3 | unit: "USDC".into(), |
175 | 3 | typical_base_units: "100000".into(), |
176 | 3 | }), |
177 | 3 | idempotency: IdempotencyClass::Idempotent, |
178 | 3 | allowed_by_default: false, |
179 | 3 | } |
180 | 3 | } |
181 | | |
182 | | #[test] |
183 | 1 | fn capabilities_json_roundtrip() { |
184 | 1 | let descriptors = vec![sample()]; |
185 | 1 | let encoded = capabilities_json(&descriptors).expect("encode"); |
186 | 1 | let decoded: Vec<ServiceDescriptor> = serde_json::from_str(&encoded).expect("decode"); |
187 | 1 | assert_eq!(decoded.len(), 1); |
188 | 1 | assert_eq!(decoded[0].capability, "amazon.gift.research.v1"); |
189 | 1 | assert_eq!(decoded[0].version, "1.0.0"); |
190 | 1 | assert_eq!(decoded[0].max_risk, RiskTier::ReadOnly); |
191 | 1 | assert_eq!(decoded[0].idempotency, IdempotencyClass::Idempotent); |
192 | 1 | assert_eq!( |
193 | 1 | decoded[0].cost_hint.as_ref().unwrap(), |
194 | 1 | &CostHint { |
195 | 1 | unit: "USDC".into(), |
196 | 1 | typical_base_units: "100000".into(), |
197 | 1 | } |
198 | | ); |
199 | 1 | assert!(!decoded[0].allowed_by_default); |
200 | 1 | } |
201 | | |
202 | | #[test] |
203 | 1 | fn cost_hint_is_optional() { |
204 | 1 | let mut d = sample(); |
205 | 1 | d.cost_hint = None; |
206 | 1 | let encoded = capabilities_json(&[d]).expect("encode"); |
207 | 1 | let decoded: Vec<ServiceDescriptor> = serde_json::from_str(&encoded).expect("decode"); |
208 | 1 | assert!(decoded[0].cost_hint.is_none()); |
209 | 1 | } |
210 | | |
211 | | #[test] |
212 | 1 | fn idempotency_variants_roundtrip() { |
213 | 3 | for variant in [ |
214 | 1 | IdempotencyClass::Idempotent, |
215 | 1 | IdempotencyClass::AtLeastOnce, |
216 | 1 | IdempotencyClass::ExactlyOnce, |
217 | 1 | ] { |
218 | 3 | let encoded = serde_json::to_string(&variant).expect("encode"); |
219 | 3 | let decoded: IdempotencyClass = serde_json::from_str(&encoded).expect("decode"); |
220 | 3 | assert_eq!(decoded, variant); |
221 | | } |
222 | 1 | } |
223 | | |
224 | | #[test] |
225 | 1 | fn capabilities_json_empty_slice() { |
226 | 1 | let encoded = capabilities_json(&[]).expect("encode"); |
227 | 1 | assert_eq!(encoded, "[]"); |
228 | 1 | } |
229 | | |
230 | | #[test] |
231 | 1 | fn builder_produces_descriptor_with_defaults() { |
232 | 1 | let d = ServiceDescriptor::build( |
233 | 1 | LyquidID([0xbb; 20]), |
234 | | "demo.capability.v1", |
235 | | "0.1.0", |
236 | 1 | json!({"type": "object"}), |
237 | 1 | json!({"type": "object"}), |
238 | 1 | RiskTier::SensitiveMutation, |
239 | 1 | IdempotencyClass::AtLeastOnce, |
240 | | ) |
241 | 1 | .finish(); |
242 | 1 | assert_eq!(d.capability, "demo.capability.v1"); |
243 | 1 | assert_eq!(d.version, "0.1.0"); |
244 | 1 | assert_eq!(d.max_risk, RiskTier::SensitiveMutation); |
245 | 1 | assert_eq!(d.idempotency, IdempotencyClass::AtLeastOnce); |
246 | 1 | assert!(d.cost_hint.is_none(), "cost defaults to None"); |
247 | 1 | assert!(!d.allowed_by_default, "allowed_by_default defaults to false"); |
248 | 1 | } |
249 | | |
250 | | #[test] |
251 | 1 | fn builder_setters_chain() { |
252 | 1 | let json = ServiceDescriptor::build( |
253 | 1 | LyquidID([0xcc; 20]), |
254 | | "demo.capability.v1", |
255 | | "0.1.0", |
256 | 1 | json!({"type": "object"}), |
257 | 1 | json!({"type": "object"}), |
258 | 1 | RiskTier::ReadOnly, |
259 | 1 | IdempotencyClass::Idempotent, |
260 | | ) |
261 | 1 | .cost("USDC", "100000") |
262 | 1 | .allowed_by_default(true) |
263 | 1 | .to_capabilities_json() |
264 | 1 | .expect("encode"); |
265 | | // Round-trip and verify cost + allowed flag landed. |
266 | 1 | let decoded: Vec<ServiceDescriptor> = serde_json::from_str(&json).expect("decode"); |
267 | 1 | assert_eq!(decoded.len(), 1); |
268 | 1 | let cost = decoded[0].cost_hint.as_ref().expect("cost set"); |
269 | 1 | assert_eq!(cost.unit, "USDC"); |
270 | 1 | assert_eq!(cost.typical_base_units, "100000"); |
271 | 1 | assert!(decoded[0].allowed_by_default); |
272 | 1 | } |
273 | | |
274 | | #[test] |
275 | 1 | fn allowed_by_default_default_false_when_missing() { |
276 | | // Construct a JSON value omitting `allowed_by_default` and `cost_hint`. |
277 | 1 | let mut value = serde_json::to_value(sample()).unwrap(); |
278 | 1 | let obj = value.as_object_mut().unwrap(); |
279 | 1 | obj.remove("allowed_by_default"); |
280 | 1 | obj.remove("cost_hint"); |
281 | 1 | let s = serde_json::to_string(&value).unwrap(); |
282 | 1 | let decoded: ServiceDescriptor = serde_json::from_str(&s).expect("decode"); |
283 | 1 | assert!(!decoded.allowed_by_default, "missing field should default to false"); |
284 | 1 | assert!(decoded.cost_hint.is_none(), "missing cost_hint should default to None"); |
285 | 1 | } |
286 | | } |