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