Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

93 changes: 93 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,16 @@ pub struct AcpClient {
/// deltas. Both goose and buzz-agent emit this notification; goose gates
/// on client capability advertisement, buzz-agent emits unconditionally.
goose_usage: UsageTracker,
/// Text emitted in `agent_message_chunk` updates for the current assistant
/// message.
///
/// The buffer is reset immediately before every `session/prompt` and when
/// a tool call starts. ACP agents may emit narration before each tool call;
/// clearing at that boundary ensures the authenticated publisher returns
/// only the last assistant answer, not every narration fragment from the
/// entire tool loop. Keeping the buffer in the ACP client means adapters do
/// not need a separate reporting tool merely to return their final answer.
agent_message: String,
}

/// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape
Expand Down Expand Up @@ -550,9 +560,15 @@ impl AcpClient {
steering_supported: false,
steer_rx: None,
goose_usage: UsageTracker::default(),
agent_message: String::new(),
})
}

/// Consume the text emitted by the agent during the most recent prompt.
pub(crate) fn take_agent_message(&mut self) -> String {
std::mem::take(&mut self.agent_message)
}

/// 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 +784,10 @@ impl AcpClient {
idle_timeout: std::time::Duration,
max_duration: std::time::Duration,
) -> Result<StopReason, AcpError> {
// Each prompt owns exactly one response buffer. This also prevents a
// new-session `initial_message` response from leaking into the first
// real channel turn.
self.agent_message.clear();
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);
Expand Down Expand Up @@ -1733,10 +1753,15 @@ impl AcpClient {
"agent_message_chunk" => {
if let Some(text) = update["content"]["text"].as_str() {
tracing::info!(target: "acp::stream", "{text}");
self.agent_message.push_str(text);
}
false
}
"tool_call" => {
// Text before a tool call is intermediate narration. A final
// answer can only be the assistant text emitted after the last
// tool call in the turn, so discard the earlier fragment.
self.agent_message.clear();
let title = update
.get("title")
.and_then(|v| v.as_str())
Expand Down Expand Up @@ -3591,6 +3616,74 @@ mod tests {
.expect("spawn cat as inert client")
}

#[tokio::test]
async fn agent_message_chunks_are_collected_and_consumed() {
let mut client = spawn_inert_client().await;
for text in ["BUZZ_REENGAGEMENT_", "DRAFT_READY"] {
let update = serde_json::json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "test-session",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"text": text}
}
}
});
let _ = client.handle_session_update(&update);
}

assert_eq!(client.take_agent_message(), "BUZZ_REENGAGEMENT_DRAFT_READY");
assert!(client.take_agent_message().is_empty());
}

#[tokio::test]
async fn agent_message_buffer_keeps_only_text_after_last_tool_call() {
let mut client = spawn_inert_client().await;
let narration = serde_json::json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "test-session",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"text": "I will research this now."}
}
}
});
let tool_call = serde_json::json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "test-session",
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "tool-1",
"title": "Read CRM",
"kind": "read"
}
}
});
let final_answer = serde_json::json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "test-session",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"text": "BUZZ_REENGAGEMENT_DRAFT_READY"}
}
}
});

let _ = client.handle_session_update(&narration);
let _ = client.handle_session_update(&tool_call);
let _ = client.handle_session_update(&final_answer);

assert_eq!(client.take_agent_message(), "BUZZ_REENGAGEMENT_DRAFT_READY");
}

/// Build a `session/update` JSON-RPC notification carrying a
/// `session_info_update` with the given `_meta.goose.activeRunId` value.
/// Pass `None` to omit the `activeRunId` field entirely.
Expand Down
11 changes: 11 additions & 0 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,12 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_NO_TYPING")]
pub no_typing: bool,

/// Publish the final ACP agent message as a signed reply to the triggering
/// Buzz event. Intended for managed agents whose only output surface is
/// their authenticated harness.
#[arg(long, env = "BUZZ_ACP_PUBLISH_FINAL_RESPONSE", default_value_t = false)]
pub publish_final_response: bool,

/// Enable NIP-AE agent core memory injection.
///
/// Memory injection is on by default. When enabled, the harness
Expand Down Expand Up @@ -520,6 +526,9 @@ pub struct Config {
pub max_turns_per_session: u32,
pub presence_enabled: bool,
pub typing_enabled: bool,
/// Whether successful channel turns publish their final ACP response as a
/// signed reply to the triggering event.
pub publish_final_response: bool,
/// Whether NIP-AE agent core memory injection is enabled. When false,
/// the harness skips the per-session core engram fetch and renders no
/// `[Agent Memory — core]` section. On by default; disabled via the
Expand Down Expand Up @@ -1086,6 +1095,7 @@ impl Config {
max_turns_per_session: args.max_turns_per_session,
presence_enabled: !args.no_presence,
typing_enabled: !args.no_typing,
publish_final_response: args.publish_final_response,
memory_enabled: args.memory && !args.no_memory,
model,
session_title: args
Expand Down Expand Up @@ -1460,6 +1470,7 @@ mod tests {
max_turns_per_session: 0,
presence_enabled: true,
typing_enabled: true,
publish_final_response: false,
memory_enabled: true,
model: None,
session_title: None,
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1835,6 +1835,7 @@ async fn tokio_main() -> Result<()> {
channel_info: pool::ChannelInfoResolver::new(channel_info_map, relay.rest_client()),
context_message_limit: config.context_message_limit,
max_turns_per_session: config.max_turns_per_session,
publish_final_response: config.publish_final_response,
permission_mode: config.permission_mode,
agent_keys: config.keys.clone(),
agent_owner_pubkey: startup_owner
Expand Down Expand Up @@ -6196,6 +6197,7 @@ mod build_mcp_servers_tests {
max_turns_per_session: 0,
presence_enabled: true,
typing_enabled: true,
publish_final_response: false,
memory_enabled: false,
model: None,
session_title: None,
Expand Down Expand Up @@ -6418,6 +6420,7 @@ mod error_outcome_emission_tests {
max_turns_per_session: 0,
presence_enabled: true,
typing_enabled: true,
publish_final_response: false,
memory_enabled: false,
model: None,
session_title: None,
Expand Down
Loading