@kybernesis/buzz 0.9.4 → 0.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/bridge.d.ts +2 -0
- package/dist/bridge.js +3 -2
- package/dist/cli.js +5 -1
- package/dist/turn.d.ts +37 -1
- package/dist/turn.js +124 -32
- 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): {
|
package/dist/bridge.js
CHANGED
|
@@ -2,7 +2,7 @@ import { channelIdentity } from "@kybernesis/enterprise";
|
|
|
2
2
|
import { fetchMedia, parseMedia } from "./media.js";
|
|
3
3
|
import { speakerCredentials } from "./credentials.js";
|
|
4
4
|
import { SessionStore } from "./sessions.js";
|
|
5
|
-
import { agentSilenceReply, answerTurn, composeMessage, DEFAULT_AGENT_SILENCE_TIMEOUT_MS, describeSilentTurn, rejectedTurnReply, silentTurnReply, validateAgentSilenceTimeoutMs, } from "./turn.js";
|
|
5
|
+
import { agentSilenceReply, answerTurn, composeMessage, DEFAULT_AGENT_SILENCE_TIMEOUT_MS, DEFAULT_AGENT_WORK_TIMEOUT_MS, describeSilentTurn, rejectedTurnReply, silentTurnReply, validateAgentSilenceTimeoutMs, } from "./turn.js";
|
|
6
6
|
import { followPendingConversation, formatInputRequests, invalidInputReply, needsPendingFollower, resolveInputReply, respondToPendingConversation, } from "./hitl.js";
|
|
7
7
|
import { existsSync } from "node:fs";
|
|
8
8
|
import { dirname, join } from "node:path";
|
|
@@ -31,6 +31,7 @@ export function buzzBridge(options) {
|
|
|
31
31
|
const key = loadKey(options.keyFile);
|
|
32
32
|
const identity = channelIdentity({ issuer: options.issuer, credential: options.credential });
|
|
33
33
|
const log = options.onLog ?? ((message) => console.log(new Date().toISOString().slice(11, 19), message));
|
|
34
|
+
const agentWorkTimeoutMs = validateAgentSilenceTimeoutMs(options.agentWorkTimeoutMs ?? DEFAULT_AGENT_WORK_TIMEOUT_MS);
|
|
34
35
|
const agentSilenceTimeoutMs = validateAgentSilenceTimeoutMs(options.agentSilenceTimeoutMs ?? DEFAULT_AGENT_SILENCE_TIMEOUT_MS);
|
|
35
36
|
/**
|
|
36
37
|
* One conversation per channel — the SESSION ID, not a handle to it.
|
|
@@ -341,7 +342,7 @@ export function buzzBridge(options) {
|
|
|
341
342
|
}
|
|
342
343
|
/** Images become file parts; unreadable files become explicit model context. */
|
|
343
344
|
const message = await composeMessage(text, attachments, (ref) => fetchMedia(key, ref), log);
|
|
344
|
-
const result = await answerTurn(clientFor(event.pubkey), sessions, channel, message, from, log, agentSilenceTimeoutMs);
|
|
345
|
+
const result = await answerTurn(clientFor(event.pubkey), sessions, channel, message, from, log, agentSilenceTimeoutMs, agentWorkTimeoutMs);
|
|
345
346
|
if (result.status === "waiting" && result.inputRequests.length > 0) {
|
|
346
347
|
sessions.set(from, channel, {
|
|
347
348
|
id: result.sessionId,
|
package/dist/cli.js
CHANGED
|
@@ -137,6 +137,9 @@ function run() {
|
|
|
137
137
|
issuer: env("KYBERNESIS_ISSUER", "https://agent.kybernesis.ai"),
|
|
138
138
|
credential: env("KYBERNESIS_AGENT_CREDENTIAL"),
|
|
139
139
|
...(process.env.BUZZ_POLL_MS ? { pollMs: Number(process.env.BUZZ_POLL_MS) } : {}),
|
|
140
|
+
...(process.env.BUZZ_AGENT_WORK_TIMEOUT_MS
|
|
141
|
+
? { agentWorkTimeoutMs: Number(process.env.BUZZ_AGENT_WORK_TIMEOUT_MS) }
|
|
142
|
+
: {}),
|
|
140
143
|
...(process.env.BUZZ_AGENT_SILENCE_TIMEOUT_MS
|
|
141
144
|
? { agentSilenceTimeoutMs: Number(process.env.BUZZ_AGENT_SILENCE_TIMEOUT_MS) }
|
|
142
145
|
: {}),
|
|
@@ -298,7 +301,8 @@ else {
|
|
|
298
301
|
BUZZ_AGENT_URL where the agent listens (default http://127.0.0.1:8000)
|
|
299
302
|
BUZZ_KEYFILE this agent's key (default ~/.kybernesis/buzz-agent.json)
|
|
300
303
|
BUZZ_SESSIONS_FILE channel-to-session continuity store
|
|
301
|
-
BUZZ_AGENT_SILENCE_TIMEOUT_MS maximum
|
|
304
|
+
BUZZ_AGENT_SILENCE_TIMEOUT_MS maximum silence before a turn is acknowledged (default 300000ms)
|
|
305
|
+
BUZZ_AGENT_WORK_TIMEOUT_MS ceiling on a turn once acknowledged (default 3600000ms)
|
|
302
306
|
KYBERNESIS_ISSUER the control plane
|
|
303
307
|
KYBERNESIS_AGENT_CREDENTIAL this agent's credential
|
|
304
308
|
`);
|
package/dist/turn.d.ts
CHANGED
|
@@ -3,6 +3,27 @@ import { type FetchedMedia, type MediaRef } from "./media.js";
|
|
|
3
3
|
import { SessionStore } from "./sessions.js";
|
|
4
4
|
/** Five minutes without an eve event or acknowledgement is treated as a stalled turn. */
|
|
5
5
|
export declare const DEFAULT_AGENT_SILENCE_TIMEOUT_MS: number;
|
|
6
|
+
/**
|
|
7
|
+
* How long a turn may run once the agent has acknowledged it.
|
|
8
|
+
*
|
|
9
|
+
* eve's response stream is quiet while the agent works: a long tool call or a
|
|
10
|
+
* delegated subagent produces no events until it returns. Reading that quiet
|
|
11
|
+
* as a stall killed every real piece of work over five minutes the day the
|
|
12
|
+
* silence watchdog shipped (a repository study, a delegation that cloned and
|
|
13
|
+
* read a codebase), while the stall it was built for happened BEFORE the
|
|
14
|
+
* first event: no run was ever created. So the short bound stays on the
|
|
15
|
+
* phases where silence is a fault (unread drain, acknowledgements) and the
|
|
16
|
+
* response stream gets this ceiling instead. Sixty minutes covers the longest
|
|
17
|
+
* genuine turn seen on a deployment (44 minutes) with room.
|
|
18
|
+
*/
|
|
19
|
+
export declare const DEFAULT_AGENT_WORK_TIMEOUT_MS: number;
|
|
20
|
+
/** The two bounds a watchdog applies, chosen by phase. */
|
|
21
|
+
export interface AgentTimeouts {
|
|
22
|
+
/** Silence before the first event of a request: drain, send/create acknowledgement. */
|
|
23
|
+
readonly silenceMs: number;
|
|
24
|
+
/** Silence between events once the response stream is open: the agent is working. */
|
|
25
|
+
readonly workMs: number;
|
|
26
|
+
}
|
|
6
27
|
export type AgentSilencePhase = "unread drain" | "send acknowledgement" | "response stream" | "create acknowledgement" | "create response stream";
|
|
7
28
|
/** A caller-owned inactivity timeout. Aborting the transport does not cancel the eve session. */
|
|
8
29
|
export declare class AgentSilenceTimeoutError extends Error {
|
|
@@ -62,8 +83,23 @@ 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. */
|
|
88
|
+
/**
|
|
89
|
+
* The two answers from eve that mean the stored session cannot carry another
|
|
90
|
+
* turn: an unknown session id (404), and a session that ended (409 with
|
|
91
|
+
* `session_not_active`, "The session is no longer active."). A session ends
|
|
92
|
+
* when one of its turns fails hard, for instance a model call that 404s at
|
|
93
|
+
* the gateway; the channel must then start a fresh one.
|
|
94
|
+
*
|
|
95
|
+
* Only these replace the mapping. A 400 about the message, a timeout, or a
|
|
96
|
+
* transport failure says nothing about whether the session is alive, and
|
|
97
|
+
* discarding it on those is how conversations were lost (KYB-502). The 409
|
|
98
|
+
* was missed when the timeout work narrowed the rule to 404 alone, and a
|
|
99
|
+
* channel then failed every message with "The session is no longer active."
|
|
100
|
+
* until someone edited the store by hand.
|
|
101
|
+
*/
|
|
102
|
+
export declare function isSessionGone(error: unknown): boolean;
|
|
67
103
|
export declare function rejectedTurnReply(error: unknown): string | null;
|
|
68
104
|
/** Give the originating channel one accurate recovery message for bridge-owned silence. */
|
|
69
105
|
export declare function agentSilenceReply(error: unknown): string | null;
|
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,
|
|
@@ -268,7 +338,7 @@ export async function answerTurn(client, sessions, channel, message, community,
|
|
|
268
338
|
catch (error) {
|
|
269
339
|
// Transport uncertainty, request rejection, and our own timeout do not
|
|
270
340
|
// prove the durable server conversation is stale. Preserve its mapping.
|
|
271
|
-
if (!(error
|
|
341
|
+
if (!isSessionGone(error))
|
|
272
342
|
throw error;
|
|
273
343
|
log(`session for ${channel.slice(0, 8)} could not continue (${error.message}); starting a new one`);
|
|
274
344
|
sessions.delete(community, channel);
|
|
@@ -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,
|
|
@@ -305,6 +375,28 @@ export async function answerTurn(client, sessions, channel, message, community,
|
|
|
305
375
|
}
|
|
306
376
|
}
|
|
307
377
|
/** What the room is told when eve refuses the turn itself. */
|
|
378
|
+
/**
|
|
379
|
+
* The two answers from eve that mean the stored session cannot carry another
|
|
380
|
+
* turn: an unknown session id (404), and a session that ended (409 with
|
|
381
|
+
* `session_not_active`, "The session is no longer active."). A session ends
|
|
382
|
+
* when one of its turns fails hard, for instance a model call that 404s at
|
|
383
|
+
* the gateway; the channel must then start a fresh one.
|
|
384
|
+
*
|
|
385
|
+
* Only these replace the mapping. A 400 about the message, a timeout, or a
|
|
386
|
+
* transport failure says nothing about whether the session is alive, and
|
|
387
|
+
* discarding it on those is how conversations were lost (KYB-502). The 409
|
|
388
|
+
* was missed when the timeout work narrowed the rule to 404 alone, and a
|
|
389
|
+
* channel then failed every message with "The session is no longer active."
|
|
390
|
+
* until someone edited the store by hand.
|
|
391
|
+
*/
|
|
392
|
+
export function isSessionGone(error) {
|
|
393
|
+
if (!(error instanceof ClientError))
|
|
394
|
+
return false;
|
|
395
|
+
if (error.status === 404)
|
|
396
|
+
return true;
|
|
397
|
+
return (error.status === 409 &&
|
|
398
|
+
(error.code === "session_not_active" || /no longer active/i.test(String(error.message ?? ""))));
|
|
399
|
+
}
|
|
308
400
|
export function rejectedTurnReply(error) {
|
|
309
401
|
if (!(error instanceof ClientError) || error.status !== 400)
|
|
310
402
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kybernesis/buzz",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
4
4
|
"description": "Put an eve agent in a Buzz workspace as a member: per-speaker verified identity resolved through the control plane, self-service identity linking, presence, typing and seen signals.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|