Skip to content
Open
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
47 changes: 47 additions & 0 deletions docs/docs/protocol/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
37 changes: 37 additions & 0 deletions nestjs/domain/bridle.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export interface IBridleOutgoingEvent {
| 'stream'
| 'stream_end'
| 'typing'
| 'thinking'
| 'ping'
| 'agent_status'
clientId?: string
Expand All @@ -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 ───────────────────────────────────

/**
Expand Down
12 changes: 12 additions & 0 deletions nestjs/handlers/bridleAgentWs.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type IBridleOutgoingEvent,
type IBridleDebugEvent,
type IBridleSyncResponse,
type IBridleThinkingEvent,
} from '../domain'

/**
Expand Down Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions runtime/bridle.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────

/**
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@cleanslice/bridle",
"version": "0.14.0",
"description": "Embeddable web chat for Bridle drop-in <script> or programmatic init.",
"version": "0.15.0",
"description": "Embeddable web chat for Bridle \u2014 drop-in <script> or programmatic init.",
"type": "module",
"main": "./dist/bridle.mjs",
"module": "./dist/bridle.mjs",
Expand Down
Loading