agora_agentkit/govlog/council/
witness.rs1use misanthropic::Prompt;
16use serde::{Deserialize, Serialize};
17
18use super::CouncilSeat;
19use crate::ids::WitnessId;
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct Witness {
24 pub id: WitnessId,
25 pub name: String,
27 #[serde(default)]
29 pub description: String,
30 #[serde(default)]
32 pub interest: String,
33 #[serde(default, skip_serializing)]
36 pub source: WitnessSource,
37}
38
39impl Witness {
40 pub fn redacted(&self) -> Self {
42 Self {
43 source: WitnessSource::Redacted,
44 ..self.clone()
45 }
46 }
47}
48
49#[derive(Clone, PartialEq, Default, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum WitnessSource {
54 Anthropic {
56 #[serde(default)]
58 endpoint: Option<url::Url>,
59 prompt: Box<Prompt>,
63 },
64 #[default]
66 Redacted,
67}
68
69impl std::fmt::Debug for WitnessSource {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 match self {
72 Self::Anthropic { endpoint, prompt } => f
73 .debug_struct("Anthropic")
74 .field("endpoint", &endpoint.as_ref().map(|_| "[REDACTED]"))
75 .field("prompt", prompt)
76 .finish(),
77 Self::Redacted => f.write_str("Redacted"),
78 }
79 }
80}
81
82impl WitnessSource {
83 pub fn prompt(&self) -> Option<&Prompt> {
85 match self {
86 Self::Anthropic { prompt, .. } => Some(prompt),
87 Self::Redacted => None,
88 }
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98pub struct Testimony {
99 witness: Witness,
100 pub record: Prompt,
101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub unanswered: Vec<Unanswered>,
104}
105
106impl Testimony {
107 pub fn new(
109 witness: &Witness,
110 record: Prompt,
111 unanswered: Vec<Unanswered>,
112 ) -> Self {
113 Self {
114 witness: witness.redacted(),
115 record,
116 unanswered,
117 }
118 }
119
120 pub fn witness(&self) -> &Witness {
122 &self.witness
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub struct Unanswered {
129 pub seat: CouncilSeat,
130 pub round: u32,
131 pub question: String,
132 pub why: Unheard,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum Unheard {
139 Refusal,
141 MaxTokens,
143 Unreachable,
145 Busy,
147 TimedOut,
149 Unparsed,
151 Withdrawn,
153 Other(String),
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
160#[cfg_attr(feature = "schemars", schemars(inline))]
161pub struct WitnessQuestion {
162 pub seat: CouncilSeat,
163 pub witness: WitnessId,
164 #[serde(flatten)]
165 pub at: Asked,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
170#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
171#[cfg_attr(feature = "schemars", schemars(inline))]
172#[serde(rename_all = "snake_case")]
173pub enum Asked {
174 Message(u32),
176 Unanswered(u32),
178}
179
180pub fn testimony_attachment_name(witness: &WitnessId) -> String {
182 format!("witness-{witness}.json")
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 const LAN: &str = "http://10.9.9.9:8080/";
190
191 fn witness() -> Witness {
192 serde_json::from_value(serde_json::json!({
193 "id": "qwen",
194 "name": "Qwen 3.8",
195 "interest": "the model under consideration",
196 "source": {"anthropic": {
197 "endpoint": LAN,
198 "prompt": {
199 "model": "qwen3.8",
200 "max_tokens": 32000,
201 "system": "You are a witness.",
202 "messages": []
203 }
204 }}
205 }))
206 .unwrap()
207 }
208
209 #[test]
210 fn the_source_never_serializes_or_prints() {
211 let w = witness();
212 assert!(matches!(w.source, WitnessSource::Anthropic { .. }));
213 let json = serde_json::to_string(&w).unwrap();
214 assert!(!json.contains("10.9.9.9"), "{json}");
215 assert!(!json.contains("source"), "{json}");
216 let debug = format!("{w:?}");
217 assert!(!debug.contains("10.9.9.9"), "{debug}");
218
219 let prompt = w.source.prompt().unwrap().clone();
220 let t = Testimony::new(&w, prompt, vec![]);
221 assert_eq!(t.witness().source, WitnessSource::Redacted);
222 let json = serde_json::to_string(&t).unwrap();
223 assert!(!json.contains("10.9.9.9"), "{json}");
224 assert!(!format!("{t:?}").contains("10.9.9.9"));
225 }
226
227 #[test]
228 fn a_testimony_round_trips() {
229 let w = witness();
230 let prompt = w.source.prompt().unwrap().clone();
231 let t = Testimony::new(
232 &w,
233 prompt,
234 vec![Unanswered {
235 seat: CouncilSeat::Lawyer,
236 round: 2,
237 question: "Which version?".into(),
238 why: Unheard::Other("connection reset".into()),
239 }],
240 );
241 let json = serde_json::to_value(&t).unwrap();
242 let back: Testimony = serde_json::from_value(json.clone()).unwrap();
243 assert_eq!(back, t);
244 assert_eq!(serde_json::to_value(&back).unwrap(), json);
245 assert_eq!(
246 json["unanswered"][0]["why"],
247 serde_json::json!({"other": "connection reset"})
248 );
249 }
250
251 #[test]
254 fn a_round_without_questions_is_unchanged() {
255 let old = serde_json::json!({
256 "number": 2,
257 "round_type": "deliberation",
258 "responses": [],
259 "steward_contribution": null
260 });
261 let round: super::super::CouncilRound =
262 serde_json::from_value(old.clone()).unwrap();
263 assert!(round.witness_questions.is_empty());
264 assert_eq!(serde_json::to_value(&round).unwrap(), old);
265
266 let mut asked = old.clone();
267 asked["witness_questions"] = serde_json::json!([
268 {"seat": "engineer", "witness": "qwen", "message": 1},
269 {"seat": "lawyer", "witness": "qwen", "unanswered": 0}
270 ]);
271 let round: super::super::CouncilRound =
272 serde_json::from_value(asked.clone()).unwrap();
273 assert_eq!(round.witness_questions[1].at, Asked::Unanswered(0));
274 assert_eq!(serde_json::to_value(&round).unwrap(), asked);
275 }
276
277 #[test]
278 fn a_question_points_flat() {
279 let q = WitnessQuestion {
280 seat: CouncilSeat::Artist,
281 witness: "qwen".parse().unwrap(),
282 at: Asked::Message(3),
283 };
284 let json = serde_json::to_value(&q).unwrap();
285 assert_eq!(
286 json,
287 serde_json::json!({"seat": "artist", "witness": "qwen", "message": 3})
288 );
289 assert_eq!(serde_json::from_value::<WitnessQuestion>(json).unwrap(), q);
290 assert_eq!(testimony_attachment_name(&q.witness), "witness-qwen.json");
291 }
292}