@kybernesis/buzz 0.9.5 → 0.9.8

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): {
@@ -29,6 +31,8 @@ export declare function buzzBridge(options: BuzzBridgeOptions): {
29
31
  pubkey: string;
30
32
  /** The communities this agent is a member of. */
31
33
  relays: string[];
34
+ /** Where this bridge declares itself for the management routes to read. */
35
+ surfaceFile: string;
32
36
  start(): void;
33
37
  /** Say goodbye rather than letting presence lapse: a stopped agent should not look online. */
34
38
  stop(): void;
package/dist/bridge.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { channelIdentity } from "@kybernesis/enterprise";
2
2
  import { fetchMedia, parseMedia } from "./media.js";
3
3
  import { speakerCredentials } from "./credentials.js";
4
+ import { surfaceWriter } from "./surface.js";
4
5
  import { SessionStore } from "./sessions.js";
5
- import { agentSilenceReply, answerTurn, composeMessage, DEFAULT_AGENT_SILENCE_TIMEOUT_MS, describeSilentTurn, rejectedTurnReply, silentTurnReply, validateAgentSilenceTimeoutMs, } from "./turn.js";
6
- import { followPendingConversation, formatInputRequests, invalidInputReply, needsPendingFollower, resolveInputReply, respondToPendingConversation, } from "./hitl.js";
6
+ import { agentSilenceReply, answerTurn, composeMessage, DEFAULT_AGENT_SILENCE_TIMEOUT_MS, DEFAULT_AGENT_WORK_TIMEOUT_MS, describeSilentTurn, rejectedTurnReply, silentTurnReply, validateAgentSilenceTimeoutMs, } from "./turn.js";
7
+ import { followPendingConversation, followerRetryDelayMs, formatInputRequests, invalidInputReply, needsPendingFollower, resolveInputReply, respondToPendingConversation, resumeStalledReply, } from "./hitl.js";
7
8
  import { existsSync } from "node:fs";
8
9
  import { dirname, join } from "node:path";
9
10
  import { Client } from "eve/client";
@@ -31,6 +32,7 @@ export function buzzBridge(options) {
31
32
  const key = loadKey(options.keyFile);
32
33
  const identity = channelIdentity({ issuer: options.issuer, credential: options.credential });
33
34
  const log = options.onLog ?? ((message) => console.log(new Date().toISOString().slice(11, 19), message));
35
+ const agentWorkTimeoutMs = validateAgentSilenceTimeoutMs(options.agentWorkTimeoutMs ?? DEFAULT_AGENT_WORK_TIMEOUT_MS);
34
36
  const agentSilenceTimeoutMs = validateAgentSilenceTimeoutMs(options.agentSilenceTimeoutMs ?? DEFAULT_AGENT_SILENCE_TIMEOUT_MS);
35
37
  /**
36
38
  * One conversation per channel — the SESSION ID, not a handle to it.
@@ -177,6 +179,14 @@ export function buzzBridge(options) {
177
179
  /** One abortable durable-stream follower owns each parked conversation. */
178
180
  const followers = new Map();
179
181
  const followerRestartTimers = new Map();
182
+ /**
183
+ * Why a follower keeps reattaching, per conversation.
184
+ *
185
+ * `index` is the durable cursor it last reattached at. The cursor moving is
186
+ * the only honest evidence that anything is happening: KYB-545's spin held a
187
+ * frozen index across 25 minutes of retries while the room was told nothing.
188
+ */
189
+ const followerRetries = new Map();
180
190
  let stopped = false;
181
191
  function clearFollowerRestart(conversation) {
182
192
  const timer = followerRestartTimers.get(conversation);
@@ -187,6 +197,7 @@ export function buzzBridge(options) {
187
197
  function stopFollower(community, channel) {
188
198
  const conversation = SessionStore.key(community, channel);
189
199
  clearFollowerRestart(conversation);
200
+ followerRetries.delete(conversation);
190
201
  followers.get(conversation)?.abort();
191
202
  followers.delete(conversation);
192
203
  }
@@ -256,13 +267,42 @@ export function buzzBridge(options) {
256
267
  if (followers.get(conversation) === controller)
257
268
  followers.delete(conversation);
258
269
  const recoverable = sessions.get(community, channel);
259
- if (!controller.signal.aborted && recoverable && needsPendingFollower(recoverable)) {
260
- const timer = setTimeout(() => {
261
- followerRestartTimers.delete(conversation);
262
- startFollower(community, channel);
263
- }, 1_000);
264
- followerRestartTimers.set(conversation, timer);
270
+ if (controller.signal.aborted || !recoverable || !needsPendingFollower(recoverable)) {
271
+ followerRetries.delete(conversation);
272
+ return;
265
273
  }
274
+ const previous = followerRetries.get(conversation);
275
+ const progressed = previous === undefined || recoverable.streamIndex > previous.index;
276
+ const retry = progressed
277
+ ? { cycles: 0, index: recoverable.streamIndex, since: Date.now() }
278
+ : { ...previous, cycles: previous.cycles + 1 };
279
+ followerRetries.set(conversation, retry);
280
+ /**
281
+ * A resumed turn is the agent working, so it answers to the same ceiling
282
+ * a live turn does. A question nobody has answered yet is not: a person
283
+ * may reply tomorrow, and waiting for them is the entire point. Only the
284
+ * first case can be a stall, and only it gives up.
285
+ */
286
+ if (recoverable.resumeInFlight && Date.now() - retry.since >= agentWorkTimeoutMs) {
287
+ followerRetries.delete(conversation);
288
+ void serialize(community, channel, async () => {
289
+ const current = sessions.get(community, channel);
290
+ if (!current || !needsPendingFollower(current))
291
+ return;
292
+ if (current.promptEventIds?.length)
293
+ relay.unwatchReplies(current.promptEventIds);
294
+ const { pendingInputRequests: _p, resumeInFlight: _r, promptEventIds: _e, ...cleared } = current;
295
+ sessions.set(community, channel, cleared);
296
+ relay.reply(channel, resumeStalledReply(agentWorkTimeoutMs));
297
+ log(`gave up on the resumed turn in ${channel.slice(0, 8)}: no progress past index ${retry.index}`);
298
+ });
299
+ return;
300
+ }
301
+ const timer = setTimeout(() => {
302
+ followerRestartTimers.delete(conversation);
303
+ startFollower(community, channel);
304
+ }, followerRetryDelayMs(retry.cycles));
305
+ followerRestartTimers.set(conversation, timer);
266
306
  });
267
307
  }
268
308
  async function handle(event, from) {
@@ -327,6 +367,24 @@ export function buzzBridge(options) {
327
367
  });
328
368
  await respondToPendingConversation(session, responses, from, channel);
329
369
  log(`submitted ${responses.length} input response(s) in ${channel.slice(0, 8)}`);
370
+ /**
371
+ * The answer is in, so this conversation is no longer waiting on a
372
+ * person. It is waiting on the agent, and that is a thing the bridge
373
+ * may time out.
374
+ *
375
+ * Recording it here rather than leaving it to the follower's
376
+ * `input.resolved` is what makes a stalled resume detectable at all.
377
+ * In KYB-545 the follower could not reach the agent, so it never saw
378
+ * that event, and the store still read "one question pending" an hour
379
+ * after the person had answered. Nothing may ever time out a wait for
380
+ * a human, so while that flag was set the room could only be told
381
+ * nothing.
382
+ */
383
+ const answered = sessions.get(from, channel);
384
+ if (answered?.pendingInputRequests?.length) {
385
+ const { pendingInputRequests: _resolved, ...rest } = answered;
386
+ sessions.set(from, channel, { ...rest, resumeInFlight: true });
387
+ }
330
388
  return;
331
389
  }
332
390
  if (pending?.resumeInFlight) {
@@ -341,7 +399,7 @@ export function buzzBridge(options) {
341
399
  }
342
400
  /** Images become file parts; unreadable files become explicit model context. */
343
401
  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);
402
+ const result = await answerTurn(clientFor(event.pubkey), sessions, channel, message, from, log, agentSilenceTimeoutMs, agentWorkTimeoutMs);
345
403
  if (result.status === "waiting" && result.inputRequests.length > 0) {
346
404
  sessions.set(from, channel, {
347
405
  id: result.sessionId,
@@ -400,14 +458,19 @@ export function buzzBridge(options) {
400
458
  stopTyping();
401
459
  }
402
460
  }
461
+ // What this bridge is, for the consoles: see surface.ts.
462
+ const surface = surfaceWriter({ relays: urls, npub: key.npub, conversations: () => sessions.size }, log);
403
463
  return {
404
464
  /** The key the workspace has to invite for any of this to happen. */
405
465
  npub: key.npub,
406
466
  pubkey: key.publicKey,
407
467
  /** The communities this agent is a member of. */
408
468
  relays: urls,
469
+ /** Where this bridge declares itself for the management routes to read. */
470
+ surfaceFile: surface.file,
409
471
  start() {
410
472
  stopped = false;
473
+ surface.start();
411
474
  for (const relay of relays.values())
412
475
  relay.connect();
413
476
  for (const { community, channel, session } of sessions.entries()) {
@@ -425,6 +488,7 @@ export function buzzBridge(options) {
425
488
  /** Say goodbye rather than letting presence lapse: a stopped agent should not look online. */
426
489
  stop() {
427
490
  stopped = true;
491
+ surface.stop();
428
492
  for (const controller of followers.values())
429
493
  controller.abort();
430
494
  followers.clear();
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/hitl.d.ts CHANGED
@@ -8,6 +8,26 @@ export declare function resolveInputReply(text: string, requests: readonly Input
8
8
  export declare function invalidInputReply(requests: readonly InputRequest[]): string;
9
9
  /** Submit a matched reply without consuming its response stream. */
10
10
  export declare function respondToPendingConversation(session: Pick<ClientSession, "respond">, responses: readonly InputResponse[], community: string, channel: string): Promise<void>;
11
+ /**
12
+ * How long a follower waits between reattaching when it saw no progress.
13
+ *
14
+ * @remarks
15
+ * The follower used to reattach on a flat one-second timer. With eve's client
16
+ * spending about 100 s on an idle stream before giving up, that produced the
17
+ * fixed ~110 s cycle observed in KYB-545: forever, for as long as the process
18
+ * lived. Backing off turns a spin into a wait, and the cap keeps a genuinely
19
+ * slow resume from being abandoned for minutes at a time.
20
+ */
21
+ export declare function followerRetryDelayMs(cycles: number, capMs?: number): number;
22
+ /**
23
+ * What the room is told when a resumed turn produced nothing for too long.
24
+ *
25
+ * @remarks
26
+ * KYB-529's rule applied to the resumed phase: the person committed attention
27
+ * to answering a question, so silence is the one outcome they must not get.
28
+ * Say the answer arrived, say the work did not, and say the channel is usable.
29
+ */
30
+ export declare function resumeStalledReply(stallMs: number): string;
11
31
  /** A session needs its follower while asking or while resumed output is unpublished. */
12
32
  export declare function needsPendingFollower(session: Pick<StoredSession, "pendingInputRequests" | "resumeInFlight">): boolean;
13
33
  export interface PendingFollowerCallbacks {
package/dist/hitl.js CHANGED
@@ -38,6 +38,34 @@ export async function respondToPendingConversation(session, responses, community
38
38
  streamReconnectPolicy: { reconnect: false },
39
39
  });
40
40
  }
41
+ /**
42
+ * How long a follower waits between reattaching when it saw no progress.
43
+ *
44
+ * @remarks
45
+ * The follower used to reattach on a flat one-second timer. With eve's client
46
+ * spending about 100 s on an idle stream before giving up, that produced the
47
+ * fixed ~110 s cycle observed in KYB-545: forever, for as long as the process
48
+ * lived. Backing off turns a spin into a wait, and the cap keeps a genuinely
49
+ * slow resume from being abandoned for minutes at a time.
50
+ */
51
+ export function followerRetryDelayMs(cycles, capMs = 60_000) {
52
+ const safeCycles = Number.isFinite(cycles) ? Math.max(0, Math.trunc(cycles)) : 0;
53
+ return Math.min(1_000 * 2 ** Math.min(safeCycles, 30), capMs);
54
+ }
55
+ /**
56
+ * What the room is told when a resumed turn produced nothing for too long.
57
+ *
58
+ * @remarks
59
+ * KYB-529's rule applied to the resumed phase: the person committed attention
60
+ * to answering a question, so silence is the one outcome they must not get.
61
+ * Say the answer arrived, say the work did not, and say the channel is usable.
62
+ */
63
+ export function resumeStalledReply(stallMs) {
64
+ const minutes = Math.max(1, Math.round(stallMs / 60_000));
65
+ return (`Your answer reached me, but the turn it restarted then went quiet for over ${minutes} minute` +
66
+ `${minutes === 1 ? "" : "s"} without producing anything, so I have stopped waiting on it. ` +
67
+ `Nothing was posted and no work landed from it. This channel is clear — ask me again and I will start fresh.`);
68
+ }
41
69
  /** A session needs its follower while asking or while resumed output is unpublished. */
42
70
  export function needsPendingFollower(session) {
43
71
  return Boolean(session.pendingInputRequests?.length || session.resumeInFlight);
package/dist/index.d.ts CHANGED
@@ -3,3 +3,4 @@ export { BuzzRelay, type NostrEvent, type PresenceStatus, type RelayOptions, KIN
3
3
  export { asHexPubkey, loadKey, loadOrCreateKey, npubEncode, type AgentKey } from "./keys.js";
4
4
  export * as profile from "./profile.js";
5
5
  export type { Profile } from "./profile.js";
6
+ export { readSurfaceManifest, surfaceWriter, surfacesDir, type SurfaceManifest } from "./surface.js";
package/dist/index.js CHANGED
@@ -2,3 +2,4 @@ export { buzzBridge } from "./bridge.js";
2
2
  export { BuzzRelay, KIND_MESSAGE, KIND_REACTION, KIND_PRESENCE, KIND_TYPING, KIND_DM_OPEN, } from "./relay.js";
3
3
  export { asHexPubkey, loadKey, loadOrCreateKey, npubEncode } from "./keys.js";
4
4
  export * as profile from "./profile.js";
5
+ export { readSurfaceManifest, surfaceWriter, surfacesDir } from "./surface.js";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The bridge, declaring itself.
3
+ *
4
+ * eve's `/eve/v1/info` lists the channels an agent authored; a workspace bridge
5
+ * is a separate process the agent knows nothing about, so every console showed
6
+ * an agent that was plainly answering in a workspace as reachable "only through
7
+ * this app". Rather than have a console guess from process names, the bridge
8
+ * writes one small file saying what it is connected to and touches it every
9
+ * minute. Anything that reads the file — the agent's management routes, and
10
+ * through them the desktop and the phone — can tell a live bridge from a stale
11
+ * file by the heartbeat alone.
12
+ */
13
+ export declare const HEARTBEAT_MS = 60000;
14
+ /** Where every surface on this host writes its manifest; the management routes read the same directory. */
15
+ export declare function surfacesDir(): string;
16
+ export interface SurfaceManifest {
17
+ kind: "buzz";
18
+ name: string;
19
+ /** The workspaces this bridge is connected to. */
20
+ relays: string[];
21
+ /** The key the workspace invited. */
22
+ npub: string;
23
+ /** Channels with a live conversation. */
24
+ conversations: number;
25
+ pid: number;
26
+ startedAt: string;
27
+ heartbeatAt: string;
28
+ }
29
+ export interface SurfaceWriter {
30
+ /** Write the manifest now and keep its heartbeat fresh until `stop()`. */
31
+ start(): void;
32
+ /** Refresh what the manifest says, without waiting for the next beat. */
33
+ update(patch: Partial<Pick<SurfaceManifest, "conversations">>): void;
34
+ /** Remove the manifest: a stopped bridge should not look like a stale one. */
35
+ stop(): void;
36
+ readonly file: string;
37
+ }
38
+ export declare function surfaceWriter(input: {
39
+ relays: string[];
40
+ npub: string;
41
+ conversations(): number;
42
+ dir?: string;
43
+ }, onError?: (message: string) => void): SurfaceWriter;
44
+ /** Read one manifest back; null when it is not one. */
45
+ export declare function readSurfaceManifest(file: string): SurfaceManifest | null;
@@ -0,0 +1,82 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ /**
5
+ * The bridge, declaring itself.
6
+ *
7
+ * eve's `/eve/v1/info` lists the channels an agent authored; a workspace bridge
8
+ * is a separate process the agent knows nothing about, so every console showed
9
+ * an agent that was plainly answering in a workspace as reachable "only through
10
+ * this app". Rather than have a console guess from process names, the bridge
11
+ * writes one small file saying what it is connected to and touches it every
12
+ * minute. Anything that reads the file — the agent's management routes, and
13
+ * through them the desktop and the phone — can tell a live bridge from a stale
14
+ * file by the heartbeat alone.
15
+ */
16
+ export const HEARTBEAT_MS = 60_000;
17
+ /** Where every surface on this host writes its manifest; the management routes read the same directory. */
18
+ export function surfacesDir() {
19
+ return process.env.KYB_SURFACES_DIR ?? join(homedir(), ".kybernesis", "surfaces");
20
+ }
21
+ export function surfaceWriter(input, onError = () => undefined) {
22
+ const dir = input.dir ?? surfacesDir();
23
+ // One file per connection set, so two bridges on one host for different workspaces do not overwrite each other.
24
+ const file = join(dir, `buzz-${input.npub.slice(-8)}.json`);
25
+ const startedAt = new Date().toISOString();
26
+ let timer;
27
+ const write = () => {
28
+ const manifest = {
29
+ kind: "buzz",
30
+ name: "Buzz",
31
+ relays: input.relays,
32
+ npub: input.npub,
33
+ conversations: input.conversations(),
34
+ pid: process.pid,
35
+ startedAt,
36
+ heartbeatAt: new Date().toISOString(),
37
+ };
38
+ try {
39
+ mkdirSync(dir, { recursive: true });
40
+ writeFileSync(file, JSON.stringify(manifest, null, 2) + "\n");
41
+ }
42
+ catch (error) {
43
+ // A manifest that cannot be written costs a row on a console, not a conversation.
44
+ onError(`could not write surface manifest ${file}: ${error.message}`);
45
+ }
46
+ };
47
+ return {
48
+ file,
49
+ start() {
50
+ write();
51
+ timer = setInterval(write, HEARTBEAT_MS);
52
+ timer.unref?.();
53
+ },
54
+ update() {
55
+ write();
56
+ },
57
+ stop() {
58
+ if (timer)
59
+ clearInterval(timer);
60
+ timer = undefined;
61
+ try {
62
+ if (existsSync(file))
63
+ unlinkSync(file);
64
+ }
65
+ catch {
66
+ /* a stale file is told apart by its heartbeat anyway */
67
+ }
68
+ },
69
+ };
70
+ }
71
+ /** Read one manifest back; null when it is not one. */
72
+ export function readSurfaceManifest(file) {
73
+ try {
74
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
75
+ if (parsed.kind !== "buzz" || !Array.isArray(parsed.relays) || typeof parsed.heartbeatAt !== "string")
76
+ return null;
77
+ return parsed;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
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.8",
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",