Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:05:01.608Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

turn.rs

244 lines · 9.0 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.