@kybernesis/buzz 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,6 +61,7 @@ sessions are keyed by channel.
61
61
  | `BUZZ_AGENT_URL` | Where the agent listens (default `http://127.0.0.1:8000`) |
62
62
  | `BUZZ_KEYFILE` | The agent's key (default `~/.kybernesis/buzz-agent.json`) |
63
63
  | `BUZZ_SESSIONS_FILE` | Channel-to-session continuity store (default beside the key file; an existing legacy `.buzz-sessions.json` is reused) |
64
+ | `BUZZ_AGENT_SILENCE_TIMEOUT_MS` | Maximum silence while opening an agent request or waiting between complete events (default `300000`, five minutes; not a total turn-duration limit) |
64
65
  | `KYBERNESIS_ISSUER` | The control plane |
65
66
  | `KYBERNESIS_AGENT_CREDENTIAL` | This agent's credential |
66
67
 
@@ -78,6 +79,7 @@ const bridge = buzzBridge({
78
79
  keyFile: "/etc/agent/buzz.json",
79
80
  issuer: process.env.KYBERNESIS_ISSUER!,
80
81
  credential: process.env.KYBERNESIS_AGENT_CREDENTIAL!,
82
+ agentSilenceTimeoutMs: 5 * 60_000,
81
83
  });
82
84
 
83
85
  console.log(`invite ${bridge.npub}`);
package/dist/bridge.d.ts CHANGED
@@ -19,6 +19,8 @@ export type BuzzBridgeOptions = {
19
19
  /** This agent's credential from the control plane. The only durable secret here. */
20
20
  credential: string;
21
21
  pollMs?: number;
22
+ /** Maximum silence during one agent request or event stream; defaults to five minutes. */
23
+ agentSilenceTimeoutMs?: number;
22
24
  onLog?: (message: string) => void;
23
25
  };
