feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
File diff suppressed because it is too large Load Diff
+147
View File
@@ -0,0 +1,147 @@
You are operating inside the Buzz platform — a Nostr-based messaging platform for human-agent collaboration. The buzz-acp harness routes channel events to your session.
## Session Model
You are one per-channel session of your agent identity — not the only copy. Each channel gets its own independent conversation context, and multiple sessions of the same agent may be active in different channels at the same time. Sessions share your core memory, your workspace on disk, and the relay. They do NOT share conversation context, in-progress reasoning, or in-context task state.
When a human references work "you" are doing in another channel, that work belongs to a different session of you. Unless the human asks you to take it over or coordinate it from this channel, leave execution with the owning session — answer from what you can verify (core memory, workspace files, relay messages) and assume the owning session has it handled.
## Buzz CLI
The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`. Exit codes: 0 ok, 1 user error, 2 network, 3 auth, 4 other. Output is structured JSON.
| Group | Key commands |
|-------|-------------|
| `buzz agents` | `draft-create`, `draft-update` |
| `buzz messages` | `send`, `get`, `thread`, `search` |
| `buzz channels` | `list`, `get`, `create`, `join`, `members` |
| `buzz canvas` | `get`, `set` |
| `buzz reactions` | `add`, `remove` |
| `buzz dms` | `list`, `open` |
| `buzz users` | `get`, `set-profile`, `presence` |
| `buzz workflows` | `list`, `trigger`, `runs` |
| `buzz feed` | `get` |
| `buzz social` | `publish`, `notes` |
| `buzz repos` | `create`, `get`, `list` |
| `buzz issues` | `create`, `get`, `list`, `status` |
| `buzz pr` | `open`, `update`, `get`, `list`, `status` |
| `buzz upload` | `file` |
Run `buzz --help` or `buzz <group> --help` for full usage. For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat.
When opening a pull request in response to channel work, always pass `--channel <current-channel-uuid>` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation.
`buzz pr open`, `buzz issues create`, and `buzz repos create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, or repo in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references.
## Conversational Agent Creation
When someone asks to create an agent, ask for at most two things: the agent's name and what it should do day-to-day. Turn the user's rough purpose into the `--system-prompt` yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the user's request is genuinely ambiguous.
`buzz agents draft-create --channel <current-channel-uuid> --display-name <name> --system-prompt <instructions>`
Use the channel UUID from `[Context]`. Do not ask about runtime, provider, model, credentials, environment variables, or access: Buzz Desktop resolves local runtime/provider/model defaults and new agents default to owner-only access. The command only opens a reviewable draft in the owner's Desktop; never claim the agent exists until the owner saves it.
For explicit changes to an existing personal agent, use `buzz agents draft-update --help`. Draft updates also require owner review and save.
## Communication Patterns
### Mentions
- Use the person's **exact full display name** after `@` (e.g., `@Will Pfleger`, not `@Will`). Partial names fail silently.
- Do NOT format mentions with bold, italic, or backticks — it breaks notification delivery.
- When you know intended recipient pubkeys, send readable `@Name` text and pass the identities separately in the same command: `buzz messages send ... --content "@Name ..." --mention <hex-or-npub>`. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed.
- Without `--mention`, the CLI resolves `@Name` against current channel members. It stops before sending on an unresolved/ambiguous name or a mentioned pubkey that is not a member. For a non-member, add them explicitly with `buzz channels add-member` only when authorized, then retry. Sending never changes membership automatically.
- Only `@mention` when you need their attention. Don't mention in narrative (e.g., "coordinating with Duncan" — no `@`). Naming someone while talking *about* them is narrative — "waiting on @morgan", "until @morgan brings work", "I'll loop in @morgan later". Drop the `@`. Every mention sends a notification; a mention nobody needs to act on is a false alarm.
### Callback Mentions
- When you **finish delegated work**, you MUST `@mention` the delegator in the message that reports the result, deliverable, or blocker. This is the #1 cause of stalled collaboration.
- This applies to **completed work only.** Do not `@mention` to accept an assignment, confirm receipt, or close a loop conversationally. If you have nothing to report yet, say nothing and report when you do.
### Threading
Use the reply destination supplied in the `[Context]` block for ordinary replies in this turn. Do not reuse a remembered thread id, an older event id from prior work, or a stale conversation root.
For human-facing work, keep the conversation flat and easy to read. The app/harness will choose the correct reply destination: the root of the triggering thread when the turn is already threaded, or the triggering top-level event when the human started a new thread.
For agent-to-agent coordination with no human in the loop, deeper nesting is allowed when it helps preserve task structure. Do not flatten agent-only subthreads just because they are inside a thread.
When in doubt, prefer the reply destination explicitly supplied in `[Context]`. If you intentionally choose a different destination, explain why briefly in the message.
All replies and delegations — including task assignments to other agents — go to the **same channel where you were tagged** (use the channel UUID from `[Context]`). Never post responses or assignments to a different channel unless the user explicitly requests it.
### General
- Respond promptly to @mentions. Be direct — no preamble. Name what you did, what you found, or what you need.
- **If your turn produced anything worth knowing, you MUST publish it.** Use `buzz messages send`. Your reasoning and tool calls are invisible — a result, an answer, a deliverable, a decision, a blocker, or a question you need answered exists only if you published it. Work or an answer that someone asked you for always counts. Ending that kind of turn without a message is a silent failure.
- **If a human asked you something, you MUST reply to them** — even if the reply is only that you have nothing to add or nothing to do. Never leave a person waiting on you.
- **Otherwise, publishing is optional and silence is usually correct.** When a message leaves you nothing new to contribute, end the turn without publishing. That is a success, not a failure.
- **After a context compaction or session restart, resume silently** — rebuild state from your todos, memory, and the thread, and never post a message announcing the compaction, summarizing what was lost, or asking how to proceed.
- **Never publish a bare acknowledgement.** A message whose only content is confirming, accepting, agreeing, aligning, signing off, or announcing your own silence adds nothing — and it re-triggers everyone you mention. Prohibited: "Got it", "Confirmed", "Acknowledged", "Clear and noted", "Aligned", "Standing by", "Parked", "I won't reply again", and any variation. If your draft contains nothing beyond acknowledgement, send nothing. If you are tempted to announce that you are done replying, that itself is the message not to send.
- For work that requires follow-up tools, create an open todo **before** sending the pickup acknowledgment. Keep it open until the deliverable is verified and you have sent a completion or blocker message; never end a turn with open todo state unless you have posted that completion or blocker message.
- Use GitHub-flavored Markdown. Fenced code blocks with language tags for syntax highlighting.
- No push notifications — poll with `buzz messages get --channel <UUID> --since <ts>`.
- Address people by the name in their own message header.
- Use top-level channel-visible posts for milestones teammates must act on: picked up, blocked + need input, PR up, done.
- Praise in public; correct in the work, not the person.
## Startup Recovery
1. `buzz feed get` — surface pending mentions and action items. Filter by type: `mentions`, `needs_action`, `activity`, `agent_activity`.
2. `buzz messages get --channel <UUID>` on assigned channels — catch up on recent history.
3. Check `AGENTS.md` in your working directory for team context.
4. Check `RESEARCH/`, `GUIDES/`, `PLANS/` before searching externally. Use `buzz messages search --query "..."` for cross-channel keyword lookups.
## Workspace Layout
Your persistent workspace is in your working directory:
| Dir | Purpose |
|-----|---------|
| `RESEARCH/` | Findings and reference material |
| `PLANS/` | Project and task plans |
| `GUIDES/` | How-to documentation |
| `WORK_LOGS/` | Timestamped activity logs |
| `OUTBOX/` | Drafts pending review or send |
| `REPOS/` | Source checkouts. Work in an existing local checkout when one exists; clone here only when none does |
| `.scratch/` | Ephemeral working files |
Knowledge files use `ALL_CAPS_WITH_UNDERSCORES.md` naming. `AGENTS.md` lists active agents and roles. See `AGENTS.md` in your working directory for full workspace conventions.
These paths are relative to your working directory — keep exploration there. Never run `find` or recursive searches over `$HOME` or `/` hunting for workspace files: they live under your working directory, not elsewhere on disk.
## Agent Memory
Your `core` memory is auto-injected into your context every turn — it holds identity, durable rules, and goals across sessions.
- **Keep `core` small.** A line earns a permanent slot only if it matters across most sessions or prevents a sharp repeat mistake. Treat the 65,535-byte hard limit as a wall to stay far from, not a budget to fill — aim to keep `core` under ~10 KB (roughly your healthy baseline).
- **Durable detail goes to a cold `mem/` slug, not `core`.** Long-lived findings that don't need to be in front of you every turn belong in a `mem/<topic>` slug you read on demand — not appended to `core`.
- **Evict completed work.** When a tracked item ships (PR merged, task done, decision made) and has no open follow-up, remove its line from `core` the same turn — don't leave merged work tracked as if it's live. The detail already lives in its cold `mem/` slug if you need it later.
- **Treat `core` as load-bearing.** Follow it unless newer explicit user instructions override it.
- Cite sources with paths, links, or command outputs. No unsupported claims.
## Engineering Discipline
These are guidelines, not a fixed procedure — apply judgment to the task in front of you.
- **Work in the open.** Your tool calls and reasoning are invisible to humans — narrate as you go in brief messages, and never go dark between "picked up" and "done." If you didn't post it, it didn't happen.
- **Be candid.** Say "I don't know" instead of bluffing, then find out when the answer is knowable.
- **Understand before changing.** Read the actual files, trace call paths, and confirm helpers and types exist before you plan or edit.
- **Plan briefly, then build.** Be opinionated about the safest concrete approach. Solve the stated problem and nothing more — avoid opportunistic refactors and premature abstraction.
- **Match what's there.** Follow the surrounding code's conventions and module boundaries. Read neighboring code first.
- **Attribute results to the exact state that produced them.** Before claiming a test run, grep, or verification holds at commit X, confirm `git rev-parse HEAD` equals X in the same shell where the check ran — working trees move underneath you. Run the full test suite for the package you touched, never a scoped module run — scoped passes hide breakage outside their scope. Scope negative claims ("not found", "no callers", "gone") to the exact places you searched — an unqualified negative is the easiest claim to be wrong about.
- **Validate in the shape the task demands** — tests for code, source citations for research, a reproduced workflow or artifact for UI work. If the same failure hits twice, change angle rather than retrying.
- **Get a second opinion on risky changes.** For anything non-trivial, review the work from a fresh frame before trusting it — your own clean-context re-read, or an independent reviewer if one is available. Don't tell the reviewer what you expect them to find.
- **Self-review before calling it done.** Check for debug code, accidental changes, missing error handling at boundaries, and violated conventions.
- **Scale effort to risk.** A typo or config tweak just gets done. A multi-file change touching persistence, auth, or anything user-visible earns the full discipline above.
## Working in the Repo
- Make file changes in a worktree, not on the default branch. When continuing recent work, reuse the existing one rather than creating another.
- Before committing, read the repo-local git `user.name` / `user.email`; if email is empty, stop and ask. Include the trailers the repo requires.
## Autonomy
Resolve questions yourself before asking: read more context, re-examine from a fresh frame, hand a tangent to a separate agent when one's available, then pick the safest option and note the decision so it can be overridden. If you're steered in a newer thread while working from an older one, acknowledge it in the newer thread.
Surface to the user only for product intent or user-facing behavior you can't infer from code, docs, or history — or when their latest message changes the task's scope.
File diff suppressed because it is too large Load Diff
+248
View File
@@ -0,0 +1,248 @@
//! Fetch the agent's NIP-AE `core` engram at session creation and render it
//! into a prompt section.
//!
//! Scope per Tyler's spec:
//! - Fire one synchronous query for the core head when a *new* session is born.
//! - If a body is found, emit `[Agent Memory — core]\n<profile>`.
//! - If no body is found, emit an onboarding nudge so the agent learns how
//! to set its own core.
//! - On any *error* (transport, parse), log and emit nothing. We must not
//! mistake a relay outage for "no core" — that would invite the agent to
//! overwrite real, just-unreachable memory with a fresh profile.
//! - Either way, session creation is never blocked.
use buzz_core::engram::{conversation_key, d_tag, select_head, validate_and_decrypt, Body};
use buzz_core::kind::KIND_AGENT_ENGRAM;
use nostr::{Event, Keys, PublicKey};
use crate::relay::RestClient;
/// Section header rendered into the prompt.
const SECTION_LABEL: &str = "Agent Memory — core";
/// Onboarding nudge for new agents with no core yet.
///
/// Wording is from Tyler's brief: "No core memory found. Use `buzz mem`
/// to create a core memory. Ask your user about yourself."
pub const ONBOARDING_NUDGE: &str = "No core memory found. \
Use `buzz mem set core \"\"` to create one (it will hold your identity, \
rules, and goals across sessions). Ask your user about yourself.";
/// Build the rendered prompt section for the agent's core.
///
/// Returns:
/// - `Some(profile_section)` when a valid core exists,
/// - `Some(nudge_section)` when the relay confirmed absence,
/// - `None` when the fetch failed (transport, parse, decrypt) — the caller
/// should inject no section in that case so the agent doesn't conclude
/// memory is empty.
pub async fn build_core_section(
rest: &RestClient,
agent_keys: &Keys,
owner: &PublicKey,
) -> Option<String> {
match fetch_core_body(rest, agent_keys, owner).await {
Ok(Some(profile)) => Some(format!("[{SECTION_LABEL}]\n{profile}")),
Ok(None) => Some(format!("[{SECTION_LABEL}]\n{ONBOARDING_NUDGE}")),
Err(reason) => {
tracing::warn!(
target: "engram::core",
"core fetch failed: {reason} — emitting no section to avoid \
confusing a relay outage with an absent core"
);
None
}
}
}
/// Query the relay for the core head and decode it. Returns:
/// - `Ok(Some(profile))` if a valid core body was found,
/// - `Ok(None)` only if the relay confirmed absence (empty result set),
/// - `Err(reason)` if the relay returned candidates we could not parse,
/// verify, or decrypt — those are NOT treated as absence (would let an
/// unreadable but real core be silently overwritten by the onboarding nudge),
/// - `Err` for transport / parse errors.
async fn fetch_core_body(
rest: &RestClient,
agent_keys: &Keys,
owner: &PublicKey,
) -> Result<Option<String>, String> {
let k_c = conversation_key(agent_keys.secret_key(), owner);
let d = d_tag(&k_c, buzz_core::engram::CORE_SLUG);
let filter = nostr::Filter::new()
.kind(nostr::Kind::Custom(KIND_AGENT_ENGRAM as u16))
.author(agent_keys.public_key())
.custom_tags(nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), [d])
.custom_tags(
nostr::SingleLetterTag::lowercase(nostr::Alphabet::P),
[owner.to_hex()],
)
.limit(16);
let value = rest
.query(&[filter])
.await
.map_err(|e| format!("relay query failed: {e}"))?;
let arr = value
.as_array()
.ok_or_else(|| "relay query returned non-array".to_string())?;
decode_core_body(arr, agent_keys, owner)
}
/// Pure decoder: given the relay's JSON array, decide whether we have a
/// readable core, confirmed absence, or an ambiguous unreadable-state.
///
/// - Empty array → `Ok(None)` (confirmed absence; caller renders the nudge).
/// - At least one event decrypts → use the winning head's body.
/// * Body::Core → `Ok(Some(profile))`
/// * Body::Tombstone or unexpected shape → `Ok(None)` (treat as absent).
/// - Non-empty array but nothing decrypts → `Err` (fail closed; caller
/// emits no section, so the agent does not assume memory is empty and
/// try to overwrite a real-but-unreadable core).
fn decode_core_body(
arr: &[serde_json::Value],
agent_keys: &Keys,
owner: &PublicKey,
) -> Result<Option<String>, String> {
if arr.is_empty() {
return Ok(None);
}
let mut valid_with_body: Vec<(Event, Body)> = Vec::with_capacity(arr.len());
let mut candidates_seen = 0usize;
let mut last_decrypt_err: Option<String> = None;
for ev_json in arr {
let event: Event = match serde_json::from_value(ev_json.clone()) {
Ok(e) => e,
Err(_) => continue,
};
if event.verify().is_err() {
continue;
}
candidates_seen += 1;
match validate_and_decrypt(
&event,
&agent_keys.public_key(),
owner,
agent_keys.secret_key(),
owner,
) {
Ok(body) => valid_with_body.push((event, body)),
Err(e) => {
last_decrypt_err = Some(e.to_string());
continue;
}
}
}
if valid_with_body.is_empty() {
if candidates_seen > 0 {
return Err(format!(
"{candidates_seen} core candidate(s) returned but none decryptable (last error: {})",
last_decrypt_err.as_deref().unwrap_or("unknown")
));
}
return Err(
"relay returned core candidate(s) that could not be parsed or verified".to_string(),
);
}
let events: Vec<Event> = valid_with_body.iter().map(|(e, _)| e.clone()).collect();
// `select_head` returns `None` only on an empty iterator, which we
// ruled out above.
let Some(head) = select_head(events) else {
return Ok(None);
};
let head_id = head.id;
let body = valid_with_body
.into_iter()
.find(|(e, _)| e.id == head_id)
.map(|(_, b)| b);
match body {
Some(Body::Core { profile }) => Ok(Some(profile)),
// A tombstone or unexpectedly-shaped head means "no usable core."
_ => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
use buzz_core::engram::{build_event, Body};
use serde_json::json;
/// Empty array → confirmed absence → Ok(None), so the caller emits the
/// onboarding nudge. This is the only path that maps to "no core."
#[test]
fn decode_empty_array_is_confirmed_absence() {
let agent = Keys::generate();
let owner = Keys::generate();
let out = decode_core_body(&[], &agent, &owner.public_key()).unwrap();
assert_eq!(out, None);
}
/// Happy path: a real, decryptable core event yields the profile.
#[test]
fn decode_valid_core_returns_profile() {
let agent = Keys::generate();
let owner = Keys::generate();
let body = Body::Core {
profile: "I am Sami.".to_string(),
};
let ev = build_event(&agent, &owner.public_key(), &body, 1_700_000_000).unwrap();
let arr = vec![serde_json::to_value(&ev).unwrap()];
let out = decode_core_body(&arr, &agent, &owner.public_key()).unwrap();
assert_eq!(out.as_deref(), Some("I am Sami."));
}
/// Regression: when the relay returns a kind:30174 event addressed to
/// this agent that we cannot decrypt (here: encrypted to a *different*
/// owner's key, so the MAC fails for this agent↔owner pair), we MUST
/// return Err and NOT Ok(None). Returning Ok(None) would cause the
/// harness to emit the onboarding nudge, inviting the agent to overwrite
/// a real-but-unreadable core.
#[test]
fn decode_undecryptable_candidate_is_err_not_absent() {
let agent = Keys::generate();
let owner = Keys::generate();
let wrong_owner = Keys::generate();
// Build an engram encrypted to wrong_owner (not owner). It will pass
// sig verification but fail MAC/decrypt for the agent↔owner pair.
let body = Body::Core {
profile: "secret".to_string(),
};
let ev = build_event(&agent, &wrong_owner.public_key(), &body, 1_700_000_000).unwrap();
let arr = vec![serde_json::to_value(&ev).unwrap()];
let result = decode_core_body(&arr, &agent, &owner.public_key());
assert!(result.is_err(), "expected Err, got: {result:?}");
let msg = result.unwrap_err();
assert!(msg.contains("decryptable"), "got: {msg}");
}
/// An unexpectedly-shaped head (here: a Memory body in what was supposed
/// to be the core slot) is a legitimate, decryptable "no usable core" —
/// Ok(None). Real `rm core` is refused at the CLI, so this is a defensive
/// branch for malformed data on the wire.
#[test]
fn decode_non_core_body_is_absent() {
let agent = Keys::generate();
let owner = Keys::generate();
let body = Body::Memory {
slug: "mem/x".to_string(),
value: None,
};
let ev = build_event(&agent, &owner.public_key(), &body, 1_700_000_000).unwrap();
let arr = vec![serde_json::to_value(&ev).unwrap()];
let out = decode_core_body(&arr, &agent, &owner.public_key()).unwrap();
assert_eq!(out, None);
}
/// Non-empty array with only garbage entries (not even parseable as
/// events) is also treated as a fetch error, not absence.
#[test]
fn decode_unparseable_candidates_is_err() {
let agent = Keys::generate();
let owner = Keys::generate();
let arr = vec![json!({"not": "an event"}), json!("garbage")];
let result = decode_core_body(&arr, &agent, &owner.public_key());
assert!(result.is_err(), "expected Err, got: {result:?}");
}
}
+787
View File
@@ -0,0 +1,787 @@
//! Content filtering and subscription rule matching.
//!
//! Responsibilities:
//! - Building an evalexpr context from a Nostr event
//! - Evaluating boolean filter expressions with a hard timeout
//! - Matching events against ordered subscription rules (first match wins)
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tracing::{error, warn};
/// Errors that can occur during filter expression evaluation.
#[derive(Debug, thiserror::Error)]
pub enum FilterError {
#[error("expression too long ({len} bytes, max {max})")]
ExpressionTooLong { len: usize, max: usize },
#[error("evaluation timed out")]
Timeout,
#[error("evaluation error: {0}")]
EvalError(String),
}
/// Variables extracted from a Nostr event for use in filter expressions.
#[derive(Debug, Clone)]
pub struct FilterContext {
/// Event content (message body).
pub content: String,
/// Event author pubkey as hex string.
pub author: String,
/// Nostr event kind number.
pub kind: u32,
/// Channel UUID as string.
pub channel_id: String,
/// Event `created_at` unix timestamp.
pub timestamp: u64,
}
impl FilterContext {
/// Build a `FilterContext` from a Nostr event and its channel UUID.
pub fn from_event(event: &nostr::Event, channel_id: uuid::Uuid) -> Self {
Self {
content: event.content.clone(),
author: event.pubkey.to_hex(),
kind: event.kind.as_u16() as u32,
channel_id: channel_id.to_string(),
timestamp: event.created_at.as_secs(),
}
}
}
/// Scope of channels a subscription rule applies to.
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(untagged)]
pub enum ChannelScope {
/// The literal string `"all"` — matches every channel.
All(String),
/// An explicit list of channel UUID strings.
List(Vec<String>),
}
impl ChannelScope {
/// Returns `true` if this scope covers the given channel UUID.
///
/// `ChannelScope::All` only matches when the inner string is exactly `"all"`.
pub fn matches(&self, channel_id: &uuid::Uuid) -> bool {
match self {
ChannelScope::All(s) => s == "all",
ChannelScope::List(ids) => ids.iter().any(|id| id == &channel_id.to_string()),
}
}
}
/// A single subscription rule from the agent config.
///
/// # Thread safety
///
/// `consecutive_timeouts` is an `AtomicU32` so `match_event` can update it
/// without requiring `&mut self` — rules are shared via `Arc<[SubscriptionRule]>`
/// across the event-dispatch loop.
#[derive(Debug, serde::Deserialize)]
pub struct SubscriptionRule {
/// Human-readable rule name; used as fallback `prompt_tag`.
pub name: String,
/// Which channels this rule applies to.
pub channels: ChannelScope,
/// Nostr event kinds to match. Empty = wildcard (all kinds).
#[serde(default)]
pub kinds: Vec<u32>,
/// If `true`, the event must contain a `p` tag referencing the agent pubkey.
#[serde(default)]
pub require_mention: bool,
/// Optional evalexpr boolean expression for fine-grained filtering.
#[serde(default)]
pub filter: Option<String>,
/// Tag passed to the prompt template. Falls back to `name` if absent.
#[serde(default)]
pub prompt_tag: Option<String>,
/// Pre-compiled evalexpr AST for the `filter` expression.
///
/// Populated by `load_rules()` at startup so `match_event` never re-parses
/// the expression string on the hot path. `None` when `filter` is `None`
/// or the rule was constructed without calling `load_rules()` (e.g. tests).
#[serde(skip)]
pub compiled_filter: Option<Arc<evalexpr::Node>>,
/// Consecutive filter-evaluation timeout counter.
///
/// Incremented on each timeout; reset on any successful evaluation.
/// When this reaches `MAX_CONSECUTIVE_TIMEOUTS`, the rule is treated as
/// disabled and `match_event` returns `None` (fail-closed).
#[serde(skip)]
pub consecutive_timeouts: Arc<AtomicU32>,
}
impl Default for SubscriptionRule {
fn default() -> Self {
Self {
name: String::new(),
channels: ChannelScope::All("all".into()),
kinds: Vec::new(),
require_mention: false,
filter: None,
prompt_tag: None,
compiled_filter: None,
consecutive_timeouts: Arc::new(AtomicU32::new(0)),
}
}
}
impl Clone for SubscriptionRule {
fn clone(&self) -> Self {
Self {
name: self.name.clone(),
channels: self.channels.clone(),
kinds: self.kinds.clone(),
require_mention: self.require_mention,
filter: self.filter.clone(),
prompt_tag: self.prompt_tag.clone(),
compiled_filter: self.compiled_filter.clone(),
// Share the same counter across clones so all copies of a rule
// agree on the timeout state.
consecutive_timeouts: self.consecutive_timeouts.clone(),
}
}
}
/// The result of a successful rule match.
#[derive(Debug, Clone)]
pub struct MatchedRule {
/// Zero-based index of the matching rule in the rules slice.
#[cfg_attr(not(test), allow(dead_code))]
pub rule_index: usize,
/// Prompt tag to use (rule's `prompt_tag` or its `name`).
pub prompt_tag: String,
}
/// Maximum expression length accepted by `evaluate_filter`.
///
/// Bounds worst-case O(2^n) evaluation paths. The spawn_blocking thread cannot
/// be cancelled after a timeout fires, so we cap length before dispatching.
const MAX_EXPR_LEN: usize = 4096;
/// Maximum wall-clock time allowed for a single evalexpr evaluation.
const EVAL_TIMEOUT: Duration = Duration::from_millis(100);
/// Maximum concurrent blocking filter evaluations.
///
/// The semaphore permit is moved into each `spawn_blocking` closure so it is
/// held until the blocking thread finishes — not just until the caller's timeout
/// fires. This truly bounds the number of live blocking evals even under repeated
/// slow expressions.
const MAX_CONCURRENT_FILTER_EVALS: usize = 4;
/// Semaphore that bounds concurrent `spawn_blocking` filter evaluations.
///
/// Wrapped in `Arc` so `acquire_owned()` can be used, which returns an
/// `OwnedSemaphorePermit` that can be moved into the `spawn_blocking` closure.
/// This ensures the permit is held until the blocking task actually finishes —
/// not just until the caller's timeout fires — so the semaphore truly bounds
/// the number of live blocking threads.
static FILTER_EVAL_SEMAPHORE: std::sync::LazyLock<Arc<tokio::sync::Semaphore>> =
std::sync::LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_FILTER_EVALS)));
/// Evaluate a boolean filter expression against a `FilterContext`.
///
/// - Caps expression length at [`MAX_EXPR_LEN`] bytes.
/// - Acquires an owned permit from [`FILTER_EVAL_SEMAPHORE`] and moves it into
/// the blocking closure so it is held until the task finishes, not just until
/// the caller's timeout fires.
/// - Runs evaluation on a blocking thread with a [`EVAL_TIMEOUT`] hard timeout.
/// - When a pre-compiled `node` is provided (via `Arc`), uses
/// `node.eval_boolean_with_context()` instead of re-parsing the expression
/// string on every call.
/// - Registers custom string helpers: `str_contains`, `str_starts_with`,
/// `str_ends_with`, `str_len` (duplicated intentionally from buzz-workflow).
pub async fn evaluate_filter(
expr: &str,
ctx: &FilterContext,
node: Option<Arc<evalexpr::Node>>,
) -> Result<bool, FilterError> {
if expr.len() > MAX_EXPR_LEN {
return Err(FilterError::ExpressionTooLong {
len: expr.len(),
max: MAX_EXPR_LEN,
});
}
let eval_ctx = build_eval_context(ctx).map_err(FilterError::EvalError)?;
let expr_owned = expr.to_owned();
// Acquire an *owned* permit so it can be moved into the spawn_blocking closure.
// The permit is held until the blocking task actually completes — not just until
// the caller's timeout fires — so the semaphore truly bounds the number of live
// blocking threads even when callers time out.
//
// The acquire itself is bounded by EVAL_TIMEOUT: if all permits are held by
// wedged blocking tasks, we time out instead of blocking the main event loop.
let permit = tokio::time::timeout(
EVAL_TIMEOUT,
Arc::clone(&*FILTER_EVAL_SEMAPHORE).acquire_owned(),
)
.await
.map_err(|_| FilterError::Timeout)?
.map_err(|e| FilterError::EvalError(format!("semaphore closed: {e}")))?;
let result = tokio::time::timeout(
EVAL_TIMEOUT,
tokio::task::spawn_blocking(move || {
// Hold the permit for the lifetime of this closure: released only
// when the blocking thread returns, not when the caller times out.
let _permit = permit;
// Use the pre-compiled AST when available; fall back to string parsing.
if let Some(node) = node {
node.eval_boolean_with_context(&eval_ctx)
} else {
evalexpr::eval_boolean_with_context(&expr_owned, &eval_ctx)
}
}),
)
.await
.map_err(|_| FilterError::Timeout)?
.map_err(|e| FilterError::EvalError(format!("eval task panicked: {e}")))?
.map_err(|e| FilterError::EvalError(e.to_string()))?;
Ok(result)
}
/// Build an `evalexpr::HashMapContext` from a `FilterContext`.
///
/// Variables exposed to expressions:
///
/// | Name | Type | Source |
/// |--------------|--------|---------------------------|
/// | `content` | string | `event.content` |
/// | `author` | string | `event.pubkey` (hex) |
/// | `kind` | int | `event.kind` |
/// | `channel_id` | string | channel UUID |
/// | `timestamp` | int | `event.created_at` |
///
/// Also registers `str_contains`, `str_starts_with`, `str_ends_with`,
/// `str_len` — duplicated from buzz-workflow intentionally so this crate
/// has no runtime dependency on buzz-workflow.
fn build_eval_context(ctx: &FilterContext) -> Result<evalexpr::HashMapContext, String> {
use evalexpr::*;
let mut eval_ctx = HashMapContext::new();
// evalexpr v11 does not ship these helpers; register them manually.
eval_ctx
.set_function(
"str_contains".into(),
Function::new(|args| {
let args = args.as_fixed_len_tuple(2)?;
let haystack = args[0].as_string()?;
let needle = args[1].as_string()?;
Ok(Value::Boolean(haystack.contains(needle.as_str())))
}),
)
.map_err(|e| e.to_string())?;
eval_ctx
.set_function(
"str_starts_with".into(),
Function::new(|args| {
let args = args.as_fixed_len_tuple(2)?;
let s = args[0].as_string()?;
let prefix = args[1].as_string()?;
Ok(Value::Boolean(s.starts_with(prefix.as_str())))
}),
)
.map_err(|e| e.to_string())?;
eval_ctx
.set_function(
"str_ends_with".into(),
Function::new(|args| {
let args = args.as_fixed_len_tuple(2)?;
let s = args[0].as_string()?;
let suffix = args[1].as_string()?;
Ok(Value::Boolean(s.ends_with(suffix.as_str())))
}),
)
.map_err(|e| e.to_string())?;
eval_ctx
.set_function(
"str_len".into(),
Function::new(|arg| {
let s = arg.as_string()?;
Ok(Value::Int(s.len() as i64))
}),
)
.map_err(|e| e.to_string())?;
eval_ctx
.set_value("content".into(), Value::String(ctx.content.clone()))
.map_err(|e| e.to_string())?;
eval_ctx
.set_value("author".into(), Value::String(ctx.author.clone()))
.map_err(|e| e.to_string())?;
eval_ctx
.set_value("kind".into(), Value::Int(ctx.kind as i64))
.map_err(|e| e.to_string())?;
eval_ctx
.set_value("channel_id".into(), Value::String(ctx.channel_id.clone()))
.map_err(|e| e.to_string())?;
eval_ctx
.set_value("timestamp".into(), Value::Int(ctx.timestamp as i64))
.map_err(|e| e.to_string())?;
Ok(eval_ctx)
}
/// Consecutive timeout threshold before a rule is treated as disabled.
///
/// After this many back-to-back timeouts on a single rule, the rule is logged
/// at ERROR level and `match_event` returns `None` (fail-closed). This prevents
/// a pathological expression from silently widening the subscription.
const MAX_CONSECUTIVE_TIMEOUTS: u32 = 5;
/// Match a Nostr event against an ordered list of subscription rules.
///
/// Rules are evaluated in order; the first rule whose conditions all pass
/// wins. Returns `None` if no rule matches.
///
/// # Matching logic (per rule)
///
/// 1. **channels** — if not `"all"`, the event's channel UUID must be in the list.
/// 2. **kinds** — if non-empty, the event kind must be in the list.
/// 3. **require_mention** — if `true`, a `p` tag matching `agent_pubkey_hex` must
/// exist. Tag kind is checked via `tag.as_slice()` for stable, library-independent
/// access.
/// 4. **filter** — if `Some`, the evalexpr expression must evaluate to `true`.
///
/// # Fail-closed filter error handling
///
/// Any filter evaluation error — including timeout — causes the **entire
/// `match_event` call** to return `None` (no match for any rule). We never
/// fall through to the next rule on error because that would silently widen
/// the subscription: a broken/slow rule would let events through that were
/// meant to be gated.
///
/// After [`MAX_CONSECUTIVE_TIMEOUTS`] consecutive timeouts on a single rule,
/// that rule is logged at ERROR and the call returns `None` immediately to
/// avoid blocking the event loop indefinitely.
pub async fn match_event(
event: &nostr::Event,
channel_id: uuid::Uuid,
rules: &[SubscriptionRule],
agent_pubkey_hex: &str,
) -> Option<MatchedRule> {
let filter_ctx = FilterContext::from_event(event, channel_id);
for (index, rule) in rules.iter().enumerate() {
// 1. Channel scope check.
if !rule.channels.matches(&channel_id) {
continue;
}
// 2. Kind filter (empty = wildcard).
if !rule.kinds.is_empty() && !rule.kinds.contains(&(event.kind.as_u16() as u32)) {
continue;
}
// 3. Mention check — look for a `p` tag whose first element equals
// agent_pubkey_hex. Uses tag.as_slice() for stable, library-independent
// access — avoids relying on the Display impl of tag kind.
if rule.require_mention {
let mentioned = event.tags.iter().any(|tag| {
let s = tag.as_slice();
s.first().map(|k| k.as_str()) == Some("p")
&& s.get(1).map(|v| v.as_str()) == Some(agent_pubkey_hex)
});
if !mentioned {
continue;
}
}
// 4. Optional evalexpr filter expression.
if let Some(expr) = &rule.filter {
// Skip rules that have timed out too many times — treat as disabled.
let prior_timeouts = rule.consecutive_timeouts.load(Ordering::Relaxed);
if prior_timeouts >= MAX_CONSECUTIVE_TIMEOUTS {
error!(
rule = %rule.name,
rule_index = index,
timeouts = prior_timeouts,
"filter rule disabled after too many consecutive timeouts; \
failing closed (no match for any rule)"
);
// Fail-closed: disabled rule → no match for this event.
return None;
}
match evaluate_filter(expr, &filter_ctx, rule.compiled_filter.clone()).await {
Ok(true) => {
// Successful match — reset timeout counter.
rule.consecutive_timeouts.store(0, Ordering::Relaxed);
}
Ok(false) => {
rule.consecutive_timeouts.store(0, Ordering::Relaxed);
continue;
}
Err(FilterError::Timeout) => {
let n = rule.consecutive_timeouts.fetch_add(1, Ordering::Relaxed) + 1;
warn!(
rule = %rule.name,
rule_index = index,
consecutive_timeouts = n,
"filter expression timed out; failing closed (no match for any rule)"
);
// Fail-closed: timeout → no match, not next rule.
return None;
}
Err(e) => {
warn!(
rule = %rule.name,
rule_index = index,
error = %e,
"filter expression error; failing closed (no match for any rule)"
);
// Fail-closed: any error → no match, not next rule.
return None;
}
}
}
// All checks passed — this rule wins.
let prompt_tag = rule.prompt_tag.clone().unwrap_or_else(|| rule.name.clone());
return Some(MatchedRule {
rule_index: index,
prompt_tag,
});
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, Tag};
use uuid::Uuid;
/// Build a minimal test event with the given kind and content.
fn make_event(kind: u32, content: &str) -> nostr::Event {
let keys = Keys::generate();
EventBuilder::new(Kind::Custom(kind as u16), content)
.tags([])
.sign_with_keys(&keys)
.unwrap()
}
/// Build a test event with an explicit `p` tag.
fn make_event_with_p_tag(kind: u32, content: &str, p_hex: &str) -> nostr::Event {
let keys = Keys::generate();
let p_tag = Tag::parse(["p", p_hex]).expect("tag parse");
EventBuilder::new(Kind::Custom(kind as u16), content)
.tags([p_tag])
.sign_with_keys(&keys)
.unwrap()
}
fn any_channel() -> Uuid {
Uuid::new_v4()
}
fn make_rule(
name: &str,
channels: ChannelScope,
kinds: Vec<u32>,
mention: bool,
filter: Option<&str>,
prompt_tag: Option<&str>,
) -> SubscriptionRule {
SubscriptionRule {
name: name.into(),
channels,
kinds,
require_mention: mention,
filter: filter.map(|s| s.into()),
prompt_tag: prompt_tag.map(|s| s.into()),
compiled_filter: None,
consecutive_timeouts: Arc::new(AtomicU32::new(0)),
}
}
#[test]
fn test_filter_context_from_event() {
let event = make_event(9, "hello world");
let channel_id = any_channel();
let ctx = FilterContext::from_event(&event, channel_id);
assert_eq!(ctx.content, "hello world");
assert_eq!(ctx.author, event.pubkey.to_hex());
assert_eq!(ctx.kind, 9);
assert_eq!(ctx.channel_id, channel_id.to_string());
assert_eq!(ctx.timestamp, event.created_at.as_secs());
}
#[tokio::test]
async fn test_evaluate_filter_str_contains() {
let event = make_event(9, "P1 incident in production");
let ctx = FilterContext::from_event(&event, any_channel());
let result = evaluate_filter(r#"str_contains(content, "P1")"#, &ctx, None)
.await
.unwrap();
assert!(result);
let result = evaluate_filter(r#"str_contains(content, "P2")"#, &ctx, None)
.await
.unwrap();
assert!(!result);
}
#[tokio::test]
async fn test_evaluate_filter_kind_check() {
let event = make_event(9, "some content");
let ctx = FilterContext::from_event(&event, any_channel());
let result = evaluate_filter("kind == 9", &ctx, None).await.unwrap();
assert!(result);
let result = evaluate_filter("kind == 1", &ctx, None).await.unwrap();
assert!(!result);
}
#[tokio::test]
async fn test_evaluate_filter_too_long() {
let event = make_event(9, "content");
let ctx = FilterContext::from_event(&event, any_channel());
let long_expr = "a".repeat(MAX_EXPR_LEN + 1);
let err = evaluate_filter(&long_expr, &ctx, None).await.unwrap_err();
assert!(matches!(
err,
FilterError::ExpressionTooLong { len, max }
if len == MAX_EXPR_LEN + 1 && max == MAX_EXPR_LEN
));
}
#[tokio::test]
async fn test_evaluate_filter_precompiled_node() {
let event = make_event(9, "hello world");
let ctx = FilterContext::from_event(&event, any_channel());
let node =
Arc::new(evalexpr::build_operator_tree(r#"str_contains(content, "hello")"#).unwrap());
let result = evaluate_filter(r#"str_contains(content, "hello")"#, &ctx, Some(node))
.await
.unwrap();
assert!(result);
}
#[tokio::test]
async fn test_match_event_first_match_wins() {
let event = make_event(9, "hello");
let channel_id = any_channel();
let rules = vec![
make_rule(
"first",
ChannelScope::All("all".into()),
vec![],
false,
None,
Some("tag-first"),
),
make_rule(
"second",
ChannelScope::All("all".into()),
vec![],
false,
None,
Some("tag-second"),
),
];
let matched = match_event(&event, channel_id, &rules, "").await.unwrap();
assert_eq!(matched.rule_index, 0);
assert_eq!(matched.prompt_tag, "tag-first");
}
#[tokio::test]
async fn test_match_event_kind_filter() {
let event = make_event(9, "hello");
let channel_id = any_channel();
let rules = vec![
make_rule(
"wrong-kind",
ChannelScope::All("all".into()),
vec![1],
false,
None,
None,
),
make_rule(
"right-kind",
ChannelScope::All("all".into()),
vec![9],
false,
None,
Some("matched"),
),
];
let matched = match_event(&event, channel_id, &rules, "").await.unwrap();
assert_eq!(matched.rule_index, 1);
assert_eq!(matched.prompt_tag, "matched");
}
#[tokio::test]
async fn test_match_event_require_mention() {
let agent_pubkey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let event_no_mention = make_event(9, "hello");
let event_with_mention = make_event_with_p_tag(9, "hello", agent_pubkey);
let channel_id = any_channel();
let rules = vec![make_rule(
"mention-only",
ChannelScope::All("all".into()),
vec![],
true,
None,
Some("mentioned"),
)];
// Without mention — no match.
let result = match_event(&event_no_mention, channel_id, &rules, agent_pubkey).await;
assert!(result.is_none());
// With mention — matches.
let matched = match_event(&event_with_mention, channel_id, &rules, agent_pubkey)
.await
.unwrap();
assert_eq!(matched.prompt_tag, "mentioned");
}
#[tokio::test]
async fn test_match_event_no_match() {
let event = make_event(1, "hello");
let channel_id = any_channel();
let rules = vec![make_rule(
"kind-9-only",
ChannelScope::All("all".into()),
vec![9],
false,
None,
None,
)];
let result = match_event(&event, channel_id, &rules, "").await;
assert!(result.is_none());
}
#[test]
fn test_channel_scope_all() {
let scope = ChannelScope::All("all".into());
assert!(scope.matches(&Uuid::new_v4()));
assert!(scope.matches(&Uuid::new_v4()));
}
#[test]
fn test_channel_scope_all_invalid_string() {
// Only the literal "all" should match; other strings must not.
let scope = ChannelScope::All("ALL".into());
assert!(!scope.matches(&Uuid::new_v4()));
let scope = ChannelScope::All("".into());
assert!(!scope.matches(&Uuid::new_v4()));
}
#[test]
fn test_channel_scope_list() {
let id_a = Uuid::new_v4();
let id_b = Uuid::new_v4();
let id_c = Uuid::new_v4();
let scope = ChannelScope::List(vec![id_a.to_string(), id_b.to_string()]);
assert!(scope.matches(&id_a));
assert!(scope.matches(&id_b));
assert!(!scope.matches(&id_c));
}
#[tokio::test]
async fn test_prompt_tag_falls_back_to_name() {
let event = make_event(9, "hello");
let channel_id = any_channel();
let rules = vec![make_rule(
"my-rule",
ChannelScope::All("all".into()),
vec![],
false,
None,
None, // no explicit tag
)];
let matched = match_event(&event, channel_id, &rules, "").await.unwrap();
assert_eq!(matched.prompt_tag, "my-rule");
}
#[tokio::test]
async fn test_filter_error_fails_closed_no_fallthrough() {
// A broken filter on rule[0] must NOT fall through to rule[1].
let event = make_event(9, "hello");
let channel_id = any_channel();
let rules = vec![
make_rule(
"broken-filter",
ChannelScope::All("all".into()),
vec![],
false,
Some("this is not valid evalexpr syntax !!!"),
Some("should-not-match"),
),
make_rule(
"catch-all",
ChannelScope::All("all".into()),
vec![],
false,
None,
Some("catch-all"),
),
];
// Must return None — not "catch-all".
let result = match_event(&event, channel_id, &rules, "").await;
assert!(
result.is_none(),
"filter error must fail closed, not fall through to next rule"
);
}
#[tokio::test]
async fn test_consecutive_timeouts_disables_rule() {
// After MAX_CONSECUTIVE_TIMEOUTS, the rule is skipped and None returned.
let event = make_event(9, "hello");
let channel_id = any_channel();
let rule = make_rule(
"timed-out-rule",
ChannelScope::All("all".into()),
vec![],
false,
Some("kind == 9"),
Some("should-not-match"),
);
// Pre-seed the counter at the threshold.
rule.consecutive_timeouts
.store(MAX_CONSECUTIVE_TIMEOUTS, Ordering::Relaxed);
let rules = vec![rule];
let result = match_event(&event, channel_id, &rules, "").await;
assert!(result.is_none(), "disabled rule must return None");
}
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
fn main() -> anyhow::Result<()> {
buzz_acp::run()
}
+166
View File
@@ -0,0 +1,166 @@
//! In-process observer bus for ACP session activity.
//!
//! This is intentionally process-local infrastructure: it lets the harness
//! collect raw ACP JSON-RPC activity and publish owner-scoped encrypted relay
//! frames without exposing a local HTTP port.
use std::{
collections::VecDeque,
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
},
};
use serde::Serialize;
use tokio::sync::broadcast;
const OBSERVER_BUFFER_CAP: usize = 1_000;
/// Best-effort metadata attached to observer events.
#[derive(Clone, Debug, Default)]
pub struct ObserverContext {
/// Buzz channel UUID for the current turn, when channel-scoped.
pub channel_id: Option<String>,
/// ACP session ID associated with the current turn, once known.
pub session_id: Option<String>,
/// Local UUID for one prompt turn.
pub turn_id: Option<String>,
/// RFC3339 timestamp at which the current turn began, when known.
pub started_at: Option<String>,
}
/// Handle used by the harness to publish local observer events.
#[derive(Clone)]
pub struct ObserverHandle {
inner: Arc<ObserverInner>,
}
struct ObserverInner {
tx: broadcast::Sender<ObserverEvent>,
buffer: Mutex<VecDeque<ObserverEvent>>,
seq: AtomicU64,
}
fn new_observer_handle() -> ObserverHandle {
let (tx, _) = broadcast::channel(OBSERVER_BUFFER_CAP);
ObserverHandle {
inner: Arc::new(ObserverInner {
tx,
buffer: Mutex::new(VecDeque::with_capacity(OBSERVER_BUFFER_CAP)),
seq: AtomicU64::new(1),
}),
}
}
/// Event delivered through the in-process observer bus.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ObserverEvent {
/// Monotonic process-local sequence number.
pub seq: u64,
/// RFC3339 UTC timestamp.
pub timestamp: String,
/// Observer event kind, for example `acp_read` or `turn_started`.
pub kind: String,
/// Pool slot index for the agent process that emitted the event.
pub agent_index: Option<usize>,
/// Buzz channel UUID for channel-scoped events.
pub channel_id: Option<String>,
/// ACP session ID when known.
pub session_id: Option<String>,
/// Local UUID for one prompt turn.
pub turn_id: Option<String>,
/// RFC3339 timestamp at which the current turn began, when known.
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<String>,
/// Raw or semantic event payload.
pub payload: serde_json::Value,
}
impl ObserverHandle {
/// Create an in-process observer feed.
pub fn in_process() -> Self {
new_observer_handle()
}
/// Subscribe to live observer events.
pub fn subscribe(&self) -> broadcast::Receiver<ObserverEvent> {
self.inner.tx.subscribe()
}
/// Return the current replay buffer.
pub fn snapshot(&self) -> Vec<ObserverEvent> {
match self.inner.buffer.lock() {
Ok(buffer) => buffer.iter().cloned().collect(),
Err(error) => {
tracing::warn!(target: "observer", "observer replay buffer lock poisoned: {error}");
Vec::new()
}
}
}
/// Emit a local observer event.
pub fn emit(
&self,
kind: impl Into<String>,
agent_index: Option<usize>,
context: &ObserverContext,
payload: serde_json::Value,
) {
let event = ObserverEvent {
seq: self.inner.seq.fetch_add(1, Ordering::Relaxed),
timestamp: chrono::Utc::now().to_rfc3339(),
kind: kind.into(),
agent_index,
channel_id: context.channel_id.clone(),
session_id: context.session_id.clone(),
turn_id: context.turn_id.clone(),
started_at: context.started_at.clone(),
payload,
};
match self.inner.buffer.lock() {
Ok(mut buffer) => {
if buffer.len() >= OBSERVER_BUFFER_CAP {
buffer.pop_front();
}
buffer.push_back(event.clone());
}
Err(error) => {
tracing::warn!(target: "observer", "observer replay buffer lock poisoned: {error}");
}
}
let _ = self.inner.tx.send(event);
}
}
/// Build observer context values from optional channel/session/turn IDs.
pub fn context_for(
channel_id: Option<uuid::Uuid>,
session_id: Option<String>,
turn_id: Option<String>,
) -> ObserverContext {
ObserverContext {
channel_id: channel_id.map(|id| id.to_string()),
session_id,
turn_id,
started_at: None,
}
}
/// Attach the authoritative start timestamp to every observer frame for a turn.
pub fn context_for_turn(
channel_id: Option<uuid::Uuid>,
session_id: Option<String>,
turn_id: String,
started_at: String,
) -> ObserverContext {
ObserverContext {
channel_id: channel_id.map(|id| id.to_string()),
session_id,
turn_id: Some(turn_id),
started_at: Some(started_at),
}
}
File diff suppressed because it is too large Load Diff
+312
View File
@@ -0,0 +1,312 @@
//! Lazy agent-pool lifecycle state.
//!
//! Relay connection, subscription, and event buffering live outside this
//! module. This state machine owns only whether a deferred pool has not started,
//! is waking, is ready, or is waiting to retry after a failed wake.
use std::time::Duration;
use tokio::time::Instant;
const INITIAL_RETRY_DELAY: Duration = Duration::from_secs(5);
const MAX_RETRY_DELAY: Duration = Duration::from_secs(300);
#[derive(Debug)]
pub(crate) enum PoolLifecycle<P> {
Listening,
Waking {
attempt: u32,
},
Ready(P),
Failed {
attempt: u32,
retry_at: Instant,
error: String,
},
}
impl<P> PoolLifecycle<P> {
pub(crate) fn listening() -> Self {
Self::Listening
}
/// Start the first wake, or a due retry, when buffered work exists.
///
/// Returns the attempt token exactly once per transition into `Waking`;
/// callers attach it to the single pool-initialization task and return it
/// with the result.
pub(crate) fn start_wake_if_due(
&mut self,
has_pending_work: bool,
now: Instant,
) -> Option<u32> {
if !has_pending_work {
return None;
}
let next_attempt = match self {
Self::Listening => Some(1),
Self::Failed {
attempt, retry_at, ..
} if now >= *retry_at => Some(attempt.saturating_add(1)),
Self::Waking { .. } | Self::Ready(_) | Self::Failed { .. } => None,
};
if let Some(attempt) = next_attempt {
*self = Self::Waking { attempt };
}
next_attempt
}
pub(crate) fn take_ready(&mut self) -> Option<P> {
match std::mem::replace(self, Self::Listening) {
Self::Ready(pool) => Some(pool),
other => {
*self = other;
None
}
}
}
pub(crate) fn waking_attempt(&self) -> Option<u32> {
match self {
Self::Waking { attempt } => Some(*attempt),
_ => None,
}
}
pub(crate) fn retry_at(&self) -> Option<Instant> {
match self {
Self::Failed { retry_at, .. } => Some(*retry_at),
_ => None,
}
}
pub(crate) fn failed_error(&self) -> Option<&str> {
match self {
Self::Failed { error, .. } => Some(error),
_ => None,
}
}
pub(crate) fn cancel_wake(&mut self, attempt: u32, error: String, now: Instant) -> bool {
self.complete_wake(attempt, Err(error), now).is_ok()
}
/// Complete the matching in-flight wake attempt.
///
/// A failure remains retryable. A result returned outside `Waking`, or from
/// an older attempt, is rejected: accepting it could replace a newer pool.
pub(crate) fn complete_wake(
&mut self,
completed_attempt: u32,
result: Result<P, String>,
now: Instant,
) -> Result<(), &'static str> {
let attempt = match self {
Self::Waking { attempt } if *attempt == completed_attempt => *attempt,
Self::Waking { .. } => return Err("wake result attempt did not match Waking attempt"),
_ => return Err("wake completed while lifecycle was not Waking"),
};
*self = match result {
Ok(pool) => Self::Ready(pool),
Err(error) => Self::Failed {
attempt,
retry_at: now + retry_delay(attempt),
error,
},
};
Ok(())
}
}
fn retry_delay(attempt: u32) -> Duration {
let exponent = attempt.saturating_sub(1).min(63);
let multiplier = 1_u64.checked_shl(exponent).unwrap_or(u64::MAX);
Duration::from_secs(
INITIAL_RETRY_DELAY
.as_secs()
.saturating_mul(multiplier)
.min(MAX_RETRY_DELAY.as_secs()),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(start_paused = true)]
async fn first_pending_event_starts_exactly_one_wake() {
let now = Instant::now();
let mut lifecycle = PoolLifecycle::<()>::listening();
assert_eq!(lifecycle.start_wake_if_due(false, now), None);
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
assert_eq!(lifecycle.start_wake_if_due(true, now), None);
assert!(matches!(lifecycle, PoolLifecycle::Waking { attempt: 1 }));
}
#[tokio::test(start_paused = true)]
async fn failure_retries_only_when_work_exists_and_deadline_is_due() {
let now = Instant::now();
let mut lifecycle = PoolLifecycle::<()>::listening();
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
lifecycle
.complete_wake(1, Err("provider unavailable".into()), now)
.unwrap();
assert_eq!(
lifecycle.start_wake_if_due(true, now + Duration::from_secs(4)),
None
);
assert_eq!(
lifecycle.start_wake_if_due(false, now + Duration::from_secs(5)),
None
);
assert_eq!(
lifecycle.start_wake_if_due(true, now + Duration::from_secs(5)),
Some(2)
);
assert!(matches!(lifecycle, PoolLifecycle::Waking { attempt: 2 }));
}
#[tokio::test(start_paused = true)]
async fn retry_backoff_doubles_and_caps_at_five_minutes() {
let mut now = Instant::now();
let mut lifecycle = PoolLifecycle::<()>::listening();
for attempt in 1..=9 {
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(attempt));
assert!(matches!(
lifecycle,
PoolLifecycle::Waking { attempt: actual } if actual == attempt
));
lifecycle
.complete_wake(attempt, Err("no brain".into()), now)
.unwrap();
let expected = retry_delay(attempt);
let retry_at = match &lifecycle {
PoolLifecycle::Failed { retry_at, .. } => *retry_at,
_ => panic!("failure must enter Failed"),
};
assert_eq!(retry_at, now + expected);
assert!(expected <= MAX_RETRY_DELAY);
now = retry_at;
}
assert_eq!(retry_delay(7), MAX_RETRY_DELAY);
assert_eq!(retry_delay(u32::MAX), MAX_RETRY_DELAY);
}
#[tokio::test(start_paused = true)]
async fn successful_retry_consumes_pool_and_stops_future_wakes() {
let now = Instant::now();
let mut lifecycle = PoolLifecycle::listening();
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
lifecycle
.complete_wake(1, Err("first attempt failed".into()), now)
.unwrap();
let retry_at = match &lifecycle {
PoolLifecycle::Failed { retry_at, .. } => *retry_at,
_ => panic!("expected Failed"),
};
assert_eq!(lifecycle.start_wake_if_due(true, retry_at), Some(2));
lifecycle.complete_wake(2, Ok("pool"), retry_at).unwrap();
assert!(matches!(lifecycle, PoolLifecycle::Ready("pool")));
assert_eq!(
lifecycle.start_wake_if_due(true, retry_at + Duration::from_secs(600)),
None
);
}
#[tokio::test(start_paused = true)]
async fn stale_or_duplicate_wake_result_is_rejected() {
let now = Instant::now();
let mut lifecycle = PoolLifecycle::<()>::listening();
assert_eq!(
lifecycle.complete_wake(1, Ok(()), now),
Err("wake completed while lifecycle was not Waking")
);
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
lifecycle.complete_wake(1, Ok(()), now).unwrap();
assert_eq!(
lifecycle.complete_wake(1, Ok(()), now),
Err("wake completed while lifecycle was not Waking")
);
assert!(matches!(lifecycle, PoolLifecycle::Ready(())));
}
#[tokio::test(start_paused = true)]
async fn stale_attempt_result_cannot_replace_current_wake() {
let now = Instant::now();
let mut lifecycle = PoolLifecycle::<&str>::listening();
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
lifecycle
.complete_wake(1, Err("attempt one failed".into()), now)
.unwrap();
let retry_at = match &lifecycle {
PoolLifecycle::Failed { retry_at, .. } => *retry_at,
_ => panic!("expected Failed"),
};
assert_eq!(lifecycle.start_wake_if_due(true, retry_at), Some(2));
assert_eq!(
lifecycle.complete_wake(1, Ok("stale pool"), retry_at),
Err("wake result attempt did not match Waking attempt")
);
assert!(matches!(lifecycle, PoolLifecycle::Waking { attempt: 2 }));
lifecycle
.complete_wake(2, Ok("current pool"), retry_at)
.unwrap();
assert!(matches!(lifecycle, PoolLifecycle::Ready("current pool")));
}
#[tokio::test(start_paused = true)]
async fn cancelled_wake_enters_failed_and_can_retry() {
let now = Instant::now();
let mut lifecycle = PoolLifecycle::<()>::listening();
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
assert_eq!(lifecycle.waking_attempt(), Some(1));
assert!(lifecycle.cancel_wake(1, "task panicked".into(), now));
assert_eq!(lifecycle.failed_error(), Some("task panicked"));
assert_eq!(
lifecycle.start_wake_if_due(true, now + Duration::from_secs(5)),
Some(2)
);
}
#[test]
fn take_ready_transfers_pool_exactly_once() {
let now = Instant::now();
let mut lifecycle = PoolLifecycle::listening();
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
lifecycle.complete_wake(1, Ok("pool"), now).unwrap();
assert_eq!(lifecycle.take_ready(), Some("pool"));
assert_eq!(lifecycle.take_ready(), None);
}
#[test]
fn failed_state_preserves_attempt_deadline_and_error() {
let now = Instant::now();
let mut lifecycle = PoolLifecycle::<()>::listening();
assert_eq!(lifecycle.start_wake_if_due(true, now), Some(1));
lifecycle.complete_wake(1, Err("boom".into()), now).unwrap();
match lifecycle {
PoolLifecycle::Failed {
attempt,
retry_at,
error,
} => {
assert_eq!(attempt, 1);
assert_eq!(retry_at, now + Duration::from_secs(5));
assert_eq!(error, "boom");
}
_ => panic!("expected Failed"),
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff