@kybernesis/buzz 0.9.5 → 0.9.6

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,7 +61,8 @@ 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
+ | `BUZZ_AGENT_SILENCE_TIMEOUT_MS` | Maximum silence before the agent acknowledges a request: unread drain, send and create acknowledgement (default `300000`, five minutes). This is where a genuine stall shows: no run is ever created. |
65
+ | `BUZZ_AGENT_WORK_TIMEOUT_MS` | Ceiling on silence once the response stream is open (default `3600000`, sixty minutes). The stream is quiet while the agent works, a long tool call or a delegated subagent produces nothing until it returns, so this is a ceiling on work, not a stall detector. |
65
66
  | `KYBERNESIS_ISSUER` | The control plane |
66
67
  | `KYBERNESIS_AGENT_CREDENTIAL` | This agent's credential |
67
68
 
package/dist/bridge.d.ts CHANGED
@@ -21,6 +21,8 @@ export type BuzzBridgeOptions = {
21
21
  pollMs?: number;
22
22
  /** Maximum silence during one agent request or event stream; defaults to five minutes. */
23
23
  agentSilenceTimeoutMs?: number;
24
+ /** Ceiling on a turn once acknowledged; the agent is working while its stream is quiet. Default 60 minutes. */
25
+ agentWorkTimeoutMs?: number;
24
26
  onLog?: (message: string) => void;
25
27
  };
26
28
  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 { agentSilenceReply, answerTurn, composeMessage, DEFAULT_AGENT_SILENCE_TIMEOUT_MS, describeSilentTurn, rejectedTurnReply, silentTurnReply, validateAgentSilenceTimeoutMs, } from "./turn.js";
