Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,20 @@ All notable changes to Agent Relay will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased - Patch]
## [Unreleased - Minor]

### Added

Comment thread
willwashburn marked this conversation as resolved.
- `@agent-relay/sdk` observer mode: `new AgentRelay({ observerToken })` streams `relay.addListener(...)` read-only from the workspace observer plane — the durable event log is REST-backfilled and merged with the live stream, deduped and ordered by `seq`, with `sinceSeq`/`onCursor` options to persist and resume the cursor across restarts. Degrades to live-only against engines without the backfill endpoint; `workspace.register()`/`reconnect()` throw in observer mode (observer tokens are read-only).

### Changed

- `@agent-relay/sdk` messaging and delivery types now derive from the canonical `@relaycast/types` schemas: `Relay*` types index into the wire contract, `normalize.ts` validates payloads with canonical-derived zod schemas at the boundary instead of probing snake/camel field variants, and `InboxItemState` builds on canonical `DeliveryStatus`, while `InjectionResult.status` derives from the adapter receipt lifecycle (`MessageReceipt`) — wire-contract changes now surface as compile errors instead of silent drift.

### Fixed

- `@agent-relay/sdk` `relay.addListener(...)` on a workspace-key client now receives channel messages, DMs, and thread replies by streaming through registered agent clients (`workspace.register`/`reconnect`) over the node transport, deduplicating events delivered to multiple locally-registered agents; previously listeners silently received nothing. Listener connect failures now surface through `onError` instead of being swallowed, and a listener with no registered agent warns after 10s.
- `agent-relay-broker` now mutes the default/extra channels it joins for its own broker-self agent, so channel messages stop writing delivery rows to that identity's permanently-offline implicit node (they previously queued until TTL expiry on every message). Muting is best-effort and never fails startup.
- `@agent-relay/sdk` messaging events map the canonical `message.reacted` WebSocket event onto `reactionAdded`/`reactionRemoved`; previously only the non-canonical `reaction.added`/`reaction.removed` names were handled, so reaction listeners never fired against current Relaycast engines.

## [10.4.0] - 2026-07-15
Expand Down
49 changes: 37 additions & 12 deletions crates/broker/src/relaycast/ws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,12 +343,15 @@ impl RelaycastHttpClient {
metadata: None,
};
match agent_client.ensure_joined_channel(request).await {
Ok(outcome) => tracing::info!(
channel = %outcome.name,
created = outcome.created,
joined = outcome.joined,
"ensured default channel membership"
),
Ok(outcome) => {
tracing::info!(
channel = %outcome.name,
created = outcome.created,
joined = outcome.joined,
"ensured default channel membership"
);
mute_self_channel(&agent_client, &outcome.name).await;
}
Err(error) => {
tracing::warn!(channel = %name, error = %error, "failed to ensure default channel membership");
}
Expand Down Expand Up @@ -385,12 +388,15 @@ impl RelaycastHttpClient {
metadata: None,
};
match agent_client.ensure_joined_channel(request).await {
Ok(outcome) => tracing::info!(
channel = %outcome.name,
created = outcome.created,
joined = outcome.joined,
"ensured extra channel membership"
),
Ok(outcome) => {
tracing::info!(
channel = %outcome.name,
created = outcome.created,
joined = outcome.joined,
"ensured extra channel membership"
);
mute_self_channel(&agent_client, &outcome.name).await;
}
Err(error) => {
tracing::warn!(channel = %name, error = %error, "failed to ensure extra channel membership");
}
Expand Down Expand Up @@ -573,6 +579,25 @@ impl RelaycastHttpClient {
}
}

/// Mute a channel for the broker-self agent, best-effort.
///
/// The broker-self identity lives on an implicit direct node that never
/// connects, so every channel message fanned out to it writes a delivery row
/// that queues forever and churns through TTL expiry. The engine's channel
/// delivery fan-out skips muted members (mentions still deliver), so muting
/// the broker-self membership stops those dead-letter rows at the source.
/// Failures only log a warning — muting is an optimization and must never
/// fail startup.
async fn mute_self_channel(agent_client: &AgentClient, channel: &str) {
if let Err(error) = agent_client.mute_channel(channel).await {
tracing::warn!(
channel = %channel,
error = %error,
"failed to mute channel for broker-self agent; channel deliveries will queue for its offline node"
);
}
}

/// Build a `RelayCast` workspace client from an API key and optional base URL.
/// When `base_url` is `None`, the SDK applies its own default.
fn build_relay_client(api_key: &str, base_url: Option<&str>) -> Option<RelayCast> {
Expand Down
3 changes: 3 additions & 0 deletions crates/broker/src/runtime/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ pub(crate) fn default_observer_token_scopes() -> Vec<ObserverScope> {
ObserverScope::ChannelsRead,
ObserverScope::ActivityRead,
ObserverScope::AgentsRead,
// Reactions surface on the observer stream as `message.reacted`;
// without this scope the live stream filters them out for UIs.
ObserverScope::ReactionsRead,
]
}

Expand Down
5 changes: 3 additions & 2 deletions crates/broker/src/runtime/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3414,14 +3414,15 @@ fn default_observer_token_scopes_are_read_only_and_exclude_unneeded_scopes() {
ObserverScope::ChannelsRead,
ObserverScope::ActivityRead,
ObserverScope::AgentsRead,
ObserverScope::ReactionsRead,
]
.into_iter()
.collect();

assert_eq!(
scopes.len(),
7,
"expected exactly 7 default observer token scopes, got {scopes:?}"
8,
"expected exactly 8 default observer token scopes, got {scopes:?}"
);
assert_eq!(
actual, expected,
Expand Down
80 changes: 40 additions & 40 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && npx tsc -p tsconfig.build.json",
"build:full": "npm run build",
"check": "tsc -p tsconfig.json --noEmit",
"test": "vitest run src/__tests__/agent-relay.test.ts src/__tests__/facade.test.ts src/__tests__/listeners.test.ts src/__tests__/integrations.test.ts src/__tests__/messaging.test.ts src/__tests__/delivery-actions.test.ts src/__tests__/relaycast-errors.test.ts src/__tests__/register-action-relay.test.ts src/__tests__/thin-client.test.ts src/__tests__/typed-action-handle.test.ts src/__tests__/webhooks.test.ts",
"test": "vitest run src/__tests__/agent-relay.test.ts src/__tests__/facade.test.ts src/__tests__/listeners.test.ts src/__tests__/integrations.test.ts src/__tests__/messaging.test.ts src/__tests__/delivery-actions.test.ts src/__tests__/relaycast-errors.test.ts src/__tests__/register-action-relay.test.ts src/__tests__/thin-client.test.ts src/__tests__/typed-action-handle.test.ts src/__tests__/webhooks.test.ts src/__tests__/event-fanin.test.ts src/__tests__/observer-source.test.ts",
"test:types": "vitest run --typecheck.only --typecheck.tsconfig tsconfig.typetest.json",
"prepack": "npm run build"
},
Expand Down
Loading
Loading