@koda-sl/baker-bridge 0.108.0-dev.0306d9ad9 → 0.108.0-dev.f7c15fa76
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 +42 -31
- package/dist/hono/agent-session.d.ts +0 -1
- package/dist/hono/agent-session.d.ts.map +1 -1
- package/dist/hono/agent-session.js +2 -13
- package/dist/hono/agent-session.js.map +1 -1
- package/dist/hono/agent-session.test.js +6 -43
- 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 +3 -7
- package/dist/hono/agent-tasks.js.map +1 -1
- package/dist/hono/agent-tasks.test.js +0 -15
- package/dist/hono/agent-tasks.test.js.map +1 -1
- package/dist/hono/agent.d.ts.map +1 -1
- package/dist/hono/agent.js +11 -10
- 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 +25 -20
- package/dist/hono/bridge-job.d.ts.map +1 -1
- package/dist/hono/bridge-job.js +164 -111
- package/dist/hono/bridge-job.js.map +1 -1
- package/dist/hono/bridge-job.test.js +295 -76
- package/dist/hono/bridge-job.test.js.map +1 -1
- package/dist/hono/convex.d.ts +1 -0
- package/dist/hono/convex.d.ts.map +1 -1
- package/dist/hono/convex.js +4 -0
- package/dist/hono/convex.js.map +1 -1
- package/dist/hono/convex.test.js +3 -3
- package/dist/hono/main-sync.d.ts +41 -0
- package/dist/hono/main-sync.d.ts.map +1 -0
- package/dist/hono/main-sync.js +390 -0
- package/dist/hono/main-sync.js.map +1 -0
- package/dist/hono/main-sync.test.d.ts +2 -0
- package/dist/hono/main-sync.test.d.ts.map +1 -0
- package/dist/hono/main-sync.test.js +266 -0
- package/dist/hono/main-sync.test.js.map +1 -0
- package/dist/hono/mcp-servers.d.ts +0 -2
- package/dist/hono/mcp-servers.d.ts.map +1 -1
- package/dist/hono/mcp-servers.js +16 -17
- package/dist/hono/mcp-servers.js.map +1 -1
- package/dist/hono/mcp-servers.test.js +38 -10
- 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 +19 -28
- package/dist/hono/model-capabilities.js.map +1 -1
- package/dist/hono/model-capabilities.test.js +15 -37
- package/dist/hono/model-capabilities.test.js.map +1 -1
- package/dist/hono/relay.d.ts +2 -1
- package/dist/hono/relay.d.ts.map +1 -1
- package/dist/hono/relay.js +3 -0
- package/dist/hono/relay.js.map +1 -1
- package/dist/hono/relay.test.js +22 -6
- package/dist/hono/relay.test.js.map +1 -1
- package/dist/hono/session-continuity.d.ts +1 -0
- package/dist/hono/session-continuity.d.ts.map +1 -1
- package/dist/hono/session-continuity.integration.test.js +4 -4
- package/dist/hono/session-continuity.js +5 -2
- package/dist/hono/session-continuity.js.map +1 -1
- 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/hono/types.d.ts +6 -0
- package/dist/hono/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/hono/bridge-job.js
CHANGED
|
@@ -71,13 +71,6 @@ 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";
|
|
81
74
|
const DEFAULT_ASK_USER_QUESTION_TIMEOUT_MS = 15 * 60 * 1000;
|
|
82
75
|
// A model request that streams nothing for this long is a hung upstream
|
|
83
76
|
// connection (observed in production: an OpenRouter request that never yields
|
|
@@ -158,11 +151,10 @@ const BASE_DISALLOWED_TOOLS = [
|
|
|
158
151
|
// not asked to fund a research fleet. Ordinary fan-out is unaffected:
|
|
159
152
|
// `Task` stays, so the scaffold's subagents still run.
|
|
160
153
|
"Workflow",
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
// until it finishes (`createTurnEndController`).
|
|
154
|
+
// Cross-session messaging. A Runtime holds exactly one session, so
|
|
155
|
+
// there is never a peer to reach; every call is a dead end that costs a
|
|
156
|
+
// round trip to discover.
|
|
157
|
+
"SendMessage",
|
|
166
158
|
// A Session review may not change anything. Removing the definitions is
|
|
167
159
|
// the second of three layers — the key it carries is read-scoped and its
|
|
168
160
|
// Bash calls go through `reviewBashPolicy` — because each fails
|
|
@@ -331,6 +323,11 @@ function isUserVisibleSdkMessage(msg) {
|
|
|
331
323
|
// failed turn clears the session, so sending again genuinely does start from the
|
|
332
324
|
// shorter rehydrated history — which is the action this asks for.
|
|
333
325
|
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.";
|
|
334
331
|
function userFacingTurnError(message) {
|
|
335
332
|
if (isContextOverflowError(message)) {
|
|
336
333
|
return CONTEXT_OVERFLOW_MESSAGE;
|
|
@@ -470,6 +467,10 @@ function sdkResultErrorMessage(msg) {
|
|
|
470
467
|
const message = msg.subtype === "success" ? sdkSuccessResultErrorMessage(msg) : msg.errors.join("; ");
|
|
471
468
|
return message.length > 0 ? message : "SDK result error";
|
|
472
469
|
}
|
|
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
|
+
}
|
|
473
474
|
function sdkResultError(msg) {
|
|
474
475
|
if (msg.type !== "result" || !msg.is_error) {
|
|
475
476
|
return undefined;
|
|
@@ -497,9 +498,9 @@ function sdkResultError(msg) {
|
|
|
497
498
|
// the whole turn; the deny THRESHOLD below still guards a one-off end-of-turn race.
|
|
498
499
|
// The trailing colon matters: the CLI always emits the envelope followed by the
|
|
499
500
|
// error that caused it ("…failed: Error: Stream closed", "…failed: AbortError: …").
|
|
500
|
-
// Prose that merely NAMES the failure quotes it without one, which is how
|
|
501
|
-
//
|
|
502
|
-
// block
|
|
501
|
+
// Prose that merely NAMES the failure quotes it without one, which is how the
|
|
502
|
+
// scaffold's guard-background-commands hook describes what it prevents — and its
|
|
503
|
+
// block is an is_error tool_result, so a colon-less match counted a healthy
|
|
503
504
|
// blocked spawn as a deny and could trip the breaker on a healthy turn.
|
|
504
505
|
const CONTROL_CHANNEL_DENY_MARKER = "Tool permission request failed:";
|
|
505
506
|
// Hook failures are reported through the same is_error tool_result channel but
|
|
@@ -662,27 +663,9 @@ export function createStallDetector(input) {
|
|
|
662
663
|
awaitingModel = (msg.type === "user" || msg.type === "stream_event") && pendingToolUses.size === 0;
|
|
663
664
|
},
|
|
664
665
|
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;
|
|
686
669
|
// Decides when the turn is really over and the CLI may be told to exit.
|
|
687
670
|
//
|
|
688
671
|
// The prompt rides a held-open input stream (see `createHeldOpenQuery` in
|
|
@@ -704,10 +687,7 @@ const USAGE_READING_TIMEOUT_MS = 5_000;
|
|
|
704
687
|
// events must not, or the end would never re-arm. After input ends, an exit
|
|
705
688
|
// guard closes the query if the CLI neither speaks nor exits, so a wedged
|
|
706
689
|
// process cannot hang the turn until the session watchdog.
|
|
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) {
|
|
690
|
+
function createTurnEndController(job, query, closeQuery, timings, onTurnEvent) {
|
|
711
691
|
const inFlightTasks = new Set();
|
|
712
692
|
// The background_tasks_changed LEVEL signal, tracked separately from the
|
|
713
693
|
// edge-paired set above: a foreground command the CLI auto-backgrounds emits
|
|
@@ -730,21 +710,6 @@ beforeEndInput) {
|
|
|
730
710
|
return;
|
|
731
711
|
}
|
|
732
712
|
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 = () => {
|
|
748
713
|
query.endInput?.();
|
|
749
714
|
onTurnEvent({
|
|
750
715
|
threadId: job.threadId,
|
|
@@ -838,7 +803,6 @@ beforeEndInput) {
|
|
|
838
803
|
scheduleEnd();
|
|
839
804
|
}
|
|
840
805
|
},
|
|
841
|
-
hasInFlightWork,
|
|
842
806
|
dispose: () => {
|
|
843
807
|
if (settleTimer) {
|
|
844
808
|
clearTimeout(settleTimer);
|
|
@@ -931,6 +895,7 @@ export class BridgeJobExecutor {
|
|
|
931
895
|
readUsageBaseline;
|
|
932
896
|
stalledCliExitMs;
|
|
933
897
|
readFlowTree;
|
|
898
|
+
mainSync;
|
|
934
899
|
constructor(dependencies) {
|
|
935
900
|
this.sdk = dependencies.sdk;
|
|
936
901
|
this.relay = dependencies.relay;
|
|
@@ -951,6 +916,7 @@ export class BridgeJobExecutor {
|
|
|
951
916
|
this.stalledCliExitMs = dependencies.stalledCliExitMs ?? STALLED_CLI_EXIT_MS;
|
|
952
917
|
this.readFlowTree =
|
|
953
918
|
dependencies.readFlowTree ?? ((flowSlug) => readRawFlowTree(bridgeRuntimeFiles.flowsDir(), flowSlug));
|
|
919
|
+
this.mainSync = dependencies.mainSync;
|
|
954
920
|
}
|
|
955
921
|
async execute(job, controls) {
|
|
956
922
|
const state = createBridgeJobState();
|
|
@@ -966,6 +932,7 @@ export class BridgeJobExecutor {
|
|
|
966
932
|
attempt = prepareSessionAttempt({
|
|
967
933
|
requestPrompt: request.prompt,
|
|
968
934
|
reminder: request.reminder,
|
|
935
|
+
recoveryReminder: request.recoveryReminder,
|
|
969
936
|
history: job.history,
|
|
970
937
|
loaded,
|
|
971
938
|
leadingCommand: request.leadingCommand,
|
|
@@ -1017,17 +984,34 @@ export class BridgeJobExecutor {
|
|
|
1017
984
|
// skill) must stay the leading token, so they skip speaker attribution and
|
|
1018
985
|
// reference context — otherwise the prepended lines push the command off the
|
|
1019
986
|
// front and it lands as ordinary message text the model just echoes.
|
|
1020
|
-
const
|
|
987
|
+
const bridgeCommand = verbatimSlashCommand(job.prompt);
|
|
988
|
+
const verbatim = bridgeCommand ??
|
|
1021
989
|
leadingSkillCommand(job.prompt, (await readSessionSkills(bridgeRuntimeFiles.projectRoot())) ?? []);
|
|
1022
990
|
let requestPrompt = verbatim ?? applyCurrentSenderAttribution(job.prompt, job.currentSender);
|
|
1023
|
-
|
|
991
|
+
// Publish and compact keep the workspace as it is (Publish rebases onto main
|
|
992
|
+
// itself). A skill reads and edits the workspace, so it is brought level
|
|
993
|
+
// first — but a note beside the command would push it off the front, so
|
|
994
|
+
// the thread hears about it on its next turn.
|
|
995
|
+
const notes = bridgeCommand === null
|
|
996
|
+
? ((await this.mainSync?.beforeTurn(job.threadId, job.publishedElsewhere ?? [], {
|
|
997
|
+
withNote: verbatim === null,
|
|
998
|
+
})) ?? {})
|
|
999
|
+
: {};
|
|
1000
|
+
const viewing = verbatim === null ? currentReferenceReminder(job.currentReference) : undefined;
|
|
1001
|
+
const reminder = joinReminders(notes.resumed, viewing);
|
|
1002
|
+
const recoveryReminder = joinReminders(notes.recovered, viewing);
|
|
1024
1003
|
if (job.attachments && job.attachments.length > 0) {
|
|
1025
1004
|
requestPrompt += await this.prepareAttachments(job.attachments);
|
|
1026
1005
|
}
|
|
1027
1006
|
if (!controls.isActive()) {
|
|
1028
1007
|
return null;
|
|
1029
1008
|
}
|
|
1030
|
-
return {
|
|
1009
|
+
return {
|
|
1010
|
+
prompt: requestPrompt,
|
|
1011
|
+
...(reminder ? { reminder } : {}),
|
|
1012
|
+
...(recoveryReminder ? { recoveryReminder } : {}),
|
|
1013
|
+
leadingCommand: verbatim !== null,
|
|
1014
|
+
};
|
|
1031
1015
|
}
|
|
1032
1016
|
/**
|
|
1033
1017
|
* The reviewer's prompt: the brief Convex wrote, with the evidence block this
|
|
@@ -1142,6 +1126,11 @@ export class BridgeJobExecutor {
|
|
|
1142
1126
|
await this.clearIfDirty(job, controls, state, "abort");
|
|
1143
1127
|
return { kind: "cancelled", costUsd, model: state.model, modelUsage };
|
|
1144
1128
|
}
|
|
1129
|
+
// No boundary is handed back, so the refused exchange never becomes part of
|
|
1130
|
+
// the conversation the next message resumes.
|
|
1131
|
+
if (state.refusedWithoutFallback) {
|
|
1132
|
+
return { kind: "errored", costUsd, model: state.model, modelUsage, errors: [MODEL_REFUSAL_MESSAGE] };
|
|
1133
|
+
}
|
|
1145
1134
|
if (!state.postedAssistantEvent && state.turnText.length > 0) {
|
|
1146
1135
|
await this.postSyntheticAssistantEvent(job, state);
|
|
1147
1136
|
}
|
|
@@ -1242,7 +1231,7 @@ export class BridgeJobExecutor {
|
|
|
1242
1231
|
}
|
|
1243
1232
|
return {
|
|
1244
1233
|
prompt: buildRehydratedPrompt(request.prompt, job.history),
|
|
1245
|
-
...(request.
|
|
1234
|
+
...(request.recoveryReminder ? { reminder: request.recoveryReminder } : {}),
|
|
1246
1235
|
attemptedRehydration: true,
|
|
1247
1236
|
resumeOptions: {},
|
|
1248
1237
|
};
|
|
@@ -1297,13 +1286,19 @@ export class BridgeJobExecutor {
|
|
|
1297
1286
|
const malformed = refuseMalformedCall(toolName, input, job.prompt, userTurnsBefore(job));
|
|
1298
1287
|
if (malformed)
|
|
1299
1288
|
return Promise.resolve({ behavior: "deny", message: malformed });
|
|
1289
|
+
const clash = toolName === "Write" && typeof input?.file_path === "string"
|
|
1290
|
+
? (this.mainSync?.refuseWrite(job.threadId, input.file_path) ?? null)
|
|
1291
|
+
: null;
|
|
1292
|
+
if (clash)
|
|
1293
|
+
return Promise.resolve({ behavior: "deny", message: clash });
|
|
1300
1294
|
// Cast what is owed FIRST, whatever comes next — including another create. Checked
|
|
1301
1295
|
// the other way round, a second create (the agent retrying after a rejected flag,
|
|
1302
1296
|
// which is what happens in practice) would overwrite the handle and the first
|
|
1303
1297
|
// avatar's casting would never open.
|
|
1304
1298
|
const command = typeof input?.command === "string" ? input.command : undefined;
|
|
1305
1299
|
// The agent asking for the casting we already owe is not the NEXT piece of work — it
|
|
1306
|
-
// is that same casting,
|
|
1300
|
+
// is that same casting, so an explicit call is tolerated (e.g. a re-ask after the
|
|
1301
|
+
// user declined every portrait).
|
|
1307
1302
|
// Handled under its own tool-use id so the user's pick reaches the call that asked
|
|
1308
1303
|
// for it; run as an auto-cast instead, the explicit call fell through to `allow` and
|
|
1309
1304
|
// the tool answered itself with `{ status: "declined" }` — the agent was told the
|
|
@@ -1370,21 +1365,11 @@ export class BridgeJobExecutor {
|
|
|
1370
1365
|
const skills = await readSessionSkills(bridgeRuntimeFiles.projectRoot());
|
|
1371
1366
|
const autoCompactWindow = autoCompactWindowForModel(model);
|
|
1372
1367
|
const betas = betasForModel(model);
|
|
1368
|
+
// No `outputStyle`: the built-in `Concise` style was pinned here against Opus 5's
|
|
1369
|
+
// preamble, and on Opus 5.5 it suppressed the one-line progress notes a
|
|
1370
|
+
// marketer reads while a long turn works. When to speak is now the scaffold
|
|
1371
|
+
// CLAUDE.md's job ("Talking to the User").
|
|
1373
1372
|
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,
|
|
1388
1373
|
// Publish opts out of the CLI's threshold-driven summarization so the turn
|
|
1389
1374
|
// starts working immediately instead of collapsing the chat first; a real
|
|
1390
1375
|
// overflow is caught in `runAttempt` and retried with compaction on. Only
|
|
@@ -1411,11 +1396,11 @@ export class BridgeJobExecutor {
|
|
|
1411
1396
|
// capacity, which also clamps the `autoCompactWindow` pin below to 200k. Not
|
|
1412
1397
|
// an API requirement — see `betasForModel`. Omitted for Haiku 4.5, genuinely 200k.
|
|
1413
1398
|
...(betas ? { betas } : {}),
|
|
1414
|
-
// How hard the model works per turn — thinking depth, preamble, and how
|
|
1415
|
-
//
|
|
1416
|
-
//
|
|
1417
|
-
//
|
|
1418
|
-
//
|
|
1399
|
+
// How hard the model works per turn — thinking depth, preamble, and how many
|
|
1400
|
+
// tool calls it makes. `medium` is Opus 5.5's API default; it is set explicitly
|
|
1401
|
+
// so an SDK or model default change cannot move it silently. Opus 5.5 thinks
|
|
1402
|
+
// more per level than Opus 5, so compare `low` on real Sessions before
|
|
1403
|
+
// treating this as tuned.
|
|
1419
1404
|
//
|
|
1420
1405
|
// MUST stay a single constant. The resolved effort is rendered into the
|
|
1421
1406
|
// prompt, so changing it between requests invalidates every cache
|
|
@@ -1444,6 +1429,12 @@ export class BridgeJobExecutor {
|
|
|
1444
1429
|
...reviewQueryOptions(job),
|
|
1445
1430
|
...(job.planMode ? { permissionMode: "plan" } : {}),
|
|
1446
1431
|
canUseTool: (toolName, input, opts) => this.routeToolUse(job, controls, toolName, input, opts),
|
|
1432
|
+
// No `fallbackModel`, and no server-side refusal `fallbacks` in the extra
|
|
1433
|
+
// body: the CLI's refusal fallback is gated on a first-party base URL (see
|
|
1434
|
+
// `betasForModel`), and nothing shows OpenRouter's passthrough honouring the
|
|
1435
|
+
// API's `fallbacks` field. A refusal therefore ends the turn on
|
|
1436
|
+
// `MODEL_REFUSAL_MESSAGE`; a `model_refusal_fallback`, if one ever arrives,
|
|
1437
|
+
// is still handled (its superseded messages are retracted).
|
|
1447
1438
|
model: modelForJob(job, model),
|
|
1448
1439
|
abortController: controls.abortController ?? { signal: controls.abortSignal },
|
|
1449
1440
|
// The SDK treats `env` as a full replacement for the subprocess environment
|
|
@@ -1525,16 +1516,7 @@ export class BridgeJobExecutor {
|
|
|
1525
1516
|
// in-progress session.
|
|
1526
1517
|
async drainQuery(job, controls, state, query, closeQuery) {
|
|
1527
1518
|
const stallDetector = this.createStallDetector(job, controls, closeQuery);
|
|
1528
|
-
|
|
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
|
-
});
|
|
1519
|
+
const turnEnd = createTurnEndController(job, query, closeQuery, { settleMs: this.turnEndSettleMs, exitGuardMs: this.turnEndExitGuardMs }, this.recordTurnEvent);
|
|
1538
1520
|
let costUsd = 0;
|
|
1539
1521
|
// Errors thrown while HANDLING a message (control-channel breaker, error
|
|
1540
1522
|
// results) are real verdicts and must surface as-is; only errors from the
|
|
@@ -1544,7 +1526,6 @@ export class BridgeJobExecutor {
|
|
|
1544
1526
|
for await (const msg of query) {
|
|
1545
1527
|
stallDetector.observeMessage(msg);
|
|
1546
1528
|
turnEnd.observeMessage(msg);
|
|
1547
|
-
controls.setWorkInFlight?.(stallDetector.hasPendingToolUses() || turnEnd.hasInFlightWork());
|
|
1548
1529
|
try {
|
|
1549
1530
|
costUsd = await this.consumeSdkMessage(job, controls, state, msg, costUsd);
|
|
1550
1531
|
}
|
|
@@ -1567,7 +1548,6 @@ export class BridgeJobExecutor {
|
|
|
1567
1548
|
throw error;
|
|
1568
1549
|
}
|
|
1569
1550
|
finally {
|
|
1570
|
-
controls.setWorkInFlight?.(false);
|
|
1571
1551
|
stallDetector.dispose();
|
|
1572
1552
|
turnEnd.dispose();
|
|
1573
1553
|
await this.openPendingCastingAtTurnEnd(job, controls);
|
|
@@ -1575,7 +1555,7 @@ export class BridgeJobExecutor {
|
|
|
1575
1555
|
if (stallDetector.stalled() && controls.isActive()) {
|
|
1576
1556
|
throw new StreamStallError();
|
|
1577
1557
|
}
|
|
1578
|
-
return
|
|
1558
|
+
return costUsd;
|
|
1579
1559
|
}
|
|
1580
1560
|
/**
|
|
1581
1561
|
* Open a casting the turn recorded but never reached.
|
|
@@ -1650,23 +1630,7 @@ export class BridgeJobExecutor {
|
|
|
1650
1630
|
return costUsd;
|
|
1651
1631
|
}
|
|
1652
1632
|
controls.markEvent?.();
|
|
1653
|
-
|
|
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 = "";
|
|
1633
|
+
if (this.droppedBeforeRelay(job, state, msg)) {
|
|
1670
1634
|
return costUsd;
|
|
1671
1635
|
}
|
|
1672
1636
|
state.inProgressSession = observeInProgressSession(state.inProgressSession, msg);
|
|
@@ -1684,7 +1648,17 @@ export class BridgeJobExecutor {
|
|
|
1684
1648
|
}
|
|
1685
1649
|
trackMainRequestUsage(state, msg);
|
|
1686
1650
|
if (msg.type === "result") {
|
|
1687
|
-
const
|
|
1651
|
+
const own = ownQueryUsage({ totalCostUsd: msg.total_cost_usd, modelUsage: msg.modelUsage }, state.queryUsageBaseline);
|
|
1652
|
+
state.observedCostUsd = own.costUsd;
|
|
1653
|
+
// A query's result `modelUsage` is cumulative (like `total_cost_usd`, which
|
|
1654
|
+
// we overwrite above), so keep the latest snapshot rather than summing —
|
|
1655
|
+
// it is folded into the turn totals when the query settles (see
|
|
1656
|
+
// `runQueryOnce`). Guard against a spuriously empty later result wiping a
|
|
1657
|
+
// real earlier one.
|
|
1658
|
+
const snapshot = queryModelUsage(own.modelUsage);
|
|
1659
|
+
if (snapshot.size > 0) {
|
|
1660
|
+
state.queryModelUsage = snapshot;
|
|
1661
|
+
}
|
|
1688
1662
|
await this.publishContextUsage(job, state, msg.modelUsage);
|
|
1689
1663
|
// What a Session review returns. The SDK enforces the JSON schema, so this
|
|
1690
1664
|
// either parses against the findings contract or the review failed to
|
|
@@ -1694,12 +1668,14 @@ export class BridgeJobExecutor {
|
|
|
1694
1668
|
if (structured !== undefined) {
|
|
1695
1669
|
state.structuredOutput = structured;
|
|
1696
1670
|
}
|
|
1697
|
-
|
|
1671
|
+
// A refused turn's result can be an error; the refusal already decided
|
|
1672
|
+
// how the turn ends, and in product words rather than the CLI's.
|
|
1673
|
+
const resultError = turnResultError(state, msg);
|
|
1698
1674
|
if (resultError) {
|
|
1699
1675
|
await this.clearIfDirty(job, controls, state, "result_error");
|
|
1700
1676
|
throw resultError;
|
|
1701
1677
|
}
|
|
1702
|
-
return
|
|
1678
|
+
return own.costUsd;
|
|
1703
1679
|
}
|
|
1704
1680
|
if (isUserVisibleSdkMessage(msg)) {
|
|
1705
1681
|
state.sawUserVisibleSdkMessage = true;
|
|
@@ -1733,7 +1709,7 @@ export class BridgeJobExecutor {
|
|
|
1733
1709
|
* turn (one per tool call, plus any same-model subagent work), so a handful of
|
|
1734
1710
|
* tool calls inflates them past the window and pins the meter at 100% on an
|
|
1735
1711
|
* almost-empty chat. The window is the model's real OpenRouter context length
|
|
1736
|
-
* (
|
|
1712
|
+
* (Opus 5.5 = 1M); we resolve it from the model id because the runtime
|
|
1737
1713
|
* under-reports it through the OpenRouter passthrough, falling back to the
|
|
1738
1714
|
* reported window for unknown models. A relay failure must never fail the turn.
|
|
1739
1715
|
*/
|
|
@@ -1789,6 +1765,61 @@ export class BridgeJobExecutor {
|
|
|
1789
1765
|
await this.relay.assistantEvent(target, msg, state.model);
|
|
1790
1766
|
}
|
|
1791
1767
|
}
|
|
1768
|
+
/**
|
|
1769
|
+
* A message consumed here and never relayed or counted as the agent's work:
|
|
1770
|
+
* a provider refusal for size, or part of a model refusal.
|
|
1771
|
+
*/
|
|
1772
|
+
droppedBeforeRelay(job, state, msg) {
|
|
1773
|
+
// Drop the size refusal outright. A recovered turn must not leave a raw
|
|
1774
|
+
// provider error sitting above the work that then succeeded, and a turn that
|
|
1775
|
+
// fails anyway says so once, in product language, through the error outcome —
|
|
1776
|
+
// never twice, and never in the provider's words. Dropping it before
|
|
1777
|
+
// `observeInProgressSession` also keeps it from setting `resumeSessionAt` —
|
|
1778
|
+
// the refusal is not work the agent did, and counting it as such would send
|
|
1779
|
+
// the retry a "continue where you left off" note instead of the `/publish`
|
|
1780
|
+
// the turn still owes.
|
|
1781
|
+
if (isContextOverflowRefusal(msg)) {
|
|
1782
|
+
// Today the refusal is synthetic — the request is rejected pre-flight, so
|
|
1783
|
+
// no deltas precede it and `turnText` is empty. Clearing it anyway is what
|
|
1784
|
+
// makes the drop hold if that ever changes: streamed text survives the
|
|
1785
|
+
// dropped message, and a retry that ends on tool work with no assistant
|
|
1786
|
+
// text of its own would post the refusal as this turn's end-of-turn text
|
|
1787
|
+
// (`postSyntheticAssistantEvent`). The live preview needs no such guard —
|
|
1788
|
+
// the next `message_start` restarts it and the turn's end clears it.
|
|
1789
|
+
state.turnText = "";
|
|
1790
|
+
return true;
|
|
1791
|
+
}
|
|
1792
|
+
return this.observeModelRefusal(job, state, msg);
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* A refusal with no fallback model to retry it. True when the message is part
|
|
1796
|
+
* of the refusal — the CLI's error frame (`stop_reason: "refusal"`, written for
|
|
1797
|
+
* a terminal user) or the refusal notice — and is consumed here, never relayed.
|
|
1798
|
+
*/
|
|
1799
|
+
observeModelRefusal(job, state, msg) {
|
|
1800
|
+
if (msg.type === "assistant") {
|
|
1801
|
+
if (msg.message.stop_reason === "refusal") {
|
|
1802
|
+
return true;
|
|
1803
|
+
}
|
|
1804
|
+
if (!msg.parent_tool_use_id) {
|
|
1805
|
+
state.refusedWithoutFallback = false;
|
|
1806
|
+
}
|
|
1807
|
+
return false;
|
|
1808
|
+
}
|
|
1809
|
+
if (msg.type !== "system" || msg.subtype !== "model_refusal_no_fallback") {
|
|
1810
|
+
return false;
|
|
1811
|
+
}
|
|
1812
|
+
state.refusedWithoutFallback = true;
|
|
1813
|
+
const category = msg.api_refusal_category ?? null;
|
|
1814
|
+
console.error(`[BridgeJob] model refused with no fallback (category: ${category ?? "none"}, model: ${msg.original_model}) for thread ${job.threadId}`);
|
|
1815
|
+
this.recordTurnEvent({
|
|
1816
|
+
threadId: job.threadId,
|
|
1817
|
+
turnId: job.turnId,
|
|
1818
|
+
event: "model_refusal",
|
|
1819
|
+
detail: { category, model: msg.original_model, fallback: false },
|
|
1820
|
+
});
|
|
1821
|
+
return true;
|
|
1822
|
+
}
|
|
1792
1823
|
async handleSystemMessage(state, msg, target) {
|
|
1793
1824
|
// Relay the compaction lifecycle so the dashboard can show a spinner then a
|
|
1794
1825
|
// summary notice. This fires for both a manual `/compact` (which otherwise
|
|
@@ -1812,6 +1843,25 @@ export class BridgeJobExecutor {
|
|
|
1812
1843
|
await this.relay.compaction(target, "done");
|
|
1813
1844
|
return;
|
|
1814
1845
|
}
|
|
1846
|
+
// A fallback model retried a refused request. The refused leg's messages
|
|
1847
|
+
// were already relayed; the SDK names them so they can leave the chat
|
|
1848
|
+
// instead of sitting above the answer that replaced them.
|
|
1849
|
+
if (msg.type === "system" && msg.subtype === "model_refusal_fallback") {
|
|
1850
|
+
this.recordTurnEvent({
|
|
1851
|
+
...target,
|
|
1852
|
+
event: "model_refusal",
|
|
1853
|
+
detail: {
|
|
1854
|
+
category: msg.api_refusal_category ?? null,
|
|
1855
|
+
model: msg.original_model,
|
|
1856
|
+
fallback: msg.fallback_model,
|
|
1857
|
+
},
|
|
1858
|
+
});
|
|
1859
|
+
const retracted = msg.retracted_message_uuids ?? [];
|
|
1860
|
+
if (retracted.length > 0) {
|
|
1861
|
+
await this.relay.retractEvents(target, retracted);
|
|
1862
|
+
}
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1815
1865
|
const agentTasks = applyTaskLifecycleMessage(state.agentTasks, msg, this.now());
|
|
1816
1866
|
if (agentTasks) {
|
|
1817
1867
|
state.agentTasks = agentTasks;
|
|
@@ -2238,4 +2288,7 @@ function userTurnsBefore(job) {
|
|
|
2238
2288
|
.trim();
|
|
2239
2289
|
return said || undefined;
|
|
2240
2290
|
}
|
|
2291
|
+
function joinReminders(...blocks) {
|
|
2292
|
+
return blocks.filter((block) => block !== undefined).join("\n\n") || undefined;
|
|
2293
|
+
}
|
|
2241
2294
|
//# sourceMappingURL=bridge-job.js.map
|