Skip to content
Merged
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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,9 @@ usage.
for agent-directed human questions. Requests are published as durable,
channel-scoped `KIND_AGENT_USER_INPUT_REQUESTED` events; owner-authored
answers use `KIND_AGENT_USER_INPUT_ANSWER` and link to the request with an
`e` tag. Permission requests remain governed by the existing bypass/approval
`e` tag. Terminal requests publish `KIND_AGENT_USER_INPUT_RESOLVED` with the
request event id and outcome (`answered`, `declined`, or `cancelled`), also
linked with an `e` tag. Permission requests remain governed by the existing bypass/approval
path. The agent-facing controls are:

```text
Expand Down
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,10 @@ The `kind` integer is the only dispatch switch. The relay routes, stores, and fa
| 45001 | KIND_FORUM_POST | Forum thread root |
| 45003 | KIND_FORUM_COMMENT | Forum thread reply |
| 46001–46012 | KIND_WORKFLOW_* | Workflow execution events |
| 46040–46041 | KIND_AGENT_USER_INPUT_* | ACP agent-directed human-input requests and answers |
| 46040–46042 | KIND_AGENT_USER_INPUT_* | ACP agent-directed human-input requests, answers, and terminal resolutions |
| 20001 | KIND_PRESENCE_UPDATE | Ephemeral presence heartbeat |

`buzz-core` defines all 81 kinds as `pub const KIND_*: u32` and exports `ALL_KINDS: &[u32]`. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Buzz uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+).
`buzz-core` defines all 82 kinds as `pub const KIND_*: u32` and exports `ALL_KINDS: &[u32]`. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Buzz uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+).

Note: `KIND_AUTH` (22242) is `pub const KIND_AUTH: u32` in `buzz-core/src/kind.rs` and imported by `buzz-relay/src/handlers/event.rs`. `KIND_CANVAS` (40100) is likewise `pub const KIND_CANVAS: u32` in `buzz-core/src/kind.rs`.

Expand Down
13 changes: 13 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -815,13 +815,26 @@ impl AcpClient {
// caller will invoke cancel_with_cleanup.
}
Err(_) => {
self.cancel_pending_user_input().await;
self.last_prompt_id = None;
self.current_hard_deadline = None;
}
}
self.parse_stop_reason(&result?)
}

async fn cancel_pending_user_input(&mut self) {
if let (Some(runtime), Some(event_id)) = (
self.user_input_runtime.as_ref(),
self.pending_user_input_event_id.as_deref(),
) {
runtime.cancel(event_id).await;
}
self.pending_user_input_id = None;
self.pending_user_input_event_id = None;
self.user_input_responded = false;
}

/// Send a `session/cancel` **notification** (no `id` field, no response expected).
///
/// After calling this, the agent will eventually respond to the in-flight
Expand Down
246 changes: 238 additions & 8 deletions crates/buzz-acp/src/elicitation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use buzz_core::{
kind::KIND_AGENT_USER_INPUT_ANSWER,
user_input::{
Engine, Option_, UserInputAnswer, UserInputAnswers, UserInputQuestion, UserInputRequest,
UserInputSelection,
UserInputResolutionOutcome, UserInputResolved, UserInputSelection,
},
};
use nostr::{Alphabet, Keys, SingleLetterTag, TagKind};
Expand Down Expand Up @@ -57,7 +57,12 @@ pub(crate) struct QuestionRuntime {
keys: Keys,
owner_cache: Arc<OwnerCache>,
rest_client: RestClient,
pending: Mutex<std::collections::HashMap<String, oneshot::Sender<Option<UserInputAnswers>>>>,
pending: Mutex<std::collections::HashMap<String, PendingRequest>>,
}

struct PendingRequest {
channel_id: Uuid,
sender: oneshot::Sender<Option<UserInputAnswers>>,
}

impl QuestionRuntime {
Expand Down Expand Up @@ -106,7 +111,13 @@ impl QuestionRuntime {
.map_err(|e| e.to_string())?;
let event_id = event.id.to_hex();
let (tx, rx) = oneshot::channel();
self.pending.lock().await.insert(event_id.clone(), tx);
self.pending.lock().await.insert(
event_id.clone(),
PendingRequest {
channel_id,
sender: tx,
},
);
if let Err(error) = self.publisher.publish_event(event).await {
self.pending.lock().await.remove(&event_id);
return Err(error.to_string());
Expand All @@ -115,8 +126,70 @@ impl QuestionRuntime {
}

pub(crate) async fn cancel(&self, event_id: &str) {
if let Some(sender) = self.pending.lock().await.remove(event_id) {
let _ = sender.send(None);
if let Some(pending) = self.pending.lock().await.remove(event_id) {
let _ = pending.sender.send(None);
self.publish_resolution(
pending.channel_id,
event_id,
UserInputResolutionOutcome::Cancelled,
)
.await;
}
}

/// Resolve every request still owned by this runtime during graceful shutdown.
pub(crate) async fn shutdown_pending(&self) {
let pending = {
let mut guard = self.pending.lock().await;
std::mem::take(&mut *guard)
};
for (event_id, pending) in pending {
let _ = pending.sender.send(None);
self.publish_resolution(
pending.channel_id,
&event_id,
UserInputResolutionOutcome::Cancelled,
)
.await;
}
}

async fn publish_resolution(
&self,
channel_id: Uuid,
request_event_id: &str,
outcome: UserInputResolutionOutcome,
) {
let content = match serde_json::to_string(&UserInputResolved {
request_event_id: request_event_id.to_owned(),
outcome,
}) {
Ok(content) => content,
Err(error) => {
tracing::warn!(%error, request_event_id, "failed to serialize user-input resolution");
return;
}
};
let builder = match buzz_sdk::build_agent_user_input_resolved(
channel_id,
request_event_id,
&content,
) {
Ok(builder) => builder,
Err(error) => {
tracing::warn!(%error, request_event_id, "failed to build user-input resolution");
return;
}
};
match builder.sign_with_keys(&self.keys) {
Ok(event) => {
if let Err(error) = self.publisher.publish_event(event).await {
tracing::warn!(%error, request_event_id, "failed to publish user-input resolution");
}
}
Err(error) => {
tracing::warn!(%error, request_event_id, "failed to sign user-input resolution");
}
}
}

Expand Down Expand Up @@ -156,9 +229,20 @@ impl QuestionRuntime {
return;
}
};
let sender = self.pending.lock().await.remove(&request_event_id);
if let Some(sender) = sender {
let _ = sender.send(Some(answers));
let pending = self.pending.lock().await.remove(&request_event_id);
if let Some(pending) = pending {
let declined = answers.values().all(Option::is_none);
let _ = pending.sender.send(Some(answers));
self.publish_resolution(
pending.channel_id,
&request_event_id,
if declined {
UserInputResolutionOutcome::Declined
} else {
UserInputResolutionOutcome::Answered
},
)
.await;
} else {
tracing::debug!(request_event_id, "ignoring late user-input answer");
}
Expand Down Expand Up @@ -278,6 +362,9 @@ pub(crate) fn normalize_form(schema: &serde_json::Value) -> Option<NormalizedFor
options,
multi_select,
allow_custom_answer: custom_key.is_some(),
required: required.contains(key.as_str()),
// ACP has no notes concept; intentionally false until an engine
// provides a notes affordance.
allow_notes: false,
};
questions.push(question);
Expand Down Expand Up @@ -395,6 +482,7 @@ pub(crate) fn reconstruct_content(
#[cfg(test)]
mod tests {
use super::*;
use tokio::sync::mpsc;

#[test]
fn normalizes_select_and_freeform() {
Expand Down Expand Up @@ -505,6 +593,29 @@ mod tests {
assert!(reconstruct_content(&form, &missing_required).is_none());
}

#[test]
fn required_round_trips_and_old_question_events_default_to_false() {
let schema = serde_json::json!({
"type":"object",
"properties":{"question_0":{"type":"string"}},
"required":["question_0"]
});
let form = normalize_form(&schema).expect("supported");
assert!(form.questions[0].required);
let encoded = serde_json::to_string(&form.questions[0]).expect("question JSON");
assert!(
serde_json::from_str::<UserInputQuestion>(&encoded)
.expect("question round trip")
.required
);
let old = r#"{"id":"q0","header":"Pick","question":"Choose","options":[]}"#;
assert!(
!serde_json::from_str::<UserInputQuestion>(old)
.expect("old question JSON")
.required
);
}

#[tokio::test]
async fn ignores_non_owner_then_accepts_first_owner_answer() {
let channel_id = Uuid::new_v4();
Expand Down Expand Up @@ -589,4 +700,123 @@ mod tests {
})
.await;
}

#[tokio::test]
async fn publishes_one_resolution_for_each_terminal_outcome() {
async fn publish_request(
channel_id: Uuid,
owner: &Keys,
) -> (
Arc<QuestionRuntime>,
mpsc::Receiver<nostr::Event>,
String,
oneshot::Receiver<Option<UserInputAnswers>>,
) {
let (publisher, published) = RelayEventPublisher::test_pair();
let runtime = QuestionRuntime::new(
publisher,
owner.clone(),
Arc::new(crate::OwnerCache::new(Some(owner.public_key().to_hex()))),
RestClient {
http: reqwest::Client::new(),
base_url: "http://127.0.0.1:0".to_string(),
keys: owner.clone(),
auth_tag_json: None,
},
);
let form = normalize_form(&serde_json::json!({
"type":"object",
"properties":{"question_0":{"type":"string"}}
}))
.expect("supported");
let (event_id, receiver) = runtime
.publish(
channel_id,
"session",
"turn",
Engine::Claude,
form,
"request",
None,
None,
)
.await
.expect("publish");
(runtime, published, event_id, receiver)
}

async fn resolution(published: &mut mpsc::Receiver<nostr::Event>) -> UserInputResolved {
let _request = published.recv().await.expect("request event");
let event = published.recv().await.expect("resolution event");
assert_eq!(
event.kind.as_u16() as u32,
buzz_core::kind::KIND_AGENT_USER_INPUT_RESOLVED
);
serde_json::from_str(&event.content).expect("resolution contract")
}

let channel_id = Uuid::new_v4();
let owner = Keys::generate();

let (runtime, mut published, event_id, receiver) =
publish_request(channel_id, &owner).await;
let answer =
buzz_sdk::build_agent_user_input_answer(channel_id, &event_id, r#"{"q0":"answer"}"#)
.expect("answer builder")
.sign_with_keys(&owner)
.expect("answer signature");
runtime
.handle_event(&BuzzEvent {
channel_id,
event: answer,
})
.await;
assert!(receiver.await.expect("answer received").is_some());
assert_eq!(
resolution(&mut published).await.outcome,
UserInputResolutionOutcome::Answered
);

let (runtime, mut published, event_id, receiver) =
publish_request(channel_id, &owner).await;
let decline =
buzz_sdk::build_agent_user_input_answer(channel_id, &event_id, r#"{"q0":null}"#)
.expect("answer builder")
.sign_with_keys(&owner)
.expect("answer signature");
runtime
.handle_event(&BuzzEvent {
channel_id,
event: decline,
})
.await;
assert!(receiver.await.expect("decline received").is_some());
assert_eq!(
resolution(&mut published).await.outcome,
UserInputResolutionOutcome::Declined
);

let (runtime, mut published, event_id, receiver) =
publish_request(channel_id, &owner).await;
runtime.cancel(&event_id).await;
assert!(receiver.await.expect("cancel received").is_none());
assert_eq!(
resolution(&mut published).await.outcome,
UserInputResolutionOutcome::Cancelled
);

let (runtime, mut published, _event_id, receiver) =
publish_request(channel_id, &owner).await;
runtime.shutdown_pending().await;
assert!(receiver.await.expect("shutdown received").is_none());
assert_eq!(
resolution(&mut published).await.outcome,
UserInputResolutionOutcome::Cancelled
);
assert!(
tokio::time::timeout(std::time::Duration::from_millis(10), published.recv())
.await
.is_err()
);
}
}
3 changes: 3 additions & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2729,6 +2729,9 @@ async fn tokio_main() -> Result<()> {
}

tracing::info!("shutdown: waiting for in-flight prompts");
// Resolve pending elicitation requests before reaping agents so clients
// receive a terminal event during graceful harness shutdown.
user_input_runtime.shutdown_pending().await;
// 30 s is generous for in-flight prompts to be cancelled; using
// max_turn_duration here would cause Ctrl+C to hang for up to an hour.
let grace = Duration::from_secs(30);
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1936,6 +1936,7 @@ mod tests {
"repos",
"social",
"upload",
"user-input",
"users",
"workflows",
];
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,8 @@ pub const KIND_WORKFLOW_APPROVAL_DENIED: u32 = 46012;
pub const KIND_AGENT_USER_INPUT_REQUESTED: u32 = 46040;
/// An owner-authored answer to an ACP agent human-input request.
pub const KIND_AGENT_USER_INPUT_ANSWER: u32 = 46041;
/// Terminal resolution of an ACP agent human-input request.
pub const KIND_AGENT_USER_INPUT_RESOLVED: u32 = 46042;

// User groups (47000–47999)

Expand Down Expand Up @@ -728,6 +730,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_WORKFLOW_APPROVAL_DENIED,
KIND_AGENT_USER_INPUT_REQUESTED,
KIND_AGENT_USER_INPUT_ANSWER,
KIND_AGENT_USER_INPUT_RESOLVED,
KIND_AUDIT_ENTRY,
KIND_HUDDLE_STARTED,
KIND_HUDDLE_PARTICIPANT_JOINED,
Expand Down
Loading