Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
bf8e2f8
fix(acp,agent): support reasoning_content fallback, turn text publish…
mainza-ai Jul 28, 2026
0f68ae4
Merge branch 'upstream/main' into main
mainza-ai Jul 28, 2026
a0c2a23
chore: remove wiki docs from PR feature branch
mainza-ai Jul 28, 2026
736b4c8
fix(desktop): add serde default annotations to ManagedAgentRecord and…
mainza-ai Jul 28, 2026
88d9698
fix(desktop): add serde default annotations to ManagedAgentRecord and…
mainza-ai Jul 28, 2026
b308a43
fix(desktop): restore persona_name_in_pack alias for persona_name_in_…
mainza-ai Jul 28, 2026
1e6f396
fix(desktop): restore persona_name_in_pack alias for persona_name_in_…
mainza-ai Jul 28, 2026
28dd5a7
fix(acp): strip python pseudo-code blocks and CLI JSON outputs from t…
mainza-ai Jul 28, 2026
3b38010
Merge branch 'main' into fix/acp-reasoning-content-and-turn-text-publ…
mainza-ai Jul 28, 2026
d3af0ee
docs(wiki): update troubleshooting and buzz-acp docs with python pseu…
mainza-ai Jul 28, 2026
8fc0594
fix(acp,tenant): normalize loopback hosts and support NIP-29 d-tag ch…
mainza-ai Jul 29, 2026
b3b5fb7
Merge main fix for loopback host normalization and NIP-29 d-tag chann…
mainza-ai Jul 29, 2026
236042b
chore: remove wiki directory from PR branch
mainza-ai Jul 29, 2026
213df5a
chore: restore wiki to match origin/main
mainza-ai Jul 29, 2026
ff486cd
Merge upstream/main into main
mainza-ai Jul 29, 2026
dc6e46e
docs(wiki): update log.md
mainza-ai Jul 29, 2026
1fd49ca
Merge upstream/main into PR branch
mainza-ai Jul 29, 2026
8fcb42e
chore: remove wiki directory from PR branch
mainza-ai Jul 29, 2026
84580a6
Merge upstream/main into main
mainza-ai Jul 29, 2026
fd26ef0
Merge upstream/main into PR branch
mainza-ai Jul 29, 2026
b3af7ee
Merge upstream/main into main
mainza-ai Jul 29, 2026
216430c
Merge upstream/main into PR branch
mainza-ai Jul 29, 2026
48ac9a1
fix(desktop): streamline types.rs line count under check-file-sizes l…
mainza-ai Jul 29, 2026
d497cbe
Merge types.rs line count fix into PR branch
mainza-ai Jul 29, 2026
ea1ac7a
Merge upstream/main into PR branch
mainza-ai Jul 29, 2026
ba9f34c
Merge upstream/main into PR branch
mainza-ai Jul 29, 2026
1932256
Merge upstream/main into PR branch
mainza-ai Jul 29, 2026
d2786a6
Merge upstream/main into PR branch
mainza-ai Jul 29, 2026
8cfc612
Merge upstream/main into PR branch
mainza-ai Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ pub struct AcpClient {
/// Other agents may leave this unset — readers must treat `None` as
/// "no active run to steer into" and fall back to cancel+merge.
active_run_id: Option<String>,
/// Accumulated agent_message_chunk text for the current turn.
accumulated_text: String,
/// Whether the agent advertised `_meta.steering.supported: true` in its
/// `initialize` response, meaning it implements the cross-adapter
/// [`ACP_STEER_METHOD`] extension.
Expand Down Expand Up @@ -561,12 +563,18 @@ impl AcpClient {
observer_agent_index: None,
observer_context: ObserverContext::default(),
active_run_id: None,
accumulated_text: String::new(),
steering_supported: false,
steer_rx: None,
goose_usage: UsageTracker::default(),
})
}

/// Take accumulated agent_message_chunk text for the current turn.
pub fn take_accumulated_text(&mut self) -> String {
std::mem::take(&mut self.accumulated_text)
}

