返回 CodeWhale
role.rs
根目录 / crates / core / src / role.rs
1 //! The closed set of message roles Codewhale can put on a transcript.
2 //!
3 //! Roles used to be free-form `String`s on [`crate::request::Message`], and
4 //! four wire adapters each decided independently what an unfamiliar role
5 //! meant: two dropped it silently, one forwarded it verbatim for the provider
6 //! to reject, one failed closed. [`Role`] closes the set so that decision can
7 //! be made once, in one table, instead of four times by accident.
8 //!
9 //! Two properties are load-bearing and are covered by tests:
10 //!
11 //! * **Byte-identical serialization.** A `Role` serializes as exactly the
12 //! string it replaced and deserializes from any string. Saved transcripts
13 //! therefore need no schema bump and no migration ladder, and a session
14 //! written by a newer build still loads here — an unfamiliar role lands in
15 //! [`Role::Unrecognized`] rather than failing the whole session load.
16 //! * **`assistant_interrupted` stays distinct.** The interrupted-assistant
17 //! sentinel is its own variant, not a flavour of [`Role::Assistant`], so it
18 //! keeps round-tripping as a separate session item.
19
20 use std::fmt;
21
22 use serde::{Deserialize, Deserializer, Serialize, Serializer};
23
24 use crate::request::INTERRUPTED_ASSISTANT_ROLE;
25
26 /// Who authored a message in a transcript.
27 ///
28 /// `Unrecognized` is deliberately part of the type: it is what lets a
29 /// transcript written by a future build round-trip through this one. It
30 /// carries no trust and grants no placement of its own — see the wire
31 /// adapters' placement table for what each dialect does with it.
32 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
33 pub enum Role {
34 /// Input authored by the human operator or by the harness on their behalf.
35 User,
36 /// Output authored by the model.
37 Assistant,
38 /// Harness-authored context injected into the transcript body — compaction
39 /// summaries, branch summaries, sub-agent framing.
40 System,
41 /// Provider-supported instruction content embedded at a transcript
42 /// position. Unlike the top-level system prompt, this role is load-bearing
43 /// history and must retain its position on wires that support it.
44 Developer,
45 /// Assistant text that was visible before the turn was interrupted. Kept
46 /// distinct from [`Role::Assistant`] so replay can mark it as incomplete.
47 InterruptedAssistant,
48 /// A role string this build does not know. Preserved verbatim so loading
49 /// and re-saving a transcript is lossless.
50 Unrecognized(String),
51 }
52
53 impl Role {
54 /// The exact wire/persisted string for this role.
55 ///
56 /// This is the serialization; do not let it drift from [`Serialize`].
57 #[must_use]
58 pub fn as_str(&self) -> &str {
59 match self {
60 Self::User => "user",
61 Self::Assistant => "assistant",
62 Self::System => "system",
63 Self::Developer => "developer",
64 Self::InterruptedAssistant => INTERRUPTED_ASSISTANT_ROLE,
65 Self::Unrecognized(raw) => raw.as_str(),
66 }
67 }
68
69 /// True for roles the model itself authored, interrupted output included.
70 #[must_use]
71 pub fn is_assistant_like(&self) -> bool {
72 matches!(self, Self::Assistant | Self::InterruptedAssistant)
73 }
74 }
75
76 impl From<&str> for Role {
77 fn from(value: &str) -> Self {
78 match value {
79 "user" => Self::User,
80 "assistant" => Self::Assistant,
81 "system" => Self::System,
82 "developer" => Self::Developer,
83 INTERRUPTED_ASSISTANT_ROLE => Self::InterruptedAssistant,
84 other => Self::Unrecognized(other.to_string()),
85 }
86 }
87 }
88
89 impl From<String> for Role {
90 fn from(value: String) -> Self {
91 match value.as_str() {
92 "user" => Self::User,
93 "assistant" => Self::Assistant,
94 "system" => Self::System,
95 "developer" => Self::Developer,
96 INTERRUPTED_ASSISTANT_ROLE => Self::InterruptedAssistant,
97 _ => Self::Unrecognized(value),
98 }
99 }
100 }
101
102 impl fmt::Display for Role {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 f.write_str(self.as_str())
105 }
106 }
107
108 impl PartialEq<str> for Role {
109 fn eq(&self, other: &str) -> bool {
110 self.as_str() == other
111 }
112 }
113
114 impl PartialEq<&str> for Role {
115 fn eq(&self, other: &&str) -> bool {
116 self.as_str() == *other
117 }
118 }
119
120 impl PartialEq<String> for Role {
121 fn eq(&self, other: &String) -> bool {
122 self.as_str() == other.as_str()
123 }
124 }
125
126 impl PartialEq<Role> for str {
127 fn eq(&self, other: &Role) -> bool {
128 self == other.as_str()
129 }
130 }
131
132 impl PartialEq<Role> for &str {
133 fn eq(&self, other: &Role) -> bool {
134 *self == other.as_str()
135 }
136 }
137
138 impl PartialEq<Role> for String {
139 fn eq(&self, other: &Role) -> bool {
140 self.as_str() == other.as_str()
141 }
142 }
143
144 impl Serialize for Role {
145 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
146 serializer.serialize_str(self.as_str())
147 }
148 }
149
150 impl<'de> Deserialize<'de> for Role {
151 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
152 Ok(Self::from(String::deserialize(deserializer)?))
153 }
154 }
155
156 #[cfg(test)]
157 mod tests {
158 use super::Role;
159 use crate::request::INTERRUPTED_ASSISTANT_ROLE;
160
161 #[test]
162 fn known_roles_serialize_as_the_strings_they_replaced() {
163 for (role, expected) in [
164 (Role::User, "\"user\""),
165 (Role::Assistant, "\"assistant\""),
166 (Role::System, "\"system\""),
167 (Role::Developer, "\"developer\""),
168 (Role::InterruptedAssistant, "\"assistant_interrupted\""),
169 ] {
170 assert_eq!(serde_json::to_string(&role).expect("serialize"), expected);
171 }
172 }
173
174 #[test]
175 fn serialized_bytes_match_the_raw_string_encoding() {
176 // The persisted format must not shift: a `Role` has to produce the
177 // same bytes the `String` field produced, or every saved session
178 // would need a schema bump and a migration ladder.
179 for raw in [
180 "user",
181 "assistant",
182 "system",
183 "developer",
184 INTERRUPTED_ASSISTANT_ROLE,
185 ] {
186 assert_eq!(
187 serde_json::to_vec(&Role::from(raw)).expect("serialize role"),
188 serde_json::to_vec(raw).expect("serialize string"),
189 "role {raw} must serialize like its string",
190 );
191 }
192 }
193
194 #[test]
195 fn unknown_roles_round_trip_verbatim() {
196 let role = Role::from("future_role");
197 assert_eq!(role, Role::Unrecognized("future_role".to_string()));
198 let encoded = serde_json::to_string(&role).expect("serialize");
199 assert_eq!(encoded, "\"future_role\"");
200 let decoded: Role = serde_json::from_str(&encoded).expect("deserialize");
201 assert_eq!(decoded, role);
202 }
203
204 #[test]
205 fn interrupted_assistant_is_not_assistant() {
206 let decoded: Role =
207 serde_json::from_str("\"assistant_interrupted\"").expect("deserialize sentinel");
208 assert_eq!(decoded, Role::InterruptedAssistant);
209 assert_ne!(decoded, Role::Assistant);
210 assert_ne!(decoded, "assistant");
211 assert!(decoded.is_assistant_like());
212 }
213
214 #[test]
215 fn string_comparisons_work_in_both_directions() {
216 assert_eq!(Role::User, "user");
217 assert_eq!("user", Role::User);
218 assert_eq!(Role::User, "user".to_string());
219 assert_ne!(Role::System, "user");
220 }
221 }
222
222 lines RUST