@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 +2 -1
- package/dist/bridge.d.ts +4 -0
- package/dist/bridge.js +73 -9
- package/dist/cli.js +5 -1
- package/dist/hitl.d.ts +20 -0
- package/dist/hitl.js +28 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/surface.d.ts +45 -0
- package/dist/surface.js +82 -0
- package/dist/turn.d.ts +22 -1
- package/dist/turn.js +101 -31
- package/package.json +1 -1
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
|
|
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 (
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
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;
|
package/dist/surface.js
ADDED
|
@@ -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(
|
|
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,
|
|
150
|
-
const watchdog = silenceWatchdog(
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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,
|
|
221
|
-
const watchdog = silenceWatchdog(
|
|
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 =
|
|
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.
|
|
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",
|