fix(loadgen): answer turn targets at the thread's first level

A turn replied under its target, so an agent answering the channel's
newest message, often another agent's reply, nested one level deeper each
time, toward the relay's 100-level depth limit. Reply under the thread
root instead, as the harness does for a human-facing turn: depth stays at
one, and a top-level target still becomes a new thread's root.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
This commit is contained in:
Luke Tornquist
2026-11-06 08:59:04 -05:00
parent 4b5e998c3c
commit d1167fd23a
2 changed files with 17 additions and 14 deletions

View File

@@ -1523,20 +1523,21 @@ struct CountReply {
_count: u64,
}
/// Ends a turn: a timed reply in the answered message's thread (or the
/// channel), then each reaction's removal, which reads the agent's
/// reactions on the target and deletes them, as the harness does.
/// Ends a turn: a timed reply in the answered message's thread, then each
/// reaction's removal, which reads the agent's reactions on the target and
/// deletes them, as the harness does.
async fn end_turn(shared: &Shared, community: &Community, keys: &Keys, auth: &Tag, turn: Turn) {
let me = keys.public_key().to_hex();
let mut tags = vec![tag(["h", &turn.channel])];
if let Some(target) = &turn.target {
// A threaded target is answered in its thread; a top-level one
// becomes the root of a new thread.
let id = target.id.to_hex();
// As a harness answers a person: a threaded target is answered at
// its thread's first level, under the root, and a top-level one
// becomes the root of a new thread. Replies never nest deeper, so
// agents answering agents can't climb to the relay's depth limit.
let root = buzz_core::nip10::parse_thread_markers(&target.tags)
.resolve()
.map_or_else(|| id.clone(), |(root, _)| root);
tags.extend(reply_tags(&root, &id));
.map_or_else(|| target.id.to_hex(), |(root, _)| root);
tags.extend(reply_tags(&root, &root));
}
let reply = EventBuilder::new(
kind(KIND_STREAM_MESSAGE),
@@ -2724,15 +2725,15 @@ hold_secs = 3
let e = reaction.tags.iter().find(|t| t.kind().as_str() == "e");
assert_eq!(e.and_then(|t| t.content()), Some(target_id.as_str()));
}
// Every finished turn replied in the target's thread, under the
// target's own root, and deleted both of its reactions; only a turn
// cut by the stop leaves any.
// Every finished turn replied at the first level of the target's
// thread, under its root, and deleted both of its reactions; only a
// turn cut by the stop leaves any.
let replies: Vec<&Event> = of(KIND_STREAM_MESSAGE).collect();
assert!(!replies.is_empty(), "{posted:?}");
for reply in &replies {
let markers = buzz_core::nip10::parse_thread_markers(&reply.tags);
assert_eq!(markers.root.as_deref(), Some(root_id.as_str()));
assert_eq!(markers.reply.as_deref(), Some(target_id.as_str()));
assert_eq!(markers.reply.as_deref(), Some(root_id.as_str()));
}
let deleted: Vec<String> = of(KIND_DELETION)
.flat_map(|e| {

View File

@@ -122,8 +122,10 @@ does around a prompt:
observer frame of `frame_bytes` plaintext every `frame_secs` over the
socket, plus `reads` HTTP `/query` reads of the channel's newest 50
messages at random points in the turn, as tool calls.
3. **End**: a timed reply in the message's thread through HTTP `/events`,
then each reaction's removal: a `/query` for the agent's reactions on
3. **End**: a timed reply through HTTP `/events`, at the first level of the
message's thread (under its root), as a harness answers a person; a
top-level message becomes the root of a new thread. Then each
reaction's removal: a `/query` for the agent's reactions on
the message and a kind:5 deletion of each one found.
An agent makes one HTTP request sequence at a time, and the socket keeps