5
+ import { agentSilenceReply, answerTurn, composeMessage, DEFAULT_AGENT_SILENCE_TIMEOUT_MS, DEFAULT_AGENT_WORK_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 agentWorkTimeoutMs = validateAgentSilenceTimeoutMs(options.agentWorkTimeoutMs ?? DEFAULT_AGENT_WORK_TIMEOUT_MS);
34
35
  const agentSilenceTimeoutMs = validateAgentSilenceTimeoutMs(options.agentSilenceTimeoutMs ?? DEFAULT_AGENT_SILENCE_TIMEOUT_MS);
35
36
  /**
36
37
  * One conversation per channel — the SESSION ID, not a handle to it.
@@ -341,7 +342,7 @@ export function buzzBridge(options) {
341
342
  }
342
343
  /** Images become file parts; unreadable files become explicit model context. */
343
344
  const message = await composeMessage(text, attachments, (ref) => fetchMedia(key, ref), log);
344
- const result = await answerTurn(clientFor(event.pubkey), sessions, channel, message, from, log, agentSilenceTimeoutMs);
345
+ const result = await answerTurn(clientFor(event.pubkey), sessions, channel, message, from, log, agentSilenceTimeoutMs, agentWorkTimeoutMs);
345
346
  if (result.status === "waiting" && result.inputRequests.length > 0) {
346
347
  sessions.set(from, channel, {
347
348
  id: result.sessionId,
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_WORK_TIMEOUT_MS
141
+ ? { agentWorkTimeoutMs: Number(process.env.BUZZ_AGENT_WORK_TIMEOUT_MS) }
142
+ : {}),
140
143
  ...(process.env.BUZZ_AGENT_SILENCE_TIMEOUT_MS
141
144
  ? { agentSilenceTimeoutMs: Number(process.env.BUZZ_AGENT_SILENCE_TIMEOUT_MS) }
142
145
  : {}),
@@ -298,7 +301,8 @@ else {
298
301
  BUZZ_AGENT_URL where the agent listens (default http://127.0.0.1:8000)
299
302
  BUZZ_KEYFILE this agent's key (default ~/.kybernesis/buzz-agent.json)
300
303
  BUZZ_SESSIONS_FILE channel-to-session continuity store
301
- BUZZ_AGENT_SILENCE_TIMEOUT_MS maximum agent silence (default 300000ms)
304
+ BUZZ_AGENT_SILENCE_TIMEOUT_MS maximum silence before a turn is acknowledged (default 300000ms)
305
+ BUZZ_AGENT_WORK_TIMEOUT_MS ceiling on a turn once acknowledged (default 3600000ms)
302
306
  KYBERNESIS_ISSUER the control plane
303
307
  KYBERNESIS_AGENT_CREDENTIAL this agent's credential
304
308
  `);
package/dist/turn.d.ts CHANGED
@@ -3,6 +3,27 @@ import { type FetchedMedia, type MediaRef } from "./media.js";
3
3
  import { SessionStore } from "./sessions.js";
4
4
  /** Five minutes without an eve event or acknowledgement is treated as a stalled turn. */
5
5
  export declare const DEFAULT_AGENT_SILENCE_TIMEOUT_MS: number;
6
+ /**
7
+ * How long a turn may run once the agent has acknowledged it.
8
+ *
9
+ * eve's response stream is quiet while the agent works: a long tool call or a
10
+ * delegated subagent produces no events until it returns. Reading that quiet
11
+ * as a stall killed every real piece of work over five minutes the day the
12
+ * silence watchdog shipped (a repository study, a delegation that cloned and
13
+ * read a codebase), while the stall it was built for happened BEFORE the
14
+ * first event: no run was ever created. So the short bound stays on the
15
+ * phases where silence is a fault (unread drain, acknowledgements) and the
16
+ * response stream gets this ceiling instead. Sixty minutes covers the longest
17
+ * genuine turn seen on a deployment (44 minutes) with room.
18
+ */
19
+ export declare const DEFAULT_AGENT_WORK_TIMEOUT_MS: number;
20
+ /** The two bounds a watchdog applies, chosen by phase. */
21
+ export interface AgentTimeouts {
22
+ /** Silence before the first event of a request: drain, send/create acknowledgement. */
23
+ readonly silenceMs: number;
24
+ /** Silence between events once the response stream is open: the agent is working. */
25
+ readonly workMs: number;
26
+ }
6
27
  export type AgentSilencePhase = "unread drain" | "send acknowledgement" | "response stream" | "create acknowledgement" | "create response stream";
7
28
  /** A caller-owned inactivity timeout. Aborting the transport does not cancel the eve session. */
8
29
  export declare class AgentSilenceTimeoutError extends Error {
@@ -62,7 +83,7 @@ export declare function describeSilentTurn(failures: readonly TurnFailure[]): st
62
83
  /** Convert one inbound Buzz message into an eve-compatible turn. */
63
84
  export declare function composeMessage(text: string, attachments: MediaRef[], fetchAttachment: (ref: MediaRef) => Promise<FetchedMedia>, log?: (message: string) => void): Promise<TurnMessage>;
64
85
  /** 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>;
86
+ export declare function answerTurn(client: Client, sessions: SessionStore, channel: string, message: TurnMessage, community: string, log?: (message: string) => void, agentSilenceTimeoutMs?: number, agentWorkTimeoutMs?: number): Promise<TurnOutcome>;
66
87
  /** What the room is told when eve refuses the turn itself. */
67
88
  /**
68
89
  * The two answers from eve that mean the stored session cannot carry another
package/dist/turn.js CHANGED
@@ -2,6 +2,21 @@ import { ClientError, createDataUrlFilePart, } from "eve/client";
2
2
  import { isImage } from "./media.js";
3
3
  /** Five minutes without an eve event or acknowledgement is treated as a stalled turn. */
4
4
  export const DEFAULT_AGENT_SILENCE_TIMEOUT_MS = 5 * 60_000;
5
+ /**
6
+ * How long a turn may run once the agent has acknowledged it.
7
+ *
8
+ * eve's response stream is quiet while the agent works: a long tool call or a
9
+ * delegated subagent produces no events until it returns. Reading that quiet
10
+ * as a stall killed every real piece of work over five minutes the day the
11
+ * silence watchdog shipped (a repository study, a delegation that cloned and
12
+ * read a codebase), while the stall it was built for happened BEFORE the
13
+ * first event: no run was ever created. So the short bound stays on the
14
+ * phases where silence is a fault (unread drain, acknowledgements) and the
15
+ * response stream gets this ceiling instead. Sixty minutes covers the longest
16
+ * genuine turn seen on a deployment (44 minutes) with room.
17
+ */
18
+ export const DEFAULT_AGENT_WORK_TIMEOUT_MS = 60 * 60_000;
19
+ const isWorkPhase = (phase) => phase.endsWith("response stream");
5
20
  /** A caller-owned inactivity timeout. Aborting the transport does not cancel the eve session. */
6
21
  export class AgentSilenceTimeoutError extends Error {
7
22
  phase;
@@ -121,7 +136,7 @@ export async function composeMessage(text, attachments, fetchAttachment, log = (
121
136
  }
122
137
  return parts;
123
138
  }
124
- function silenceWatchdog(intervalMs) {
139
+ function silenceWatchdog(timeouts) {
125
140
  const controller = new AbortController();
126
141
  let timer;
127
142
  let timeout;
@@ -130,6 +145,7 @@ function silenceWatchdog(intervalMs) {
130
145
  arm(phase) {
131
146
  if (timer)
132
147
  clearTimeout(timer);
148
+ const intervalMs = isWorkPhase(phase) ? timeouts.workMs : timeouts.silenceMs;
133
149
  timer = setTimeout(() => {
134
150
  timeout = new AgentSilenceTimeoutError(phase, intervalMs);
135
151
  controller.abort(timeout);
@@ -146,8 +162,8 @@ function silenceWatchdog(intervalMs) {
146
162
  },
147
163
  };
148
164
  }
149
- async function drainUnread(stream, intervalMs) {
150
- const watchdog = silenceWatchdog(intervalMs);
165
+ async function drainUnread(stream, timeouts) {
166
+ const watchdog = silenceWatchdog(timeouts);
151
167
  let unread = 0;
152
168
  watchdog.arm("unread drain");
153
169
  try {
@@ -169,33 +185,84 @@ async function drainUnread(stream, intervalMs) {
169
185
  watchdog.dispose();
170
186
  }
171
187
  }
172
- async function collectResponse(response, watchdog, phase, streamIndex) {
188
+ const isTurnBoundary = (type) => type === "session.waiting" || type === "session.completed" || type === "session.failed";
189
+ async function collectResponse(response, watchdog, phase, streamIndex, continueStream, log = () => { }) {
173
190
  let message = "";
174
191
  let status = "completed";
175
- const inputRequests = [];
176
- const failures = [];
192
+ let inputRequests = [];
193
+ let failures = [];
194
+ let sawTurnStart = false;
195
+ let endedAtOwnBoundary = false;
196
+ let staleBoundaryFirst = false;
197
+ let seen = 0;
198
+ /**
199
+ * One event of this turn, or of the turn before it.
200
+ *
201
+ * The client ends a send's stream at the first session boundary after the
202
+ * cursor. When the send itself ends an earlier, still-running turn (a person
203
+ * asks again while a twenty-minute delegation is under way), that earlier
204
+ * turn's `session.waiting` is the first thing on the wire, before this
205
+ * turn's `turn.started`. Read as ours it made an empty answer, and the
206
+ * abandoned stream then closed the request, which cancelled the real turn
207
+ * five minutes into its work. So: nothing counts until this turn has
208
+ * started, and a boundary only ends the collection once it has.
209
+ */
210
+ const consume = (event) => {
211
+ watchdog.arm(phase);
212
+ seen += 1;
213
+ // A boundary as the very first event, before anything of this turn, is the
214
+ // earlier turn's. Anything else is treated as ours (a stream without
215
+ // turn.started still answers as before).
216
+ if (seen === 1 && isTurnBoundary(event.type) && !sawTurnStart)
217
+ staleBoundaryFirst = true;
218
+ if (event.type === "turn.started") {
219
+ sawTurnStart = true;
220
+ message = "";
221
+ status = "completed";
222
+ inputRequests = [];
223
+ failures = [];
224
+ return false;
225
+ }
226
+ const failure = failureFromEvent(event);
227
+ if (failure)
228
+ failures.push(failure);
229
+ if (event.type === "message.completed" && event.data.finishReason !== "tool-calls") {
230
+ message = event.data.message ?? "";
231
+ }
232
+ else if (event.type === "input.requested") {
233
+ inputRequests.push(...event.data.requests);
234
+ }
235
+ else if (event.type === "session.waiting") {
236
+ status = "waiting";
237
+ }
238
+ else if (event.type === "session.failed") {
239
+ status = "failed";
240
+ }
241
+ else if (event.type === "session.completed") {
242
+ status = "completed";
243
+ }
244
+ if (isTurnBoundary(event.type) && !staleBoundaryFirst) {
245
+ endedAtOwnBoundary = true;
246
+ return true;
247
+ }
248
+ if (isTurnBoundary(event.type) && staleBoundaryFirst && sawTurnStart) {
249
+ endedAtOwnBoundary = true;
250
+ return true;
251
+ }
252
+ return false;
253
+ };
177
254
  watchdog.arm(phase);
178
255
  try {
179
256
  try {
180
257
  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";
258
+ if (consume(event))
259
+ break;
260
+ }
261
+ if (staleBoundaryFirst && !endedAtOwnBoundary && continueStream) {
262
+ log("the stream ended on an earlier turn's boundary before this turn started; following the session until this turn ends");
263
+ for await (const event of continueStream(watchdog.signal)) {
264
+ if (consume(event))
265
+ break;
199
266
  }
200
267
  }
201
268
  }
@@ -217,8 +284,8 @@ async function collectResponse(response, watchdog, phase, streamIndex) {
217
284
  watchdog.dispose();
218
285
  }
219
286
  }
220
- async function sendExisting(responsePromise, streamIndex, intervalMs) {
221
- const watchdog = silenceWatchdog(intervalMs);
287
+ async function sendExisting(responsePromise, streamIndex, timeouts, continueStream, log = () => { }) {
288
+ const watchdog = silenceWatchdog(timeouts);
222
289
  watchdog.arm("send acknowledgement");
223
290
  try {
224
291
  let response;
@@ -230,15 +297,18 @@ async function sendExisting(responsePromise, streamIndex, intervalMs) {
230
297
  throw error;
231
298
  }
232
299
  watchdog.throwIfTimedOut();
233
- return await collectResponse(response, watchdog, "response stream", streamIndex);
300
+ return await collectResponse(response, watchdog, "response stream", streamIndex, continueStream, log);
234
301
  }
235
302
  finally {
236
303
  watchdog.dispose();
237
304
  }
238
305
  }
239
306
  /** 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);
307
+ export async function answerTurn(client, sessions, channel, message, community, log = () => { }, agentSilenceTimeoutMs = DEFAULT_AGENT_SILENCE_TIMEOUT_MS, agentWorkTimeoutMs = DEFAULT_AGENT_WORK_TIMEOUT_MS) {
308
+ const intervalMs = {
309
+ silenceMs: validateAgentSilenceTimeoutMs(agentSilenceTimeoutMs),
310
+ workMs: validateAgentSilenceTimeoutMs(agentWorkTimeoutMs),
311
+ };
242
312
  const existing = sessions.get(community, channel);
243
313
  if (existing) {
244
314
  try {
@@ -258,7 +328,7 @@ export async function answerTurn(client, sessions, channel, message, community,
258
328
  const result = await sendExisting((signal) => session.send(message, {
259
329
  clientContext: { buzzCommunity: community, buzzChannel: channel },
260
330
  signal,
261
- }), () => session.state.streamIndex, intervalMs);
331
+ }), () => session.state.streamIndex, intervalMs, (signal) => session.stream({ follow: true, startIndex: session.state.streamIndex, signal }), log);
262
332
  sessions.set(community, channel, {
263
333
  id: existing.id,
264
334
  streamIndex: session.state.streamIndex,
@@ -293,7 +363,7 @@ export async function answerTurn(client, sessions, channel, message, community,
293
363
  throw error;
294
364
  }
295
365
  watchdog.throwIfTimedOut();
296
- const result = await collectResponse(created.response, watchdog, "create response stream", () => created.session.state.streamIndex);
366
+ const result = await collectResponse(created.response, watchdog, "create response stream", () => created.session.state.streamIndex, (signal) => created.session.stream({ follow: true, startIndex: created.session.state.streamIndex, signal }), log);
297
367
  sessions.set(community, channel, {
298
368
  id: created.session.state.sessionId,
299
369
  streamIndex: created.session.state.streamIndex,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/buzz",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
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",