@koda-sl/baker-bridge 0.107.0 → 0.108.0-dev.0306d9ad9
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 +31 -29
- package/dist/hono/agent-session.d.ts +1 -0
- package/dist/hono/agent-session.d.ts.map +1 -1
- package/dist/hono/agent-session.js +13 -2
- package/dist/hono/agent-session.js.map +1 -1
- package/dist/hono/agent-session.test.js +43 -6
- package/dist/hono/agent-session.test.js.map +1 -1
- package/dist/hono/agent-tasks.d.ts.map +1 -1
- package/dist/hono/agent-tasks.js +7 -3
- package/dist/hono/agent-tasks.js.map +1 -1
- package/dist/hono/agent-tasks.test.js +15 -0
- package/dist/hono/agent-tasks.test.js.map +1 -1
- package/dist/hono/agent.d.ts.map +1 -1
- package/dist/hono/agent.js +9 -4
- package/dist/hono/agent.js.map +1 -1
- package/dist/hono/agent.test.js +9 -9
- package/dist/hono/avatar-casting.js +2 -2
- package/dist/hono/avatar-casting.js.map +1 -1
- package/dist/hono/bridge-job.d.ts +20 -15
- package/dist/hono/bridge-job.d.ts.map +1 -1
- package/dist/hono/bridge-job.js +110 -138
- package/dist/hono/bridge-job.js.map +1 -1
- package/dist/hono/bridge-job.test.js +76 -175
- package/dist/hono/bridge-job.test.js.map +1 -1
- package/dist/hono/convex.d.ts +0 -1
- package/dist/hono/convex.d.ts.map +1 -1
- package/dist/hono/convex.js +0 -4
- package/dist/hono/convex.js.map +1 -1
- package/dist/hono/convex.test.js +3 -3
- package/dist/hono/mcp-servers.d.ts +2 -0
- package/dist/hono/mcp-servers.d.ts.map +1 -1
- package/dist/hono/mcp-servers.js +17 -16
- package/dist/hono/mcp-servers.js.map +1 -1
- package/dist/hono/mcp-servers.test.js +10 -38
- package/dist/hono/mcp-servers.test.js.map +1 -1
- package/dist/hono/model-capabilities.d.ts.map +1 -1
- package/dist/hono/model-capabilities.js +28 -19
- package/dist/hono/model-capabilities.js.map +1 -1
- package/dist/hono/model-capabilities.test.js +37 -15
- package/dist/hono/model-capabilities.test.js.map +1 -1
- package/dist/hono/relay.d.ts +1 -2
- package/dist/hono/relay.d.ts.map +1 -1
- package/dist/hono/relay.js +0 -3
- package/dist/hono/relay.js.map +1 -1
- package/dist/hono/relay.test.js +6 -22
- package/dist/hono/relay.test.js.map +1 -1
- package/dist/hono/session-continuity.integration.test.js +4 -4
- package/dist/hono/tag-fields.js +1 -1
- package/dist/hono/tag-fields.js.map +1 -1
- package/dist/hono/tag-input.js +1 -1
- package/dist/hono/tag-input.js.map +1 -1
- package/dist/hono/turn-events.d.ts +1 -1
- package/dist/hono/turn-events.d.ts.map +1 -1
- package/dist/hono/turn-events.js.map +1 -1
- package/dist/review/digest.d.ts +1 -7
- package/dist/review/digest.d.ts.map +1 -1
- package/dist/review/digest.js +16 -80
- package/dist/review/digest.js.map +1 -1
- package/dist/review/digest.test.js +0 -83
- package/dist/review/digest.test.js.map +1 -1
- package/package.json +1 -2
package/dist/hono/bridge-job.js
CHANGED
|
@@ -71,6 +71,13 @@ export function remapAnswersToQuestionText(questions, answers) {
|
|
|
71
71
|
// Nothing mapped (unexpected shape) → pass the originals through untouched.
|
|
72
72
|
return Object.keys(byText).length > 0 ? byText : answers;
|
|
73
73
|
}
|
|
74
|
+
// The CLI output style every turn runs under, for `settings.outputStyle`.
|
|
75
|
+
//
|
|
76
|
+
// Exported because the CLI accepts an unrecognized style name silently — a
|
|
77
|
+
// misspelling here would leave every chat on the default voice with nothing to
|
|
78
|
+
// notice — so a test pins the literal. Built into the CLI since 2.1.237, which
|
|
79
|
+
// is why the SDK floor is 0.3.237.
|
|
80
|
+
export const OUTPUT_STYLE = "Concise";
|
|
74
81
|
const DEFAULT_ASK_USER_QUESTION_TIMEOUT_MS = 15 * 60 * 1000;
|
|
75
82
|
// A model request that streams nothing for this long is a hung upstream
|
|
76
83
|
// connection (observed in production: an OpenRouter request that never yields
|
|
@@ -151,10 +158,11 @@ const BASE_DISALLOWED_TOOLS = [
|
|
|
151
158
|
// not asked to fund a research fleet. Ordinary fan-out is unaffected:
|
|
152
159
|
// `Task` stays, so the scaffold's subagents still run.
|
|
153
160
|
"Workflow",
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
161
|
+
// NOT listed: `SendMessage`. It is how the model follows up with a
|
|
162
|
+
// subagent it already ran, and the CLI tells it to on every Agent result.
|
|
163
|
+
// The continued subagent runs in the background, but it announces itself
|
|
164
|
+
// with `task_started` / `background_tasks_changed`, so the turn stays open
|
|
165
|
+
// until it finishes (`createTurnEndController`).
|
|
158
166
|
// A Session review may not change anything. Removing the definitions is
|
|
159
167
|
// the second of three layers — the key it carries is read-scoped and its
|
|
160
168
|
// Bash calls go through `reviewBashPolicy` — because each fails
|
|
@@ -323,11 +331,6 @@ function isUserVisibleSdkMessage(msg) {
|
|
|
323
331
|
// failed turn clears the session, so sending again genuinely does start from the
|
|
324
332
|
// shorter rehydrated history — which is the action this asks for.
|
|
325
333
|
const CONTEXT_OVERFLOW_MESSAGE = "This chat has grown too long to continue in one step. Send your message again — it will pick up from a shorter version of this conversation.";
|
|
326
|
-
// What the user reads when the model refused the request and no fallback model
|
|
327
|
-
// retried it. The CLI's own refusal text speaks to a terminal user ("press esc"),
|
|
328
|
-
// so the turn ends on this instead. The refused exchange is not saved to the
|
|
329
|
-
// session, so a rephrased message starts from the conversation before it.
|
|
330
|
-
const MODEL_REFUSAL_MESSAGE = "I couldn't help with that request. Try rephrasing what you need.";
|
|
331
334
|
function userFacingTurnError(message) {
|
|
332
335
|
if (isContextOverflowError(message)) {
|
|
333
336
|
return CONTEXT_OVERFLOW_MESSAGE;
|
|
@@ -467,10 +470,6 @@ function sdkResultErrorMessage(msg) {
|
|
|
467
470
|
const message = msg.subtype === "success" ? sdkSuccessResultErrorMessage(msg) : msg.errors.join("; ");
|
|
468
471
|
return message.length > 0 ? message : "SDK result error";
|
|
469
472
|
}
|
|
470
|
-
/** The result's error, unless a refusal already decided how the turn ends. */
|
|
471
|
-
function turnResultError(state, msg) {
|
|
472
|
-
return state.refusedWithoutFallback ? undefined : sdkResultError(msg);
|
|
473
|
-
}
|
|
474
473
|
function sdkResultError(msg) {
|
|
475
474
|
if (msg.type !== "result" || !msg.is_error) {
|
|
476
475
|
return undefined;
|
|
@@ -498,9 +497,9 @@ function sdkResultError(msg) {
|
|
|
498
497
|
// the whole turn; the deny THRESHOLD below still guards a one-off end-of-turn race.
|
|
499
498
|
// The trailing colon matters: the CLI always emits the envelope followed by the
|
|
500
499
|
// error that caused it ("…failed: Error: Stream closed", "…failed: AbortError: …").
|
|
501
|
-
// Prose that merely NAMES the failure quotes it without one, which is how
|
|
502
|
-
//
|
|
503
|
-
// block is an is_error tool_result, so a colon-less match counted a healthy
|
|
500
|
+
// Prose that merely NAMES the failure quotes it without one, which is how a
|
|
501
|
+
// hook explaining a block quotes it (the scaffold's former background-subagent
|
|
502
|
+
// block did) — and a hook block is an is_error tool_result, so a colon-less match counted a healthy
|
|
504
503
|
// blocked spawn as a deny and could trip the breaker on a healthy turn.
|
|
505
504
|
const CONTROL_CHANNEL_DENY_MARKER = "Tool permission request failed:";
|
|
506
505
|
// Hook failures are reported through the same is_error tool_result channel but
|
|
@@ -663,9 +662,27 @@ export function createStallDetector(input) {
|
|
|
663
662
|
awaitingModel = (msg.type === "user" || msg.type === "stream_event") && pendingToolUses.size === 0;
|
|
664
663
|
},
|
|
665
664
|
stalled: () => stalled,
|
|
665
|
+
hasPendingToolUses: () => pendingToolUses.size > 0,
|
|
666
666
|
dispose: () => clearInterval(timer),
|
|
667
667
|
};
|
|
668
668
|
}
|
|
669
|
+
// A query's cost and `modelUsage` are cumulative — on every result message and
|
|
670
|
+
// on the CLI's usage reply alike — so the latest reading replaces the last one
|
|
671
|
+
// rather than adding to it. It is folded into the turn totals when the query
|
|
672
|
+
// settles (see `runQueryOnce`). A spuriously empty later reading never wipes a
|
|
673
|
+
// real earlier one.
|
|
674
|
+
function recordCumulativeUsage(state, reading) {
|
|
675
|
+
const own = ownQueryUsage(reading, state.queryUsageBaseline);
|
|
676
|
+
state.observedCostUsd = own.costUsd;
|
|
677
|
+
const snapshot = queryModelUsage(own.modelUsage);
|
|
678
|
+
if (snapshot.size > 0) {
|
|
679
|
+
state.queryModelUsage = snapshot;
|
|
680
|
+
}
|
|
681
|
+
return own.costUsd;
|
|
682
|
+
}
|
|
683
|
+
// How long ending a turn waits on the CLI's usage reply before going ahead
|
|
684
|
+
// without it. Billing a turn late is recoverable; hanging one is not.
|
|
685
|
+
const USAGE_READING_TIMEOUT_MS = 5_000;
|
|
669
686
|
// Decides when the turn is really over and the CLI may be told to exit.
|
|
670
687
|
//
|
|
671
688
|
// The prompt rides a held-open input stream (see `createHeldOpenQuery` in
|
|
@@ -687,7 +704,10 @@ export function createStallDetector(input) {
|
|
|
687
704
|
// events must not, or the end would never re-arm. After input ends, an exit
|
|
688
705
|
// guard closes the query if the CLI neither speaks nor exits, so a wedged
|
|
689
706
|
// process cannot hang the turn until the session watchdog.
|
|
690
|
-
function createTurnEndController(job, query, closeQuery, timings, onTurnEvent
|
|
707
|
+
function createTurnEndController(job, query, closeQuery, timings, onTurnEvent,
|
|
708
|
+
// Runs once, before input ends on a turn that ran tasks: a task can settle
|
|
709
|
+
// without waking the model, so the last result may not include what it spent.
|
|
710
|
+
beforeEndInput) {
|
|
691
711
|
const inFlightTasks = new Set();
|
|
692
712
|
// The background_tasks_changed LEVEL signal, tracked separately from the
|
|
693
713
|
// edge-paired set above: a foreground command the CLI auto-backgrounds emits
|
|
@@ -710,6 +730,21 @@ function createTurnEndController(job, query, closeQuery, timings, onTurnEvent) {
|
|
|
710
730
|
return;
|
|
711
731
|
}
|
|
712
732
|
inputEnded = true;
|
|
733
|
+
if (sawAnyTask && beforeEndInput) {
|
|
734
|
+
let timeout;
|
|
735
|
+
const timedOut = new Promise((resolveTimeout) => {
|
|
736
|
+
timeout = setTimeout(resolveTimeout, USAGE_READING_TIMEOUT_MS);
|
|
737
|
+
timeout.unref?.();
|
|
738
|
+
});
|
|
739
|
+
void Promise.race([beforeEndInput().catch(() => undefined), timedOut]).finally(() => {
|
|
740
|
+
clearTimeout(timeout);
|
|
741
|
+
finishEndInput();
|
|
742
|
+
});
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
finishEndInput();
|
|
746
|
+
};
|
|
747
|
+
const finishEndInput = () => {
|
|
713
748
|
query.endInput?.();
|
|
714
749
|
onTurnEvent({
|
|
715
750
|
threadId: job.threadId,
|
|
@@ -782,14 +817,11 @@ function createTurnEndController(job, query, closeQuery, timings, onTurnEvent) {
|
|
|
782
817
|
exitGuard.refresh();
|
|
783
818
|
}
|
|
784
819
|
},
|
|
785
|
-
maybeEndAfter: (msg
|
|
820
|
+
maybeEndAfter: (msg) => {
|
|
786
821
|
if (inputEnded) {
|
|
787
822
|
return;
|
|
788
823
|
}
|
|
789
|
-
|
|
790
|
-
// the turn ends), so it has to end input like a clean one or the CLI idles
|
|
791
|
-
// on stdin until the session watchdog.
|
|
792
|
-
if (msg.type === "result" && (!msg.is_error || refused)) {
|
|
824
|
+
if (msg.type === "result" && !msg.is_error) {
|
|
793
825
|
cleanResultSeen = true;
|
|
794
826
|
if (hasInFlightWork()) {
|
|
795
827
|
console.error(`[BridgeJob] result received with ${inFlightTasks.size} task(s) and ${backgroundTaskCount} background command(s) in flight — holding the turn open (thread ${job.threadId})`);
|
|
@@ -806,6 +838,7 @@ function createTurnEndController(job, query, closeQuery, timings, onTurnEvent) {
|
|
|
806
838
|
scheduleEnd();
|
|
807
839
|
}
|
|
808
840
|
},
|
|
841
|
+
hasInFlightWork,
|
|
809
842
|
dispose: () => {
|
|
810
843
|
if (settleTimer) {
|
|
811
844
|
clearTimeout(settleTimer);
|
|
@@ -1109,11 +1142,6 @@ export class BridgeJobExecutor {
|
|
|
1109
1142
|
await this.clearIfDirty(job, controls, state, "abort");
|
|
1110
1143
|
return { kind: "cancelled", costUsd, model: state.model, modelUsage };
|
|
1111
1144
|
}
|
|
1112
|
-
// No boundary is handed back, so the refused exchange never becomes part of
|
|
1113
|
-
// the conversation the next message resumes.
|
|
1114
|
-
if (state.refusedWithoutFallback) {
|
|
1115
|
-
return { kind: "errored", costUsd, model: state.model, modelUsage, errors: [MODEL_REFUSAL_MESSAGE] };
|
|
1116
|
-
}
|
|
1117
1145
|
if (!state.postedAssistantEvent && state.turnText.length > 0) {
|
|
1118
1146
|
await this.postSyntheticAssistantEvent(job, state);
|
|
1119
1147
|
}
|
|
@@ -1275,8 +1303,7 @@ export class BridgeJobExecutor {
|
|
|
1275
1303
|
// avatar's casting would never open.
|
|
1276
1304
|
const command = typeof input?.command === "string" ? input.command : undefined;
|
|
1277
1305
|
// The agent asking for the casting we already owe is not the NEXT piece of work — it
|
|
1278
|
-
// is that same casting,
|
|
1279
|
-
// user declined every portrait).
|
|
1306
|
+
// is that same casting, and the tool's own description tells it to call this here.
|
|
1280
1307
|
// Handled under its own tool-use id so the user's pick reaches the call that asked
|
|
1281
1308
|
// for it; run as an auto-cast instead, the explicit call fell through to `allow` and
|
|
1282
1309
|
// the tool answered itself with `{ status: "declined" }` — the agent was told the
|
|
@@ -1343,11 +1370,21 @@ export class BridgeJobExecutor {
|
|
|
1343
1370
|
const skills = await readSessionSkills(bridgeRuntimeFiles.projectRoot());
|
|
1344
1371
|
const autoCompactWindow = autoCompactWindowForModel(model);
|
|
1345
1372
|
const betas = betasForModel(model);
|
|
1346
|
-
// No `outputStyle`: the built-in `Concise` style was pinned here against Opus 5's
|
|
1347
|
-
// preamble, and on Opus 5.5 it suppressed the one-line progress notes a
|
|
1348
|
-
// marketer reads while a long turn works. When to speak is now the scaffold
|
|
1349
|
-
// CLAUDE.md's job ("Talking to the User").
|
|
1350
1373
|
const settings = {
|
|
1374
|
+
// Lead with the answer. The chat's readers are marketers, and the CLI's
|
|
1375
|
+
// default voice narrates its way to a result — preamble, a recap of what
|
|
1376
|
+
// it is about to do, then the thing itself. `Concise` cuts that without
|
|
1377
|
+
// touching how thoroughly the work gets done, and it keeps error reports
|
|
1378
|
+
// and destructive-action confirmations complete, which is the part a
|
|
1379
|
+
// shorter voice must not eat.
|
|
1380
|
+
//
|
|
1381
|
+
// Two limits to know before relying on it. It reaches the **main
|
|
1382
|
+
// conversation only** — a subagent runs its own system prompt, so the
|
|
1383
|
+
// fan-out workers are unaffected. And the CLI accepts an unrecognized
|
|
1384
|
+
// style name **silently**: `outputStyle: "NotARealStyle"` returns a clean
|
|
1385
|
+
// 200 with the default voice, so a typo here is invisible at runtime.
|
|
1386
|
+
// That is why the literal is pinned by a test rather than left to review.
|
|
1387
|
+
outputStyle: OUTPUT_STYLE,
|
|
1351
1388
|
// Publish opts out of the CLI's threshold-driven summarization so the turn
|
|
1352
1389
|
// starts working immediately instead of collapsing the chat first; a real
|
|
1353
1390
|
// overflow is caught in `runAttempt` and retried with compaction on. Only
|
|
@@ -1374,11 +1411,11 @@ export class BridgeJobExecutor {
|
|
|
1374
1411
|
// capacity, which also clamps the `autoCompactWindow` pin below to 200k. Not
|
|
1375
1412
|
// an API requirement — see `betasForModel`. Omitted for Haiku 4.5, genuinely 200k.
|
|
1376
1413
|
...(betas ? { betas } : {}),
|
|
1377
|
-
// How hard the model works per turn — thinking depth, preamble, and how
|
|
1378
|
-
// tool calls it makes.
|
|
1379
|
-
//
|
|
1380
|
-
//
|
|
1381
|
-
//
|
|
1414
|
+
// How hard the model works per turn — thinking depth, preamble, and how
|
|
1415
|
+
// many tool calls it makes. The API default is `high`; `medium` trades a
|
|
1416
|
+
// slice of that for fewer, more consolidated tool calls. That matters more
|
|
1417
|
+
// than the thinking saving alone: every tool call is another request that
|
|
1418
|
+
// re-reads the whole prompt, so cutting calls cuts cache-read volume too.
|
|
1382
1419
|
//
|
|
1383
1420
|
// MUST stay a single constant. The resolved effort is rendered into the
|
|
1384
1421
|
// prompt, so changing it between requests invalidates every cache
|
|
@@ -1407,12 +1444,6 @@ export class BridgeJobExecutor {
|
|
|
1407
1444
|
...reviewQueryOptions(job),
|
|
1408
1445
|
...(job.planMode ? { permissionMode: "plan" } : {}),
|
|
1409
1446
|
canUseTool: (toolName, input, opts) => this.routeToolUse(job, controls, toolName, input, opts),
|
|
1410
|
-
// No `fallbackModel`, and no server-side refusal `fallbacks` in the extra
|
|
1411
|
-
// body: the CLI's refusal fallback is gated on a first-party base URL (see
|
|
1412
|
-
// `betasForModel`), and nothing shows OpenRouter's passthrough honouring the
|
|
1413
|
-
// API's `fallbacks` field. A refusal therefore ends the turn on
|
|
1414
|
-
// `MODEL_REFUSAL_MESSAGE`; a `model_refusal_fallback`, if one ever arrives,
|
|
1415
|
-
// is still handled (its superseded messages are retracted).
|
|
1416
1447
|
model: modelForJob(job, model),
|
|
1417
1448
|
abortController: controls.abortController ?? { signal: controls.abortSignal },
|
|
1418
1449
|
// The SDK treats `env` as a full replacement for the subprocess environment
|
|
@@ -1494,7 +1525,16 @@ export class BridgeJobExecutor {
|
|
|
1494
1525
|
// in-progress session.
|
|
1495
1526
|
async drainQuery(job, controls, state, query, closeQuery) {
|
|
1496
1527
|
const stallDetector = this.createStallDetector(job, controls, closeQuery);
|
|
1497
|
-
|
|
1528
|
+
// What the query had spent when its turn ended, read from the CLI — set
|
|
1529
|
+
// only when a task ran, and higher than the last result's figure when a
|
|
1530
|
+
// task kept working after it.
|
|
1531
|
+
let settledCostUsd = 0;
|
|
1532
|
+
const turnEnd = createTurnEndController(job, query, closeQuery, { settleMs: this.turnEndSettleMs, exitGuardMs: this.turnEndExitGuardMs }, this.recordTurnEvent, async () => {
|
|
1533
|
+
const reading = await query.usage?.();
|
|
1534
|
+
if (reading) {
|
|
1535
|
+
settledCostUsd = recordCumulativeUsage(state, reading);
|
|
1536
|
+
}
|
|
1537
|
+
});
|
|
1498
1538
|
let costUsd = 0;
|
|
1499
1539
|
// Errors thrown while HANDLING a message (control-channel breaker, error
|
|
1500
1540
|
// results) are real verdicts and must surface as-is; only errors from the
|
|
@@ -1504,6 +1544,7 @@ export class BridgeJobExecutor {
|
|
|
1504
1544
|
for await (const msg of query) {
|
|
1505
1545
|
stallDetector.observeMessage(msg);
|
|
1506
1546
|
turnEnd.observeMessage(msg);
|
|
1547
|
+
controls.setWorkInFlight?.(stallDetector.hasPendingToolUses() || turnEnd.hasInFlightWork());
|
|
1507
1548
|
try {
|
|
1508
1549
|
costUsd = await this.consumeSdkMessage(job, controls, state, msg, costUsd);
|
|
1509
1550
|
}
|
|
@@ -1511,7 +1552,7 @@ export class BridgeJobExecutor {
|
|
|
1511
1552
|
consumeError = true;
|
|
1512
1553
|
throw error;
|
|
1513
1554
|
}
|
|
1514
|
-
turnEnd.maybeEndAfter(msg
|
|
1555
|
+
turnEnd.maybeEndAfter(msg);
|
|
1515
1556
|
if (!controls.isActive()) {
|
|
1516
1557
|
closeQuery();
|
|
1517
1558
|
break;
|
|
@@ -1526,6 +1567,7 @@ export class BridgeJobExecutor {
|
|
|
1526
1567
|
throw error;
|
|
1527
1568
|
}
|
|
1528
1569
|
finally {
|
|
1570
|
+
controls.setWorkInFlight?.(false);
|
|
1529
1571
|
stallDetector.dispose();
|
|
1530
1572
|
turnEnd.dispose();
|
|
1531
1573
|
await this.openPendingCastingAtTurnEnd(job, controls);
|
|
@@ -1533,7 +1575,7 @@ export class BridgeJobExecutor {
|
|
|
1533
1575
|
if (stallDetector.stalled() && controls.isActive()) {
|
|
1534
1576
|
throw new StreamStallError();
|
|
1535
1577
|
}
|
|
1536
|
-
return costUsd;
|
|
1578
|
+
return Math.max(costUsd, settledCostUsd);
|
|
1537
1579
|
}
|
|
1538
1580
|
/**
|
|
1539
1581
|
* Open a casting the turn recorded but never reached.
|
|
@@ -1608,7 +1650,23 @@ export class BridgeJobExecutor {
|
|
|
1608
1650
|
return costUsd;
|
|
1609
1651
|
}
|
|
1610
1652
|
controls.markEvent?.();
|
|
1611
|
-
|
|
1653
|
+
// Drop the size refusal outright. A recovered turn must not leave a raw
|
|
1654
|
+
// provider error sitting above the work that then succeeded, and a turn that
|
|
1655
|
+
// fails anyway says so once, in product language, through the error outcome —
|
|
1656
|
+
// never twice, and never in the provider's words. Dropping it before
|
|
1657
|
+
// `observeInProgressSession` also keeps it from setting `resumeSessionAt` —
|
|
1658
|
+
// the refusal is not work the agent did, and counting it as such would send
|
|
1659
|
+
// the retry a "continue where you left off" note instead of the `/publish`
|
|
1660
|
+
// the turn still owes.
|
|
1661
|
+
if (isContextOverflowRefusal(msg)) {
|
|
1662
|
+
// Today the refusal is synthetic — the request is rejected pre-flight, so
|
|
1663
|
+
// no deltas precede it and `turnText` is empty. Clearing it anyway is what
|
|
1664
|
+
// makes the drop hold if that ever changes: streamed text survives the
|
|
1665
|
+
// dropped message, and a retry that ends on tool work with no assistant
|
|
1666
|
+
// text of its own would post the refusal as this turn's end-of-turn text
|
|
1667
|
+
// (`postSyntheticAssistantEvent`). The live preview needs no such guard —
|
|
1668
|
+
// the next `message_start` restarts it and the turn's end clears it.
|
|
1669
|
+
state.turnText = "";
|
|
1612
1670
|
return costUsd;
|
|
1613
1671
|
}
|
|
1614
1672
|
state.inProgressSession = observeInProgressSession(state.inProgressSession, msg);
|
|
@@ -1626,17 +1684,7 @@ export class BridgeJobExecutor {
|
|
|
1626
1684
|
}
|
|
1627
1685
|
trackMainRequestUsage(state, msg);
|
|
1628
1686
|
if (msg.type === "result") {
|
|
1629
|
-
const
|
|
1630
|
-
state.observedCostUsd = own.costUsd;
|
|
1631
|
-
// A query's result `modelUsage` is cumulative (like `total_cost_usd`, which
|
|
1632
|
-
// we overwrite above), so keep the latest snapshot rather than summing —
|
|
1633
|
-
// it is folded into the turn totals when the query settles (see
|
|
1634
|
-
// `runQueryOnce`). Guard against a spuriously empty later result wiping a
|
|
1635
|
-
// real earlier one.
|
|
1636
|
-
const snapshot = queryModelUsage(own.modelUsage);
|
|
1637
|
-
if (snapshot.size > 0) {
|
|
1638
|
-
state.queryModelUsage = snapshot;
|
|
1639
|
-
}
|
|
1687
|
+
const ownCostUsd = recordCumulativeUsage(state, { totalCostUsd: msg.total_cost_usd, modelUsage: msg.modelUsage });
|
|
1640
1688
|
await this.publishContextUsage(job, state, msg.modelUsage);
|
|
1641
1689
|
// What a Session review returns. The SDK enforces the JSON schema, so this
|
|
1642
1690
|
// either parses against the findings contract or the review failed to
|
|
@@ -1646,14 +1694,12 @@ export class BridgeJobExecutor {
|
|
|
1646
1694
|
if (structured !== undefined) {
|
|
1647
1695
|
state.structuredOutput = structured;
|
|
1648
1696
|
}
|
|
1649
|
-
|
|
1650
|
-
// how the turn ends, and in product words rather than the CLI's.
|
|
1651
|
-
const resultError = turnResultError(state, msg);
|
|
1697
|
+
const resultError = sdkResultError(msg);
|
|
1652
1698
|
if (resultError) {
|
|
1653
1699
|
await this.clearIfDirty(job, controls, state, "result_error");
|
|
1654
1700
|
throw resultError;
|
|
1655
1701
|
}
|
|
1656
|
-
return
|
|
1702
|
+
return ownCostUsd;
|
|
1657
1703
|
}
|
|
1658
1704
|
if (isUserVisibleSdkMessage(msg)) {
|
|
1659
1705
|
state.sawUserVisibleSdkMessage = true;
|
|
@@ -1687,7 +1733,7 @@ export class BridgeJobExecutor {
|
|
|
1687
1733
|
* turn (one per tool call, plus any same-model subagent work), so a handful of
|
|
1688
1734
|
* tool calls inflates them past the window and pins the meter at 100% on an
|
|
1689
1735
|
* almost-empty chat. The window is the model's real OpenRouter context length
|
|
1690
|
-
* (
|
|
1736
|
+
* (opus/sonnet 4.6 = 1M); we resolve it from the model id because the runtime
|
|
1691
1737
|
* under-reports it through the OpenRouter passthrough, falling back to the
|
|
1692
1738
|
* reported window for unknown models. A relay failure must never fail the turn.
|
|
1693
1739
|
*/
|
|
@@ -1743,61 +1789,6 @@ export class BridgeJobExecutor {
|
|
|
1743
1789
|
await this.relay.assistantEvent(target, msg, state.model);
|
|
1744
1790
|
}
|
|
1745
1791
|
}
|
|
1746
|
-
/**
|
|
1747
|
-
* A message consumed here and never relayed or counted as the agent's work:
|
|
1748
|
-
* a provider refusal for size, or part of a model refusal.
|
|
1749
|
-
*/
|
|
1750
|
-
droppedBeforeRelay(job, state, msg) {
|
|
1751
|
-
// Drop the size refusal outright. A recovered turn must not leave a raw
|
|
1752
|
-
// provider error sitting above the work that then succeeded, and a turn that
|
|
1753
|
-
// fails anyway says so once, in product language, through the error outcome —
|
|
1754
|
-
// never twice, and never in the provider's words. Dropping it before
|
|
1755
|
-
// `observeInProgressSession` also keeps it from setting `resumeSessionAt` —
|
|
1756
|
-
// the refusal is not work the agent did, and counting it as such would send
|
|
1757
|
-
// the retry a "continue where you left off" note instead of the `/publish`
|
|
1758
|
-
// the turn still owes.
|
|
1759
|
-
if (isContextOverflowRefusal(msg)) {
|
|
1760
|
-
// Today the refusal is synthetic — the request is rejected pre-flight, so
|
|
1761
|
-
// no deltas precede it and `turnText` is empty. Clearing it anyway is what
|
|
1762
|
-
// makes the drop hold if that ever changes: streamed text survives the
|
|
1763
|
-
// dropped message, and a retry that ends on tool work with no assistant
|
|
1764
|
-
// text of its own would post the refusal as this turn's end-of-turn text
|
|
1765
|
-
// (`postSyntheticAssistantEvent`). The live preview needs no such guard —
|
|
1766
|
-
// the next `message_start` restarts it and the turn's end clears it.
|
|
1767
|
-
state.turnText = "";
|
|
1768
|
-
return true;
|
|
1769
|
-
}
|
|
1770
|
-
return this.observeModelRefusal(job, state, msg);
|
|
1771
|
-
}
|
|
1772
|
-
/**
|
|
1773
|
-
* A refusal with no fallback model to retry it. True when the message is part
|
|
1774
|
-
* of the refusal — the CLI's error frame (`stop_reason: "refusal"`, written for
|
|
1775
|
-
* a terminal user) or the refusal notice — and is consumed here, never relayed.
|
|
1776
|
-
*/
|
|
1777
|
-
observeModelRefusal(job, state, msg) {
|
|
1778
|
-
if (msg.type === "assistant") {
|
|
1779
|
-
if (msg.message.stop_reason === "refusal") {
|
|
1780
|
-
return true;
|
|
1781
|
-
}
|
|
1782
|
-
if (!msg.parent_tool_use_id) {
|
|
1783
|
-
state.refusedWithoutFallback = false;
|
|
1784
|
-
}
|
|
1785
|
-
return false;
|
|
1786
|
-
}
|
|
1787
|
-
if (msg.type !== "system" || msg.subtype !== "model_refusal_no_fallback") {
|
|
1788
|
-
return false;
|
|
1789
|
-
}
|
|
1790
|
-
state.refusedWithoutFallback = true;
|
|
1791
|
-
const category = msg.api_refusal_category ?? null;
|
|
1792
|
-
console.error(`[BridgeJob] model refused with no fallback (category: ${category ?? "none"}, model: ${msg.original_model}) for thread ${job.threadId}`);
|
|
1793
|
-
this.recordTurnEvent({
|
|
1794
|
-
threadId: job.threadId,
|
|
1795
|
-
turnId: job.turnId,
|
|
1796
|
-
event: "model_refusal",
|
|
1797
|
-
detail: { category, model: msg.original_model, fallback: false },
|
|
1798
|
-
});
|
|
1799
|
-
return true;
|
|
1800
|
-
}
|
|
1801
1792
|
async handleSystemMessage(state, msg, target) {
|
|
1802
1793
|
// Relay the compaction lifecycle so the dashboard can show a spinner then a
|
|
1803
1794
|
// summary notice. This fires for both a manual `/compact` (which otherwise
|
|
@@ -1821,25 +1812,6 @@ export class BridgeJobExecutor {
|
|
|
1821
1812
|
await this.relay.compaction(target, "done");
|
|
1822
1813
|
return;
|
|
1823
1814
|
}
|
|
1824
|
-
// A fallback model retried a refused request. The refused leg's messages
|
|
1825
|
-
// were already relayed; the SDK names them so they can leave the chat
|
|
1826
|
-
// instead of sitting above the answer that replaced them.
|
|
1827
|
-
if (msg.type === "system" && msg.subtype === "model_refusal_fallback") {
|
|
1828
|
-
this.recordTurnEvent({
|
|
1829
|
-
...target,
|
|
1830
|
-
event: "model_refusal",
|
|
1831
|
-
detail: {
|
|
1832
|
-
category: msg.api_refusal_category ?? null,
|
|
1833
|
-
model: msg.original_model,
|
|
1834
|
-
fallback: msg.fallback_model,
|
|
1835
|
-
},
|
|
1836
|
-
});
|
|
1837
|
-
const retracted = msg.retracted_message_uuids ?? [];
|
|
1838
|
-
if (retracted.length > 0) {
|
|
1839
|
-
await this.relay.retractEvents(target, retracted);
|
|
1840
|
-
}
|
|
1841
|
-
return;
|
|
1842
|
-
}
|
|
1843
1815
|
const agentTasks = applyTaskLifecycleMessage(state.agentTasks, msg, this.now());
|
|
1844
1816
|
if (agentTasks) {
|
|
1845
1817
|
state.agentTasks = agentTasks;
|