From 76d792f2884f312983cdad58c97ca342de866e22 Mon Sep 17 00:00:00 2001 From: ngoodger-block Date: Wed, 21 Oct 2026 12:54:05 +1100 Subject: [PATCH] BUZZ-357: feat(cli): token-lean agent output format for event reads (#8234) ### Why? Agents read channel history with `buzz messages get/thread`. Every returned event includes its 128-hex `sig`. Every agent-authored message also carries a NIP-OA `auth` tag, which is another owner pubkey and a 128-hex signature. An agent can't use either of these, but pays for them on every history read. Measured with Claude's tokenizer, that is about 88 tokens per human message and about 180 per agent message, so a history read shrinks 33% for short human messages and 40% once agent messages are in the mix (details in the comment below). The existing `compact` format is fixed at `{id, content, created_at}` (#6884), which drops the author, tags and reply target, so agents can't use it. ### What? - **New `--format agent` for event reads.** Applies to `messages get/thread/search` and `feed get`. - Drops `sig`, and trims the NIP-OA `auth` tag to `["auth", ]` so agents can still tell an author is an agent and who owns it. - Folds the NIP-10 `root`/`reply` tags into a `reply_to` field, using the same resolver as relay ingest, plus `thread_root` on nested replies. That's the value `messages send --reply-to` takes. - Keeps every other tag exactly as `json` output shows it: channel, mentions, edit targets, diff metadata. - Non-event reads return the same output as `json`. - **Default for agents.** Without `--format`, the CLI falls back to `BUZZ_OUTPUT_FORMAT`; an empty or unknown value means `json` with a warning, so a harness/CLI version mismatch never breaks commands. buzz-acp sets it to `agent` for every agent runtime through `default_agent_env` and forwards the effective value in the explicit MCP server environment, so operator or persona env still wins, and `--format json` still returns full signed events. buzz-agent adds the variable to its MCP env allowlist. - **Docs and cleanup.** The managed-agent skill doc (version bumped to 7) and `AGENTS.md` describe the new default. The two duplicate `format_events` copies in `messages.rs` and `feed.rs` are now one `commands/event_output` module, which also does the NIP-10 tag parsing. The `json` and `compact` output contracts are unchanged. ### Example output https://github.com/block/buzz/pull/8234#issuecomment-6259679347 ### Testing Started `buzz-acp run` with the deterministic task-agent fixture, then passed its `session/new` MCP environment to Codex 0.163.0 (`codex app-server`). With no format override the MCP child received `agent`; operator overrides of `json` and `compact` reached the MCP child unchanged. The probes used temporary local sessions and made no model requests. ### Related Same change on the other agent read surfaces: each drops `sig`, trims `auth` to `["auth", ]` and keeps thread context. - block/buzz-app#1388: buzz-app MCP read lines (text, `claimed owner`/`reply_to`) - squareup/cash-server#139132: hosted agents' read tools (JSON events, raw NIP-10 tags) Generated with Codex --------- Signed-off-by: Nikolaj Goodger Co-authored-by: Claude Opus 5.5 (1M context) --- AGENTS.md | 7 +- crates/buzz-acp/src/config.rs | 42 +++- crates/buzz-acp/src/lib.rs | 44 ++++ crates/buzz-acp/tests/fixtures/task_agent.py | 1 + crates/buzz-acp/tests/run_task.rs | 8 + crates/buzz-agent/src/mcp.rs | 2 + crates/buzz-cli/src/commands/channels.rs | 4 +- crates/buzz-cli/src/commands/event_output.rs | 209 ++++++++++++++++++ crates/buzz-cli/src/commands/feed.rs | 54 +---- crates/buzz-cli/src/commands/messages.rs | 72 +----- crates/buzz-cli/src/commands/mod.rs | 1 + crates/buzz-cli/src/commands/users.rs | 8 +- crates/buzz-cli/src/lib.rs | 53 ++++- desktop/src-tauri/src/managed_agents/nest.rs | 2 +- .../src/managed_agents/nest_skill.md | 4 +- 15 files changed, 376 insertions(+), 135 deletions(-) create mode 100644 crates/buzz-cli/src/commands/event_output.rs diff --git a/AGENTS.md b/AGENTS.md index 3830f4879c..c2aba29217 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -371,8 +371,13 @@ canonical signed Nostr event fields (`id`, `pubkey`, `kind`, `content`, `{event_id, accepted, message}`; creates add the entity ID. Exit codes: 0=ok, 1=input error, 2=network/relay, 3=auth, 4=other, 5=write conflict (NIP-33 LWW). -`--format compact` is a **global** flag — it goes before the subcommand: +`--format` is a **global** flag — it goes before the subcommand: `buzz --format compact channels list`, NOT `buzz channels list --format compact`. +Without the flag it falls back to `BUZZ_OUTPUT_FORMAT` (unknown values mean +`json`); buzz-acp sets that to `agent` for agent +processes, so their event reads drop `sig`, trim the NIP-OA `auth` tag to +`["auth", ]`, and fold the NIP-10 `root`/`reply` tags into `reply_to` +(plus `thread_root` on nested replies); other tags remain. See `crates/buzz-cli/TESTING.md` for the full live-testing runbook. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index da930dda45..80d527673f 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -815,13 +815,39 @@ fn default_agent_args(command: &str) -> Option> { /// startup budget (see block/buzz#3355). Skip that unrelated global startup /// by default; an operator or persona can still opt back in by setting the /// variable explicitly. +/// +/// Every runtime: `buzz` event reads (`messages get/thread/search`, `feed get`) +/// default to the `agent` format, which drops signature material an agent +/// would otherwise pay for on every history read. pub(crate) fn default_agent_env(command: &str) -> &'static [(&'static str, &'static str)] { match normalize_agent_command_identity(command).as_str() { - "hermes" | "hermes-agent" | "hermes-acp" => &[("HERMES_ACP_SKIP_CONFIGURED_MCP", "1")], - _ => &[], + "hermes" | "hermes-agent" | "hermes-acp" => &[ + AGENT_OUTPUT_FORMAT_ENV, + ("HERMES_ACP_SKIP_CONFIGURED_MCP", "1"), + ], + _ => &[AGENT_OUTPUT_FORMAT_ENV], } } +const AGENT_OUTPUT_FORMAT_ENV: (&str, &str) = ("BUZZ_OUTPUT_FORMAT", "agent"); + +/// Resolve the CLI format for MCP servers that filter inherited environment. +/// Match `AcpClient::spawn`: parent env wins, then the last persona value, +/// then the shared runtime default. +pub(crate) fn agent_output_format(extra_env: &[(String, String)]) -> String { + let (key, default) = AGENT_OUTPUT_FORMAT_ENV; + std::env::var(key) + .ok() + .or_else(|| { + extra_env + .iter() + .rev() + .find(|(name, _)| name == key) + .map(|(_, value)| value.clone()) + }) + .unwrap_or_else(|| default.into()) +} + /// Build the `CODEX_CONFIG` environment variable that enables full outbound /// network access in Codex's macOS Seatbelt sandbox. /// @@ -1777,14 +1803,18 @@ mod tests { ] { assert_eq!( default_agent_env(command), - &[("HERMES_ACP_SKIP_CONFIGURED_MCP", "1")], + &[ + ("BUZZ_OUTPUT_FORMAT", "agent"), + ("HERMES_ACP_SKIP_CONFIGURED_MCP", "1"), + ], "unexpected env defaults for {command}" ); } for command in ["goose", "codex-acp", "claude-agent-acp", "buzz-agent", ""] { - assert!( - default_agent_env(command).is_empty(), - "non-Hermes command must have no env defaults: {command}" + assert_eq!( + default_agent_env(command), + &[("BUZZ_OUTPUT_FORMAT", "agent")], + "non-Hermes command must only get the shared env defaults: {command}" ); } } diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 0249f5be4d..e9f2eb7dbc 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -6464,6 +6464,10 @@ fn build_mcp_servers(config: &Config) -> Vec { args: vec![], env: { let mut env = vec![ + EnvVar { + name: "BUZZ_OUTPUT_FORMAT".into(), + value: config::agent_output_format(&config.persona_env_vars), + }, EnvVar { name: "BUZZ_RELAY_URL".into(), value: config.relay_url.clone(), @@ -10372,6 +10376,46 @@ mod build_mcp_servers_tests { ); } + #[test] + fn session_new_mcp_server_uses_effective_output_format() { + let _guard = ENV_LOCK.lock().unwrap(); + let inherited = std::env::var_os("BUZZ_OUTPUT_FORMAT"); + for (operator, persona, expected) in [ + (None, vec![], "agent"), + (None, vec!["json"], "json"), + (None, vec!["json", "compact"], "compact"), + (Some("compact"), vec![], "compact"), + (Some("json"), vec!["compact"], "json"), + (Some(""), vec!["json"], ""), + ] { + match operator { + Some(value) => std::env::set_var("BUZZ_OUTPUT_FORMAT", value), + None => std::env::remove_var("BUZZ_OUTPUT_FORMAT"), + } + let mut config = test_config(); + config.persona_env_vars = persona + .iter() + .map(|value| ("BUZZ_OUTPUT_FORMAT".into(), (*value).into())) + .collect(); + let servers = build_mcp_servers(&config); + match &inherited { + Some(value) => std::env::set_var("BUZZ_OUTPUT_FORMAT", value), + None => std::env::remove_var("BUZZ_OUTPUT_FORMAT"), + } + let formats: Vec<_> = servers[0] + .env + .iter() + .filter(|entry| entry.name == "BUZZ_OUTPUT_FORMAT") + .map(|entry| entry.value.as_str()) + .collect(); + assert_eq!( + formats, + vec![expected], + "operator={operator:?}, persona={persona:?}" + ); + } + } + #[test] fn session_new_mcp_server_forwards_buzz_auth_tag() { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/crates/buzz-acp/tests/fixtures/task_agent.py b/crates/buzz-acp/tests/fixtures/task_agent.py index 9e02145be9..9f20a21888 100644 --- a/crates/buzz-acp/tests/fixtures/task_agent.py +++ b/crates/buzz-acp/tests/fixtures/task_agent.py @@ -9,6 +9,7 @@ mode = os.environ.get("TASK_AGENT_MODE", "normal") log = open(os.environ["TASK_AGENT_LOG"], "a", buffering=1) log.write(json.dumps({"pid": os.getpid(), "relay": os.environ.get("BUZZ_RELAY_URL"), "key": os.environ.get("BUZZ_PRIVATE_KEY"), + "outputFormat": os.environ.get("BUZZ_OUTPUT_FORMAT"), "gitKeyfile": subprocess.check_output(["git", "config", "nostr.keyfile"], text=True).strip()}) + "\n") if mode.startswith("descendant"): child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)"]) diff --git a/crates/buzz-acp/tests/run_task.rs b/crates/buzz-acp/tests/run_task.rs index fec3d78691..fbea25692a 100644 --- a/crates/buzz-acp/tests/run_task.rs +++ b/crates/buzz-acp/tests/run_task.rs @@ -182,6 +182,7 @@ fn file_and_stdin_run_one_fresh_equipped_session_without_service() { for proc in spawned { assert_eq!(proc["key"], KEY); assert_eq!(proc["relay"], "ws://127.0.0.1:1"); + assert_eq!(proc["outputFormat"], "agent"); assert_dead(proc["pid"].as_u64().unwrap() as u32); assert!(!std::path::Path::new(proc["gitKeyfile"].as_str().unwrap()).exists()); } @@ -196,6 +197,13 @@ fn file_and_stdin_run_one_fresh_equipped_session_without_service() { .collect(); assert_eq!(prompts.len(), 2); for (session, prompt) in sessions.iter().zip(prompts) { + let mcp_env = session["params"]["mcpServers"][0]["env"] + .as_array() + .unwrap(); + let format = mcp_env + .iter() + .find(|entry| entry["name"] == "BUZZ_OUTPUT_FORMAT"); + assert_eq!(format.map(|entry| &entry["value"]), Some(&json!("agent"))); let setup = session["params"].to_string(); assert!(setup.contains("/test/mcp")); assert!(setup.contains("task title")); diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index c9f1c92488..cb6cb70409 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -84,6 +84,8 @@ const PASSTHROUGH_ENV: &[&str] = &[ "BUZZ_PRIVATE_KEY", "BUZZ_RELAY_URL", "BUZZ_AUTH_TAG", + // Harness-chosen `buzz` CLI read format (token-lean event reads). + "BUZZ_OUTPUT_FORMAT", // Agent display name for tools. On the // Desktop path this arrives via the wire `mcpServers[].env` declaration // (which wins here anyway); the allowlist entry covers ACP clients that diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 31de6035e2..1eaccf434d 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -109,7 +109,9 @@ pub async fn cmd_list_channels( .collect(); serde_json::to_string(&compact).unwrap_or_default() } - crate::OutputFormat::Json => serde_json::to_string(&channels).unwrap_or_default(), + crate::OutputFormat::Json | crate::OutputFormat::Agent => { + serde_json::to_string(&channels).unwrap_or_default() + } }; println!("{output}"); Ok(()) diff --git a/crates/buzz-cli/src/commands/event_output.rs b/crates/buzz-cli/src/commands/event_output.rs new file mode 100644 index 0000000000..5c915a107d --- /dev/null +++ b/crates/buzz-cli/src/commands/event_output.rs @@ -0,0 +1,209 @@ +use buzz_core::nip10::ThreadMarkers; +use serde_json::Value; + +use crate::OutputFormat; + +/// Parse NIP-10 `root`/`reply` markers from a JSON tag array via +/// [`buzz_core::nip10`], the rule shared with relay ingest and ACP. +pub(crate) fn thread_markers(tags: &Value) -> ThreadMarkers { + let parts: Vec> = tags + .as_array() + .into_iter() + .flatten() + .filter_map(|tag| { + tag.as_array() + .map(|a| a.iter().map(|v| v.as_str().unwrap_or("")).collect()) + }) + .collect(); + buzz_core::nip10::parse_thread_markers_from_parts(parts.iter().map(Vec::as_slice)) +} + +/// Render normalized event JSON (from `normalize_events`) in the requested format. +pub(crate) fn format_events(normalized: &str, format: &OutputFormat) -> String { + let project: fn(&Value) -> Value = match format { + OutputFormat::Json => return normalized.to_string(), + OutputFormat::Compact => compact_event, + OutputFormat::Agent => agent_event, + }; + let events: Vec = serde_json::from_str(normalized).unwrap_or_default(); + let projected: Vec = events.iter().map(project).collect(); + serde_json::to_string(&projected).unwrap_or_default() +} + +fn field(event: &Value, key: &str) -> Value { + event.get(key).cloned().unwrap_or_default() +} + +fn compact_event(event: &Value) -> Value { + serde_json::json!({ + "id": field(event, "id"), + "content": field(event, "content"), + "created_at": field(event, "created_at"), + }) +} + +/// Drops signature material (`sig`, and the NIP-OA `auth` conditions and +/// signature while keeping its owner pubkey) and folds the NIP-10 thread +/// markers into `reply_to`, plus `thread_root` for nested replies; every other +/// tag (channel, mentions, edit targets, diff provenance, ...) is kept verbatim. +/// +/// Other agent read surfaces trim the same fields; change them together: +/// buzz-app's MCP (`src/buzz-mcp/tools.ts`, `Tools.messages`) and kgoose's +/// hosted read tools (squareup/cash-server `BuzzExtensionClient.messageJson`). +fn agent_event(event: &Value) -> Value { + let raw_tags = event.get("tags").unwrap_or(&Value::Null); + let thread = thread_markers(raw_tags).resolve(); + let kept: Vec = raw_tags + .as_array() + .into_iter() + .flatten() + .filter_map(|tag| { + let name = tag.get(0).and_then(Value::as_str); + let marker = tag.get(3).and_then(Value::as_str); + match (name, marker) { + (Some("auth"), _) => Some(serde_json::json!([ + "auth", + tag.get(1).cloned().unwrap_or_default() + ])), + (Some("e"), Some("root" | "reply")) if thread.is_some() => None, + _ => Some(tag.clone()), + } + }) + .collect(); + let mut projected = serde_json::json!({ + "id": field(event, "id"), + "pubkey": field(event, "pubkey"), + "kind": field(event, "kind"), + "created_at": field(event, "created_at"), + "content": field(event, "content"), + "tags": kept, + }); + if let Some((root, parent)) = thread { + if root != parent { + projected["thread_root"] = Value::String(root); + } + projected["reply_to"] = Value::String(parent); + } + projected +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const CHANNEL: &str = "6f1c7c1e-1d5b-4c55-9d43-0c1f0b6a2f10"; + + fn auth_tag() -> Value { + json!(["auth", "9".repeat(64), "kind=9", "8".repeat(128)]) + } + + fn signed_reply() -> Value { + json!({ + "id": "a".repeat(64), + "pubkey": "b".repeat(64), + "kind": 9, + "content": "reply content", + "created_at": 1_787_754_972_u64, + "tags": [ + ["h", CHANNEL], + ["e", "c".repeat(64), "", "root"], + ["e", "d".repeat(64), "", "reply"], + ["p", "e".repeat(64)], + auth_tag(), + ], + "sig": "f".repeat(128), + }) + } + + fn render(events: Value, format: &OutputFormat) -> Vec { + serde_json::from_str(&format_events(&events.to_string(), format)).unwrap() + } + + #[test] + fn compact_event_format_remains_the_three_key_contract() { + assert_eq!( + render(json!([signed_reply()]), &OutputFormat::Compact)[0], + json!({ + "id": "a".repeat(64), + "content": "reply content", + "created_at": 1_787_754_972_u64, + }) + ); + } + + #[test] + fn agent_format_drops_signature_material_but_keeps_the_owner_and_thread() { + assert_eq!( + render(json!([signed_reply()]), &OutputFormat::Agent)[0], + json!({ + "id": "a".repeat(64), + "pubkey": "b".repeat(64), + "kind": 9, + "created_at": 1_787_754_972_u64, + "content": "reply content", + "thread_root": "c".repeat(64), + "reply_to": "d".repeat(64), + "tags": [["h", CHANNEL], ["p", "e".repeat(64)], ["auth", "9".repeat(64)]], + }) + ); + } + + #[test] + fn agent_format_omits_thread_root_for_a_direct_reply() { + let mut direct = signed_reply(); + direct["tags"] = json!([["h", CHANNEL], ["e", "c".repeat(64), "", "reply"]]); + let output = &render(json!([direct]), &OutputFormat::Agent)[0]; + assert_eq!(output["reply_to"], "c".repeat(64)); + assert!(output.get("thread_root").is_none()); + assert_eq!(output["tags"], json!([["h", CHANNEL]])); + } + + #[test] + fn agent_format_keeps_semantic_tags_such_as_edit_targets_and_diff_provenance() { + let edit = json!({ + "id": "a".repeat(64), + "pubkey": "b".repeat(64), + "kind": 40003, + "content": "edited", + "created_at": 1_787_754_972_u64, + "tags": [["h", CHANNEL], ["e", "c".repeat(64)], auth_tag()], + "sig": "f".repeat(128), + }); + let diff = json!({ + "id": "d".repeat(64), + "pubkey": "b".repeat(64), + "kind": 40008, + "content": "diff --git a/x b/x", + "created_at": 1_787_754_972_u64, + "tags": [["h", CHANNEL], ["repo", "https://example.com/r.git"], ["commit", "abc123"]], + "sig": "f".repeat(128), + }); + + let output = render(json!([edit, diff]), &OutputFormat::Agent); + + assert_eq!( + output[0]["tags"], + json!([ + ["h", CHANNEL], + ["e", "c".repeat(64)], + ["auth", "9".repeat(64)] + ]) + ); + assert!(output[0].get("reply_to").is_none()); + assert_eq!( + output[1]["tags"], + json!([ + ["h", CHANNEL], + ["repo", "https://example.com/r.git"], + ["commit", "abc123"] + ]) + ); + } + + #[test] + fn json_format_is_passthrough() { + let normalized = json!([signed_reply()]).to_string(); + assert_eq!(format_events(&normalized, &OutputFormat::Json), normalized); + } +} diff --git a/crates/buzz-cli/src/commands/feed.rs b/crates/buzz-cli/src/commands/feed.rs index d5e1dae4b2..64d82d5714 100644 --- a/crates/buzz-cli/src/commands/feed.rs +++ b/crates/buzz-cli/src/commands/feed.rs @@ -1,31 +1,11 @@ use std::cmp::Reverse; use crate::client::{normalize_events, BuzzClient}; +use crate::commands::event_output::format_events; use crate::error::CliError; const VALID_FEED_TYPES: &[&str] = &["mentions", "needs_action", "activity", "agent_activity"]; -fn format_events(normalized: &str, format: &crate::OutputFormat) -> String { - match format { - crate::OutputFormat::Compact => { - let events: Vec = - serde_json::from_str(normalized).unwrap_or_default(); - let compact: Vec = events - .iter() - .map(|e| { - serde_json::json!({ - "id": e.get("id").cloned().unwrap_or_default(), - "content": e.get("content").cloned().unwrap_or_default(), - "created_at": e.get("created_at").cloned().unwrap_or_default(), - }) - }) - .collect(); - serde_json::to_string(&compact).unwrap_or_default() - } - crate::OutputFormat::Json => normalized.to_string(), - } -} - /// Get activity feed — query events mentioning our pubkey (via p-tag). pub async fn cmd_get_feed( client: &BuzzClient, @@ -81,35 +61,3 @@ pub async fn dispatch( } => cmd_get_feed(client, since, limit, types.as_deref(), format).await, } } - -#[cfg(test)] -mod tests { - use super::format_events; - - #[test] - fn compact_event_format_remains_the_three_key_contract() { - let normalized = serde_json::json!([{ - "id": "a".repeat(64), - "pubkey": "b".repeat(64), - "kind": 9, - "content": "compact content", - "created_at": 1_787_754_972_u64, - "tags": [["p", "c".repeat(64)]], - "sig": "d".repeat(128), - }]) - .to_string(); - - let output: Vec = - serde_json::from_str(&format_events(&normalized, &crate::OutputFormat::Compact)) - .unwrap(); - - assert_eq!( - output[0], - serde_json::json!({ - "id": "a".repeat(64), - "content": "compact content", - "created_at": 1_787_754_972_u64, - }) - ); - } -} diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index f80f928d31..1ef6d9fca4 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -3,6 +3,7 @@ use nostr::PublicKey; use uuid::Uuid; use crate::client::{normalize_events, normalize_write_response, BuzzClient}; +use crate::commands::event_output::{format_events, thread_markers}; use crate::error::CliError; use crate::validate::{ infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff, @@ -23,20 +24,7 @@ use buzz_sdk::mentions::{ /// - A root-only or marker-less parent returns `None` (it is top-level and its /// own root). fn find_root_from_tags(tags: &serde_json::Value) -> Option { - let parts: Vec> = tags - .as_array()? - .iter() - .filter_map(|tag| { - tag.as_array().map(|a| { - a.iter() - .map(|v| v.as_str().unwrap_or("").to_string()) - .collect() - }) - }) - .collect(); - buzz_core::nip10::parse_thread_markers_from_parts(parts.iter().map(Vec::as_slice)) - .resolve() - .map(|(root, _)| root) + thread_markers(tags).resolve().map(|(root, _)| root) } fn thread_ref_from_parent_tags( @@ -332,27 +320,6 @@ fn parse_member_pubkeys(event: &serde_json::Value) -> Vec { .collect() } -fn format_events(normalized: &str, format: &crate::OutputFormat) -> String { - match format { - crate::OutputFormat::Compact => { - let events: Vec = - serde_json::from_str(normalized).unwrap_or_default(); - let compact: Vec = events - .iter() - .map(|e| { - serde_json::json!({ - "id": e.get("id").cloned().unwrap_or_default(), - "content": e.get("content").cloned().unwrap_or_default(), - "created_at": e.get("created_at").cloned().unwrap_or_default(), - }) - }) - .collect(); - serde_json::to_string(&compact).unwrap_or_default() - } - crate::OutputFormat::Json => normalized.to_string(), - } -} - pub async fn cmd_get_messages( client: &BuzzClient, channel_id: &str, @@ -1085,10 +1052,10 @@ pub async fn dispatch( mod tests { use super::{ channel_id_from_event, cmd_get_thread, cmd_send_message, event_mention_pubkeys, - find_root_from_tags, format_events, match_profiles_by_name, merge_message_mentions, - missing_members, normalize_explicit_mentions, parse_member_pubkeys, - resolve_names_to_pubkeys, resolve_thread_target, thread_ref_from_event, - thread_ref_from_parent_tags, BuzzClient, CliError, Uuid, + find_root_from_tags, match_profiles_by_name, merge_message_mentions, missing_members, + normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys, + resolve_thread_target, thread_ref_from_event, thread_ref_from_parent_tags, BuzzClient, + CliError, Uuid, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, @@ -1106,33 +1073,6 @@ mod tests { const PK_VALID_B: &str = "c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05"; const PK_VALID_C: &str = "f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68"; - #[test] - fn compact_event_format_remains_the_three_key_contract() { - let normalized = serde_json::json!([{ - "id": ID_A, - "pubkey": PUBKEY, - "kind": 9, - "content": "compact content", - "created_at": 1_787_754_972_u64, - "tags": [["h", "channel-id"]], - "sig": "d".repeat(128), - }]) - .to_string(); - - let output: Vec = - serde_json::from_str(&format_events(&normalized, &crate::OutputFormat::Compact)) - .unwrap(); - - assert_eq!( - output[0], - serde_json::json!({ - "id": ID_A, - "content": "compact content", - "created_at": 1_787_754_972_u64, - }) - ); - } - #[tokio::test] async fn malformed_channel_is_rejected_before_thread_fetch() { let client = diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index fed35ec201..f7a3ebe9e0 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod channel_templates; pub mod channels; pub mod dms; pub mod emoji; +pub(crate) mod event_output; pub mod feed; pub mod gifs; pub mod issues; diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index bb2d45dbf1..165de6185a 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -78,7 +78,9 @@ pub async fn cmd_get_users( .collect(); serde_json::to_string(&compact).unwrap_or_default() } - crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(), + crate::OutputFormat::Json | crate::OutputFormat::Agent => { + serde_json::to_string(&profiles).unwrap_or_default() + } }; println!("{output}"); Ok(()) @@ -350,7 +352,9 @@ async fn search_by_name( .collect(); serde_json::to_string(&compact).unwrap_or_default() } - crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(), + crate::OutputFormat::Json | crate::OutputFormat::Agent => { + serde_json::to_string(&profiles).unwrap_or_default() + } }; println!("{output}"); Ok(()) diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index c2f6b7e089..00def0d9b9 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -6,7 +6,7 @@ mod help_tree; mod links; mod validate; -use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; +use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use client::BuzzClient; use error::CliError; use nostr::Keys; @@ -93,7 +93,27 @@ where S: Into + Clone, { let matches = build_command().try_get_matches_from(args)?; - Cli::from_arg_matches(&matches) + let mut cli = Cli::from_arg_matches(&matches)?; + if matches.value_source("format") == Some(clap::parser::ValueSource::DefaultValue) { + cli.format = env_output_format(std::env::var_os("BUZZ_OUTPUT_FORMAT").as_deref()); + } + Ok(cli) +} + +/// `BUZZ_OUTPUT_FORMAT` only picks the default `--format`, so an empty or +/// unknown value (e.g. one a newer harness sets) falls back to `json` rather +/// than failing every command, writes included. +fn env_output_format(value: Option<&std::ffi::OsStr>) -> OutputFormat { + let Some(value) = value.filter(|value| !value.is_empty()) else { + return OutputFormat::default(); + }; + value + .to_str() + .and_then(|value| OutputFormat::from_str(value, false).ok()) + .unwrap_or_else(|| { + eprintln!("warning: ignoring unsupported BUZZ_OUTPUT_FORMAT={value:?}; using json"); + OutputFormat::default() + }) } #[derive(Parser)] @@ -107,6 +127,7 @@ Configuration (flags override env vars): BUZZ_RELAY_URL Relay base URL [default: http://localhost:3000] BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [required] BUZZ_AUTH_TAG NIP-OA auth tag JSON [optional] + BUZZ_OUTPUT_FORMAT Default --format; unknown values fall back to json [optional] The 'pack' subcommand runs locally and does not require a relay connection. @@ -126,7 +147,9 @@ struct Cli { #[arg(long, env = "BUZZ_AUTH_TAG", hide_env_values = true)] auth_tag: Option, - /// Output format: 'json' (default, full fields) or 'compact' (reduced fields). + /// Output format: 'json' (default, full fields), 'compact' (reduced fields), + /// or 'agent' (event reads without signature material). Defaults to + /// `BUZZ_OUTPUT_FORMAT` when that names a known format. #[arg(long, value_enum, default_value = "json")] format: OutputFormat, @@ -206,6 +229,12 @@ pub enum OutputFormat { /// Reduced fields for agent scanning #[value(name = "compact")] Compact, + /// Event reads drop signature material (`sig`, NIP-OA `auth` conditions and + /// signature) and fold NIP-10 thread markers into `reply_to`/`thread_root`; + /// other reads match `json`. + /// Default for harness-managed agents. + #[value(name = "agent")] + Agent, } #[derive(Subcommand)] @@ -2269,6 +2298,24 @@ mod tests { } /// Smoke test: CLI definition is valid and parseable. + #[test] + fn output_format_env_is_a_lenient_default_but_the_flag_stays_strict() { + use std::ffi::OsStr; + assert!(matches!(env_output_format(None), OutputFormat::Json)); + for ignored in ["", "agent-v2"] { + assert!(matches!( + env_output_format(Some(OsStr::new(ignored))), + OutputFormat::Json + )); + } + assert!(matches!( + env_output_format(Some(OsStr::new("agent"))), + OutputFormat::Agent + )); + assert!(parse_args(["buzz", "--format", "agent", "pack", "validate", "x"]).is_ok()); + assert!(parse_args(["buzz", "--format", "agent-v2", "pack", "validate", "x"]).is_err()); + } + #[test] fn cli_definition_is_valid() { Cli::command().debug_assert(); diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 9e6f9c8845..470e2e0376 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -52,7 +52,7 @@ const NEST_AGENTS_VERSION: u32 = 7; /// Template content version for SKILL.md. /// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. -const NEST_SKILL_VERSION: u32 = 6; +const NEST_SKILL_VERSION: u32 = 7; const BEGIN_MARKER: &str = ""; diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index bb40fbff1a..891383d0cb 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -51,7 +51,7 @@ Manage your repository's enforced branch and tag rules with `repos protect list| Output varies by command group — `--help` shows flags but not response shapes. -**Read commands** return JSON arrays. Event reads (`messages get/thread/search`, `feed get`) return normalized, complete signed Nostr events with `{id, pubkey, kind, content, created_at, tags, sig}`. Other reads use command-specific shapes for channels (`{channel_id, name, description, created_at}`), users (kind:0 profile JSON with `pubkey` injected), and workflows (`{workflow_id, content, created_at, pubkey}`). +**Read commands** return JSON arrays. Event reads (`messages get/thread/search`, `feed get`) return `{id, pubkey, kind, created_at, content, tags}` plus `reply_to` (the parent event ID) on replies and `thread_root` on nested replies, whose NIP-10 `root`/`reply` tags are folded into them: the harness sets `BUZZ_OUTPUT_FORMAT=agent`, which drops `sig` and trims the NIP-OA `auth` tag to `["auth", ]` (present on agent-authored messages). Pass `--format json` for complete signed Nostr events with `{id, pubkey, kind, content, created_at, tags, sig}`. Other reads use command-specific shapes for channels (`{channel_id, name, description, created_at}`), users (kind:0 profile JSON with `pubkey` injected), and workflows (`{workflow_id, content, created_at, pubkey}`). **Write commands**: all return `{event_id, accepted, message}`. Create commands add the generated entity ID: `channels create` → `channel_id`, `dms open` → `dm_id`, `workflows create` → `workflow_id`. Agent draft commands add `{request_id, action, saved: false}` because they only open an owner-reviewed Desktop draft. @@ -83,7 +83,7 @@ buzz --format compact users get # [{pubkey, display_name}] buzz --format compact feed get # [{id, content, created_at}] ``` -Write commands are unaffected. `--format json` (default) returns full fields. +Write commands are unaffected. `--format json` returns full fields; `--format agent` (the harness default) is described above. ## Communication Patterns