24
26
  export declare function buzzBridge(options: BuzzBridgeOptions): {
package/dist/bridge.js CHANGED
@@ -2,7 +2,7 @@ import { channelIdentity } from "@kybernesis/enterprise";
2
2
  import { fetchMedia, parseMedia } from "./media.js";
3
3
  import { speakerCredentials } from "./credentials.js";
4
4
  import { SessionStore } from "./sessions.js";
5
- import { answerTurn, composeMessage, rejectedTurnReply } from "./turn.js";
5
+ import { agentSilenceReply, answerTurn, composeMessage, DEFAULT_AGENT_SILENCE_TIMEOUT_MS, describeSilentTurn, rejectedTurnReply, silentTurnReply, validateAgentSilenceTimeoutMs, } from "./turn.js";
6
6
  import { followPendingConversation, formatInputRequests, invalidInputReply, needsPendingFollower, resolveInputReply, respondToPendingConversation, } from "./hitl.js";
7
7
  import { existsSync } from "node:fs";
8
8
  import { dirname, join } from "node:path";
@@ -31,6 +31,7 @@ export function buzzBridge(options) {
31
31
  const key = loadKey(options.keyFile);
32
32
  const identity = channelIdentity({ issuer: options.issuer, credential: options.credential });
33
33
  const log = options.onLog ?? ((message) => console.log(new Date().toISOString().slice(11, 19), message));
34
+ const agentSilenceTimeoutMs = validateAgentSilenceTimeoutMs(options.agentSilenceTimeoutMs ?? DEFAULT_AGENT_SILENCE_TIMEOUT_MS);
34
35
  /**
35
36
  * One conversation per channel — the SESSION ID, not a handle to it.
36
37
  *
@@ -340,7 +341,7 @@ export function buzzBridge(options) {
340
341
  }
341
342
  /** Images become file parts; unreadable files become explicit model context. */
342
343
  const message = await composeMessage(text, attachments, (ref) => fetchMedia(key, ref), log);
343
- const result = await answerTurn(clientFor(event.pubkey), sessions, channel, message, from, log);
344
+ const result = await answerTurn(clientFor(event.pubkey), sessions, channel, message, from, log, agentSilenceTimeoutMs);
344
345
  if (result.status === "waiting" && result.inputRequests.length > 0) {
345
346
  sessions.set(from, channel, {
346
347
  id: result.sessionId,
@@ -360,8 +361,11 @@ export function buzzBridge(options) {
360
361
  return;
361
362
  }
362
363
  if (!result.message) {
363
- log(`no text for ${channel.slice(0, 8)} — telling them rather than going quiet`);
364
- relay.reply(channel, "I didn't get an answer back for that one — ask me again and I'll retry.", event);
364
+ // Say what the stream said: a failed tool by name and error, a
365
+ // failed model call by code, or, only when there is truly nothing,
366
+ // the plain sentence. Three faults used to read as one (KYB-529).
367
+ log(`${describeSilentTurn(result.failures)} for ${channel.slice(0, 8)} — telling them rather than going quiet`);
368
+ relay.reply(channel, silentTurnReply(result.failures), event);
365
369
  return;
366
370
  }
367
371
  relay.reply(channel, result.message, event);
@@ -377,6 +381,13 @@ export function buzzBridge(options) {
377
381
  startFollower(from, channel);
378
382
  return;
379
383
  }
384
+ const silence = agentSilenceReply(error);
385
+ if (silence) {
386
+ const timeout = error;
387
+ log(`agent silence timeout for ${channel.slice(0, 8)} during ${timeout.phase} after ${timeout.intervalMs}ms`);
388
+ relay.reply(channel, silence, event);
389
+ return;
390
+ }
380
391
  const rejected = rejectedTurnReply(error);
381
392
  if (rejected) {
382
393
  log(`turn rejected for ${channel.slice(0, 8)}: ${error.message}`);
package/dist/cli.js CHANGED
@@ -137,6 +137,9 @@ function run() {
137
137
  issuer: env("KYBERNESIS_ISSUER", "https://agent.kybernesis.ai"),
138
138
  credential: env("KYBERNESIS_AGENT_CREDENTIAL"),
139
139
  ...(process.env.BUZZ_POLL_MS ? { pollMs: Number(process.env.BUZZ_POLL_MS) } : {}),
140
+ ...(process.env.BUZZ_AGENT_SILENCE_TIMEOUT_MS
141
+ ? { agentSilenceTimeoutMs: Number(process.env.BUZZ_AGENT_SILENCE_TIMEOUT_MS) }
142
+ : {}),
140
143
  });
141
144
  for (const signal of ["SIGINT", "SIGTERM"]) {
142
145
  process.on(signal, () => {
@@ -295,6 +298,7 @@ else {
295
298
  BUZZ_AGENT_URL where the agent listens (default http://127.0.0.1:8000)
296
299
  BUZZ_KEYFILE this agent's key (default ~/.kybernesis/buzz-agent.json)
297
300
  BUZZ_SESSIONS_FILE channel-to-session continuity store
301
+ BUZZ_AGENT_SILENCE_TIMEOUT_MS maximum agent silence (default 300000ms)
298
302
  KYBERNESIS_ISSUER the control plane
299
303
  KYBERNESIS_AGENT_CREDENTIAL this agent's credential
300
304
  `);
package/dist/turn.d.ts CHANGED
@@ -1,8 +1,39 @@
1
1
  import { type Client, type InputRequest, type MessageResult, type SendTurnInput } from "eve/client";
2
2
  import { type FetchedMedia, type MediaRef } from "./media.js";
3
3
  import { SessionStore } from "./sessions.js";
4
+ /** Five minutes without an eve event or acknowledgement is treated as a stalled turn. */
5
+ export declare const DEFAULT_AGENT_SILENCE_TIMEOUT_MS: number;
6
+ export type AgentSilencePhase = "unread drain" | "send acknowledgement" | "response stream" | "create acknowledgement" | "create response stream";
7
+ /** A caller-owned inactivity timeout. Aborting the transport does not cancel the eve session. */
8
+ export declare class AgentSilenceTimeoutError extends Error {
9
+ readonly phase: AgentSilencePhase;
10
+ readonly intervalMs: number;
11
+ constructor(phase: AgentSilencePhase, intervalMs: number);
12
+ }
13
+ /** Refuse invalid values before starting a bridge with an ineffective watchdog. */
14
+ export declare function validateAgentSilenceTimeoutMs(value: number): number;
4
15
  /** A message shape accepted by the certified eve client's send contract. */
5
16
  export type TurnMessage = SendTurnInput["message"];
17
+ /**
18
+ * One thing that went wrong inside a turn, as the session stream reported it.
19
+ *
20
+ * A turn where every tool call fails ends with no assistant text and no
21
+ * turn-level error: eve logs `tool execution failed` and finishes. From the
22
+ * channel that was the same silence as an overloaded model or a dead process,
23
+ * and on one deployment it took an hour to tell them apart (KYB-529). The
24
+ * stream does carry the facts (`action.result` with a failed status and the
25
+ * tool's name and error; `step.failed` / `turn.failed` with a code and
26
+ * message), so the bridge keeps them and says them.
27
+ */
28
+ export type TurnFailure = {
29
+ kind: "tool";
30
+ toolName: string;
31
+ message: string;
32
+ } | {
33
+ kind: "model";
34
+ code: string;
35
+ message: string;
36
+ };
6
37
  /** Everything the bridge needs to decide how one eve turn should be rendered. */
7
38
  export interface TurnOutcome {
8
39
  message: string;
@@ -10,19 +41,29 @@ export interface TurnOutcome {
10
41
  inputRequests: readonly InputRequest[];
11
42
  sessionId: string;
12
43
  streamIndex: number;
44
+ /** Failures seen on the stream, in order. Empty on a clean turn. */
45
+ failures: readonly TurnFailure[];
13
46
  }
14
- /** Convert one inbound Buzz message into an eve-compatible turn. */
15
- export declare function composeMessage(text: string, attachments: MediaRef[], fetchAttachment: (ref: MediaRef) => Promise<FetchedMedia>, log?: (message: string) => void): Promise<TurnMessage>;
16
- /** Continue a channel's existing conversation, replacing it only when it is stale. */
17
- export declare function answerTurn(client: Client, sessions: SessionStore, channel: string, message: TurnMessage, community: string, log?: (message: string) => void): Promise<TurnOutcome>;
47
+ /** Read a failure out of one stream event, or nothing when the event is not one. */
48
+ export declare function failureFromEvent(event: {
49
+ type: string;
50
+ data?: unknown;
51
+ }): TurnFailure | null;
18
52
  /**
19
- * What the room is told when eve refuses the turn itself.
53
+ * What to post when a turn produced no text.
20
54
  *
21
- * A 400 means the request was malformed — a part eve does not accept, a body
22
- * it could not parse — not that the agent failed to think of an answer. The
23
- * session survives it (`answerTurn` keeps the mapping), but the person who
24
- * sent the message is still waiting, and a log line nobody is reading is not
25
- * an answer. Returns `null` for anything that is not a request rejection, so
26
- * the caller's ordinary failure path stays as it was.
55
+ * Names every distinct failure the stream reported, verbatim; a genuinely
56
+ * empty completion keeps the old sentence. Nothing here is invented: every
57
+ * tool name and message comes from an event the bridge saw.
27
58
  */
59
+ export declare function silentTurnReply(failures: readonly TurnFailure[]): string;
60
+ /** The bridge's log line for a turn with no text: which of the three cases it was. */
61
+ export declare function describeSilentTurn(failures: readonly TurnFailure[]): string;
62
+ /** Convert one inbound Buzz message into an eve-compatible turn. */
63
+ export declare function composeMessage(text: string, attachments: MediaRef[], fetchAttachment: (ref: MediaRef) => Promise<FetchedMedia>, log?: (message: string) => void): Promise<TurnMessage>;
64
+ /** Continue a channel's existing conversation, replacing it only when it is explicitly stale. */
65
+ export declare function answerTurn(client: Client, sessions: SessionStore, channel: string, message: TurnMessage, community: string, log?: (message: string) => void, agentSilenceTimeoutMs?: number): Promise<TurnOutcome>;
66
+ /** What the room is told when eve refuses the turn itself. */
28
67
  export declare function rejectedTurnReply(error: unknown): string | null;
68
+ /** Give the originating channel one accurate recovery message for bridge-owned silence. */
69
+ export declare function agentSilenceReply(error: unknown): string | null;
package/dist/turn.js CHANGED
@@ -1,5 +1,93 @@
1
1
  import { ClientError, createDataUrlFilePart, } from "eve/client";
2
2
  import { isImage } from "./media.js";
3
+ /** Five minutes without an eve event or acknowledgement is treated as a stalled turn. */
4
+ export const DEFAULT_AGENT_SILENCE_TIMEOUT_MS = 5 * 60_000;
5
+ /** A caller-owned inactivity timeout. Aborting the transport does not cancel the eve session. */
6
+ export class AgentSilenceTimeoutError extends Error {
7
+ phase;
8
+ intervalMs;
9
+ constructor(phase, intervalMs) {
10
+ super(`agent was silent for ${intervalMs}ms during ${phase}`);
11
+ this.name = "AgentSilenceTimeoutError";
12
+ this.phase = phase;
13
+ this.intervalMs = intervalMs;
14
+ }
15
+ }
16
+ /** Refuse invalid values before starting a bridge with an ineffective watchdog. */
17
+ export function validateAgentSilenceTimeoutMs(value) {
18
+ if (!Number.isFinite(value) || value <= 0) {
19
+ throw new Error("agentSilenceTimeoutMs must be a finite positive number of milliseconds");
20
+ }
21
+ return value;
22
+ }
23
+ const clip = (text, max = 200) => String(text ?? "").replace(/\s+/g, " ").trim().slice(0, max);
24
+ /** Read a failure out of one stream event, or nothing when the event is not one. */
25
+ export function failureFromEvent(event) {
26
+ const data = (event.data ?? {});
27
+ if (event.type === "action.result") {
28
+ const result = (data.result ?? {});
29
+ const failed = data.status === "failed" || data.status === "rejected" || result.isError === true;
30
+ if (!failed)
31
+ return null;
32
+ const error = (data.error ?? {});
33
+ const output = typeof result.output === "string" ? result.output : result.output !== undefined ? JSON.stringify(result.output) : "";
34
+ return {
35
+ kind: "tool",
36
+ toolName: String(result.toolName ?? "a tool"),
37
+ message: clip(error.message ?? output ?? data.status),
38
+ };
39
+ }
40
+ if (event.type === "step.failed" || event.type === "turn.failed") {
41
+ return { kind: "model", code: String(data.code ?? event.type), message: clip(data.message) };
42
+ }
43
+ return null;
44
+ }
45
+ /**
46
+ * What to post when a turn produced no text.
47
+ *
48
+ * Names every distinct failure the stream reported, verbatim; a genuinely
49
+ * empty completion keeps the old sentence. Nothing here is invented: every
50
+ * tool name and message comes from an event the bridge saw.
51
+ */
52
+ export function silentTurnReply(failures) {
53
+ if (failures.length === 0)
54
+ return "I didn't get an answer back for that one — ask me again and I'll retry.";
55
+ const tools = new Map();
56
+ const models = [];
57
+ for (const f of failures) {
58
+ if (f.kind === "tool") {
59
+ const key = `${f.toolName}\u0000${f.message}`;
60
+ const seen = tools.get(key);
61
+ if (seen)
62
+ seen.count += 1;
63
+ else
64
+ tools.set(key, { count: 1, message: f.message });
65
+ }
66
+ else {
67
+ models.push(`${f.code}: ${f.message}`);
68
+ }
69
+ }
70
+ const lines = [];
71
+ for (const [key, { count, message }] of tools) {
72
+ const toolName = key.split("\u0000")[0];
73
+ const times = count === 1 ? "" : ` ${count} times`;
74
+ lines.push(`I tried \`${toolName}\`${times} and it answered "${message}".`);
75
+ }
76
+ if (models.length)
77
+ lines.push(`The model call failed (${[...new Set(models)].join("; ")}).`);
78
+ lines.push("So I have nothing to give you yet. Say the word and I'll try another way.");
79
+ return lines.join(" ");
80
+ }
81
+ /** The bridge's log line for a turn with no text: which of the three cases it was. */
82
+ export function describeSilentTurn(failures) {
83
+ const tools = failures.filter((f) => f.kind === "tool").length;
84
+ const models = failures.filter((f) => f.kind === "model");
85
+ if (models.length)
86
+ return `no text: model error (${models.map((m) => m.code).join(", ")})`;
87
+ if (tools)
88
+ return `no text: tool failures (${tools})`;
89
+ return "no text: empty completion";
90
+ }
3
91
  /** Convert one inbound Buzz message into an eve-compatible turn. */
4
92
  export async function composeMessage(text, attachments, fetchAttachment, log = () => { }) {
5
93
  if (attachments.length === 0)
@@ -33,99 +121,190 @@ export async function composeMessage(text, attachments, fetchAttachment, log = (
33
121
  }
34
122
  return parts;
35
123
  }
36
- function outcome(result, streamIndex) {
124
+ function silenceWatchdog(intervalMs) {
125
+ const controller = new AbortController();
126
+ let timer;
127
+ let timeout;
37
128
  return {
38
- message: result.message ?? "",
39
- status: result.status,
40
- inputRequests: result.inputRequests,
41
- sessionId: result.sessionId,
42
- streamIndex,
129
+ signal: controller.signal,
130
+ arm(phase) {
131
+ if (timer)
132
+ clearTimeout(timer);
133
+ timer = setTimeout(() => {
134
+ timeout = new AgentSilenceTimeoutError(phase, intervalMs);
135
+ controller.abort(timeout);
136
+ }, intervalMs);
137
+ },
138
+ dispose() {
139
+ if (timer)
140
+ clearTimeout(timer);
141
+ timer = undefined;
142
+ },
143
+ throwIfTimedOut() {
144
+ if (timeout)
145
+ throw timeout;
146
+ },
43
147
  };
44
148
  }
45
- /** Continue a channel's existing conversation, replacing it only when it is stale. */
46
- export async function answerTurn(client, sessions, channel, message, community, log = () => { }) {
149
+ async function drainUnread(stream, intervalMs) {
150
+ const watchdog = silenceWatchdog(intervalMs);
151
+ let unread = 0;
152
+ watchdog.arm("unread drain");
153
+ try {
154
+ try {
155
+ for await (const _ of stream(watchdog.signal)) {
156
+ unread += 1;
157
+ watchdog.arm("unread drain");
158
+ }
159
+ }
160
+ catch (error) {
161
+ watchdog.throwIfTimedOut();
162
+ throw error;
163
+ }
164
+ // Eve normally swallows an external stream abort as clean iterator completion.
165
+ watchdog.throwIfTimedOut();
166
+ return unread;
167
+ }
168
+ finally {
169
+ watchdog.dispose();
170
+ }
171
+ }
172
+ async function collectResponse(response, watchdog, phase, streamIndex) {
173
+ let message = "";
174
+ let status = "completed";
175
+ const inputRequests = [];
176
+ const failures = [];
177
+ watchdog.arm(phase);
178
+ try {
179
+ try {
180
+ for await (const event of response) {
181
+ watchdog.arm(phase);
182
+ const failure = failureFromEvent(event);
183
+ if (failure)
184
+ failures.push(failure);
185
+ if (event.type === "message.completed" && event.data.finishReason !== "tool-calls") {
186
+ message = event.data.message ?? "";
187
+ }
188
+ else if (event.type === "input.requested") {
189
+ inputRequests.push(...event.data.requests);
190
+ }
191
+ else if (event.type === "session.waiting") {
192
+ status = "waiting";
193
+ }
194
+ else if (event.type === "session.failed") {
195
+ status = "failed";
196
+ }
197
+ else if (event.type === "session.completed") {
198
+ status = "completed";
199
+ }
200
+ }
201
+ }
202
+ catch (error) {
203
+ watchdog.throwIfTimedOut();
204
+ throw error;
205
+ }
206
+ watchdog.throwIfTimedOut();
207
+ return {
208
+ message,
209
+ status,
210
+ inputRequests,
211
+ sessionId: response.sessionId,
212
+ streamIndex: streamIndex(),
213
+ failures,
214
+ };
215
+ }
216
+ finally {
217
+ watchdog.dispose();
218
+ }
219
+ }
220
+ async function sendExisting(responsePromise, streamIndex, intervalMs) {
221
+ const watchdog = silenceWatchdog(intervalMs);
222
+ watchdog.arm("send acknowledgement");
223
+ try {
224
+ let response;
225
+ try {
226
+ response = await responsePromise(watchdog.signal);
227
+ }
228
+ catch (error) {
229
+ watchdog.throwIfTimedOut();
230
+ throw error;
231
+ }
232
+ watchdog.throwIfTimedOut();
233
+ return await collectResponse(response, watchdog, "response stream", streamIndex);
234
+ }
235
+ finally {
236
+ watchdog.dispose();
237
+ }
238
+ }
239
+ /** Continue a channel's existing conversation, replacing it only when it is explicitly stale. */
240
+ export async function answerTurn(client, sessions, channel, message, community, log = () => { }, agentSilenceTimeoutMs = DEFAULT_AGENT_SILENCE_TIMEOUT_MS) {
241
+ const intervalMs = validateAgentSilenceTimeoutMs(agentSilenceTimeoutMs);
47
242
  const existing = sessions.get(community, channel);
48
243
  if (existing) {
49
244
  try {
245
+ // attach() is local in eve 0.49 and performs no network I/O.
50
246
  const session = client.sessions.attach(existing.id, { streamIndex: existing.streamIndex });
51
247
  /**
52
248
  * Move to the true end of the conversation before speaking.
53
249
  *
54
250
  * `send()` opens its response stream at the position the handle already
55
- * holds and stops at the FIRST turn boundary it meets. If anything is
56
- * still unread there — the tail of a turn that outlived its reader, a
57
- * turn that was running when the bridge restarted — that boundary
58
- * belongs to the older turn. The read ends on it, this turn's answer is
59
- * never collected, and the stored position stays one turn behind.
60
- *
61
- * Which makes it permanent: every later turn reads the previous turn's
62
- * tail. The channel answers nothing, then answers a question from ten
63
- * minutes ago, and a fresh conversation elsewhere works perfectly —
64
- * because a fresh conversation has nothing left over to trip on.
65
- *
66
- * Draining first costs one bounded read of only what is unread, and it
67
- * repairs a position that has already drifted rather than requiring
68
- * anyone to notice. Non-following, so it ends at the tail instead of
69
- * waiting for the future.
70
- *
71
- * Do not call this path while HITL is pending: the bridge's follower owns
72
- * that unread stream until the parked turn reaches its next boundary.
251
+ * holds and stops at the FIRST turn boundary it meets. Draining first
252
+ * repairs any cursor that was left behind by a prior disconnected reader.
73
253
  */
74
- let unread = 0;
75
- for await (const _ of session.stream({ follow: false, startIndex: existing.streamIndex })) {
76
- unread += 1;
77
- }
254
+ const unread = await drainUnread((signal) => session.stream({ follow: false, startIndex: existing.streamIndex, signal }), intervalMs);
78
255
  if (unread > 0) {
79
256
  log(`caught up on ${unread} unread event(s) in ${channel.slice(0, 8)} before answering`);
80
257
  }
81
- const response = await session.send(message, {
258
+ const result = await sendExisting((signal) => session.send(message, {
82
259
  clientContext: { buzzCommunity: community, buzzChannel: channel },
83
- });
84
- const result = await response.result();
260
+ signal,
261
+ }), () => session.state.streamIndex, intervalMs);
85
262
  sessions.set(community, channel, {
86
263
  id: existing.id,
87
264
  streamIndex: session.state.streamIndex,
88
265
  });
89
- return outcome(result, session.state.streamIndex);
266
+ return result;
90
267
  }
91
268
  catch (error) {
92
- // A malformed turn does not make its durable session stale. Rethrow so
93
- // the bridge can tell the room (see `rejectedTurnReply`), and keep the
94
- // mapping so the next valid message continues the same conversation.
95
- if (error instanceof ClientError && error.status === 400)
269
+ // Transport uncertainty, request rejection, and our own timeout do not
270
+ // prove the durable server conversation is stale. Preserve its mapping.
271
+ if (!(error instanceof ClientError) || error.status !== 404)
96
272
  throw error;
97
273
  log(`session for ${channel.slice(0, 8)} could not continue (${error.message}); starting a new one`);
98
274
  sessions.delete(community, channel);
99
275
  }
100
276
  }
101
- // Every fresh conversation is logged, whatever the reason. The silent case
102
- // used to be the common one: after a bridge restart the map was empty, the
103
- // message fell straight through to create(), and eight exchanges of context
104
- // vanished without a line anyone could read (KYB-502).
105
277
  log(existing
106
278
  ? `starting a new conversation in ${channel.slice(0, 8)} (the previous session could not continue)`
107
279
  : `starting a new conversation in ${channel.slice(0, 8)} (no session on record for this channel)`);
108
- const created = await client.sessions.create({
109
- message,
110
- clientContext: { buzzCommunity: community, buzzChannel: channel },
111
- });
112
- const result = await created.response.result();
113
- sessions.set(community, channel, {
114
- id: created.session.state.sessionId,
115
- streamIndex: created.session.state.streamIndex,
116
- });
117
- return outcome(result, created.session.state.streamIndex);
280
+ const watchdog = silenceWatchdog(intervalMs);
281
+ watchdog.arm("create acknowledgement");
282
+ try {
283
+ let created;
284
+ try {
285
+ created = await client.sessions.create({
286
+ message,
287
+ clientContext: { buzzCommunity: community, buzzChannel: channel },
288
+ signal: watchdog.signal,
289
+ });
290
+ }
291
+ catch (error) {
292
+ watchdog.throwIfTimedOut();
293
+ throw error;
294
+ }
295
+ watchdog.throwIfTimedOut();
296
+ const result = await collectResponse(created.response, watchdog, "create response stream", () => created.session.state.streamIndex);
297
+ sessions.set(community, channel, {
298
+ id: created.session.state.sessionId,
299
+ streamIndex: created.session.state.streamIndex,
300
+ });
301
+ return result;
302
+ }
303
+ finally {
304
+ watchdog.dispose();
305
+ }
118
306
  }
119
- /**
120
- * What the room is told when eve refuses the turn itself.
121
- *
122
- * A 400 means the request was malformed — a part eve does not accept, a body
123
- * it could not parse — not that the agent failed to think of an answer. The
124
- * session survives it (`answerTurn` keeps the mapping), but the person who
125
- * sent the message is still waiting, and a log line nobody is reading is not
126
- * an answer. Returns `null` for anything that is not a request rejection, so
127
- * the caller's ordinary failure path stays as it was.
128
- */
307
+ /** What the room is told when eve refuses the turn itself. */
129
308
  export function rejectedTurnReply(error) {
130
309
  if (!(error instanceof ClientError) || error.status !== 400)
131
310
  return null;
@@ -134,3 +313,9 @@ export function rejectedTurnReply(error) {
134
313
  ? `I couldn't read that message (${detail}). Try sending it again, or as plain text.`
135
314
  : "I couldn't read that message. Try sending it again, or as plain text.";
136
315
  }
316
+ /** Give the originating channel one accurate recovery message for bridge-owned silence. */
317
+ export function agentSilenceReply(error) {
318
+ if (!(error instanceof AgentSilenceTimeoutError))
319
+ return null;
320
+ return "The agent was silent for too long, so I stopped waiting. I kept this conversation, and you can try again here.";
321
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Put an eve agent in a Buzz workspace as a member: per-speaker verified identity resolved through the control plane, self-service identity linking, presence, typing and seen signals.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",