viber-channel 0.8.7 → 0.8.9
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/lib/auth.ts +5 -0
- package/lib/base_urls.ts +250 -0
- package/lib/bridge_core.ts +290 -35
- package/lib/bridge_tool_host.ts +24 -8
- package/lib/channel_instructions.ts +87 -15
- package/lib/channel_session.ts +10 -0
- package/lib/claude_tool_defs.ts +124 -0
- package/lib/control_stream.ts +9 -2
- package/lib/conversation.ts +6 -0
- package/lib/dm_stream.ts +7 -2
- package/lib/fingerprint.ts +11 -0
- package/lib/hash_key.ts +65 -0
- package/lib/heartbeat.ts +44 -8
- package/lib/instance.ts +16 -2
- package/lib/lockfile.ts +9 -0
- package/lib/messages.ts +2 -1
- package/lib/runner_enroll.ts +210 -0
- package/lib/runner_exec.ts +436 -0
- package/lib/runner_stream.ts +259 -0
- package/lib/urls.ts +54 -0
- package/package.json +1 -1
- package/viber-channel.ts +81 -121
- package/viber-codex-bridge.ts +72 -27
- package/viber-gemma-bridge.ts +28 -8
package/lib/bridge_core.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { createTokenRefreshScheduler, type TokenRefreshScheduler } from "./token_refresh.js";
|
|
18
|
+
import type { ApiBaseUrlResolver } from "./base_urls.js";
|
|
18
19
|
import { lockFilePath } from "./lockfile.js";
|
|
19
20
|
import {
|
|
20
21
|
type ConversationMintResponse,
|
|
@@ -46,6 +47,21 @@ export interface ConversationMessage {
|
|
|
46
47
|
sender_instance_id?: string | null;
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Persistent cross-run dedup watermark for ONE conversation (#429, mirror of
|
|
52
|
+
* `DmWatermark` in dm_stream.ts). Holds the highest message id already DELIVERED
|
|
53
|
+
* (replied to) in a prior run of this conversation's stream. The caller keeps one
|
|
54
|
+
* per `convId` and passes it back on every re-join, so it SURVIVES the
|
|
55
|
+
* abort+restart cycle `runConversationStream` does on a re-pushed join — unlike
|
|
56
|
+
* the run-scoped `handledIds` (fresh + empty each run). Without it the first
|
|
57
|
+
* await-invite pass re-forwards (re-replies to) the most recent trigger on every
|
|
58
|
+
* re-spawn/rejoin. Mutated in place. `0` = nothing delivered yet.
|
|
59
|
+
*/
|
|
60
|
+
export interface BridgeWatermark {
|
|
61
|
+
/** Highest delivered (replied-to) message id (0 = nothing delivered yet). */
|
|
62
|
+
lastId: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
/** One live conversation stream's mutable runtime state (token rotates). */
|
|
50
66
|
export interface ConversationRuntime {
|
|
51
67
|
id: string;
|
|
@@ -163,15 +179,21 @@ export function isProcessAlive(pid: number): boolean {
|
|
|
163
179
|
* bridge's log lines under its own tag.
|
|
164
180
|
*/
|
|
165
181
|
export function acquireBridgeLock(opts: {
|
|
166
|
-
|
|
182
|
+
/**
|
|
183
|
+
* #480: the LOGICAL backend identity, NOT the API transport host. The lock
|
|
184
|
+
* path must stay stable when agent traffic is redirected, otherwise a running
|
|
185
|
+
* agent stops recognizing its own lock and the #311/#293 duplicate window
|
|
186
|
+
* re-opens. Injected rather than read from the env so this stays pure.
|
|
187
|
+
*/
|
|
188
|
+
identityBaseUrl: string;
|
|
167
189
|
fingerprint: string;
|
|
168
190
|
sessionId: string;
|
|
169
191
|
lockDir: string;
|
|
170
192
|
logPrefix: string;
|
|
171
193
|
}): BridgeLock {
|
|
172
|
-
const {
|
|
194
|
+
const { identityBaseUrl, fingerprint, sessionId, lockDir, logPrefix } = opts;
|
|
173
195
|
mkdirSync(lockDir, { recursive: true });
|
|
174
|
-
const path = lockFilePath(
|
|
196
|
+
const path = lockFilePath(identityBaseUrl, fingerprint, sessionId, lockDir);
|
|
175
197
|
const release = (): void => {
|
|
176
198
|
try {
|
|
177
199
|
unlinkSync(path);
|
|
@@ -235,11 +257,12 @@ function refreshLeadSeconds(logPrefix: string): number | undefined {
|
|
|
235
257
|
export function startTokenRefresh(opts: {
|
|
236
258
|
runtime: ConversationRuntime;
|
|
237
259
|
fingerprint: string;
|
|
238
|
-
|
|
260
|
+
/** #480 — long-lived scheduler: read the host per refresh, never capture it. */
|
|
261
|
+
getBaseUrl: () => string;
|
|
239
262
|
requestShutdown: (err: BridgeShutdownError) => void;
|
|
240
263
|
logPrefix: string;
|
|
241
264
|
}): TokenRefreshScheduler {
|
|
242
|
-
const { runtime, fingerprint,
|
|
265
|
+
const { runtime, fingerprint, getBaseUrl, requestShutdown, logPrefix } = opts;
|
|
243
266
|
const leadSeconds = refreshLeadSeconds(logPrefix);
|
|
244
267
|
const scheduler = createTokenRefreshScheduler(
|
|
245
268
|
async () => {
|
|
@@ -247,7 +270,7 @@ export function startTokenRefresh(opts: {
|
|
|
247
270
|
const headroomSeconds = 10;
|
|
248
271
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
249
272
|
try {
|
|
250
|
-
const refreshed = await refreshConversationToken(
|
|
273
|
+
const refreshed = await refreshConversationToken(getBaseUrl(), runtime.id, runtime.token, fingerprint);
|
|
251
274
|
runtime.token = refreshed.conversation_token;
|
|
252
275
|
runtime.expiresAt = refreshed.expires_at;
|
|
253
276
|
process.stderr.write(`${logPrefix} token refreshed for conversation ${runtime.id}\n`);
|
|
@@ -333,6 +356,32 @@ export function decideTurnPostAction(opts: {
|
|
|
333
356
|
return "auto-post";
|
|
334
357
|
}
|
|
335
358
|
|
|
359
|
+
/**
|
|
360
|
+
* #429 cross-run dedup, mirror of dm_stream's `forwardMessage` guard (L146-147).
|
|
361
|
+
* True when this id was already DELIVERED in a prior run and must be skipped on a
|
|
362
|
+
* re-join. The D1 message id is `INTEGER PRIMARY KEY AUTOINCREMENT` (migration
|
|
363
|
+
* 0009) → strictly increasing, never reused, so anything at or below the watermark
|
|
364
|
+
* was already handled. `Number.isSafeInteger` (not `isFinite`) gate: an id beyond
|
|
365
|
+
* 2^53 — where `Number()` loses precision and could collide — falls through to
|
|
366
|
+
* normal handling instead of being mis-gated. No watermark / absent / NaN id →
|
|
367
|
+
* never skips. Pure + exported for unit tests (pattern `decideTurnPostAction`).
|
|
368
|
+
*/
|
|
369
|
+
export function watermarkShouldSkip(numId: number, watermark?: BridgeWatermark): boolean {
|
|
370
|
+
return !!watermark && Number.isSafeInteger(numId) && numId <= watermark.lastId;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* #429 advance the cross-run watermark after a SUCCESSFUL delivery (mirror of
|
|
375
|
+
* dm_stream L166-168). Monotone: only ever moves forward, so a brief overlap of
|
|
376
|
+
* two streams during supersession can't regress it. Same `Number.isSafeInteger`
|
|
377
|
+
* gate; a NaN/absent/unsafe id is never watermarked. Pure + exported.
|
|
378
|
+
*/
|
|
379
|
+
export function watermarkAdvance(numId: number, watermark?: BridgeWatermark): void {
|
|
380
|
+
if (watermark && Number.isSafeInteger(numId) && numId > watermark.lastId) {
|
|
381
|
+
watermark.lastId = numId;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
336
385
|
/** Bearer + fingerprint + CF Access headers for a conversation request. */
|
|
337
386
|
function conversationHeaders(conversationToken: string, fingerprint: string): Record<string, string> {
|
|
338
387
|
return {
|
|
@@ -378,9 +427,25 @@ export interface ConversationStreamDeps {
|
|
|
378
427
|
options: SseLoopOptions;
|
|
379
428
|
/** Registry of live streams (keyed by conversation id) — re-join supersedes. */
|
|
380
429
|
activeStreams: Map<string, AbortController>;
|
|
430
|
+
/**
|
|
431
|
+
* Registry of persistent per-conversation watermarks (#429). Keyed by
|
|
432
|
+
* conversation id. UNLIKE `activeStreams`, this MUST survive the abort+restart
|
|
433
|
+
* on re-join (never deleted in the stream's finally) — that persistence is the
|
|
434
|
+
* whole point of the cross-run dedup.
|
|
435
|
+
*/
|
|
436
|
+
watermarks: Map<string, BridgeWatermark>;
|
|
381
437
|
/** Global shutdown signal — aborting it stops every stream. */
|
|
382
438
|
parentSignal: AbortSignal;
|
|
383
|
-
|
|
439
|
+
/**
|
|
440
|
+
* #480: the API host as STATE, not a string. Passing the resolver rather than
|
|
441
|
+
* a captured value is what lets a long-lived stream follow the host the server
|
|
442
|
+
* announces mid-run; a `string` here would freeze the bootstrap host for the
|
|
443
|
+
* whole run. It also removes the swap hazard a second `string` field next to
|
|
444
|
+
* `identityBaseUrl` would create.
|
|
445
|
+
*/
|
|
446
|
+
api: ApiBaseUrlResolver;
|
|
447
|
+
/** Logical backend identity — what the session handle is keyed on (#480). */
|
|
448
|
+
identityBaseUrl: string;
|
|
384
449
|
lockDir: string;
|
|
385
450
|
logPrefix: string;
|
|
386
451
|
/** Session-handle tag distinguishing runtimes in the handle path (codex/gemma). */
|
|
@@ -405,7 +470,9 @@ export async function runConversationStream(
|
|
|
405
470
|
minted: ConversationMintResponse,
|
|
406
471
|
deps: ConversationStreamDeps,
|
|
407
472
|
): Promise<void> {
|
|
408
|
-
const { fingerprint, instanceKey, options, activeStreams, parentSignal,
|
|
473
|
+
const { fingerprint, instanceKey, options, activeStreams, watermarks, parentSignal, api, identityBaseUrl, lockDir, logPrefix, sessionTag, makeRunTurn } = deps;
|
|
474
|
+
// #480: a FUNCTION, not a captured string — the host can change mid-stream.
|
|
475
|
+
const baseUrl = (): string => api.current();
|
|
409
476
|
const convId = minted.conversation_id;
|
|
410
477
|
if (parentSignal.aborted) return;
|
|
411
478
|
|
|
@@ -430,12 +497,14 @@ export async function runConversationStream(
|
|
|
430
497
|
parentSignal.addEventListener("abort", onParentAbort, { once: true });
|
|
431
498
|
// Per-conversation resume handle, keyed by conversation id so a secondary DM
|
|
432
499
|
// never clobbers the main conversation's handle.
|
|
433
|
-
|
|
500
|
+
// #480: keyed on the identity, not the transport host — a redirected agent
|
|
501
|
+
// must find the SAME handle and resume its conversation, not mint a new one.
|
|
502
|
+
writeHandle(sessionFilePath(identityBaseUrl, `${instanceKey}:${sessionTag}:${convId}`, lockDir), convId);
|
|
434
503
|
// Token-refresh failure aborts ONLY this stream, not the whole bridge.
|
|
435
504
|
startTokenRefresh({
|
|
436
505
|
runtime,
|
|
437
506
|
fingerprint,
|
|
438
|
-
baseUrl,
|
|
507
|
+
getBaseUrl: baseUrl,
|
|
439
508
|
requestShutdown: (err) => {
|
|
440
509
|
if (!ctrl.signal.aborted) ctrl.abort(err);
|
|
441
510
|
},
|
|
@@ -443,7 +512,15 @@ export async function runConversationStream(
|
|
|
443
512
|
});
|
|
444
513
|
const runTurn = await makeRunTurn(convId, runtime, ctrl.signal);
|
|
445
514
|
process.stderr.write(`${logPrefix} stream ready for ${convId}\n`);
|
|
446
|
-
|
|
515
|
+
// Get-or-create the persistent watermark for this conversation. It lives in
|
|
516
|
+
// the caller-owned `watermarks` registry so it SURVIVES this stream's
|
|
517
|
+
// abort+restart on re-join (the finally below deliberately does NOT delete it).
|
|
518
|
+
let watermark = watermarks.get(convId);
|
|
519
|
+
if (!watermark) {
|
|
520
|
+
watermark = { lastId: 0 };
|
|
521
|
+
watermarks.set(convId, watermark);
|
|
522
|
+
}
|
|
523
|
+
await sseLoop({ minted, runtime, fingerprint, ownInstanceKey: instanceKey, options, signal: ctrl.signal, getBaseUrl: baseUrl, logPrefix, runTurn, watermark });
|
|
447
524
|
} finally {
|
|
448
525
|
parentSignal.removeEventListener("abort", onParentAbort);
|
|
449
526
|
runtime.scheduler?.cancel();
|
|
@@ -481,11 +558,26 @@ export async function sseLoop(opts: {
|
|
|
481
558
|
ownInstanceKey: string;
|
|
482
559
|
options: SseLoopOptions;
|
|
483
560
|
signal: AbortSignal;
|
|
484
|
-
|
|
561
|
+
/** #480 — the loop outlives the announcement; read the host per use. */
|
|
562
|
+
getBaseUrl: () => string;
|
|
485
563
|
logPrefix: string;
|
|
486
564
|
runTurn: RunTurn;
|
|
565
|
+
/**
|
|
566
|
+
* Persistent cross-run watermark for this conversation (#429). When present,
|
|
567
|
+
* a re-join skips re-replying to any message id already delivered in a prior
|
|
568
|
+
* run. Unused in step-01 (plumbing only); the guard/advance lands in step-02.
|
|
569
|
+
* `runConversationStream` always passes it; a direct test caller may omit it.
|
|
570
|
+
*/
|
|
571
|
+
watermark?: BridgeWatermark;
|
|
572
|
+
/** Injectable for tests; defaults to global fetch (mirror dm_stream). */
|
|
573
|
+
fetchImpl?: typeof fetch;
|
|
574
|
+
/** Injectable catch-up fetch; defaults to messages.ts `fetchMessages`. */
|
|
575
|
+
fetchMessagesImpl?: typeof fetchMessages;
|
|
487
576
|
}): Promise<void> {
|
|
488
|
-
const { minted, runtime, fingerprint, ownInstanceKey, options, signal,
|
|
577
|
+
const { minted, runtime, fingerprint, ownInstanceKey, options, signal, getBaseUrl, logPrefix, runTurn, watermark } = opts;
|
|
578
|
+
const baseUrl = getBaseUrl; // called at each use below
|
|
579
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
580
|
+
const fetchMessagesImpl = opts.fetchMessagesImpl ?? fetchMessages;
|
|
489
581
|
const sseUrl = buildSseUrl(minted.ws_url, minted.conversation_id);
|
|
490
582
|
const ownPostedIds = new Set<string>();
|
|
491
583
|
|
|
@@ -522,7 +614,7 @@ export async function sseLoop(opts: {
|
|
|
522
614
|
process.stderr.write(`${logPrefix} turn on ${runtime.id} produced no text and used no outbound tool; nothing to deliver\n`);
|
|
523
615
|
return false;
|
|
524
616
|
case "auto-post":
|
|
525
|
-
await postBridgeReply({ baseUrl, runtime, text: reply, ownPostedIds, signal, logPrefix });
|
|
617
|
+
await postBridgeReply({ baseUrl: baseUrl(), runtime, text: reply, ownPostedIds, signal, logPrefix });
|
|
526
618
|
return false;
|
|
527
619
|
}
|
|
528
620
|
}
|
|
@@ -563,7 +655,7 @@ export async function sseLoop(opts: {
|
|
|
563
655
|
if (!idStr) return;
|
|
564
656
|
if (ackedIds.has(idStr) || ackInFlightIds.has(idStr)) return;
|
|
565
657
|
ackInFlightIds.add(idStr);
|
|
566
|
-
void ackMessage(baseUrl, runtime.id, runtime.token, idStr, signal)
|
|
658
|
+
void ackMessage(baseUrl(), runtime.id, runtime.token, idStr, signal, fetchImpl)
|
|
567
659
|
.then((ok) => {
|
|
568
660
|
if (ok) ackedIds.add(idStr);
|
|
569
661
|
else process.stderr.write(`${logPrefix} ack failed for message ${idStr} (will retry on reconnect)\n`);
|
|
@@ -573,10 +665,18 @@ export async function sseLoop(opts: {
|
|
|
573
665
|
|
|
574
666
|
async function handleInboundMessage(msg: ConversationMessage): Promise<boolean> {
|
|
575
667
|
const idStr = msg.id !== undefined && msg.id !== null ? String(msg.id) : undefined;
|
|
668
|
+
// TRUTHY check (not `!== undefined`): idStr === "" must yield NaN, not
|
|
669
|
+
// Number("") === 0, so a blank id never satisfies the `<= lastId` guard.
|
|
670
|
+
const numId = idStr ? Number(idStr) : Number.NaN;
|
|
576
671
|
// Confirm receipt to the web INDEPENDENTLY of handled/reply state, BEFORE the
|
|
577
672
|
// handledIds early-return — so a previously-failed ACK is retried when this
|
|
578
673
|
// message comes back through catch-up on reconnect (codex step-02 review).
|
|
579
674
|
ackInboundReceipt(msg);
|
|
675
|
+
// #429 cross-run dedup: a re-join re-delivers the trigger; skip replying to
|
|
676
|
+
// anything already delivered in a prior run. Placed AFTER ackInboundReceipt
|
|
677
|
+
// so the #346 read-ACK still fires on a re-delivered trigger, only the reply
|
|
678
|
+
// is suppressed (mirror of dm_stream; handledIds still gates the intra-run dup).
|
|
679
|
+
if (watermarkShouldSkip(numId, watermark)) return false;
|
|
580
680
|
if (idStr && handledIds.has(idStr)) return false;
|
|
581
681
|
if (!shouldHandleMessage(msg, ownInstanceKey, ownPostedIds, options)) {
|
|
582
682
|
if (idStr) handledIds.add(idStr); // skip decision is final — role won't change
|
|
@@ -590,6 +690,12 @@ export async function sseLoop(opts: {
|
|
|
590
690
|
try {
|
|
591
691
|
const aborted = await replyForTurn(msg.content!, msg);
|
|
592
692
|
if (aborted) return false;
|
|
693
|
+
// #429 advance ONLY after a non-aborted reply (delivered — incl.
|
|
694
|
+
// suppress-outbound and skip-empty). INSIDE the try, after the abort
|
|
695
|
+
// check, never in catch/finally: a thrown reply (caught below) is a
|
|
696
|
+
// failed delivery → NO advance, so it retries on the next re-join
|
|
697
|
+
// (at-least-once, same asymmetry as dm_stream's watermark).
|
|
698
|
+
watermarkAdvance(numId, watermark);
|
|
593
699
|
} catch (err) {
|
|
594
700
|
if (err instanceof BridgeShutdownError) throw err;
|
|
595
701
|
process.stderr.write(`${logPrefix} failed to handle message: ${safeErrorMessage(err)}\n`);
|
|
@@ -607,7 +713,7 @@ export async function sseLoop(opts: {
|
|
|
607
713
|
// is the SSE/voice host (the CF tunnel in staging/prod). Using ws_url here
|
|
608
714
|
// 404s whenever the SSE host differs from the API host (masked in dev where
|
|
609
715
|
// they collapse to one origin). The SSE subscription still uses ws_url.
|
|
610
|
-
msgs = await
|
|
716
|
+
msgs = await fetchMessagesImpl(baseUrl(), minted.conversation_id, runtime.token);
|
|
611
717
|
} catch (err) {
|
|
612
718
|
if (err instanceof ConversationTokenExpiredError) throw err;
|
|
613
719
|
process.stderr.write(`${logPrefix} catch-up fetch failed: ${safeErrorMessage(err)}\n`);
|
|
@@ -660,7 +766,7 @@ export async function sseLoop(opts: {
|
|
|
660
766
|
requestHeaders.Accept = "text/event-stream";
|
|
661
767
|
process.stderr.write(`${logPrefix} connecting to SSE: ${sseUrl}\n`);
|
|
662
768
|
|
|
663
|
-
const resp = await
|
|
769
|
+
const resp = await fetchImpl(sseUrl, { headers: requestHeaders, signal });
|
|
664
770
|
if (resp.status === 401) throw new ConversationTokenExpiredError();
|
|
665
771
|
if (!resp.ok || !resp.body) throw new Error(`SSE connection failed: HTTP ${resp.status}`);
|
|
666
772
|
|
|
@@ -774,9 +880,69 @@ export function sanitizeAgentRole(raw: string | undefined): string {
|
|
|
774
880
|
return code < 0x20 || code === 0x7f ? " " : ch;
|
|
775
881
|
})
|
|
776
882
|
.join("");
|
|
777
|
-
const stripped = noCtrl.replace(/\s+/g, " ").trim();
|
|
778
|
-
|
|
779
|
-
|
|
883
|
+
const stripped = normalizeEscapables(noCtrl.replace(/\s+/g, " ").trim());
|
|
884
|
+
return capUtf16Safe(stripped, MAX_ROLE_LENGTH);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Normalize the two characters `JSON.stringify` expands MOST in practice (#479): a quote
|
|
889
|
+
* becomes an apostrophe, a backslash a forward slash. Both stay readable —
|
|
890
|
+
* `C:\Users` → `C:/Users`, `the "taskman" skill` → `the 'taskman' skill` — and
|
|
891
|
+
* neither `'` nor `/` is escaped, so a 600-char role can no longer encode to 1200
|
|
892
|
+
* and blow the instructions budget. Removing them outright would corrupt paths and
|
|
893
|
+
* citations; rewriting them keeps the meaning and bounds the length.
|
|
894
|
+
*
|
|
895
|
+
* They are not the ONLY expanding inputs: control characters (already turned into
|
|
896
|
+
* spaces by the callers) and a LONE SURROGATE (escaped as `\udXXX` — six chars for
|
|
897
|
+
* one code unit) also grow. Unpaired surrogates are therefore dropped here, and
|
|
898
|
+
* `capUtf16Safe` below makes sure the length cap never creates a new one. Between
|
|
899
|
+
* the two, no input can make the encoded text longer than the raw text.
|
|
900
|
+
*/
|
|
901
|
+
function normalizeEscapables(text: string): string {
|
|
902
|
+
return dropLoneSurrogates(text).replaceAll('"', "'").replaceAll("\\", "/");
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Drop UNPAIRED surrogates (#479 opus review). `capUtf16Safe` stops the cap from
|
|
907
|
+
* CREATING one, but an unpaired unit already present in the input would sail
|
|
908
|
+
* through and encode to `\udXXX` — six chars for one unit, so a 600-char role could
|
|
909
|
+
* still reach ~3600 encoded. Dropping them closes the last expansion path, which is
|
|
910
|
+
* what makes IDENTITY_BUDGET exact for ANY input and the clip a true never-path.
|
|
911
|
+
* A lone surrogate carries no text anyway: it is a broken half-character.
|
|
912
|
+
*/
|
|
913
|
+
function dropLoneSurrogates(text: string): string {
|
|
914
|
+
let out = "";
|
|
915
|
+
for (let i = 0; i < text.length; i++) {
|
|
916
|
+
const code = text.charCodeAt(i);
|
|
917
|
+
const isHigh = code >= 0xd800 && code <= 0xdbff;
|
|
918
|
+
const isLow = code >= 0xdc00 && code <= 0xdfff;
|
|
919
|
+
if (isHigh) {
|
|
920
|
+
const next = text.charCodeAt(i + 1);
|
|
921
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
922
|
+
out += text[i] + text[i + 1];
|
|
923
|
+
i++;
|
|
924
|
+
}
|
|
925
|
+
continue; // unpaired high → dropped
|
|
926
|
+
}
|
|
927
|
+
if (isLow) continue; // unpaired low → dropped
|
|
928
|
+
out += text[i];
|
|
929
|
+
}
|
|
930
|
+
return out;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Cap `text` to `max` UTF-16 units WITHOUT leaving a lone surrogate behind
|
|
935
|
+
* (codex-479 review): a plain `slice` can cut an emoji in half, and a lone
|
|
936
|
+
* surrogate is itself escaped by `JSON.stringify` (`\udXXX`) — the very expansion
|
|
937
|
+
* the normalization above exists to prevent, plus an unpaired code unit in the
|
|
938
|
+
* prompt. The cap stays in UTF-16 units so `IDENTITY_BUDGET` remains exact.
|
|
939
|
+
*/
|
|
940
|
+
function capUtf16Safe(text: string, max: number): string {
|
|
941
|
+
if (text.length <= max) return text;
|
|
942
|
+
const cut = text.slice(0, max);
|
|
943
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
944
|
+
const orphanHighSurrogate = last >= 0xd800 && last <= 0xdbff;
|
|
945
|
+
return (orphanHighSurrogate ? cut.slice(0, -1) : cut).trimEnd();
|
|
780
946
|
}
|
|
781
947
|
|
|
782
948
|
/** Max explicit-name length after sanitization (chars). */
|
|
@@ -797,8 +963,8 @@ export function sanitizeAgentName(raw: string | undefined): string {
|
|
|
797
963
|
return code < 0x20 || code === 0x7f ? " " : ch;
|
|
798
964
|
})
|
|
799
965
|
.join("");
|
|
800
|
-
const stripped = noCtrl.replace(/\s+/g, " ").trim();
|
|
801
|
-
return stripped
|
|
966
|
+
const stripped = normalizeEscapables(noCtrl.replace(/\s+/g, " ").trim());
|
|
967
|
+
return capUtf16Safe(stripped, MAX_NAME_LENGTH);
|
|
802
968
|
}
|
|
803
969
|
|
|
804
970
|
/**
|
|
@@ -824,6 +990,13 @@ export function sanitizeAgentName(raw: string | undefined): string {
|
|
|
824
990
|
export function buildAgentIdentityInstructions(opts: {
|
|
825
991
|
explicitName?: string;
|
|
826
992
|
role?: string;
|
|
993
|
+
/**
|
|
994
|
+
* Hard ceiling (chars) for the RENDERED line (#479). The MCP client truncates
|
|
995
|
+
* long `instructions`, so the identity line must not eat the channel-contract
|
|
996
|
+
* budget: the caller reserves a slice and we fit into it OURSELVES rather than
|
|
997
|
+
* letting the client cut wherever it lands. Omitted = no ceiling.
|
|
998
|
+
*/
|
|
999
|
+
budget?: number;
|
|
827
1000
|
}): string {
|
|
828
1001
|
const name = sanitizeAgentName(opts.explicitName);
|
|
829
1002
|
const role = sanitizeAgentRole(opts.role);
|
|
@@ -835,8 +1008,57 @@ export function buildAgentIdentityInstructions(opts: {
|
|
|
835
1008
|
const nameClause = name
|
|
836
1009
|
? ` Your explicit spawn name is ${JSON.stringify(name)}; if asked your name, answer with it.`
|
|
837
1010
|
: " You do not have an explicit agent name, so do not invent one if asked.";
|
|
838
|
-
|
|
839
|
-
|
|
1011
|
+
|
|
1012
|
+
// The guard and the name are NEVER trimmed (#328: an agent that lost its name
|
|
1013
|
+
// is the bug this line exists to prevent; a half-quoted guard would weaken the
|
|
1014
|
+
// "descriptive DATA" framing). Only the role clause absorbs the budget.
|
|
1015
|
+
const fixed = `${guard}${nameClause}`;
|
|
1016
|
+
const roleClause = role ? renderRoleClause(role, budgetForRole(opts.budget, fixed.length)) : "";
|
|
1017
|
+
return `${fixed}${roleClause}`;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** Chars left for the role clause once guard + name are reserved. */
|
|
1021
|
+
function budgetForRole(budget: number | undefined, fixedLength: number): number {
|
|
1022
|
+
if (budget === undefined) return Number.POSITIVE_INFINITY;
|
|
1023
|
+
return budget - fixedLength;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Render ` Your assigned role is "<role>".` within `available` chars, or "" when
|
|
1028
|
+
* even the empty envelope does not fit (#479).
|
|
1029
|
+
*
|
|
1030
|
+
* Clipping happens on the role text BEFORE serialization, by CODE POINT — never
|
|
1031
|
+
* on the serialized string (codex review): cutting encoded text can split an
|
|
1032
|
+
* escape sequence (`é`) or a surrogate pair and leave the JSON string
|
|
1033
|
+
* unterminated, which is exactly what would break out of the quoted-data framing.
|
|
1034
|
+
* So we search the longest code-point prefix whose `JSON.stringify` still fits.
|
|
1035
|
+
*/
|
|
1036
|
+
function renderRoleClause(role: string, available: number): string {
|
|
1037
|
+
const wrap = (encoded: string): string => ` Your assigned role is ${encoded}.`;
|
|
1038
|
+
if (wrap(JSON.stringify("")).length > available) return "";
|
|
1039
|
+
const full = wrap(JSON.stringify(role));
|
|
1040
|
+
if (full.length <= available) return full;
|
|
1041
|
+
|
|
1042
|
+
// Binary-search the longest fitting prefix. Code points, not UTF-16 units, so a
|
|
1043
|
+
// non-BMP character is never cut in half.
|
|
1044
|
+
const points = Array.from(role);
|
|
1045
|
+
let lo = 0;
|
|
1046
|
+
let hi = points.length;
|
|
1047
|
+
while (lo < hi) {
|
|
1048
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
1049
|
+
if (wrap(JSON.stringify(points.slice(0, mid).join(""))).length <= available) lo = mid;
|
|
1050
|
+
else hi = mid - 1;
|
|
1051
|
+
}
|
|
1052
|
+
const clipped = wrap(JSON.stringify(points.slice(0, lo).join("").trimEnd()));
|
|
1053
|
+
// Never silent: a clip means the operator's role prompt did not fully reach the
|
|
1054
|
+
// agent. Lengths only — the role itself is not logged (it can be long, and the
|
|
1055
|
+
// log is not the place for it).
|
|
1056
|
+
process.stderr.write(
|
|
1057
|
+
`[viber-channel] identity: role clause CLIPPED to fit the instructions budget ` +
|
|
1058
|
+
`(${points.length} code points → ${lo}; clause ${full.length} → ${clipped.length} chars, ` +
|
|
1059
|
+
`available ${available}). Shorten the role prompt to avoid losing its tail.\n`,
|
|
1060
|
+
);
|
|
1061
|
+
return clipped;
|
|
840
1062
|
}
|
|
841
1063
|
|
|
842
1064
|
/**
|
|
@@ -848,7 +1070,7 @@ export function buildAgentIdentityInstructions(opts: {
|
|
|
848
1070
|
* - a Claude terminal session (#328) has no bridge — its label is
|
|
849
1071
|
* `VIBER_CHANNEL_LABEL` (set by buildClaudeLaunchSpec) — so fall back to it.
|
|
850
1072
|
*/
|
|
851
|
-
export function agentIdentityFromEnv(): string {
|
|
1073
|
+
export function agentIdentityFromEnv(budget?: number): string {
|
|
852
1074
|
const explicit = process.env.VIBER_AGENT_NAME_EXPLICIT === "1";
|
|
853
1075
|
// A BLANK bridge label counts as absent (defense in depth, #328): the terminal
|
|
854
1076
|
// launcher wipes an inherited VIBER_CODEX_BRIDGE_LABEL to "", so a Claude
|
|
@@ -859,6 +1081,10 @@ export function agentIdentityFromEnv(): string {
|
|
|
859
1081
|
return buildAgentIdentityInstructions({
|
|
860
1082
|
explicitName: explicit ? label : undefined,
|
|
861
1083
|
role: process.env.VIBER_AGENT_ROLE,
|
|
1084
|
+
// #479: the MCP surface passes IDENTITY_BUDGET so the line cannot eat the
|
|
1085
|
+
// channel contract. The bridges pass nothing — their developer instructions
|
|
1086
|
+
// are not truncated, so a full role is safe there.
|
|
1087
|
+
budget,
|
|
862
1088
|
});
|
|
863
1089
|
}
|
|
864
1090
|
|
|
@@ -869,6 +1095,8 @@ export interface BridgeIdentity {
|
|
|
869
1095
|
instanceKey: string;
|
|
870
1096
|
instanceToken: string;
|
|
871
1097
|
bridgeLock: BridgeLock;
|
|
1098
|
+
/** #480 — host announced at register, when this call actually registered. */
|
|
1099
|
+
apiBaseUrlAnnounced?: string;
|
|
872
1100
|
}
|
|
873
1101
|
|
|
874
1102
|
/**
|
|
@@ -909,7 +1137,10 @@ export async function acquireBridgeInstance(opts: {
|
|
|
909
1137
|
* both await an invite. Mirrors the Codex bridge's acquireBridgeIdentity, generic.
|
|
910
1138
|
*/
|
|
911
1139
|
export async function acquireBridgeIdentity(opts: {
|
|
1140
|
+
/** Transport host — what we fetch (#480). */
|
|
912
1141
|
baseUrl: string;
|
|
1142
|
+
/** Logical backend identity — keys the bridge lock (#480). */
|
|
1143
|
+
identityBaseUrl: string;
|
|
913
1144
|
auth: AuthJson;
|
|
914
1145
|
fingerprint: string;
|
|
915
1146
|
label: string;
|
|
@@ -919,17 +1150,22 @@ export async function acquireBridgeIdentity(opts: {
|
|
|
919
1150
|
sessionTag: string;
|
|
920
1151
|
reuseAuthInstance: boolean;
|
|
921
1152
|
}): Promise<BridgeIdentity> {
|
|
922
|
-
const {
|
|
1153
|
+
const { identityBaseUrl, fingerprint, lockDir, logPrefix, sessionTag } = opts;
|
|
923
1154
|
mkdirSync(lockDir, { recursive: true });
|
|
924
1155
|
const acquired = await acquireBridgeInstance(opts);
|
|
925
1156
|
const bridgeLock = acquireBridgeLock({
|
|
926
|
-
|
|
1157
|
+
identityBaseUrl,
|
|
927
1158
|
fingerprint,
|
|
928
1159
|
sessionId: `${sessionTag}-agent:${acquired.instance_key}`,
|
|
929
1160
|
lockDir,
|
|
930
1161
|
logPrefix,
|
|
931
1162
|
});
|
|
932
|
-
return {
|
|
1163
|
+
return {
|
|
1164
|
+
instanceKey: acquired.instance_key,
|
|
1165
|
+
instanceToken: acquired.instance_token,
|
|
1166
|
+
bridgeLock,
|
|
1167
|
+
apiBaseUrlAnnounced: acquired.api_base_url,
|
|
1168
|
+
};
|
|
933
1169
|
}
|
|
934
1170
|
|
|
935
1171
|
/** Live helpers a runtime adapter uses inside onStart (e.g. the Codex tool host). */
|
|
@@ -947,7 +1183,10 @@ export interface BridgeHelpers {
|
|
|
947
1183
|
|
|
948
1184
|
/** A runtime's adapter for the shared await-invite bridge. */
|
|
949
1185
|
export interface AwaitInviteAdapter {
|
|
950
|
-
|
|
1186
|
+
/** #480 — API host as state (see ConversationStreamDeps.api). */
|
|
1187
|
+
api: ApiBaseUrlResolver;
|
|
1188
|
+
/** Logical backend identity — keys the lock + session handle (#480). */
|
|
1189
|
+
identityBaseUrl: string;
|
|
951
1190
|
lockDir: string;
|
|
952
1191
|
logPrefix: string;
|
|
953
1192
|
/** Lock + session-handle tag distinguishing runtimes (codex / gemma). */
|
|
@@ -978,16 +1217,25 @@ export interface AwaitInviteAdapter {
|
|
|
978
1217
|
* makeRunTurn). Throws BridgeShutdownError so the caller maps it to an exit code.
|
|
979
1218
|
*/
|
|
980
1219
|
export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise<void> {
|
|
981
|
-
const {
|
|
1220
|
+
const { api, identityBaseUrl, lockDir, logPrefix, sessionTag, label, kind, options, auth, fingerprint, makeRunTurn, onStart, onShutdown } = adapter;
|
|
1221
|
+
const baseUrl = (): string => api.current();
|
|
982
1222
|
const shutdown = new AbortController();
|
|
983
1223
|
let bridgeLock: BridgeLock | null = null;
|
|
984
1224
|
let controlStream: PersistentControlStream | null = null;
|
|
985
1225
|
const activeStreams = new Map<string, AbortController>();
|
|
1226
|
+
// Persistent per-conversation watermarks (#429). Lives for the WHOLE bridge run
|
|
1227
|
+
// (never cleared per-stream) so a conversation's watermark survives the
|
|
1228
|
+
// abort+restart on re-join. Bounded growth: one `{lastId}` per convId seen —
|
|
1229
|
+
// acceptable (consensus reviewers, step-01 invariant).
|
|
1230
|
+
const watermarks = new Map<string, BridgeWatermark>();
|
|
986
1231
|
const streamTasks = new Set<Promise<void>>();
|
|
987
1232
|
let voiceBaseUrl = "";
|
|
988
1233
|
let instanceKey = "";
|
|
989
1234
|
|
|
990
1235
|
const startConversation = (minted: ConversationMintResponse): void => {
|
|
1236
|
+
// A pushed join is the only announcement a bridge that reuses a persisted
|
|
1237
|
+
// token ever sees (no register call ⇒ nothing announced there).
|
|
1238
|
+
api.offer(minted.api_base_url);
|
|
991
1239
|
if (!voiceBaseUrl) voiceBaseUrl = minted.ws_url;
|
|
992
1240
|
process.stderr.write(`${logPrefix} join pushed for ${minted.conversation_id} — starting stream\n`);
|
|
993
1241
|
// `let task!` (not const): the `.finally` closure reads `task`; with a const
|
|
@@ -998,8 +1246,10 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
|
|
|
998
1246
|
instanceKey,
|
|
999
1247
|
options,
|
|
1000
1248
|
activeStreams,
|
|
1249
|
+
watermarks,
|
|
1001
1250
|
parentSignal: shutdown.signal,
|
|
1002
|
-
|
|
1251
|
+
api,
|
|
1252
|
+
identityBaseUrl,
|
|
1003
1253
|
lockDir,
|
|
1004
1254
|
logPrefix,
|
|
1005
1255
|
sessionTag,
|
|
@@ -1021,11 +1271,14 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
|
|
|
1021
1271
|
|
|
1022
1272
|
try {
|
|
1023
1273
|
const identity = await acquireBridgeIdentity({
|
|
1024
|
-
baseUrl, auth, fingerprint, label, kind, lockDir, logPrefix, sessionTag,
|
|
1274
|
+
baseUrl: baseUrl(), identityBaseUrl, auth, fingerprint, label, kind, lockDir, logPrefix, sessionTag,
|
|
1025
1275
|
reuseAuthInstance: options.reuseAuthInstance ?? false,
|
|
1026
1276
|
});
|
|
1027
1277
|
bridgeLock = identity.bridgeLock;
|
|
1028
1278
|
instanceKey = identity.instanceKey;
|
|
1279
|
+
// #480: adopt BEFORE the control stream and the heartbeat start — they are
|
|
1280
|
+
// this bridge's two longest-lived flows and the whole point of the change.
|
|
1281
|
+
api.offer(identity.apiBaseUrlAnnounced);
|
|
1029
1282
|
|
|
1030
1283
|
const helpers: BridgeHelpers = {
|
|
1031
1284
|
startConversation,
|
|
@@ -1039,7 +1292,9 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
|
|
|
1039
1292
|
|
|
1040
1293
|
process.stderr.write(`${logPrefix} await-invite: control stream open, waiting for joins (instance ${identity.instanceKey.slice(0, 8)})\n`);
|
|
1041
1294
|
controlStream = runPersistentControlStream(
|
|
1042
|
-
|
|
1295
|
+
// Evaluated here, i.e. AFTER api.offer() above — so the stream opens on
|
|
1296
|
+
// the announced host rather than the bootstrap one.
|
|
1297
|
+
{ getBaseUrl: baseUrl, instanceId: identity.instanceKey, instanceToken: identity.instanceToken, signal: shutdown.signal },
|
|
1043
1298
|
{
|
|
1044
1299
|
log: (m) => process.stderr.write(m),
|
|
1045
1300
|
onJoin: (minted) => startConversation(minted),
|
|
@@ -1052,12 +1307,12 @@ export async function runAwaitInviteBridge(adapter: AwaitInviteAdapter): Promise
|
|
|
1052
1307
|
);
|
|
1053
1308
|
},
|
|
1054
1309
|
onBeatNow: () => {
|
|
1055
|
-
void sendInstanceHeartbeat(baseUrl, identity.instanceKey, identity.instanceToken);
|
|
1310
|
+
void sendInstanceHeartbeat(baseUrl(), identity.instanceKey, identity.instanceToken);
|
|
1056
1311
|
},
|
|
1057
1312
|
},
|
|
1058
1313
|
);
|
|
1059
1314
|
const ihb = startInstanceHeartbeat({
|
|
1060
|
-
baseUrl,
|
|
1315
|
+
getBaseUrl: baseUrl,
|
|
1061
1316
|
instanceId: identity.instanceKey,
|
|
1062
1317
|
getInstanceToken: () => identity.instanceToken,
|
|
1063
1318
|
});
|
package/lib/bridge_tool_host.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type AgentToolsContext,
|
|
30
30
|
type AgentToolResult,
|
|
31
31
|
} from "./agent_tools.js";
|
|
32
|
+
import { CONTRACT_STYLE_NEUTRAL, CONTRACT_STYLE_TOOLS } from "./channel_instructions.js";
|
|
32
33
|
|
|
33
34
|
/** The three channel tools, named so codex (and the elicitation whitelist) match. */
|
|
34
35
|
export const BRIDGE_TOOL_NAMES = ["send_message", "list_agents", "message_agent"] as const;
|
|
@@ -44,17 +45,32 @@ export const TOOL_DEFS = [
|
|
|
44
45
|
{
|
|
45
46
|
name: "send_message",
|
|
46
47
|
description:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
"Calling this
|
|
48
|
+
// #479 codex review: this used to claim send_message was "the ONLY way to
|
|
49
|
+
// reach the user". False on THIS surface — the bridge auto-posts a turn's
|
|
50
|
+
// text when no outbound tool succeeded, and the system prompt says so. Two
|
|
51
|
+
// contradictory statements about delivery is exactly the defect #479 fixes
|
|
52
|
+
// elsewhere, so the wording is non-exclusive here.
|
|
53
|
+
"Reply to the USER by posting into the current Viber conversation. Your runtime/stdout is NOT the user — never treat it as the reply. " +
|
|
54
|
+
"Calling this delivers your reply for the turn (and suppresses the bridge's auto-post fallback). " +
|
|
55
|
+
// #479 volet 2: the SAME contract fragments Claude gets. They used to exist
|
|
56
|
+
// only in the MCP `instructions` blob, which never reaches codex/gemma at all
|
|
57
|
+
// — so NO EMOJI and the literal-identifier rule were simply absent here.
|
|
58
|
+
CONTRACT_STYLE_NEUTRAL +
|
|
59
|
+
" " +
|
|
60
|
+
CONTRACT_STYLE_TOOLS,
|
|
54
61
|
inputSchema: {
|
|
55
62
|
type: "object",
|
|
56
63
|
properties: {
|
|
57
|
-
|
|
64
|
+
// #479: this used to read "Plain sentences." — a leftover of the retired
|
|
65
|
+
// "no markdown" rule, contradicting the description right above it, which
|
|
66
|
+
// asks for light Markdown. The contract has ONE wording now.
|
|
67
|
+
text: {
|
|
68
|
+
type: "string",
|
|
69
|
+
description:
|
|
70
|
+
"Spoken/conversational reply: read aloud AND shown as Markdown. Short and natural, light " +
|
|
71
|
+
"Markdown (short lists, **bold**) for readability. Actions and questions go here, visibly. " +
|
|
72
|
+
"NO emoji (it is read aloud). Write identifiers LITERALLY (288, auth.json).",
|
|
73
|
+
},
|
|
58
74
|
artifact: {
|
|
59
75
|
type: "object",
|
|
60
76
|
properties: {
|