/// Attach a local observer feed to this ACP client.
pub fn set_observer(&mut self, observer: Option<ObserverHandle>, agent_index: usize) {
self.observer = observer;
Expand Down Expand Up @@ -768,6 +776,7 @@ impl AcpClient {
let params = build_prompt_params(session_id, prompt_blocks);
let hard_deadline = tokio::time::Instant::now() + max_duration;
self.current_hard_deadline = Some(hard_deadline);
self.accumulated_text.clear();

// Mark the usage tracker as in-flight for this turn BEFORE sending the
// prompt so that any setup notifications recorded earlier are not
Expand Down Expand Up @@ -1729,6 +1738,7 @@ impl AcpClient {
"agent_message_chunk" => {
if let Some(text) = update["content"]["text"].as_str() {
tracing::info!(target: "acp::stream", "{text}");
self.accumulated_text.push_str(text);
}
false
}
Expand Down
84 changes: 84 additions & 0 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2112,6 +2112,20 @@ pub async fn run_prompt_task(
)
.await;

let raw_turn_text = agent.acp.take_accumulated_text();
let turn_text = clean_agent_text_response(&raw_turn_text);
if !turn_text.is_empty() {
if let (PromptSource::Channel(channel_id), Some(ref b)) = (&source, &batch) {
let thread_tags = b
.events
.last()
.map(|be| crate::queue::parse_thread_tags(&be.event))
.unwrap_or_default();
post_failure_notice(&ctx.rest_client, *channel_id, &thread_tags, &turn_text)
.await;
}
}

send_prompt_result(
&result_tx,
&turn_id,
Expand Down Expand Up @@ -3565,6 +3579,64 @@ pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str
}
}

pub(crate) fn clean_agent_text_response(text: &str) -> String {
let mut cleaned = text.to_string();

if let Some(pos) = cleaned.rfind("```json") {
let block = &cleaned[pos..];
if block.contains("reply_to")
|| block.contains("channel")
|| block.contains("command")
|| block.contains("accept")
|| block.contains("event_id")
{
cleaned.truncate(pos);
}
}
if let Some(pos) = cleaned.rfind("```python") {
let block = &cleaned[pos..];
if block.contains("reply_to")
|| block.contains("publish_response")
|| block.contains("channel")
|| block.contains("event_id")
|| block.contains("execute")
{
cleaned.truncate(pos);
}
}
if let Some(pos) = cleaned.rfind("{\"accept\"") {
cleaned.truncate(pos);
}
if let Some(pos) = cleaned.rfind("{\"accepted\"") {
cleaned.truncate(pos);
}
if let Some(pos) = cleaned.rfind("{\"reply_to\"") {
cleaned.truncate(pos);
}
if let Some(pos) = cleaned.rfind("call:buzz-dev-mcp") {
cleaned.truncate(pos);
}
if let Some(pos) = cleaned.rfind("Call reply_to") {
cleaned.truncate(pos);
}

cleaned = cleaned
.replace("<|tool_call>", "")
.replace("<|tool_calls>", "")
.replace("<|im_end|>", "")
.replace("<|endoftext|>", "")
.replace("<|im_start|>", "");

let trimmed = cleaned.trim();
if trimmed.starts_with("Step 1:")
&& (trimmed.contains("reply_to_mention") || trimmed.contains("execute the reply"))
{
return String::new();
}

trimmed.to_string()
}

/// Best-effort: post a visible failure notice (kind:9) to a channel after a
/// batch is dead-lettered. Replies into the thread of `thread_tags` when the
/// triggering event was threaded. Errors are logged and swallowed — the
Expand Down Expand Up @@ -5836,4 +5908,16 @@ mod tests {
);
server.abort();
}

#[test]
fn test_clean_agent_text_response_python_and_json_cleaning() {
let python_code = "Step 1: Acknowledge the request.\nStep 2: reply with the requested message.\n\n```python\ndef reply_to_mention(event_id, channel_uuid):\n pass\n```\nStep 3: execute the reply.\n\n```python\nreply_to_mention()\n```";
assert_eq!(super::clean_agent_text_response(python_code), "");

let cli_json = "I'll set up those two tasks and draft the message.\n\n[Draft]\n@Bumble, please ask @Honey to introduce herself.\n\n{\"accept\": true, \"event_id\": \"5c32b8c3d5879d942cc...\"}";
assert_eq!(
super::clean_agent_text_response(cli_json),
"I'll set up those two tasks and draft the message.\n\n[Draft]\n@Bumble, please ask @Honey to introduce herself."
);
}
}
33 changes: 27 additions & 6 deletions crates/buzz-acp/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2075,11 +2075,11 @@ async fn handle_ws_message(
Err(mpsc::error::TrySendError::Closed(_)) => return false,
}
} else if subscription_id == MEMBERSHIP_NOTIF_SUB_ID {
// Membership notification — extract channel UUID from h tag.
let channel_uuid = match extract_h_tag_uuid(&event) {
// Membership notification — extract channel UUID from d or h tag.
let channel_uuid = match extract_channel_uuid_from_event(&event) {
Some(uuid) => uuid,
None => {
warn!("membership notification missing h tag — dropping");
warn!("membership notification missing d or h tag — dropping");
return true;
}
};
Expand Down Expand Up @@ -3414,11 +3414,11 @@ async fn dns_flat_sleep(
}
}

/// Extract a channel UUID from the h tag of a Nostr event.
fn extract_h_tag_uuid(event: &nostr::Event) -> Option<Uuid> {
/// Extract a channel UUID from the d or h tag of a Nostr event.
fn extract_channel_uuid_from_event(event: &nostr::Event) -> Option<Uuid> {
event.tags.iter().find_map(|tag| {
let tag_vec = tag.as_slice();
if tag_vec.len() >= 2 && tag_vec[0] == "h" {
if tag_vec.len() >= 2 && (tag_vec[0] == "h" || tag_vec[0] == "d") {
tag_vec[1].parse::<Uuid>().ok()
} else {
None
Expand Down Expand Up @@ -4147,6 +4147,27 @@ mod tests {
assert!(map.contains_key(&ch), "archived=false is treated as live");
}

#[test]
fn extract_channel_uuid_from_event_supports_both_d_and_h_tags() {
let channel_id = Uuid::new_v4();
let keys = Keys::generate();

// NIP-29 kind:39002 membership event with 'd' tag
let event_d = EventBuilder::new(Kind::Custom(39002), "")
.tag(Tag::parse(["d", &channel_id.to_string()]).unwrap())
.sign_with_keys(&keys)
.unwrap();

// Standard event with 'h' tag
let event_h = EventBuilder::new(Kind::Custom(9), "")
.tag(Tag::parse(["h", &channel_id.to_string()]).unwrap())
.sign_with_keys(&keys)
.unwrap();

assert_eq!(extract_channel_uuid_from_event(&event_d), Some(channel_id));
assert_eq!(extract_channel_uuid_from_event(&event_h), Some(channel_id));
}

#[test]
fn parse_ok_accepted() {
let text = r#"["OK","abc123",true,""]"#;
Expand Down
8 changes: 7 additions & 1 deletion crates/buzz-agent/src/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1169,6 +1169,9 @@ fn parse_responses(v: Value) -> Result<LlmResponse, AgentError> {
Some("completed") => ProviderStop::EndTurn,
_ => ProviderStop::Other,
};
if text.is_empty() && !reasoning.is_empty() && tool_calls.is_empty() {
text = reasoning.clone();
}
let input_tokens = sum_usage(&v, &["input_tokens"]);
let output_tokens = sum_usage(&v, &["output_tokens"]);
// The Responses API nests the cache split under `input_tokens_details`.
Expand Down Expand Up @@ -1358,7 +1361,7 @@ fn parse_openai(v: Value) -> Result<LlmResponse, AgentError> {
let msg = choice
.get("message")
.ok_or_else(|| AgentError::Llm("missing message".into()))?;
let text = str_field(msg, "content");
let mut text = str_field(msg, "content");
// DeepSeek and vLLM-style OpenAI-compat hosts expose reasoning tokens on the
// message object. Prefer `reasoning_content` (DeepSeek's field name); fall
// back to `reasoning` (some other providers). Both are absent for standard
Expand Down Expand Up @@ -1387,6 +1390,9 @@ fn parse_openai(v: Value) -> Result<LlmResponse, AgentError> {
)?);
}
}
if text.is_empty() && !reasoning.is_empty() && tool_calls.is_empty() {
text = reasoning.clone();
}
let input_tokens = openai_chat_input_tokens(&v);
let output_tokens = sum_usage(&v, &["completion_tokens"]);
let cached_input_tokens = openai_chat_cached_tokens(&v);
Expand Down
27 changes: 15 additions & 12 deletions crates/buzz-auth/src/nip98.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ fn normalize_url(raw: &str) -> String {
};
let path = parsed.path().trim_end_matches('/').to_string();
parsed.set_path(&path);
if let Some(host) = parsed.host_str() {
let authority = match parsed.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_string(),
};
let norm_authority = buzz_core::tenant::normalize_host(&authority);
let _ = parsed.set_host(Some(&norm_authority));
}
parsed.to_string()
}

Expand Down Expand Up @@ -286,30 +294,25 @@ mod tests {
}

#[test]
fn loopback_aliases_are_distinct_hosts() {
// Under multi-tenant, the `u`-tag host is the row-zero community
// binding. An event signed for `localhost` MUST NOT pass against an
// expected URL on `127.0.0.1` (or `::1`) — collapsing the three would
// be a host-check side door. Production reconstructs `expected_url`
// from the community-bound host; tests do the same.
fn loopback_aliases_fold_to_canonical_host() {
// Loopback host variants (localhost, 127.0.0.1, [::1]) normalize to 127.0.0.1
// so NIP-98 authentication succeeds across equivalent local addresses.
let keys = Keys::generate();
let localhost_url = "http://localhost:3000/api/tokens";
let loopback_url = "http://127.0.0.1:3000/api/tokens";
let json = make_nip98_event(&keys, localhost_url, TEST_METHOD, None, None);
let result = verify_nip98_event(&json, loopback_url, TEST_METHOD, None);
assert!(
matches!(result, Err(AuthError::Nip98Invalid(_))),
"localhost u-tag must NOT match a 127.0.0.1 expected_url; got {result:?}"
result.is_ok(),
"localhost u-tag MUST match 127.0.0.1 expected_url; got {result:?}"
);

// Symmetric: signed-for-127.0.0.1 against expected localhost — same answer.
let json2 = make_nip98_event(&keys, loopback_url, TEST_METHOD, None, None);
let result2 = verify_nip98_event(&json2, localhost_url, TEST_METHOD, None);
assert!(
matches!(result2, Err(AuthError::Nip98Invalid(_))),
"127.0.0.1 u-tag must NOT match a localhost expected_url; got {result2:?}"
result2.is_ok(),
"127.0.0.1 u-tag MUST match localhost expected_url; got {result2:?}"
);

// And identity still holds — same host on both sides verifies.
let json3 = make_nip98_event(&keys, loopback_url, TEST_METHOD, None, None);
assert!(verify_nip98_event(&json3, loopback_url, TEST_METHOD, None).is_ok());
Expand Down
55 changes: 44 additions & 11 deletions crates/buzz-core/src/tenant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,36 @@ pub fn normalize_host(host: &str) -> String {
if let Some(stripped) = host.strip_suffix('.') {
host = stripped.to_string();
}
host
if host.is_empty() {
return host;
}

// Extract hostname and port suffix if present
let (hostname, port) = if host.starts_with('[') {
if let Some(close_bracket) = host.find(']') {
let (h, p) = host.split_at(close_bracket + 1);
(h, p)
} else {
(host.as_str(), "")
}
} else if let Some(last_colon) = host.rfind(':') {
let (h, p) = host.split_at(last_colon);
(h, p)
} else {
(host.as_str(), "")
};

let is_loopback = hostname == "localhost"
|| hostname == "127.0.0.1"
|| hostname == "[::1]"
|| hostname == "0.0.0.0"
|| hostname.starts_with("127.");

if is_loopback {
format!("127.0.0.1{port}")
} else {
host
}
}

/// Extract the authority (host plus an explicit non-default port, if present)
Expand Down Expand Up @@ -219,10 +248,14 @@ mod tests {
}

#[test]
fn normalize_host_leaves_ipv6_literal_intact() {
// IPv6 literals contain colons but no trailing default-port suffix.
assert_eq!(normalize_host("[::1]"), "[::1]");
assert_eq!(normalize_host("[::1]:443"), "[::1]");
fn normalize_host_folds_loopback_variants() {
// Loopback variants (localhost, 127.0.0.1, [::1]) all normalize to 127.0.0.1.
for variant in ["localhost", "127.0.0.1", "[::1]", "0.0.0.0"] {
assert_eq!(normalize_host(variant), "127.0.0.1");
}
for variant in ["localhost:3000", "127.0.0.1:3000", "[::1]:3000"] {
assert_eq!(normalize_host(variant), "127.0.0.1:3000");
}
}

#[test]
Expand All @@ -235,9 +268,9 @@ mod tests {
#[test]
fn relay_url_authority_keeps_explicit_nondefault_port() {
// The default dev seed: startup, bind_deployment_community, and
// buzz-admin must all derive `localhost:3000` (NOT bare `localhost`),
// buzz-admin must all derive `127.0.0.1:3000` (NOT bare `127.0.0.1`),
// or the admin lookup misses the community startup seeded.
assert_eq!(relay_url_authority("ws://localhost:3000"), "localhost:3000");
assert_eq!(relay_url_authority("ws://localhost:3000"), "127.0.0.1:3000");
assert_eq!(
relay_url_authority("wss://relay.example:8443"),
"relay.example:8443"
Expand All @@ -260,10 +293,10 @@ mod tests {
}

#[test]
fn relay_url_authority_preserves_ipv6_brackets() {
// `host_str()` strips IPv6 brackets and the port; `relay_url_authority`
// must keep both so the authority matches `communities.host`.
assert_eq!(relay_url_authority("ws://[::1]:3000"), "[::1]:3000");
fn relay_url_authority_folds_loopback_hosts() {
// Loopback hosts in URLs fold to 127.0.0.1 while keeping non-default ports.
assert_eq!(relay_url_authority("ws://[::1]:3000"), "127.0.0.1:3000");
assert_eq!(relay_url_authority("ws://localhost:3000"), "127.0.0.1:3000");
}

#[test]
Expand Down
Loading