Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
eabd537
feat(huddle): cut voice-turn time-to-first-audio via env-gated latenc…
Aug 12, 2026
49ec563
feat(huddle): voice-mode guidelines — instant spoken ack + write-for-…
Aug 12, 2026
a3e6407
Merge origin/main into eva/huddle-first-audio-latency
Aug 12, 2026
88d80af
fix(buzz-voice): key voice caches by sample content, not buffer address
Aug 12, 2026
7445169
fix(huddle): round flush-ms override up to whole VAD frames; assert w…
Aug 12, 2026
a978b6f
refactor(huddle): restore desktop file-size ratchet by extracting gro…
Aug 12, 2026
18fab2e
fix(huddle): remove the flush-window override; never silence-flush wh…
Aug 12, 2026
017d35f
Merge origin/main into eva/huddle-first-audio-latency
Aug 12, 2026
f13bcbf
Merge origin/main into eva/huddle-first-audio-latency
Aug 12, 2026
12184e6
fix(huddle): raise agent speech limit
Aug 12, 2026
4979d59
fix(huddle): default push-to-talk to a muted microphone
Aug 12, 2026
997f283
fix(huddle): load voice instructions into system prompt (#5707)
tlongwell-block Aug 12, 2026
c89d413
Merge origin/main into eva/huddle-first-audio-latency
Aug 12, 2026
ec2af79
fix(huddle): keep transcribing while agent TTS plays
Aug 13, 2026
01e57a7
Merge origin/main into eva/huddle-first-audio-latency
Aug 13, 2026
a37c1d1
feat(huddle): add current-channel toggle shortcut
Aug 13, 2026
f9451f1
Merge branch 'main' into eva/huddle-first-audio-latency
Aug 13, 2026
b4f4774
fix(desktop): scope capture-phase keydown to the huddle shortcut
Aug 13, 2026
fbf773a
feat(voice): pack natural TTS sentences without fixed silence (#5766)
tlongwell-block Aug 13, 2026
e113824
Merge main into eva/huddle-first-audio-latency to pick up #5799 e2e f…
Aug 13, 2026
3b80679
Merge main into eva/huddle-first-audio-latency, resolving AppHuddleSh…
Aug 13, 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
198 changes: 179 additions & 19 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -958,14 +958,19 @@ async fn resolve_new_session_channel_context(
/// On error from `session_new_full()`, returns the `AcpError` — caller handles
/// error reporting. Model-switch failures are logged and gracefully ignored
/// (the agent proceeds with its default model).
struct NewSessionChannelContext<'a> {
huddle_instructions: Option<&'a str>,
canvas: Option<&'a str>,
name: Option<&'a str>,
id: Option<Uuid>,
channel_type: Option<&'a str>,
}

async fn create_session_and_apply_model(
agent: &mut OwnedAgent,
ctx: &PromptContext,
agent_core: Option<&str>,
agent_canvas: Option<&str>,
channel_name: Option<&str>,
channel_id: Option<Uuid>,
channel_type: Option<&str>,
channel: NewSessionChannelContext<'_>,
) -> Result<String, AcpError> {
// Build base_prompt + system_prompt + agent core + canvas metadata into a
// single prompt. Standard protocol-v2 agents receive it in `session/new`;
Expand All @@ -975,24 +980,27 @@ async fn create_session_and_apply_model(
// `[Channel Canvas]` header; both are appended with a blank-line separator.
let is_goose = agent.agent_name == "goose";
let combined_system_prompt = with_canvas(
with_core(
with_team(
framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()),
ctx.team_instructions.as_deref(),
with_huddle_instructions(
with_core(
with_team(
framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()),
ctx.team_instructions.as_deref(),
),
agent_core,
),
agent_core,
channel.huddle_instructions,
),
agent_canvas,
channel.canvas,
);

let session_title = ctx
.session_title
.as_deref()
.map(|agent_name| compose_session_title(agent_name, channel_name));
.map(|agent_name| compose_session_title(agent_name, channel.name));
let mcp_servers = mcp_servers_with_git_origin(
&ctx.mcp_servers,
channel_id,
channel_type,
channel.id,
channel.channel_type,
ctx.session_title.as_deref(),
);

Expand Down Expand Up @@ -1394,6 +1402,21 @@ fn with_core(framed: Option<String>, core: Option<&str>) -> Option<String> {
}
}

/// Append owner-signed huddle instructions to this channel session's system prompt.
fn with_huddle_instructions(prompt: Option<String>, instructions: Option<&str>) -> Option<String> {
let instructions = instructions
.map(str::trim)
.filter(|value| !value.is_empty());
match (prompt, instructions) {
(Some(prompt), Some(instructions)) => {
Some(format!("{prompt}\n\n[Huddle Instructions]\n{instructions}"))
}
(None, Some(instructions)) => Some(format!("[Huddle Instructions]\n{instructions}")),
(Some(prompt), None) => Some(prompt),
(None, None) => None,
}
}

/// Append the `[Channel Canvas]` metadata section onto the accumulated system prompt.
///
/// The canvas section already carries its `[Channel Canvas]` header (from
Expand Down Expand Up @@ -1616,6 +1639,7 @@ pub async fn run_prompt_task(
// prevents a stale revision A surviving a failed create and being re-used by
// the next attempt after the canvas was cleared.
let mut pending_canvas: Option<(Uuid, String)> = None;
let mut huddle_instructions: Option<String> = None;
// Channel name for the session title, from the same single resolve the
// canvas DM check uses — see `resolve_new_session_channel_context`.
let mut title_channel: Option<String> = None;
Expand All @@ -1628,6 +1652,10 @@ pub async fn run_prompt_task(
resolve_new_session_channel_context(&ctx.channel_info, *cid).await;
title_channel = resolved_channel;
origin_channel_type = resolved_channel_type;
if let Some(owner) = ctx.agent_owner_pubkey.as_ref() {
huddle_instructions =
fetch_huddle_instructions(*cid, owner, &ctx.rest_client).await;
}
// A confirmed DM never receives a canvas section; an undeterminable
// channel type fails closed as a DM for the same reason.
if needs_canvas && !is_dm {
Expand Down Expand Up @@ -1670,10 +1698,13 @@ pub async fn run_prompt_task(
&mut agent,
&ctx,
agent_core.as_deref(),
agent_canvas.as_deref(),
title_channel.as_deref(),
Some(*cid),
origin_channel_type.as_deref(),
NewSessionChannelContext {
huddle_instructions: huddle_instructions.as_deref(),
canvas: agent_canvas.as_deref(),
name: title_channel.as_deref(),
id: Some(*cid),
channel_type: origin_channel_type.as_deref(),
},
)
.await
{
Expand Down Expand Up @@ -1728,8 +1759,19 @@ pub async fn run_prompt_task(
if let Some(sid) = &agent.state.heartbeat_session {
(sid.clone(), false)
} else {
match create_session_and_apply_model(&mut agent, &ctx, None, None, None, None, None)
.await
match create_session_and_apply_model(
&mut agent,
&ctx,
None,
NewSessionChannelContext {
huddle_instructions: None,
canvas: None,
name: None,
id: None,
channel_type: None,
},
)
.await
{
Ok(sid) => {
tracing::info!(
Expand Down Expand Up @@ -1798,6 +1840,7 @@ pub async fn run_prompt_task(
system_prompt: ctx.system_prompt.as_deref(),
team_instructions: ctx.team_instructions.as_deref(),
agent_core: agent_core.as_deref(),
huddle_instructions: huddle_instructions.as_deref(),
agent_canvas: agent_canvas.as_deref(),
};
// Delivery state is committed only after ACP confirms success. Existing
Expand Down Expand Up @@ -2056,6 +2099,7 @@ pub async fn run_prompt_task(
b,
&crate::queue::FormatPromptArgs {
agent_core: standing.agent_core,
huddle_instructions: standing.huddle_instructions,
channel_info: channel_info.as_ref(),
conversation_context: conversation_context.as_ref(),
conversation_context_had_delivered_events,
Expand Down Expand Up @@ -2638,6 +2682,67 @@ pub(crate) async fn fetch_channel_info(
.await
}

/// Fetch owner-signed huddle instructions for a new channel session.
///
/// The event is promoted into the system role, so accepting any channel member's
/// event would be a privilege escalation. Only the configured agent owner's
/// valid signature is accepted; absence or failure simply yields no section.
async fn fetch_huddle_instructions(
channel_id: Uuid,
owner: &nostr::PublicKey,
rest: &RestClient,
) -> Option<String> {
use nostr::{Alphabet, SingleLetterTag};

let h_tag = SingleLetterTag::lowercase(Alphabet::H);
let filter = nostr::Filter::new()
.kind(nostr::Kind::Custom(
buzz_core::kind::KIND_HUDDLE_GUIDELINES as u16,
))
.author(*owner)
.custom_tags(h_tag, [channel_id.to_string()])
.limit(1);
let json = match timeout(
CONTEXT_FETCH_TIMEOUT,
rest.query(std::slice::from_ref(&filter)),
)
.await
{
Ok(Ok(json)) => json,
Ok(Err(error)) => {
tracing::warn!(channel = %channel_id, "huddle instructions query failed: {error}");
return None;
}
Err(_) => {
tracing::warn!(channel = %channel_id, "huddle instructions query timed out");
return None;
}
};
huddle_instructions_from_query_response(json.as_array()?, channel_id, owner)
}

fn huddle_instructions_from_query_response(
events: &[serde_json::Value],
channel_id: Uuid,
owner: &nostr::PublicKey,
) -> Option<String> {
let raw = events.first()?;
let event = serde_json::from_value::<nostr::Event>(raw.clone()).ok()?;
event.verify().ok()?;
let channel_id = channel_id.to_string();
if event.pubkey != *owner
|| event.kind.as_u16() as u32 != buzz_core::kind::KIND_HUDDLE_GUIDELINES
|| !event
.tags
.iter()
.any(|tag| tag.kind().to_string() == "h" && tag.content() == Some(channel_id.as_str()))
{
return None;
}
let content = event.content.trim();
(!content.is_empty()).then(|| content.to_owned())
}

/// Fetch the latest canvas event for `channel_id` and return a rendered
/// `[Channel Canvas]` metadata section, or `None` if absent/blank/error.
///
Expand Down Expand Up @@ -4451,6 +4556,7 @@ mod tests {
system_prompt: Some("you are Eva"),
team_instructions: Some("ship small"),
agent_core: Some("[Agent Memory — core]\nremember this"),
huddle_instructions: Some("reply immediately"),
agent_canvas: Some("[Channel Canvas]\ncanvas content"),
}
}
Expand All @@ -4466,6 +4572,7 @@ mod tests {
"[System]",
"[Team Instructions]",
"[Agent Memory — core]",
"[Huddle Instructions]",
"[Channel Canvas]",
"do the thing",
]
Expand Down Expand Up @@ -7496,6 +7603,59 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'"
}
}

// ── huddle instructions ─────────────────────────────────────────────────

#[test]
fn huddle_instructions_append_as_system_section() {
assert_eq!(
with_huddle_instructions(Some("base".into()), Some(" reply now ")).as_deref(),
Some("base\n\n[Huddle Instructions]\nreply now")
);
}

#[test]
fn huddle_instructions_require_owner_signature_and_channel() {
let owner = Keys::generate();
let stranger = Keys::generate();
let channel = Uuid::parse_str("00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae").unwrap();
let event = |keys: &Keys, channel_id: Uuid| {
let channel_id = channel_id.to_string();
let h_tag = Tag::parse(["h", channel_id.as_str()]).unwrap();
serde_json::to_value(
EventBuilder::new(
Kind::Custom(buzz_core::kind::KIND_HUDDLE_GUIDELINES as u16),
"reply immediately",
)
.tags([h_tag])
.sign_with_keys(keys)
.unwrap(),
)
.unwrap()
};

assert_eq!(
huddle_instructions_from_query_response(
&[event(&owner, channel)],
channel,
&owner.public_key(),
)
.as_deref(),
Some("reply immediately")
);
assert!(huddle_instructions_from_query_response(
&[event(&stranger, channel)],
channel,
&owner.public_key(),
)
.is_none());
assert!(huddle_instructions_from_query_response(
&[event(&owner, Uuid::new_v4())],
channel,
&owner.public_key(),
)
.is_none());
}

// ── render_canvas_section ────────────────────────────────────────────────

#[test]
Expand Down
14 changes: 13 additions & 1 deletion crates/buzz-acp/src/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,8 @@ fn format_conversation_context(
#[derive(Default)]
pub struct FormatPromptArgs<'a> {
pub agent_core: Option<&'a str>,
/// Owner-signed instructions for an active huddle channel.
pub huddle_instructions: Option<&'a str>,
pub channel_info: Option<&'a PromptChannelInfo>,
pub conversation_context: Option<&'a ConversationContext>,
/// True when delivery-delta filtering removed at least one event that this
Expand Down Expand Up @@ -1496,13 +1498,14 @@ pub(crate) struct StandingContext<'a> {
pub system_prompt: Option<&'a str>,
pub team_instructions: Option<&'a str>,
pub agent_core: Option<&'a str>,
pub huddle_instructions: Option<&'a str>,
pub agent_canvas: Option<&'a str>,
}

impl StandingContext<'_> {
/// Render the sections in the order legacy agents have always seen them.
pub(crate) fn sections(&self) -> Vec<String> {
let mut sections = Vec::with_capacity(5);
let mut sections = Vec::with_capacity(6);
if let Some(bp) = self.base_prompt {
sections.push(base_section(bp));
}
Expand All @@ -1519,6 +1522,13 @@ impl StandingContext<'_> {
if let Some(core) = self.agent_core {
sections.push(core.to_string());
}
if let Some(instructions) = self
.huddle_instructions
.map(str::trim)
.filter(|value| !value.is_empty())
{
sections.push(format!("[Huddle Instructions]\n{instructions}"));
}
if let Some(canvas) = self.agent_canvas {
sections.push(canvas.to_string());
}
Expand Down Expand Up @@ -1587,6 +1597,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec<Str
system_prompt: args.system_prompt,
team_instructions: args.team_instructions,
agent_core: args.agent_core,
huddle_instructions: args.huddle_instructions,
agent_canvas: args.agent_canvas,
}
.sections(),
Expand Down Expand Up @@ -2644,6 +2655,7 @@ mod tests {
system_prompt: Some("test system prompt"),
team_instructions: Some("ship small"),
agent_core: Some(core),
huddle_instructions: None,
agent_canvas: Some(canvas),
standing_context_sent: sent,
..Default::default()
Expand Down
Loading
Loading