Skip to repository content244 lines · 9.0 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:05:01.608Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
turn.rs
1//! Reading what Exo said. `OMEGA-DELTA-0042`, omega#87.
2//!
3//! Tier A is coarse on purpose. Exo's turn streaming exists only as an
4//! in-process `ExecutionStreamEvent` enum consumed by its own terminal REPL and
5//! serialised to no transport whatsoever, so a host application gets one shot
6//! per turn, no live text deltas, and tool activity only after the fact. That
7//! is a limit of Exo at this pin, not a shortcut here, and Tier B is where it
8//! is lifted — by contributing a transport to Exo, whose adapter types are a
9//! closed Rust enum.
10//!
11//! What `exo conversation send` prints is the messages the turn appended, one
12//! per line, each prefixed with a compact clock and a role:
13//!
14//! ```text
15//! [04:52:12] user: Reply with exactly the word OMEGA-EXO-TIER-A and nothing else.
16//! [04:52:12] assistant: [reasoning]
17//! [04:52:12] assistant: OMEGA-EXO-TIER-A
18//! ```
19//!
20//! # A turn is not "whatever came back on stdout"
21//!
22//! This is the parser's real job, and the reason it refuses rather than doing
23//! its best. Driven against the pinned Exo, `conversation send` with a prompt of
24//! `--help` **exits 0 and prints Exo's usage text**, because Exo accepts its
25//! global options after the subcommand and consumed the prompt as one.
26//! [`crate::command`] makes that unreachable with an argument terminator — but a
27//! reader that treated any successful stdout as a reply would have rendered a
28//! usage message as the model's answer, and it would have done so on exit 0
29//! with nothing to see in a log.
30//!
31//! So [`ExoTurn::read`] requires the shape of a turn — at least one assistant
32//! line — and returns [`NotATurn`] otherwise. Two independent guards against one
33//! silent failure, because the failure was silent.
34
35/// What Exo produced for one turn.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct ExoTurn {
38 /// The assistant's text, reasoning parts removed, lines joined.
39 pub text: String,
40 /// Tool activity, in the order Exo printed it. Visible after the fact; see
41 /// the module documentation.
42 pub tools: Vec<ExoToolActivity>,
43}
44
45/// One tool result Exo recorded during the turn.
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct ExoToolActivity {
48 /// The tool's name, as Exo reports it.
49 pub name: String,
50 /// The rendered result. Exo shows a preview here; the full result is an
51 /// artifact in its durable log.
52 pub output: String,
53}
54
55/// Why stdout was not a turn.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum NotATurn {
58 /// Nothing on stdout carried a role at all. Exo's usage text lands here,
59 /// which is the case this exists for.
60 NoMessages,
61 /// Messages were printed and none of them were the assistant's. A turn
62 /// that echoed the user and said nothing is not a reply.
63 NoAssistantMessage,
64}
65
66impl std::fmt::Display for NotATurn {
67 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 formatter.write_str(match self {
69 Self::NoMessages => "Exo printed no messages, so this turn produced no reply",
70 Self::NoAssistantMessage => "Exo printed no assistant message for this turn",
71 })
72 }
73}
74
75impl std::error::Error for NotATurn {}
76
77/// The prefix Exo puts on a reasoning part.
78const REASONING_PREFIX: &str = "[reasoning]";
79
80impl ExoTurn {
81 /// Read `exo conversation send` output.
82 ///
83 /// # Errors
84 ///
85 /// [`NotATurn`] when the output does not have the shape of a turn.
86 pub fn read(stdout: &str) -> Result<Self, NotATurn> {
87 let mut saw_message = false;
88 let mut text_lines: Vec<String> = Vec::new();
89 let mut tools = Vec::new();
90 let mut in_assistant = false;
91
92 for line in stdout.lines() {
93 match strip_clock(line) {
94 Some(body) => {
95 saw_message = true;
96 in_assistant = false;
97 if let Some(assistant) = body.strip_prefix("assistant: ") {
98 in_assistant = true;
99 push_assistant(&mut text_lines, assistant);
100 } else if let Some(tool) = body.strip_prefix("tool ") {
101 if let Some((name, output)) = tool.split_once(": ") {
102 tools.push(ExoToolActivity {
103 name: name.to_owned(),
104 output: output.to_owned(),
105 });
106 }
107 }
108 }
109 // A continuation of the previous message: assistant text with a
110 // newline in it. Only kept while an assistant message is open,
111 // so a multi-line tool result cannot leak into the reply.
112 None if in_assistant => text_lines.push(line.to_owned()),
113 None => {}
114 }
115 }
116
117 if !saw_message {
118 return Err(NotATurn::NoMessages);
119 }
120 if text_lines.is_empty() {
121 return Err(NotATurn::NoAssistantMessage);
122 }
123 Ok(Self {
124 text: text_lines.join("\n").trim().to_owned(),
125 tools,
126 })
127 }
128}
129
130/// `[04:52:12] rest` → `rest`.
131///
132/// Matched by shape rather than by regex: eleven characters, brackets at the
133/// ends of the clock, digits and colons between. A line Exo did not print with
134/// a clock is a continuation, not a message.
135fn strip_clock(line: &str) -> Option<&str> {
136 let rest = line.strip_prefix('[')?;
137 let (clock, rest) = rest.split_once("] ")?;
138 if clock.len() != 8 || !clock.chars().all(|c| c.is_ascii_digit() || c == ':') {
139 return None;
140 }
141 Some(rest)
142}
143
144/// Keep the assistant's words and drop the parts that are not words.
145///
146/// Reasoning is dropped rather than rendered: at this pin Exo prints it as a
147/// bare `[reasoning]` marker with the text usually empty, and showing an empty
148/// marker as the model's reply would be worse than showing nothing. Tool-call
149/// and tool-result parts are dropped from the *text* because they are carried
150/// as [`ExoToolActivity`] instead, so a reader does not see them twice.
151fn push_assistant(text_lines: &mut Vec<String>, content: &str) {
152 let content = content.trim();
153 if content.is_empty() || content == REASONING_PREFIX {
154 return;
155 }
156 if content.starts_with(REASONING_PREFIX)
157 || content.starts_with("[tool_call ")
158 || content.starts_with("[tool_result ")
159 {
160 return;
161 }
162 text_lines.push(content.to_owned());
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 /// Exactly what the pinned Exo printed for the turn that proved this lane,
170 /// on 2026-07-25. Copied from the terminal, not composed here.
171 const DRIVEN_TURN: &str = "\
172[04:52:12] user: Reply with exactly the word OMEGA-EXO-TIER-A and nothing else.
173[04:52:12] assistant: [reasoning]
174[04:52:12] assistant: OMEGA-EXO-TIER-A
175";
176
177 #[test]
178 fn the_turn_that_was_actually_driven_reads_back() {
179 let turn = ExoTurn::read(DRIVEN_TURN).expect("a real turn");
180 assert_eq!(turn.text, "OMEGA-EXO-TIER-A");
181 assert!(turn.tools.is_empty());
182 }
183
184 /// The silent failure, as a test. Exo's usage text on exit 0 must not be
185 /// readable as a reply.
186 #[test]
187 fn exos_usage_text_is_not_a_turn() {
188 let usage = "\
189Usage: exo conversation send [OPTIONS] <AGENT> <CONVERSATION> <PROMPT>
190
191Arguments:
192 <AGENT>
193 <CONVERSATION>
194 <PROMPT>
195";
196 assert_eq!(ExoTurn::read(usage), Err(NotATurn::NoMessages));
197 }
198
199 #[test]
200 fn a_turn_with_no_assistant_message_is_refused() {
201 let echoed = "[04:52:12] user: hello\n";
202 assert_eq!(ExoTurn::read(echoed), Err(NotATurn::NoAssistantMessage));
203 }
204
205 #[test]
206 fn tool_activity_is_read_and_kept_out_of_the_reply_text() {
207 let with_tools = "\
208[04:52:12] user: list the directory
209[04:52:13] assistant: [tool_call shell] {\"command\":\"ls\"}
210[04:52:14] tool shell: Cargo.toml
211[04:52:15] assistant: There is one file.
212";
213 let turn = ExoTurn::read(with_tools).expect("a turn");
214 assert_eq!(turn.text, "There is one file.");
215 assert_eq!(
216 turn.tools,
217 vec![ExoToolActivity {
218 name: "shell".into(),
219 output: "Cargo.toml".into(),
220 }]
221 );
222 }
223
224 /// A reply with a newline in it arrives as an unprefixed continuation line.
225 #[test]
226 fn a_multi_line_reply_keeps_its_lines() {
227 let multi = "[04:52:12] assistant: first\nsecond\n";
228 assert_eq!(ExoTurn::read(multi).expect("a turn").text, "first\nsecond");
229 }
230
231 /// A multi-line *tool* result must not run on into the reply, or a tool
232 /// that printed a paragraph would be attributed to the model.
233 #[test]
234 fn a_multi_line_tool_result_does_not_leak_into_the_reply() {
235 let leaky = "\
236[04:52:14] tool shell: line one
237line two
238[04:52:15] assistant: done
239";
240 let turn = ExoTurn::read(leaky).expect("a turn");
241 assert_eq!(turn.text, "done");
242 }
243}
244