diff --git a/docs/docs/protocol/streaming.md b/docs/docs/protocol/streaming.md index 67ed392..7638c1b 100644 --- a/docs/docs/protocol/streaming.md +++ b/docs/docs/protocol/streaming.md @@ -119,3 +119,50 @@ Treat all three (`stream`, `stream_end`, `message`) as the same kind of update ``` This means the same client code that handles `parts[]` for non-streaming messages works for streams too — no special-casing. + +## Thinking events + +While the agent works with tools between text outputs, the chat would otherwise sit silent. Two mechanisms cover that window: + +1. **Early `typing`.** The runtime emits `typing` at turn start and again before each tool batch — every client (including older SDKs) shows its thinking indicator through the whole generation, not just the final response. +2. **`thinking` events** — a live, Rovo-style timeline of what the agent is doing, rendered by capable clients as named, expandable steps above the incoming answer. + +``` +thinking { clientId, turnId, step: { id, label, detail?, state }, ts } // step update +thinking { clientId, turnId, done: true, ts } // turn complete +``` + +- `turnId` groups every event of one agent turn (minted per run). +- `step.state` is `'active'` before the work starts and `'done'` (same `step.id`, updated in place) when it finishes. +- `step.label` is a humanized, visitor-safe name (e.g. `"Search knowledge base"`); `step.detail` is optional reasoning prose (markdown). **Never put raw tool params or prompts here** — unlike `debug`, this event is relayed to regular visitors, not admin-gated. +- The terminal `done: true` event closes the timeline; the widget collapses the block into a re-expandable summary row. + +### Capability gating + +The SDK (≥ v0.15.0) advertises `'thinking'` in its handshake `capabilities`. The hub forwards the list on every message, and the runtime emits `thinking` events **only** when the triggering message carried the capability. Telegram clients and older SDKs receive nothing new. + +### Compatibility matrix + +| SDK | Hub | Runtime | Behavior | +|-----|-----|---------|----------| +| old | any | new | indicator appears at turn start (early `typing`) — strict improvement | +| new | old | new | shimmer status only; the old hub's whitelist silently drops `thinking` | +| new | new | old | behavior unchanged from before | +| new | new | new | full thinking timeline | + +Every partial deployment state is safe — the event is strictly additive. Recommended rollout order: hub → runtime → SDK. + +### Emitting from an agent + +```ts +const turnId = crypto.randomUUID() +const step = { id: crypto.randomUUID(), label: 'Search knowledge base' } + +bridle.sendThinking(msg.from, turnId, { ...step, state: 'active' }) +// ... do the work ... +bridle.sendThinking(msg.from, turnId, { ...step, state: 'done' }) +// ... when the whole turn is finished: +bridle.sendThinking(msg.from, turnId) // no step ⇒ done: true +``` + +Gate on `msg.capabilities?.includes('thinking')` before emitting. diff --git a/nestjs/domain/bridle.types.ts b/nestjs/domain/bridle.types.ts index 4a58818..13d0af3 100644 --- a/nestjs/domain/bridle.types.ts +++ b/nestjs/domain/bridle.types.ts @@ -135,6 +135,7 @@ export interface IBridleOutgoingEvent { | 'stream' | 'stream_end' | 'typing' + | 'thinking' | 'ping' | 'agent_status' clientId?: string @@ -148,6 +149,42 @@ export interface IBridleOutgoingEvent { connected?: boolean } +// ── Thinking (live reasoning steps) ────────────────────────── + +/** One published unit of agent work inside a thinking timeline. */ +export interface IBridleThinkingStep { + /** Stable per-step id — the `done` update reuses the `active` event's id. */ + id: string + /** Human-readable, visitor-safe step name (e.g. "Search knowledge base"). */ + label: string + /** + * Optional visitor-safe reasoning prose (markdown). Never raw tool + * params or prompts — this event is NOT admin-gated (unlike `debug`). + */ + detail?: string + state: 'active' | 'done' +} + +/** + * Agent → Hub → Browser: live "what the agent is doing" feed, rendered by + * thinking-capable clients as a collapsible timeline while the answer is + * being prepared. Two shapes share the event: a step update (`step` set) + * and turn completion (`done: true`, no step) which closes the open block. + * The hub relays it to the addressed client like `stream`. Agents emit it + * only toward clients whose handshake `capabilities` include `'thinking'`. + */ +export interface IBridleThinkingEvent { + type: 'thinking' + clientId: string + /** Groups every step of one agent turn (minted per loop run). */ + turnId: string + /** Present on step updates; absent on the terminal `done` event. */ + step?: IBridleThinkingStep + /** True on the terminal event of a turn. */ + done?: boolean + ts: number +} + // ── Admin: debug snapshots ─────────────────────────────────── /** diff --git a/nestjs/handlers/bridleAgentWs.handler.ts b/nestjs/handlers/bridleAgentWs.handler.ts index b64aeae..6218523 100644 --- a/nestjs/handlers/bridleAgentWs.handler.ts +++ b/nestjs/handlers/bridleAgentWs.handler.ts @@ -15,6 +15,7 @@ import { type IBridleOutgoingEvent, type IBridleDebugEvent, type IBridleSyncResponse, + type IBridleThinkingEvent, } from '../domain' /** @@ -136,6 +137,17 @@ export class BridleAgentWsHandler implements OnGatewayConnection, OnGatewayDisco } } + @SubscribeMessage('thinking') + handleThinking( + @ConnectedSocket() client: Socket, + @MessageBody() data: IBridleThinkingEvent, + ) { + const agentId = client.data?.agentId as string + if (data?.clientId && data?.turnId && agentId) { + this.hub.handleAgentEvent(agentId, { ...data, type: 'thinking' }) + } + } + @SubscribeMessage('debug') handleDebug( @ConnectedSocket() client: Socket, diff --git a/runtime/bridle.repository.ts b/runtime/bridle.repository.ts index 5a5f52b..a576b10 100644 --- a/runtime/bridle.repository.ts +++ b/runtime/bridle.repository.ts @@ -159,6 +159,20 @@ export interface IBridleMessageData { capabilities?: string[] } +// ── Thinking (live reasoning steps) ────────────────────────── + +/** + * One published unit of agent work inside a thinking timeline. Pass to + * `sendThinking()` — `active` before the work starts, `done` (same `id`) + * when it finishes. Labels/detail must be visitor-safe. + */ +export interface IBridleThinkingStep { + id: string + label: string + detail?: string + state: 'active' | 'done' +} + // ── Admin protocol — debug + sync ───────────────────────────── /** @@ -269,6 +283,35 @@ export class BridleRepository implements IChannelGateway { this.syncHandler = handler } + /** + * Bare typing signal so the browser lights its thinking indicator before + * the first LLM byte (streamSend fires its own once streaming starts). + * No-op if the socket is offline. + */ + sendTyping(to: string): void { + if (!this.socket?.connected) return + this.socket.emit('typing', { clientId: to, ts: Date.now() }) + } + + /** + * Publish one thinking-timeline update: a step (`state: 'active' | 'done'`) + * or, with `step` omitted, the terminal turn-completion event. Emit only + * toward clients whose message `capabilities` include `'thinking'` — + * others can't render it. Payload must stay visitor-safe: humanized step + * labels and reasoning prose only, never raw tool params or prompts. + * No-op if the socket is offline. + */ + sendThinking(to: string, turnId: string, step?: IBridleThinkingStep): void { + if (!this.socket?.connected) return + this.socket.emit('thinking', { + type: 'thinking', + clientId: to, + turnId, + ...(step ? { step } : { done: true }), + ts: Date.now(), + }) + } + /** * Push an LLM round-trip snapshot to the hub. Hub fans it out to admin * clients only. No-op if the socket is offline. diff --git a/sdk/package.json b/sdk/package.json index f4d7458..b601f5f 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,7 +1,7 @@ { "name": "@cleanslice/bridle", - "version": "0.14.0", - "description": "Embeddable web chat for Bridle — drop-in