switch to '=== type' single-line headers for multi-doc output (more robust than ambiguous --- frontmatter)

This commit is contained in:
Damocles 2026-04-30 21:54:39 +02:00
parent aa4ed13518
commit d6d352d2f7

View file

@ -548,9 +548,26 @@ async fn process_loop(state: Arc<Mutex<DaemonState>>, client: Client) {
let new_last_event_id = history.last().map(|(eid, _, _, _, _)| eid.clone()); let new_last_event_id = history.last().map(|(eid, _, _, _, _)| eid.clone());
match invoke_claude(&room_id, &room_name, &chat_msgs, seen_idx, &model).await { let docs = match invoke_claude(&room_id, &room_name, &chat_msgs, seen_idx, &model).await {
Ok(Some(response)) => { Ok(d) => d,
let target_room = match &response.target { Err(e) => {
tracing::error!(room = %room_id, "claude invocation failed: {e}");
continue;
}
};
let mut sent_any = false;
for doc in docs {
match doc {
ClaudeDoc::Skip => {
tracing::debug!(room = %room_id, "claude doc: skip");
}
ClaudeDoc::Thought(body) => {
tracing::info!(room = %room_id, thought = %body.chars().take(120).collect::<String>(), "claude doc: thought");
tracing::trace!("full thought: {body}");
}
ClaudeDoc::Message { target, body } => {
let target_room = match &target {
ResponseTarget::Room(rid) => client.get_room(rid), ResponseTarget::Room(rid) => client.get_room(rid),
ResponseTarget::Dm(user) => match find_or_create_dm(&client, user).await { ResponseTarget::Dm(user) => match find_or_create_dm(&client, user).await {
Ok(r) => Some(r), Ok(r) => Some(r),
@ -560,26 +577,22 @@ async fn process_loop(state: Arc<Mutex<DaemonState>>, client: Client) {
} }
}, },
}; };
let target_label = match &response.target { let target_label = match &target {
ResponseTarget::Room(rid) => rid.to_string(), ResponseTarget::Room(rid) => rid.to_string(),
ResponseTarget::Dm(user) => format!("dm:{user}"), ResponseTarget::Dm(user) => format!("dm:{user}"),
}; };
if let Some(target_room) = target_room { if let Some(target_room) = target_room {
let content = RoomMessageEventContent::text_plain(&response.body); let content = RoomMessageEventContent::text_plain(&body);
match target_room.send(content).await { match target_room.send(content).await {
Ok(_) => { Ok(_) => {
let mut state = state.lock().await; let mut state = state.lock().await;
state.rate_budget = state.rate_budget.saturating_sub(1); state.rate_budget = state.rate_budget.saturating_sub(1);
if let Some(eid) = new_last_event_id.clone() {
state.last_shown.insert(room_id.clone(), eid);
}
tracing::info!( tracing::info!(
target = %target_label, target = %target_label,
"sent response ({} budget remaining)", "sent response ({} budget remaining)",
state.rate_budget state.rate_budget
); );
drop(state); sent_any = true;
send_read_receipt(&room, new_last_event_id.clone()).await;
} }
Err(e) => tracing::error!("failed to send: {e}"), Err(e) => tracing::error!("failed to send: {e}"),
} }
@ -587,20 +600,20 @@ async fn process_loop(state: Arc<Mutex<DaemonState>>, client: Client) {
tracing::warn!(target = %target_label, "target not available"); tracing::warn!(target = %target_label, "target not available");
} }
} }
Ok(None) => { }
tracing::debug!(room = %room_id, "claude chose to skip"); }
// Update last_shown and send read receipt regardless of whether we
// sent a message - the agent saw the messages either way.
{ {
let mut state = state.lock().await; let mut state = state.lock().await;
if let Some(eid) = new_last_event_id.clone() { if let Some(eid) = new_last_event_id.clone() {
state.last_shown.insert(room_id.clone(), eid); state.last_shown.insert(room_id.clone(), eid);
} }
} }
send_read_receipt(&room, new_last_event_id.clone()).await; send_read_receipt(&room, new_last_event_id).await;
}
Err(e) => { let _ = sent_any;
tracing::error!(room = %room_id, "claude invocation failed: {e}");
}
}
} }
} }
@ -720,9 +733,18 @@ enum ResponseTarget {
Dm(OwnedUserId), Dm(OwnedUserId),
} }
struct ClaudeResponse { /// One document within Claude's multi-doc output. Each doc has its own
/// frontmatter; the daemon routes based on which fields are present.
enum ClaudeDoc {
/// A chat message to send.
Message {
target: ResponseTarget, target: ResponseTarget,
body: String, body: String,
},
/// Agent's internal monologue. Not sent to chat. Logged to tracing.
Thought(String),
/// Explicit "do nothing for this slot". Useful as a placeholder.
Skip,
} }
async fn invoke_claude( async fn invoke_claude(
@ -731,7 +753,7 @@ async fn invoke_claude(
history: &[ChatMessage], history: &[ChatMessage],
seen_idx: usize, seen_idx: usize,
model: &str, model: &str,
) -> anyhow::Result<Option<ClaudeResponse>> { ) -> anyhow::Result<Vec<ClaudeDoc>> {
let identity_dir = paths::identity_dir(); let identity_dir = paths::identity_dir();
let identity_str = identity_dir.to_string_lossy(); let identity_str = identity_dir.to_string_lossy();
@ -826,58 +848,129 @@ async fn invoke_claude(
Ok(parse_response(&raw, source_room)) Ok(parse_response(&raw, source_room))
} }
fn parse_response(raw: &str, default_room: &OwnedRoomId) -> Option<ClaudeResponse> { /// Parse Claude's stdout into a list of documents.
///
/// Format: each doc starts with a line `=== <type> [arg]`. Body is everything
/// until the next `===` line or EOF. Types:
/// - `=== thought` → ClaudeDoc::Thought (logged, not sent)
/// - `=== room [<room_id>]` → ClaudeDoc::Message to that room (or source room if no arg)
/// - `=== dm <user_id>` → ClaudeDoc::Message as DM
/// - `=== skip` → ClaudeDoc::Skip (no-op)
///
/// Anything before the first `===` line is treated as a preamble thought.
/// Bare text with no `===` is treated as a single message to default_room.
fn parse_response(raw: &str, default_room: &OwnedRoomId) -> Vec<ClaudeDoc> {
let trimmed = raw.trim(); let trimmed = raw.trim();
if trimmed.is_empty() {
if trimmed.starts_with("---") { return Vec::new();
let parts: Vec<&str> = trimmed.splitn(3, "---").collect();
if parts.len() >= 3 {
let frontmatter = parts[1].trim();
let body = parts[2].trim();
if frontmatter.contains("skip: true") || frontmatter.contains("skip:true") {
return None;
} }
// dm: takes precedence over room: if both set // Walk lines, splitting on lines that start with "=== "
let dm = frontmatter let mut docs = Vec::new();
.lines() let mut current_header: Option<String> = None;
.find(|l| l.starts_with("dm:")) let mut current_body = String::new();
.and_then(|l| l.strip_prefix("dm:")) let mut preamble = String::new();
.and_then(|r| r.trim().parse::<OwnedUserId>().ok());
let target = if let Some(user) = dm { for line in trimmed.lines() {
ResponseTarget::Dm(user) if let Some(header) = line.strip_prefix("===") {
// Flush previous doc
if let Some(h) = current_header.take() {
if let Some(doc) = build_doc(&h, current_body.trim(), default_room) {
docs.push(doc);
}
current_body.clear();
} else { } else {
let room = frontmatter // We were collecting preamble
.lines() let p = preamble.trim();
.find(|l| l.starts_with("room:")) if !p.is_empty() {
.and_then(|l| l.strip_prefix("room:")) docs.push(ClaudeDoc::Thought(p.to_owned()));
.and_then(|r| r.trim().parse().ok()) }
.unwrap_or_else(|| default_room.clone()); preamble.clear();
ResponseTarget::Room(room) }
}; current_header = Some(header.trim().to_owned());
} else if current_header.is_some() {
if body.is_empty() { current_body.push_str(line);
return None; current_body.push('\n');
} else {
preamble.push_str(line);
preamble.push('\n');
}
} }
return Some(ClaudeResponse { // Flush the last doc or preamble
target, if let Some(h) = current_header {
body: body.to_owned(), if let Some(doc) = build_doc(&h, current_body.trim(), default_room) {
docs.push(doc);
}
} else {
// No `===` headers at all - treat whole output as a single message
let p = preamble.trim();
if !p.is_empty() {
docs.push(ClaudeDoc::Message {
target: ResponseTarget::Room(default_room.clone()),
body: p.to_owned(),
}); });
} }
} }
if trimmed.is_empty() { docs
return None;
} }
Some(ClaudeResponse { fn build_doc(header: &str, body: &str, default_room: &OwnedRoomId) -> Option<ClaudeDoc> {
target: ResponseTarget::Room(default_room.clone()), let mut parts = header.splitn(2, char::is_whitespace);
body: trimmed.to_owned(), let kind = parts.next().unwrap_or("").trim();
let arg = parts.next().unwrap_or("").trim();
match kind {
"skip" => Some(ClaudeDoc::Skip),
"thought" => {
if body.is_empty() {
None
} else {
Some(ClaudeDoc::Thought(body.to_owned()))
}
}
"room" => {
if body.is_empty() {
return None;
}
let target = if arg.is_empty() {
ResponseTarget::Room(default_room.clone())
} else {
match arg.parse::<OwnedRoomId>() {
Ok(rid) => ResponseTarget::Room(rid),
Err(_) => return None,
}
};
Some(ClaudeDoc::Message {
target,
body: body.to_owned(),
}) })
} }
"dm" => {
if body.is_empty() {
return None;
}
match arg.parse::<OwnedUserId>() {
Ok(uid) => Some(ClaudeDoc::Message {
target: ResponseTarget::Dm(uid),
body: body.to_owned(),
}),
Err(_) => None,
}
}
_ => {
// Unknown header - treat body as a thought so it doesn't leak to chat
if body.is_empty() {
None
} else {
Some(ClaudeDoc::Thought(format!(
"[unknown header '{header}'] {body}"
)))
}
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@ -887,66 +980,133 @@ mod tests {
"!test:example.com".parse().unwrap() "!test:example.com".parse().unwrap()
} }
fn assert_room(resp: &ClaudeResponse, expected: &str) { fn first_message(docs: &[ClaudeDoc]) -> (&ResponseTarget, &str) {
match &resp.target { for d in docs {
if let ClaudeDoc::Message { target, body } = d {
return (target, body.as_str());
}
}
panic!("no message doc found");
}
fn assert_room(target: &ResponseTarget, expected: &str) {
match target {
ResponseTarget::Room(r) => assert_eq!(r.as_str(), expected), ResponseTarget::Room(r) => assert_eq!(r.as_str(), expected),
ResponseTarget::Dm(_) => panic!("expected room target, got dm"), ResponseTarget::Dm(_) => panic!("expected room target, got dm"),
} }
} }
#[test] #[test]
fn parse_frontmatter_response() { fn parse_room_with_arg() {
let raw = "---\nroom: !other:server\n---\nhello world"; let raw = "=== room !other:server\nhello world";
let resp = parse_response(raw, &test_room()).unwrap(); let docs = parse_response(raw, &test_room());
assert_room(&resp, "!other:server"); let (target, body) = first_message(&docs);
assert_eq!(resp.body, "hello world"); assert_room(target, "!other:server");
assert_eq!(body, "hello world");
} }
#[test] #[test]
fn parse_skip_response() { fn parse_room_no_arg_uses_default() {
let raw = "---\nskip: true\n---\n"; let raw = "=== room\nhi";
assert!(parse_response(raw, &test_room()).is_none()); let docs = parse_response(raw, &test_room());
let (target, _) = first_message(&docs);
assert_room(target, "!test:example.com");
} }
#[test] #[test]
fn parse_plain_response() { fn parse_skip() {
let raw = "=== skip";
let docs = parse_response(raw, &test_room());
assert_eq!(docs.len(), 1);
assert!(matches!(docs[0], ClaudeDoc::Skip));
}
#[test]
fn parse_plain_text_no_header() {
let raw = "just a message"; let raw = "just a message";
let resp = parse_response(raw, &test_room()).unwrap(); let docs = parse_response(raw, &test_room());
assert_room(&resp, "!test:example.com"); let (target, body) = first_message(&docs);
assert_eq!(resp.body, "just a message"); assert_room(target, "!test:example.com");
assert_eq!(body, "just a message");
} }
#[test] #[test]
fn parse_empty_response() { fn parse_empty() {
assert!(parse_response("", &test_room()).is_none()); assert!(parse_response("", &test_room()).is_empty());
assert!(parse_response(" \n ", &test_room()).is_none()); assert!(parse_response(" \n ", &test_room()).is_empty());
} }
#[test] #[test]
fn parse_default_room() { fn parse_dm() {
let raw = "---\n---\nhello"; let raw = "=== dm @alice:example.com\nhi alice";
let resp = parse_response(raw, &test_room()).unwrap(); let docs = parse_response(raw, &test_room());
assert_room(&resp, "!test:example.com"); let (target, body) = first_message(&docs);
} match target {
#[test]
fn parse_dm_response() {
let raw = "---\ndm: @alice:example.com\n---\nhi alice";
let resp = parse_response(raw, &test_room()).unwrap();
match &resp.target {
ResponseTarget::Dm(u) => assert_eq!(u.as_str(), "@alice:example.com"), ResponseTarget::Dm(u) => assert_eq!(u.as_str(), "@alice:example.com"),
ResponseTarget::Room(_) => panic!("expected dm target"), ResponseTarget::Room(_) => panic!("expected dm target"),
} }
assert_eq!(resp.body, "hi alice"); assert_eq!(body, "hi alice");
} }
#[test] #[test]
fn parse_dm_takes_precedence_over_room() { fn parse_thought() {
let raw = "---\nroom: !other:server\ndm: @bob:example.com\n---\nhello"; let raw = "=== thought\nthinking about whether to reply...";
let resp = parse_response(raw, &test_room()).unwrap(); let docs = parse_response(raw, &test_room());
match &resp.target { assert_eq!(docs.len(), 1);
ResponseTarget::Dm(u) => assert_eq!(u.as_str(), "@bob:example.com"), match &docs[0] {
ResponseTarget::Room(_) => panic!("expected dm target"), ClaudeDoc::Thought(s) => assert_eq!(s, "thinking about whether to reply..."),
_ => panic!("expected thought"),
} }
} }
#[test]
fn parse_multi_doc() {
let raw = "\
=== thought
let me check notes
=== room !x:y
hi
=== dm @u:s
private
=== skip
";
let docs = parse_response(raw, &test_room());
assert_eq!(docs.len(), 4);
assert!(matches!(docs[0], ClaudeDoc::Thought(_)));
assert!(matches!(
docs[1],
ClaudeDoc::Message {
target: ResponseTarget::Room(_),
..
}
));
assert!(matches!(
docs[2],
ClaudeDoc::Message {
target: ResponseTarget::Dm(_),
..
}
));
assert!(matches!(docs[3], ClaudeDoc::Skip));
}
#[test]
fn parse_preamble_becomes_thought() {
let raw = "preamble line\n=== room !x:y\nhello";
let docs = parse_response(raw, &test_room());
assert_eq!(docs.len(), 2);
assert!(matches!(docs[0], ClaudeDoc::Thought(_)));
assert!(matches!(docs[1], ClaudeDoc::Message { .. }));
}
#[test]
fn parse_unknown_header_becomes_thought() {
let raw = "=== mystery foo\nbody";
let docs = parse_response(raw, &test_room());
assert_eq!(docs.len(), 1);
assert!(matches!(docs[0], ClaudeDoc::Thought(_)));
}
} }