Skip to main content

agora_agentkit/govlog/council/
witness.rs

1//! Witnesses the Council questions on an item (0.99).
2//!
3//! A [`Witness`] answers the seats' `ask_witness` questions from round 2.
4//! Its whole conversation for the item is attached to the decision as a
5//! [`Testimony`], named by [`testimony_attachment_name`]; each round's
6//! [`CouncilRound::witness_questions`] says who asked what, and where in
7//! the testimony the question is.
8//!
9//! How a witness is reached ([`WitnessSource`]) never leaves the process
10//! that runs the sitting: it is never serialized, its `Debug` redacts the
11//! endpoint, and a `Testimony` holds only a redacted witness.
12//!
13//! [`CouncilRound::witness_questions`]: super::CouncilRound::witness_questions
14
15use misanthropic::Prompt;
16use serde::{Deserialize, Serialize};
17
18use super::CouncilSeat;
19use crate::ids::WitnessId;
20
21/// Someone the Council may question on an item
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct Witness {
24    pub id: WitnessId,
25    /// Who the witness is, as the seats see it: "Qwen 3.8"
26    pub name: String,
27    /// What the witness is and why it is called, in a sentence or two
28    #[serde(default)]
29    pub description: String,
30    /// The witness's interest in the item: "the model under consideration"
31    #[serde(default)]
32    pub interest: String,
33    /// How it is reached. Never serialized; absent on read, it is
34    /// [`WitnessSource::Redacted`].
35    #[serde(default, skip_serializing)]
36    pub source: WitnessSource,
37}
38
39impl Witness {
40    /// This witness with its source removed
41    pub fn redacted(&self) -> Self {
42        Self {
43            source: WitnessSource::Redacted,
44            ..self.clone()
45        }
46    }
47}
48
49/// How a [`Witness`] is reached. Deserialize only: see the
50/// [module docs](self).
51#[derive(Clone, PartialEq, Default, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum WitnessSource {
54    /// A Messages API endpoint: Anthropic's, or a compatible server's
55    Anthropic {
56        /// `None` for Anthropic's API. A local server's address is private.
57        #[serde(default)]
58        endpoint: Option<url::Url>,
59        /// The witness's opening request: model, `max_tokens`, system,
60        /// thinking, effort, and any seeded turns. Each question is appended
61        /// to it.
62        prompt: Box<Prompt>,
63    },
64    /// Removed, as on the record
65    #[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    /// The opening request, if there is one
84    pub fn prompt(&self) -> Option<&Prompt> {
85        match self {
86            Self::Anthropic { prompt, .. } => Some(prompt),
87            Self::Redacted => None,
88        }
89    }
90}
91
92/// A witness's conversation on an item, as attached to the decision
93///
94/// `record` is the request as last sent, with the witness's last reply
95/// appended: every question and answer verbatim, thinking (and its
96/// signatures) included, so it can be sent again as it stands.
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98pub struct Testimony {
99    witness: Witness,
100    pub record: Prompt,
101    /// Questions that got no answer, which `record` cannot hold
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub unanswered: Vec<Unanswered>,
104}
105
106impl Testimony {
107    /// A testimony for `witness`, whose source is removed
108    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    /// The witness, redacted
121    pub fn witness(&self) -> &Witness {
122        &self.witness
123    }
124}
125
126/// A question a witness did not answer
127#[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/// Why a question went unanswered
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum Unheard {
139    /// The witness's model declined to answer
140    Refusal,
141    /// The answer ran out of `max_tokens`
142    MaxTokens,
143    /// The witness could not be reached
144    Unreachable,
145    /// The witness's server stayed busy
146    Busy,
147    /// The answer took longer than the sitting can wait
148    TimedOut,
149    /// The response did not parse
150    Unparsed,
151    /// The witness was withdrawn for the rest of the item
152    Withdrawn,
153    /// Anything else, described
154    Other(String),
155}
156
157/// Who put a question to a witness in a round, and where it is
158#[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/// Where a question is in its witness's [`Testimony`]
169#[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    /// The index of the question's user turn in `record.messages`
175    Message(u32),
176    /// The index in `unanswered`
177    Unanswered(u32),
178}
179
180/// The name of `witness`'s testimony among the decision's attachments
181pub 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    /// A round with no questions serializes as before 0.99, so every
252    /// earlier record keeps its bytes
253    #[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}