cascade-ai 0.49.0 → 0.51.0

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/dist/index.js CHANGED
@@ -1845,7 +1845,7 @@ function reconcilePrice(model, live) {
1845
1845
  }
1846
1846
 
1847
1847
  // src/constants.ts
1848
- var CASCADE_VERSION = "0.49.0";
1848
+ var CASCADE_VERSION = "0.51.0";
1849
1849
  var CASCADE_CONFIG_DIR = ".cascade";
1850
1850
  var CASCADE_MD_FILE = "CASCADE.md";
1851
1851
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -2358,46 +2358,37 @@ var BaseProvider = class {
2358
2358
  };
2359
2359
 
2360
2360
  // src/providers/model-filter.ts
2361
- var NON_CHAT_PATTERNS = [
2362
- "embed",
2361
+ var MODALITY_PATTERNS = [
2362
+ ["embedding", ["embed"]],
2363
2363
  // text-embedding-3, nomic-embed, mxbai-embed, snowflake-arctic-embed
2364
- "(?:^|[-_/])tts(?:[-_/]|$)",
2365
- "text-to-speech",
2366
- "speech",
2367
- // text-to-speech
2368
- "whisper",
2369
- "transcrib",
2370
- "(?:^|[-_/])stt(?:[-_/]|$)",
2371
- // speech-to-text
2372
- "dall[-]?e",
2373
- "imagen",
2374
- "image-generation",
2375
- "gpt-image",
2376
- "(?:^|[-_/])image(?:[-_/]|$)",
2377
- "stable-diffusion",
2378
- "sdxl",
2379
- "flux",
2380
- // image gen
2381
- "moderation",
2382
- "(?:^|[-_/])guard(?:[-_/]|$)",
2383
- // moderation / safety
2384
- "rerank",
2385
- "(?:^|[-_/])audio(?:[-_/]|$)",
2386
- "native-audio",
2387
- "realtime",
2388
- // audio / realtime
2389
- "veo",
2390
- "sora",
2391
- "(?:^|[-_/])video(?:[-_/]|$)",
2392
- // video
2393
- "(?:^|[-_/])aqa(?:[-_/]|$)",
2394
- // attributed QA
2395
- "(?:^|[-_/])live(?:[-_/]|$)",
2396
- // live-audio variants
2397
- "(?:^|[-_/])(?:davinci|babbage)(?:[-_/]|$)"
2398
- // legacy completion-only base models
2364
+ ["transcription", ["whisper", "transcrib", "(?:^|[-_/])stt(?:[-_/]|$)"]],
2365
+ ["speech", ["(?:^|[-_/])tts(?:[-_/]|$)", "text-to-speech", "speech"]],
2366
+ ["video", ["veo", "sora", "(?:^|[-_/])video(?:[-_/]|$)"]],
2367
+ ["image", [
2368
+ "dall[-]?e",
2369
+ "imagen",
2370
+ "image-generation",
2371
+ "gpt-image",
2372
+ "(?:^|[-_/])image(?:[-_/]|$)",
2373
+ "stable-diffusion",
2374
+ "sdxl",
2375
+ "flux"
2376
+ ]],
2377
+ ["moderation", ["moderation", "(?:^|[-_/])guard(?:[-_/]|$)"]],
2378
+ ["rerank", ["rerank"]],
2379
+ ["realtime", [
2380
+ "(?:^|[-_/])audio(?:[-_/]|$)",
2381
+ "native-audio",
2382
+ "realtime",
2383
+ "(?:^|[-_/])live(?:[-_/]|$)",
2384
+ // live-audio variants
2385
+ "(?:^|[-_/])aqa(?:[-_/]|$)"
2386
+ // attributed QA
2387
+ ]],
2388
+ ["legacy-completion", ["(?:^|[-_/])(?:davinci|babbage)(?:[-_/]|$)"]]
2399
2389
  ];
2400
- var NON_CHAT = new RegExp(NON_CHAT_PATTERNS.join("|"), "i");
2390
+ MODALITY_PATTERNS.map(([m, pats]) => [m, new RegExp(pats.join("|"), "i")]);
2391
+ var NON_CHAT = new RegExp(MODALITY_PATTERNS.flatMap(([, p]) => p).join("|"), "i");
2401
2392
  function isChatModel(id, methods) {
2402
2393
  if (methods && methods.length > 0) {
2403
2394
  const canGenerate = methods.some((m) => /generatecontent|generatemessage|chat|completion/i.test(m));
@@ -2925,6 +2916,115 @@ var AzureOpenAIProvider = class extends OpenAIProvider {
2925
2916
  }
2926
2917
  }
2927
2918
  };
2919
+
2920
+ // src/providers/gemini-schema.ts
2921
+ var ALLOWED_KEYS = /* @__PURE__ */ new Set([
2922
+ "type",
2923
+ "format",
2924
+ "title",
2925
+ "description",
2926
+ "nullable",
2927
+ "enum",
2928
+ "items",
2929
+ "properties",
2930
+ "required",
2931
+ "propertyOrdering",
2932
+ "minItems",
2933
+ "maxItems",
2934
+ "minProperties",
2935
+ "maxProperties",
2936
+ "minimum",
2937
+ "maximum",
2938
+ "minLength",
2939
+ "maxLength",
2940
+ "pattern",
2941
+ "example",
2942
+ "default",
2943
+ "anyOf"
2944
+ ]);
2945
+ var ALLOWED_TYPES = /* @__PURE__ */ new Set(["string", "number", "integer", "boolean", "array", "object"]);
2946
+ function isPlainObject(v) {
2947
+ return typeof v === "object" && v !== null && !Array.isArray(v);
2948
+ }
2949
+ function normalizeType(value) {
2950
+ if (typeof value === "string") {
2951
+ return ALLOWED_TYPES.has(value) ? { type: value } : {};
2952
+ }
2953
+ if (Array.isArray(value)) {
2954
+ const types = value.filter((t) => typeof t === "string");
2955
+ const nullable = types.includes("null");
2956
+ const concrete = types.find((t) => t !== "null" && ALLOWED_TYPES.has(t));
2957
+ return {
2958
+ ...concrete ? { type: concrete } : {},
2959
+ ...nullable ? { nullable: true } : {}
2960
+ };
2961
+ }
2962
+ return {};
2963
+ }
2964
+ function normalizeEnum(value) {
2965
+ if (!Array.isArray(value) || value.length === 0) return void 0;
2966
+ return value.map((v) => typeof v === "string" ? v : JSON.stringify(v));
2967
+ }
2968
+ function sanitizeGeminiSchema(schema) {
2969
+ if (!isPlainObject(schema)) return void 0;
2970
+ const out = {};
2971
+ for (const [key, value] of Object.entries(schema)) {
2972
+ if (!ALLOWED_KEYS.has(key)) continue;
2973
+ switch (key) {
2974
+ case "type": {
2975
+ Object.assign(out, normalizeType(value));
2976
+ break;
2977
+ }
2978
+ case "enum": {
2979
+ const e = normalizeEnum(value);
2980
+ if (e) out["enum"] = e;
2981
+ break;
2982
+ }
2983
+ case "properties": {
2984
+ if (!isPlainObject(value)) break;
2985
+ const props = {};
2986
+ for (const [propName, propSchema] of Object.entries(value)) {
2987
+ const cleaned = sanitizeGeminiSchema(propSchema);
2988
+ props[propName] = cleaned && Object.keys(cleaned).length ? cleaned : { type: "string" };
2989
+ }
2990
+ out["properties"] = props;
2991
+ break;
2992
+ }
2993
+ case "items": {
2994
+ const cleaned = sanitizeGeminiSchema(value);
2995
+ if (cleaned) out["items"] = cleaned;
2996
+ break;
2997
+ }
2998
+ case "anyOf": {
2999
+ if (!Array.isArray(value)) break;
3000
+ const variants = value.map((v) => sanitizeGeminiSchema(v)).filter((v) => !!v && Object.keys(v).length > 0);
3001
+ if (variants.length) out["anyOf"] = variants;
3002
+ break;
3003
+ }
3004
+ case "required": {
3005
+ if (Array.isArray(value)) {
3006
+ out["required"] = value.filter((r) => typeof r === "string");
3007
+ }
3008
+ break;
3009
+ }
3010
+ default:
3011
+ out[key] = value;
3012
+ }
3013
+ }
3014
+ if (!out["type"] && out["properties"]) out["type"] = "object";
3015
+ if (!out["type"] && out["items"]) out["type"] = "array";
3016
+ return out;
3017
+ }
3018
+ function toGeminiParameters(inputSchema) {
3019
+ const cleaned = sanitizeGeminiSchema(inputSchema);
3020
+ if (!cleaned) return void 0;
3021
+ const props = cleaned["properties"];
3022
+ if (isPlainObject(props) && Object.keys(props).length === 0) return void 0;
3023
+ if (Object.keys(cleaned).length === 0) return void 0;
3024
+ return cleaned;
3025
+ }
3026
+
3027
+ // src/providers/gemini.ts
2928
3028
  var GeminiProvider = class extends BaseProvider {
2929
3029
  client;
2930
3030
  constructor(config, model) {
@@ -3145,7 +3245,12 @@ var GeminiProvider = class extends BaseProvider {
3145
3245
  return {
3146
3246
  name: tool.name,
3147
3247
  description: tool.description,
3148
- parameters: tool.inputSchema
3248
+ // Convert, never cast. Gemini's parameters field is an OpenAPI subset
3249
+ // that 400s on unknown keys, and MCP servers ship schemas carrying their
3250
+ // own extensions (GitHub's adds `x-mcp-header` to every header-bound
3251
+ // property). Casting sent those through verbatim and every request failed
3252
+ // before the model saw it. See providers/gemini-schema.ts.
3253
+ parameters: toGeminiParameters(tool.inputSchema)
3149
3254
  };
3150
3255
  }
3151
3256
  };
@@ -3173,7 +3278,7 @@ async function nodeHttpFetch(input, init = {}, redirectCount = 0) {
3173
3278
  headers["accept-encoding"] = "gzip, deflate, br";
3174
3279
  }
3175
3280
  const body = init.body == null ? void 0 : typeof init.body === "string" ? init.body : Buffer.from(init.body);
3176
- return new Promise((resolve, reject) => {
3281
+ return new Promise((resolve2, reject) => {
3177
3282
  const req = lib.request(
3178
3283
  {
3179
3284
  hostname: u.hostname,
@@ -3190,7 +3295,7 @@ async function nodeHttpFetch(input, init = {}, redirectCount = 0) {
3190
3295
  const nextUrl = new URL(location, u).href;
3191
3296
  const downgrade = status === 303 || (status === 301 || status === 302) && method !== "GET" && method !== "HEAD";
3192
3297
  const nextInit = downgrade ? { ...init, method: "GET", body: void 0 } : init;
3193
- resolve(nodeHttpFetch(nextUrl, nextInit, redirectCount + 1));
3298
+ resolve2(nodeHttpFetch(nextUrl, nextInit, redirectCount + 1));
3194
3299
  return;
3195
3300
  }
3196
3301
  const encoding = (res.headers["content-encoding"] ?? "").toLowerCase();
@@ -3205,7 +3310,7 @@ async function nodeHttpFetch(input, init = {}, redirectCount = 0) {
3205
3310
  if (Array.isArray(v)) respHeaders.set(k, v.join(", "));
3206
3311
  else if (typeof v === "string") respHeaders.set(k, v);
3207
3312
  }
3208
- resolve(new Response(stream, {
3313
+ resolve2(new Response(stream, {
3209
3314
  status,
3210
3315
  statusText: res.statusMessage ?? "",
3211
3316
  headers: respHeaders
@@ -3850,7 +3955,7 @@ var TpmLimiter = class {
3850
3955
  }
3851
3956
  const deficit = want - bucket.available;
3852
3957
  const waitMs = Math.max(50, Math.ceil(deficit / bucket.tokensPerMinute * 6e4));
3853
- await new Promise((resolve) => setTimeout(resolve, waitMs));
3958
+ await new Promise((resolve2) => setTimeout(resolve2, waitMs));
3854
3959
  }
3855
3960
  }
3856
3961
  /**
@@ -3913,14 +4018,14 @@ var LocalRequestQueue = class {
3913
4018
  this.active++;
3914
4019
  return this.makeRelease();
3915
4020
  }
3916
- return new Promise((resolve, reject) => {
4021
+ return new Promise((resolve2, reject) => {
3917
4022
  let settled = false;
3918
4023
  let timer;
3919
4024
  const resolver = (release) => {
3920
4025
  if (settled) return;
3921
4026
  settled = true;
3922
4027
  if (timer !== void 0) clearTimeout(timer);
3923
- resolve(release);
4028
+ resolve2(release);
3924
4029
  };
3925
4030
  if (timeoutMs !== void 0 && timeoutMs > 0) {
3926
4031
  timer = setTimeout(() => {
@@ -4551,6 +4656,7 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
4551
4656
  tpmLimiter;
4552
4657
  localQueue;
4553
4658
  taskAnalyzer;
4659
+ pendingFeedbackSource;
4554
4660
  worldStateDB;
4555
4661
  privacyPaths;
4556
4662
  guidanceQueue;
@@ -5005,8 +5111,18 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
5005
5111
  return windows.length ? Math.min(...windows) : void 0;
5006
5112
  }
5007
5113
  /** Wire the Cascade Auto task analyzer used for per-subtask model routing. */
5114
+ /**
5115
+ * Feed per-model thumbs counts into Auto routing. Forwarded to the analyzer
5116
+ * because that is where the public benchmark score is combined; the prior
5117
+ * adjusts that number rather than sitting anywhere else in the pipeline.
5118
+ */
5119
+ setFeedbackSource(source) {
5120
+ this.taskAnalyzer?.setFeedbackSource(source);
5121
+ this.pendingFeedbackSource = source;
5122
+ }
5008
5123
  setTaskAnalyzer(analyzer) {
5009
5124
  this.taskAnalyzer = analyzer;
5125
+ if (this.pendingFeedbackSource) analyzer.setFeedbackSource(this.pendingFeedbackSource);
5010
5126
  }
5011
5127
  setWorldStateDB(db) {
5012
5128
  this.worldStateDB = db;
@@ -5461,6 +5577,14 @@ var BaseTier = class extends EventEmitter {
5461
5577
  this.emit("tier:status", event);
5462
5578
  }
5463
5579
  /** Record the model serving this tier; future status events carry it. */
5580
+ /**
5581
+ * The model actually serving this tier ("provider:id"), once one has been
5582
+ * chosen. The run breaker reads it to attribute a failure to a model rather
5583
+ * than to the subtask that happened to hit it.
5584
+ */
5585
+ getServingModel() {
5586
+ return this.servingModel;
5587
+ }
5464
5588
  setServingModel(model) {
5465
5589
  this.servingModel = model || void 0;
5466
5590
  }
@@ -6092,8 +6216,11 @@ Now execute your subtask using this context where relevant.`
6092
6216
  const result = await this.runAgentLoop(systemPrompt, this.tools);
6093
6217
  output = result.output;
6094
6218
  toolCalls = result.toolCalls;
6095
- this.sendStatusUpdate({ progressPct: 65, currentAction: "Verifying required artifacts", status: "IN_PROGRESS" });
6096
- const artifactCheck = await this.verifyArtifacts(assignment);
6219
+ const mustProduceArtifact = this.requiresArtifact();
6220
+ if (mustProduceArtifact) {
6221
+ this.sendStatusUpdate({ progressPct: 65, currentAction: "Verifying required artifacts", status: "IN_PROGRESS" });
6222
+ }
6223
+ const artifactCheck = mustProduceArtifact ? await this.verifyArtifacts(assignment) : { ok: true, issues: [] };
6097
6224
  if (!artifactCheck.ok) {
6098
6225
  correctionAttempts = 1;
6099
6226
  issues.push(...artifactCheck.issues);
@@ -6373,11 +6500,11 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
6373
6500
  if (!wasApproved) return `Tool ${tc.name} was denied by user.`;
6374
6501
  } else {
6375
6502
  const LEGACY_APPROVAL_TIMEOUT_MS = 6e5;
6376
- const legacyDecision = await new Promise((resolve) => {
6503
+ const legacyDecision = await new Promise((resolve2) => {
6377
6504
  const eventName = `tool:approval-response:${this.id}-${tc.id}`;
6378
6505
  const timer = setTimeout(() => {
6379
6506
  this.removeAllListeners(eventName);
6380
- resolve({ approved: false });
6507
+ resolve2({ approved: false });
6381
6508
  }, LEGACY_APPROVAL_TIMEOUT_MS);
6382
6509
  timer.unref?.();
6383
6510
  this.emit("tool:approval-request", {
@@ -6390,7 +6517,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
6390
6517
  });
6391
6518
  this.once(eventName, (d) => {
6392
6519
  clearTimeout(timer);
6393
- resolve(d);
6520
+ resolve2(d);
6394
6521
  });
6395
6522
  });
6396
6523
  if (legacyDecision.always) this.sessionApprovals.set(tc.name, legacyDecision.approved);
@@ -6410,6 +6537,9 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
6410
6537
  tierId: this.id,
6411
6538
  sessionId: this.taskId,
6412
6539
  requireApproval: false,
6540
+ // Media generation can run for a minute; without this a cancelled run
6541
+ // still pays for an image nobody will see.
6542
+ ...this.signal ? { signal: this.signal } : {},
6413
6543
  saveSnapshot: async (path26, content) => {
6414
6544
  this.store?.addFileSnapshot(this.taskId, path26, content);
6415
6545
  },
@@ -6457,7 +6587,8 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
6457
6587
  const result = await this.toolRegistry.execute(altTool, tc.input, {
6458
6588
  tierId: this.id,
6459
6589
  sessionId: this.taskId,
6460
- requireApproval: false
6590
+ requireApproval: false,
6591
+ ...this.signal ? { signal: this.signal } : {}
6461
6592
  });
6462
6593
  const str = typeof result === "string" ? result : JSON.stringify(result);
6463
6594
  if (!str.startsWith("Tool error:") && !str.startsWith("Error:")) {
@@ -6479,7 +6610,8 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
6479
6610
  const result = await this.toolRegistry.execute(newToolName, tc.input, {
6480
6611
  tierId: this.id,
6481
6612
  sessionId: this.taskId,
6482
- requireApproval: false
6613
+ requireApproval: false,
6614
+ ...this.signal ? { signal: this.signal } : {}
6483
6615
  });
6484
6616
  const str = typeof result === "string" ? result : JSON.stringify(result);
6485
6617
  if (!str.startsWith("Tool error:")) return `[Synthesized ${newToolName}]: ${str}`;
@@ -6897,7 +7029,7 @@ var PeerBus = class extends EventEmitter {
6897
7029
  this.outputs.set(subtaskId, entry);
6898
7030
  this.emit("output:ready", entry);
6899
7031
  const waiting = this.waiters.get(subtaskId) ?? [];
6900
- for (const resolve of waiting) resolve(entry);
7032
+ for (const resolve2 of waiting) resolve2(entry);
6901
7033
  this.waiters.delete(subtaskId);
6902
7034
  }
6903
7035
  /**
@@ -6928,10 +7060,10 @@ var PeerBus = class extends EventEmitter {
6928
7060
  waitFor(subtaskId, timeoutMs = 12e4) {
6929
7061
  const existing = this.outputs.get(subtaskId);
6930
7062
  if (existing && !this.retryPending.has(subtaskId)) return Promise.resolve(existing);
6931
- return new Promise((resolve, reject) => {
7063
+ return new Promise((resolve2, reject) => {
6932
7064
  const resolver = (output) => {
6933
7065
  clearTimeout(timer);
6934
- resolve(output);
7066
+ resolve2(output);
6935
7067
  };
6936
7068
  const timer = setTimeout(() => {
6937
7069
  const waiting = this.waiters.get(subtaskId);
@@ -6985,10 +7117,10 @@ var PeerBus = class extends EventEmitter {
6985
7117
  */
6986
7118
  collect(timeoutMs) {
6987
7119
  const collected = [...this.broadcastLog];
6988
- return new Promise((resolve) => {
7120
+ return new Promise((resolve2) => {
6989
7121
  const timer = setTimeout(() => {
6990
7122
  off();
6991
- resolve(collected);
7123
+ resolve2(collected);
6992
7124
  }, timeoutMs);
6993
7125
  const handler = (msg) => {
6994
7126
  collected.push({ fromId: msg.fromId, payload: msg.payload, timestamp: msg.timestamp });
@@ -7002,7 +7134,7 @@ var PeerBus = class extends EventEmitter {
7002
7134
  const broadcasters = new Set(collected.map((m) => m.fromId));
7003
7135
  if (broadcasters.size >= this.members.size) {
7004
7136
  off();
7005
- resolve(collected);
7137
+ resolve2(collected);
7006
7138
  }
7007
7139
  };
7008
7140
  this.on("broadcast", checkComplete);
@@ -7020,7 +7152,7 @@ var PeerBus = class extends EventEmitter {
7020
7152
  return;
7021
7153
  }
7022
7154
  this.emitCoordination(tierId, `\u23F3 waiting for ${filePath} (held by ${existing.holderId})`);
7023
- return new Promise((resolve, reject) => {
7155
+ return new Promise((resolve2, reject) => {
7024
7156
  const timer = setTimeout(() => {
7025
7157
  reject(new Error(`File lock timeout for ${filePath} (held by ${existing.holderId})`));
7026
7158
  }, timeoutMs);
@@ -7028,7 +7160,7 @@ var PeerBus = class extends EventEmitter {
7028
7160
  clearTimeout(timer);
7029
7161
  this.fileLocks.set(filePath, { holderId: tierId, lockedAt: (/* @__PURE__ */ new Date()).toISOString(), waiters: [] });
7030
7162
  this.emitCoordination(tierId, `\u{1F512} locked ${filePath}`);
7031
- resolve();
7163
+ resolve2();
7032
7164
  });
7033
7165
  });
7034
7166
  }
@@ -7053,11 +7185,11 @@ var PeerBus = class extends EventEmitter {
7053
7185
  */
7054
7186
  waitForFileRelease(filePath, timeoutMs = 3e4) {
7055
7187
  if (!this.fileLocks.has(filePath)) return Promise.resolve();
7056
- return new Promise((resolve, reject) => {
7188
+ return new Promise((resolve2, reject) => {
7057
7189
  const timer = setTimeout(() => reject(new Error(`Timeout waiting for file release: ${filePath}`)), timeoutMs);
7058
7190
  this.once(`file:released:${filePath}`, () => {
7059
7191
  clearTimeout(timer);
7060
- resolve();
7192
+ resolve2();
7061
7193
  });
7062
7194
  });
7063
7195
  }
@@ -7123,13 +7255,13 @@ var PeerBus = class extends EventEmitter {
7123
7255
  this.emit(`barrier:${barrierName}`);
7124
7256
  return;
7125
7257
  }
7126
- return new Promise((resolve, reject) => {
7258
+ return new Promise((resolve2, reject) => {
7127
7259
  const timer = setTimeout(() => {
7128
7260
  reject(new Error(`Barrier timeout: ${barrierName} (${bar.arrived.size}/${bar.total} arrived)`));
7129
7261
  }, 12e4);
7130
7262
  this.once(`barrier:${barrierName}`, () => {
7131
7263
  clearTimeout(timer);
7132
- resolve();
7264
+ resolve2();
7133
7265
  });
7134
7266
  });
7135
7267
  }
@@ -7196,6 +7328,7 @@ var T2Manager = class extends BaseTier {
7196
7328
  escalations = [];
7197
7329
  peerSyncBuffer = [];
7198
7330
  store;
7331
+ runBreaker;
7199
7332
  t3PeerBus = new PeerBus();
7200
7333
  // ← T3↔T3 bus (local to this T2)
7201
7334
  t2PeerBus;
@@ -7226,6 +7359,15 @@ var T2Manager = class extends BaseTier {
7226
7359
  this.router = router;
7227
7360
  this.toolRegistry = toolRegistry;
7228
7361
  }
7362
+ /**
7363
+ * Share the run-wide circuit breaker. Every T2 in a run gets the SAME
7364
+ * instance: three sections each failing once against a dead model is the
7365
+ * same fact as one section failing three times, and a per-section breaker
7366
+ * would never see it.
7367
+ */
7368
+ setRunBreaker(breaker) {
7369
+ this.runBreaker = breaker;
7370
+ }
7229
7371
  setStore(store) {
7230
7372
  this.store = store;
7231
7373
  }
@@ -7349,7 +7491,8 @@ Guidance (must be followed): ${decision.note}`
7349
7491
  const overallStatus = this.determineStatus(t3Results);
7350
7492
  const isOk = overallStatus === "COMPLETED" || overallStatus === "PARTIAL";
7351
7493
  this.setStatus(isOk ? "COMPLETED" : "FAILED", summary);
7352
- this.sendStatusUpdate({ progressPct: 100, currentAction: "Section complete", status: "IN_PROGRESS", output: summary });
7494
+ const sectionAction = overallStatus === "COMPLETED" ? "Section complete" : overallStatus === "PARTIAL" ? "Section partially complete \u2014 some subtasks failed" : overallStatus === "ESCALATED" ? "Section escalated \u2014 needs a decision" : "Section failed \u2014 no subtask completed";
7495
+ this.sendStatusUpdate({ progressPct: 100, currentAction: sectionAction, status: "IN_PROGRESS", output: summary });
7353
7496
  const result = {
7354
7497
  sectionId: assignment.sectionId,
7355
7498
  sectionTitle: assignment.sectionTitle,
@@ -7519,6 +7662,24 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
7519
7662
  const reinforceCfg = this.router.getReinforcementsConfig?.() ?? { enabled: false, maxPerSection: 4 };
7520
7663
  let reinforcementsAdded = 0;
7521
7664
  while (remaining.size > 0) {
7665
+ if (this.runBreaker?.isOpen()) {
7666
+ const why = this.runBreaker.skipMessage();
7667
+ this.log(`Run breaker open \u2014 skipping ${remaining.size} remaining subtask(s)`);
7668
+ for (const id of remaining) {
7669
+ this.t3PeerBus.publish(this.id, id, why, "FAILED");
7670
+ resultMap.set(id, {
7671
+ subtaskId: id,
7672
+ status: "FAILED",
7673
+ output: why,
7674
+ testResults: { checksRun: [], passed: [], failed: [] },
7675
+ issues: [why],
7676
+ peerSyncsUsed: [],
7677
+ correctionAttempts: 0
7678
+ });
7679
+ }
7680
+ remaining.clear();
7681
+ break;
7682
+ }
7522
7683
  const runnableIds = [...remaining].filter((id) => (inDegree.get(id) ?? 0) === 0);
7523
7684
  if (runnableIds.length === 0) {
7524
7685
  const fallbackId = [...remaining].sort(
@@ -7621,8 +7782,32 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
7621
7782
  remaining.delete(id);
7622
7783
  const r = waveResults[i];
7623
7784
  if (r.status === "rejected") {
7624
- this.log(`T3 worker ${id} failed: ${r.reason instanceof Error ? r.reason.message : String(r.reason)} \u2014 retrying once`);
7625
7785
  const assignment = sanitizedAssignments.find((a) => a.subtaskId === id);
7786
+ const failedModel = workerMap.get(id)?.getServingModel();
7787
+ const tripped = this.runBreaker?.record(r.reason, failedModel);
7788
+ if (tripped) {
7789
+ this.log(`Run breaker opened: ${tripped.failures} consecutive ${tripped.kind} failures on ${tripped.modelId} \u2014 stopping`);
7790
+ this.emit("run:breaker", tripped);
7791
+ }
7792
+ if (this.runBreaker?.isOpen()) {
7793
+ const why = this.runBreaker.skipMessage();
7794
+ this.log(`T3 worker ${id} failed and the run breaker is open \u2014 not retrying`);
7795
+ this.t3PeerBus.publish(this.id, id, why, "FAILED");
7796
+ resultMap.set(id, {
7797
+ subtaskId: id,
7798
+ status: "FAILED",
7799
+ output: why,
7800
+ testResults: { checksRun: [], passed: [], failed: [] },
7801
+ issues: [why],
7802
+ peerSyncsUsed: [],
7803
+ correctionAttempts: 0
7804
+ });
7805
+ for (const dependent of adj.get(id) ?? []) {
7806
+ inDegree.set(dependent, Math.max(0, (inDegree.get(dependent) ?? 0) - 1));
7807
+ }
7808
+ continue;
7809
+ }
7810
+ this.log(`T3 worker ${id} failed: ${r.reason instanceof Error ? r.reason.message : String(r.reason)} \u2014 retrying once`);
7626
7811
  try {
7627
7812
  const retried = await this.retryT3(assignment, taskId);
7628
7813
  resultMap.set(id, retried);
@@ -7954,6 +8139,7 @@ var T1Administrator = class extends BaseTier {
7954
8139
  t2Managers = /* @__PURE__ */ new Map();
7955
8140
  escalations = [];
7956
8141
  store;
8142
+ runBreaker;
7957
8143
  t2PeerBus = new PeerBus();
7958
8144
  permissionEscalator;
7959
8145
  toolCreator;
@@ -7968,6 +8154,10 @@ var T1Administrator = class extends BaseTier {
7968
8154
  this.toolRegistry = toolRegistry;
7969
8155
  this.config = config;
7970
8156
  }
8157
+ /** Share the run-wide circuit breaker with every section manager. */
8158
+ setRunBreaker(breaker) {
8159
+ this.runBreaker = breaker;
8160
+ }
7971
8161
  setStore(store) {
7972
8162
  this.store = store;
7973
8163
  }
@@ -8330,6 +8520,7 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
8330
8520
  };
8331
8521
  const managers = sections.map((section) => {
8332
8522
  const manager = new T2Manager(this.router, this.toolRegistry, this.id);
8523
+ if (this.runBreaker) manager.setRunBreaker(this.runBreaker);
8333
8524
  manager.setHierarchyContext(`You are a T2 Manager for the section "${section.sectionTitle}". You are part of a COMPLEX task overseen by T1 Administrator.`);
8334
8525
  if (this.store) {
8335
8526
  manager.setStore(this.store);
@@ -8615,6 +8806,156 @@ Reply with exactly one word: YES, NO, or UNSURE.
8615
8806
  }
8616
8807
  };
8617
8808
 
8809
+ // src/core/multimodal/registry.ts
8810
+ var CAPABILITIES = [
8811
+ // ── Image ──
8812
+ {
8813
+ modality: "image",
8814
+ provider: "openai",
8815
+ modelId: "dall-e-3",
8816
+ api: "openai-images"
8817
+ },
8818
+ {
8819
+ modality: "image",
8820
+ provider: "gemini",
8821
+ modelId: "gemini-3-pro-image",
8822
+ api: "gemini-predict"
8823
+ },
8824
+ // ── Speech (text → audio) ──
8825
+ {
8826
+ modality: "speech",
8827
+ provider: "openai",
8828
+ modelId: "tts-1",
8829
+ api: "openai-speech"
8830
+ },
8831
+ {
8832
+ // The only quality claim in this table that needs no leaderboard: OpenAI
8833
+ // ships these as the same voice at two fidelities and charges double for
8834
+ // this one. That is the vendor's own ordering, not an inference.
8835
+ modality: "speech",
8836
+ provider: "openai",
8837
+ modelId: "tts-1-hd",
8838
+ api: "openai-speech",
8839
+ quality: 70,
8840
+ qualityBasis: "OpenAI's own hd variant of tts-1, priced 2x"
8841
+ },
8842
+ // ── Transcription (audio → text) ──
8843
+ {
8844
+ modality: "transcription",
8845
+ provider: "openai",
8846
+ modelId: "whisper-1",
8847
+ api: "openai-transcriptions"
8848
+ },
8849
+ // ── Video ──
8850
+ {
8851
+ // Listed so `describe()` can tell the truth about what exists, and so the
8852
+ // planner is told video is out of reach rather than left to invent a tool.
8853
+ // Veo is a long-running operation: submit, poll, then fetch bytes from a
8854
+ // signed URL. That is a different execution shape from every other entry
8855
+ // here and is not wired up — claiming otherwise would produce a tool that
8856
+ // fails at call time instead of an honest "not yet".
8857
+ modality: "video",
8858
+ provider: "gemini",
8859
+ modelId: "veo-3.1-generate-001",
8860
+ api: "gemini-predict",
8861
+ unsupported: "Veo runs as a long-running operation (submit \u2192 poll \u2192 fetch). Not wired up yet."
8862
+ }
8863
+ ];
8864
+ function capabilityCost(cap) {
8865
+ const match = findPricing(cap.modelId, cap.provider, {
8866
+ fallbackProviders: cap.provider === "gemini" ? ["vertex"] : []
8867
+ });
8868
+ const rate = match?.entry.rates?.[0];
8869
+ if (!rate) return null;
8870
+ const unitLabel = {
8871
+ per_image: "per image",
8872
+ per_second: "per second of video",
8873
+ per_character: "per character",
8874
+ per_audio_minute: "per audio minute"
8875
+ };
8876
+ const unit = unitLabel[rate.unit] ?? rate.unit;
8877
+ const amount = rate.amount;
8878
+ const shown = amount < 1e-3 ? amount.toExponential(2) : amount.toFixed(4);
8879
+ return { label: `$${shown} ${unit}`, amount, unit };
8880
+ }
8881
+ var MultimodalRegistry = class {
8882
+ /** Provider types the user has actually configured a key for. */
8883
+ available;
8884
+ constructor(availableProviders) {
8885
+ this.available = new Set(availableProviders);
8886
+ }
8887
+ /** Every capability whose provider is configured AND which Cascade can call. */
8888
+ usable(modality) {
8889
+ return CAPABILITIES.filter(
8890
+ (c) => !c.unsupported && this.available.has(c.provider) && (!modality || c.modality === modality)
8891
+ );
8892
+ }
8893
+ /** Modalities this account can actually generate right now. */
8894
+ availableModalities() {
8895
+ return [...new Set(this.usable().map((c) => c.modality))];
8896
+ }
8897
+ /**
8898
+ * Pick a model for a modality.
8899
+ *
8900
+ * Ranked models beat unranked ones, then higher quality, then lower unit
8901
+ * price. An explicit `preferModel` wins outright when it is usable — the user
8902
+ * naming a model is a stronger signal than any prior.
8903
+ */
8904
+ select(modality, opts = {}) {
8905
+ const pool = this.usable(modality);
8906
+ if (!pool.length) return null;
8907
+ if (opts.preferModel) {
8908
+ const wanted = pool.find((c) => c.modelId === opts.preferModel);
8909
+ if (wanted) {
8910
+ return { capability: wanted, cost: capabilityCost(wanted), reason: `Requested explicitly: ${wanted.modelId}.` };
8911
+ }
8912
+ }
8913
+ const scored = pool.map((c) => ({ c, cost: capabilityCost(c) })).sort((a, b) => {
8914
+ const qa = a.c.quality ?? -1;
8915
+ const qb = b.c.quality ?? -1;
8916
+ if (qa !== qb) return qb - qa;
8917
+ const pa = a.cost?.amount ?? Number.POSITIVE_INFINITY;
8918
+ const pb = b.cost?.amount ?? Number.POSITIVE_INFINITY;
8919
+ return pa - pb;
8920
+ });
8921
+ const best = scored[0];
8922
+ const ranked = best.c.quality !== void 0;
8923
+ const reason = ranked ? `${best.c.modelId} (${best.c.provider}) \u2014 ranked highest for ${modality}: ${best.c.qualityBasis}.` + (best.cost ? ` ${best.cost.label}.` : "") : `${best.c.modelId} (${best.c.provider}) \u2014 cheapest usable ${modality} model` + (best.cost ? ` at ${best.cost.label}` : "") + ". No published quality ranking is applied for this modality, so this is a cost choice, not a quality one.";
8924
+ return { capability: best.c, cost: best.cost, reason };
8925
+ }
8926
+ /**
8927
+ * A plain-language account of what this account can and cannot generate —
8928
+ * including the things it cannot, and why. The planner sees this, so being
8929
+ * candid here is what stops it inventing a `generate_music` call.
8930
+ */
8931
+ describe() {
8932
+ const lines = [];
8933
+ const usable = this.usable();
8934
+ if (usable.length) {
8935
+ lines.push("Generation models available on your configured providers:");
8936
+ for (const modality of ["image", "speech", "transcription", "video"]) {
8937
+ const forModality = usable.filter((c) => c.modality === modality);
8938
+ if (!forModality.length) continue;
8939
+ const shown = forModality.map((c) => {
8940
+ const cost = capabilityCost(c);
8941
+ return `${c.modelId}${cost ? ` (${cost.label})` : ""}`;
8942
+ });
8943
+ lines.push(` ${modality}: ${shown.join(", ")}`);
8944
+ }
8945
+ } else {
8946
+ lines.push("No generation models are available \u2014 none of your configured providers offers one.");
8947
+ }
8948
+ const blocked = CAPABILITIES.filter((c) => c.unsupported && this.available.has(c.provider));
8949
+ for (const c of blocked) {
8950
+ lines.push(` ${c.modality}: ${c.modelId} exists on your account but is not callable. ${c.unsupported}`);
8951
+ }
8952
+ lines.push(
8953
+ " music: no supported provider exposes a music-generation API, so Cascade cannot generate music."
8954
+ );
8955
+ return lines.join("\n");
8956
+ }
8957
+ };
8958
+
8618
8959
  // src/tools/base.ts
8619
8960
  var BaseTool = class {
8620
8961
  workspaceRoot = process.cwd();
@@ -8633,7 +8974,446 @@ var BaseTool = class {
8633
8974
  }
8634
8975
  };
8635
8976
 
8636
- // src/tools/shell.ts
8977
+ // src/core/router/provider-errors.ts
8978
+ var SYSTEMIC = /* @__PURE__ */ new Set([
8979
+ "rate_limit",
8980
+ "quota_exhausted",
8981
+ "auth",
8982
+ "model_unavailable"
8983
+ ]);
8984
+ function isSystemicKind(kind) {
8985
+ return SYSTEMIC.has(kind);
8986
+ }
8987
+ function statusOf(err) {
8988
+ if (!err || typeof err !== "object") return void 0;
8989
+ const e = err;
8990
+ for (const key of ["status", "statusCode", "code"]) {
8991
+ const v = e[key];
8992
+ if (typeof v === "number" && v >= 100 && v < 600) return v;
8993
+ }
8994
+ const resp = e["response"];
8995
+ if (resp && typeof resp["status"] === "number") return resp["status"];
8996
+ const inner = e["error"];
8997
+ if (inner && typeof inner["code"] === "number") return inner["code"];
8998
+ return void 0;
8999
+ }
9000
+ function messageOf(err) {
9001
+ if (err instanceof Error) return err.message;
9002
+ if (typeof err === "string") return err;
9003
+ if (err && typeof err === "object") {
9004
+ const e = err;
9005
+ if (typeof e["message"] === "string") return e["message"];
9006
+ const inner = e["error"];
9007
+ if (inner && typeof inner["message"] === "string") return inner["message"];
9008
+ try {
9009
+ return JSON.stringify(err);
9010
+ } catch {
9011
+ }
9012
+ }
9013
+ return String(err);
9014
+ }
9015
+ function classifyProviderError(err) {
9016
+ const raw = messageOf(err).trim();
9017
+ const status = statusOf(err);
9018
+ const m = raw.toLowerCase();
9019
+ const kind = (() => {
9020
+ if (status === 429) {
9021
+ return /quota|billing|credit|exceeded your current quota|insufficient/.test(m) ? "quota_exhausted" : "rate_limit";
9022
+ }
9023
+ if (status === 401 || status === 403) return "auth";
9024
+ if (status === 404) return "model_unavailable";
9025
+ if (/rate limit|too many requests|resource_exhausted/.test(m)) {
9026
+ return /quota|billing|credit|insufficient/.test(m) ? "quota_exhausted" : "rate_limit";
9027
+ }
9028
+ if (/quota|billing|insufficient_quota|insufficient credit|payment required/.test(m)) return "quota_exhausted";
9029
+ if (/api key|unauthorized|authentication|permission denied|invalid[_ ]api/.test(m)) return "auth";
9030
+ if (/model .*(not found|does not exist|unavailable)|no such model|unknown model|not supported for/.test(m)) {
9031
+ return "model_unavailable";
9032
+ }
9033
+ if (/context length|too many tokens|maximum context|prompt is too long|input too long/.test(m)) return "context_length";
9034
+ if (/safety|content filter|blocked|recitation|prohibited/.test(m)) return "content_filter";
9035
+ if (/econnreset|etimedout|enotfound|socket hang up|network|fetch failed|aborted/.test(m)) return "network";
9036
+ return "unknown";
9037
+ })();
9038
+ return { kind, systemic: isSystemicKind(kind), status, raw };
9039
+ }
9040
+ function describeProviderError(c, modelId) {
9041
+ const on = modelId ? ` on ${modelId}` : "";
9042
+ const detail = c.raw ? ` Provider said: ${c.raw}` : "";
9043
+ switch (c.kind) {
9044
+ case "rate_limit":
9045
+ return `Rate limit hit${on}. The provider is throttling requests \u2014 lower the number of parallel workers, or use a model with a higher requests-per-minute allowance.${detail}`;
9046
+ case "quota_exhausted":
9047
+ return `Quota or billing limit reached${on}. This will not recover on its own \u2014 check the provider's billing page, or switch this tier to a different provider.${detail}`;
9048
+ case "auth":
9049
+ return `Authentication failed${on}. The API key is missing, expired, or lacks access to this model \u2014 re-check the key in Settings > Providers.${detail}`;
9050
+ case "model_unavailable":
9051
+ return `Model unavailable${on}. The id may be wrong, retired, or not enabled for this API key \u2014 pick a different model for this tier.${detail}`;
9052
+ case "context_length":
9053
+ return `The prompt was too long${on}. Reduce the attached context, or use a model with a larger context window for this tier.${detail}`;
9054
+ case "content_filter":
9055
+ return `The provider's safety filter refused this request${on}.${detail}`;
9056
+ case "network":
9057
+ return `Network error reaching the provider${on}. This is usually transient.${detail}`;
9058
+ default:
9059
+ return `The model call failed${on}.${detail}`;
9060
+ }
9061
+ }
9062
+
9063
+ // src/core/multimodal/generate.ts
9064
+ var DEFAULT_TIMEOUT_MS = 12e4;
9065
+ async function callProvider(fn, modelId) {
9066
+ try {
9067
+ return await fn();
9068
+ } catch (err) {
9069
+ const classified = classifyProviderError(err);
9070
+ throw new Error(describeProviderError(classified, modelId));
9071
+ }
9072
+ }
9073
+ async function postJson(url, body, headers, signal) {
9074
+ const ac = new AbortController();
9075
+ const timer = setTimeout(() => ac.abort(), DEFAULT_TIMEOUT_MS);
9076
+ const onAbort = () => ac.abort();
9077
+ signal?.addEventListener("abort", onAbort);
9078
+ try {
9079
+ const res = await fetch(url, {
9080
+ method: "POST",
9081
+ headers: { "Content-Type": "application/json", ...headers },
9082
+ body: JSON.stringify(body),
9083
+ signal: ac.signal
9084
+ });
9085
+ if (!res.ok) {
9086
+ const text = await res.text().catch(() => "");
9087
+ throw Object.assign(new Error(text || res.statusText), { status: res.status });
9088
+ }
9089
+ return res;
9090
+ } finally {
9091
+ clearTimeout(timer);
9092
+ signal?.removeEventListener("abort", onAbort);
9093
+ }
9094
+ }
9095
+ function baseUrlFor(cfg, fallback) {
9096
+ return (cfg.baseUrl ?? fallback).replace(/\/+$/, "");
9097
+ }
9098
+ function stamp(ext) {
9099
+ return `cascade-${Date.now()}.${ext}`;
9100
+ }
9101
+ async function generateImage(cap, cfg, req, signal) {
9102
+ return callProvider(async () => {
9103
+ if (cap.api === "openai-images") {
9104
+ const url = `${baseUrlFor(cfg, "https://api.openai.com/v1")}/images/generations`;
9105
+ const res = await postJson(url, {
9106
+ model: cap.modelId,
9107
+ prompt: req.prompt,
9108
+ size: req.size ?? "1024x1024",
9109
+ n: 1,
9110
+ // b64 rather than a URL: see the file header — provider URLs expire.
9111
+ response_format: "b64_json"
9112
+ }, { Authorization: `Bearer ${cfg.apiKey ?? ""}` }, signal);
9113
+ const body = await res.json();
9114
+ const first = body.data?.[0];
9115
+ if (!first?.b64_json) throw new Error("Image response contained no image data.");
9116
+ return {
9117
+ data: Buffer.from(first.b64_json, "base64"),
9118
+ mimeType: "image/png",
9119
+ filename: stamp("png"),
9120
+ modelId: cap.modelId,
9121
+ provider: cap.provider,
9122
+ ...first.revised_prompt ? { revisedPrompt: first.revised_prompt } : {}
9123
+ };
9124
+ }
9125
+ if (cap.api === "gemini-predict") {
9126
+ const base = baseUrlFor(cfg, "https://generativelanguage.googleapis.com/v1beta");
9127
+ const url = `${base}/models/${encodeURIComponent(cap.modelId)}:predict`;
9128
+ const res = await postJson(url, {
9129
+ instances: [{ prompt: req.prompt }],
9130
+ parameters: { sampleCount: 1 }
9131
+ }, { "x-goog-api-key": cfg.apiKey ?? "" }, signal);
9132
+ const body = await res.json();
9133
+ const first = body.predictions?.[0];
9134
+ if (!first?.bytesBase64Encoded) throw new Error("Image response contained no image data.");
9135
+ const mimeType = first.mimeType ?? "image/png";
9136
+ return {
9137
+ data: Buffer.from(first.bytesBase64Encoded, "base64"),
9138
+ mimeType,
9139
+ filename: stamp(mimeType.includes("jpeg") ? "jpg" : "png"),
9140
+ modelId: cap.modelId,
9141
+ provider: cap.provider
9142
+ };
9143
+ }
9144
+ throw new Error(`${cap.modelId} is not an image generator.`);
9145
+ }, cap.modelId);
9146
+ }
9147
+ async function generateSpeech(cap, cfg, req, signal) {
9148
+ return callProvider(async () => {
9149
+ if (cap.api !== "openai-speech") throw new Error(`${cap.modelId} is not a speech model.`);
9150
+ const format = req.format ?? "mp3";
9151
+ const url = `${baseUrlFor(cfg, "https://api.openai.com/v1")}/audio/speech`;
9152
+ const res = await postJson(url, {
9153
+ model: cap.modelId,
9154
+ input: req.text,
9155
+ voice: req.voice ?? "alloy",
9156
+ response_format: format
9157
+ }, { Authorization: `Bearer ${cfg.apiKey ?? ""}` }, signal);
9158
+ const mime = {
9159
+ mp3: "audio/mpeg",
9160
+ opus: "audio/opus",
9161
+ aac: "audio/aac",
9162
+ flac: "audio/flac",
9163
+ wav: "audio/wav"
9164
+ };
9165
+ return {
9166
+ data: Buffer.from(await res.arrayBuffer()),
9167
+ mimeType: mime[format] ?? "application/octet-stream",
9168
+ filename: stamp(format),
9169
+ modelId: cap.modelId,
9170
+ provider: cap.provider
9171
+ };
9172
+ }, cap.modelId);
9173
+ }
9174
+ async function transcribeAudio(cap, cfg, req, signal) {
9175
+ return callProvider(async () => {
9176
+ if (cap.api !== "openai-transcriptions") throw new Error(`${cap.modelId} is not a transcription model.`);
9177
+ const url = `${baseUrlFor(cfg, "https://api.openai.com/v1")}/audio/transcriptions`;
9178
+ const form = new FormData();
9179
+ form.append("model", cap.modelId);
9180
+ if (req.language) form.append("language", req.language);
9181
+ form.append("file", new Blob([new Uint8Array(req.audio)]), req.filename);
9182
+ const ac = new AbortController();
9183
+ const timer = setTimeout(() => ac.abort(), DEFAULT_TIMEOUT_MS);
9184
+ const onAbort = () => ac.abort();
9185
+ signal?.addEventListener("abort", onAbort);
9186
+ try {
9187
+ const res = await fetch(url, {
9188
+ method: "POST",
9189
+ headers: { Authorization: `Bearer ${cfg.apiKey ?? ""}` },
9190
+ body: form,
9191
+ signal: ac.signal
9192
+ });
9193
+ if (!res.ok) {
9194
+ const text = await res.text().catch(() => "");
9195
+ throw Object.assign(new Error(text || res.statusText), { status: res.status });
9196
+ }
9197
+ const body = await res.json();
9198
+ return { text: body.text ?? "", modelId: cap.modelId };
9199
+ } finally {
9200
+ clearTimeout(timer);
9201
+ signal?.removeEventListener("abort", onAbort);
9202
+ }
9203
+ }, cap.modelId);
9204
+ }
9205
+
9206
+ // src/tools/generate-media.ts
9207
+ function resolve(deps, modality, preferModel) {
9208
+ const selected = deps.registry.select(modality, preferModel ? { preferModel } : {});
9209
+ if (!selected) {
9210
+ return { ok: false, error: `No ${modality} model is available on your configured providers.` };
9211
+ }
9212
+ const cfg = deps.lookupProvider(selected.capability.provider);
9213
+ if (!cfg) {
9214
+ return {
9215
+ ok: false,
9216
+ error: `${selected.capability.modelId} needs the ${selected.capability.provider} provider, which is not configured.`
9217
+ };
9218
+ }
9219
+ return { ok: true, selected, cfg };
9220
+ }
9221
+ var GenerateImageTool = class extends BaseTool {
9222
+ constructor(deps) {
9223
+ super();
9224
+ this.deps = deps;
9225
+ }
9226
+ deps;
9227
+ name = "generate_image";
9228
+ description = "Generate an image from a text description using the best available image model. Returns where the image was saved. Use when the user asks for a picture, illustration, diagram render, logo or any visual asset.";
9229
+ inputSchema = {
9230
+ type: "object",
9231
+ properties: {
9232
+ prompt: { type: "string", description: "What the image should show. Be specific about subject, style and composition." },
9233
+ size: { type: "string", description: 'Pixel size, e.g. "1024x1024" (default), "1792x1024", "1024x1792".' },
9234
+ model: { type: "string", description: "Optional: force a specific image model instead of the automatic choice." }
9235
+ },
9236
+ required: ["prompt"]
9237
+ };
9238
+ async execute(input, options) {
9239
+ const prompt = String(input["prompt"] ?? "").trim();
9240
+ if (!prompt) return 'Provide a "prompt" describing the image.';
9241
+ const r = resolve(this.deps, "image", input["model"] ? String(input["model"]) : void 0);
9242
+ if (!r.ok) return r.error;
9243
+ const size = input["size"] ? String(input["size"]) : void 0;
9244
+ const asset = await generateImage(
9245
+ r.selected.capability,
9246
+ r.cfg,
9247
+ { prompt, ...size ? { size } : {} },
9248
+ options.signal
9249
+ );
9250
+ const location = await this.deps.sink(asset);
9251
+ return [
9252
+ `Image generated and saved to ${location}.`,
9253
+ `Model: ${asset.modelId} \u2014 ${r.selected.reason}`,
9254
+ // DALL·E rewrites the prompt before drawing; the model needs to know what
9255
+ // was actually rendered, not what it asked for, or its description of the
9256
+ // result will not match the image.
9257
+ asset.revisedPrompt ? `The provider rewrote the prompt to: ${asset.revisedPrompt}` : ""
9258
+ ].filter(Boolean).join("\n");
9259
+ }
9260
+ };
9261
+ var GenerateSpeechTool = class extends BaseTool {
9262
+ constructor(deps) {
9263
+ super();
9264
+ this.deps = deps;
9265
+ }
9266
+ deps;
9267
+ name = "generate_speech";
9268
+ description = "Turn text into spoken audio using the best available text-to-speech model. Returns where the audio was saved. Use when the user asks for narration, a voiceover or an audio version of some text.";
9269
+ inputSchema = {
9270
+ type: "object",
9271
+ properties: {
9272
+ text: { type: "string", description: "The text to speak." },
9273
+ voice: { type: "string", description: "Voice id: alloy, echo, fable, onyx, nova, shimmer. Default alloy." },
9274
+ format: { type: "string", description: "Audio format: mp3 (default), opus, aac, flac, wav." },
9275
+ model: { type: "string", description: "Optional: force a specific speech model." }
9276
+ },
9277
+ required: ["text"]
9278
+ };
9279
+ async execute(input, options) {
9280
+ const text = String(input["text"] ?? "").trim();
9281
+ if (!text) return 'Provide "text" to speak.';
9282
+ const r = resolve(this.deps, "speech", input["model"] ? String(input["model"]) : void 0);
9283
+ if (!r.ok) return r.error;
9284
+ const fmt = String(input["format"] ?? "mp3");
9285
+ const allowed = ["mp3", "opus", "aac", "flac", "wav"];
9286
+ const format = allowed.includes(fmt) ? fmt : "mp3";
9287
+ const asset = await generateSpeech(
9288
+ r.selected.capability,
9289
+ r.cfg,
9290
+ { text, voice: input["voice"] ? String(input["voice"]) : void 0, format },
9291
+ options.signal
9292
+ );
9293
+ const location = await this.deps.sink(asset);
9294
+ return `Speech generated and saved to ${location}.
9295
+ Model: ${asset.modelId} \u2014 ${r.selected.reason}`;
9296
+ }
9297
+ };
9298
+ var TranscribeAudioTool = class extends BaseTool {
9299
+ constructor(deps, readFile) {
9300
+ super();
9301
+ this.deps = deps;
9302
+ this.readFile = readFile;
9303
+ }
9304
+ deps;
9305
+ readFile;
9306
+ name = "transcribe_audio";
9307
+ description = "Transcribe a local audio file to text using the best available speech-to-text model. Use when the user supplies audio and wants its contents in writing.";
9308
+ inputSchema = {
9309
+ type: "object",
9310
+ properties: {
9311
+ path: { type: "string", description: "Path to the audio file to transcribe." },
9312
+ language: { type: "string", description: 'Optional ISO-639-1 language hint, e.g. "en", improves accuracy.' },
9313
+ model: { type: "string", description: "Optional: force a specific transcription model." }
9314
+ },
9315
+ required: ["path"]
9316
+ };
9317
+ async execute(input, options) {
9318
+ const filePath = String(input["path"] ?? "").trim();
9319
+ if (!filePath) return 'Provide a "path" to the audio file.';
9320
+ const r = resolve(this.deps, "transcription", input["model"] ? String(input["model"]) : void 0);
9321
+ if (!r.ok) return r.error;
9322
+ let audio;
9323
+ try {
9324
+ audio = await this.readFile(filePath);
9325
+ } catch {
9326
+ return `Could not read the audio file at ${filePath}.`;
9327
+ }
9328
+ const { text, modelId } = await transcribeAudio(
9329
+ r.selected.capability,
9330
+ r.cfg,
9331
+ {
9332
+ audio,
9333
+ filename: filePath.split(/[\\/]/).pop() || "audio",
9334
+ ...input["language"] ? { language: String(input["language"]) } : {}
9335
+ },
9336
+ options.signal
9337
+ );
9338
+ return text ? `Transcript (${modelId}):
9339
+
9340
+ ${text}` : `${modelId} returned an empty transcript \u2014 the file may be silent or in an unsupported format.`;
9341
+ }
9342
+ };
9343
+ function buildMediaTools(deps, readFile) {
9344
+ const tools = [];
9345
+ const modalities = new Set(deps.registry.availableModalities());
9346
+ if (modalities.has("image")) tools.push(new GenerateImageTool(deps));
9347
+ if (modalities.has("speech")) tools.push(new GenerateSpeechTool(deps));
9348
+ if (modalities.has("transcription") && readFile) tools.push(new TranscribeAudioTool(deps, readFile));
9349
+ return tools;
9350
+ }
9351
+
9352
+ // src/core/run-breaker.ts
9353
+ var DEFAULT_FAILURE_THRESHOLD = 3;
9354
+ var RunBreaker = class {
9355
+ threshold;
9356
+ /** modelId → consecutive systemic failures of the SAME kind. */
9357
+ counts = /* @__PURE__ */ new Map();
9358
+ trip = null;
9359
+ constructor(threshold = DEFAULT_FAILURE_THRESHOLD) {
9360
+ this.threshold = Math.max(1, threshold);
9361
+ }
9362
+ /**
9363
+ * Record a failed model call. Returns the trip if this failure is the one
9364
+ * that opened the breaker, otherwise null.
9365
+ *
9366
+ * A non-systemic failure RESETS the model's counter: it is evidence the model
9367
+ * is reachable and answering, which is exactly what a systemic streak claims
9368
+ * it is not.
9369
+ */
9370
+ record(err, modelId) {
9371
+ if (this.trip) return null;
9372
+ const id = modelId?.trim() || "unknown-model";
9373
+ const c = classifyProviderError(err);
9374
+ if (!c.systemic) {
9375
+ this.counts.delete(id);
9376
+ return null;
9377
+ }
9378
+ const prev = this.counts.get(id);
9379
+ const n = prev && prev.kind === c.kind ? prev.n + 1 : 1;
9380
+ this.counts.set(id, { kind: c.kind, n, last: c });
9381
+ if (n >= this.threshold) {
9382
+ this.trip = {
9383
+ modelId: id,
9384
+ kind: c.kind,
9385
+ failures: n,
9386
+ message: describeProviderError(c, id)
9387
+ };
9388
+ return this.trip;
9389
+ }
9390
+ return null;
9391
+ }
9392
+ /** True once the run should stop launching work that would fail identically. */
9393
+ isOpen() {
9394
+ return this.trip !== null;
9395
+ }
9396
+ /** Why the breaker opened, or null while it is closed. */
9397
+ reason() {
9398
+ return this.trip;
9399
+ }
9400
+ /**
9401
+ * One line for a worker/section that never ran because the breaker was
9402
+ * already open. Says the work was skipped deliberately — not that it failed
9403
+ * on its own merits, which is what an empty FAILED status would imply.
9404
+ */
9405
+ skipMessage() {
9406
+ const t = this.trip;
9407
+ if (!t) return "Skipped.";
9408
+ return `Skipped \u2014 the run stopped after ${t.failures} consecutive failures on ${t.modelId}. ${t.message}`;
9409
+ }
9410
+ /** Test/diagnostic view of the per-model streaks. */
9411
+ streaks() {
9412
+ const out = {};
9413
+ for (const [k, v] of this.counts) out[k] = { kind: v.kind, n: v.n };
9414
+ return out;
9415
+ }
9416
+ };
8637
9417
  var execAsync = promisify(exec);
8638
9418
  var ShellTool = class extends BaseTool {
8639
9419
  name = "shell";
@@ -9329,7 +10109,7 @@ var PDFCreateTool = class extends BaseTool {
9329
10109
  if (!fs23.existsSync(dir)) {
9330
10110
  fs23.mkdirSync(dir, { recursive: true });
9331
10111
  }
9332
- return new Promise((resolve, reject) => {
10112
+ return new Promise((resolve2, reject) => {
9333
10113
  try {
9334
10114
  const doc = new PDFDocument({ margin: 50 });
9335
10115
  const stream = fs23.createWriteStream(filePath);
@@ -9347,7 +10127,7 @@ var PDFCreateTool = class extends BaseTool {
9347
10127
  });
9348
10128
  doc.end();
9349
10129
  stream.on("finish", () => {
9350
- resolve(`Successfully created PDF at ${filePath} (${content.length} characters)`);
10130
+ resolve2(`Successfully created PDF at ${filePath} (${content.length} characters)`);
9351
10131
  });
9352
10132
  stream.on("error", (err) => {
9353
10133
  reject(new Error(`Failed to write PDF: ${err.message}`));
@@ -9419,7 +10199,7 @@ var CodeInterpreterTool = class extends BaseTool {
9419
10199
  const filePath = path24.join(tmpDir, fileName);
9420
10200
  fs23.writeFileSync(filePath, code, "utf-8");
9421
10201
  const execArgs = [filePath, ...args];
9422
- return new Promise((resolve) => {
10202
+ return new Promise((resolve2) => {
9423
10203
  const startMs = Date.now();
9424
10204
  execFile(cmdPrefix, execArgs, { cwd: this.workspaceRoot, timeout: 3e4 }, (error, stdout, stderr) => {
9425
10205
  const duration = Date.now() - startMs;
@@ -9433,17 +10213,17 @@ var CodeInterpreterTool = class extends BaseTool {
9433
10213
  if (error) {
9434
10214
  const timedOut = error.killed && duration >= 3e4;
9435
10215
  if (timedOut) {
9436
- resolve(`Execution timed out after 30s. Consider breaking the task into smaller pieces.
10216
+ resolve2(`Execution timed out after 30s. Consider breaking the task into smaller pieces.
9437
10217
  Partial stdout: ${stdout}
9438
10218
  Stderr: ${stderr}`);
9439
10219
  } else {
9440
- resolve(`Execution failed (${duration}ms):
10220
+ resolve2(`Execution failed (${duration}ms):
9441
10221
  Error: ${error.message}
9442
10222
  Stderr: ${stderr}
9443
10223
  Stdout: ${stdout}`);
9444
10224
  }
9445
10225
  } else {
9446
- resolve(`Execution successful (${duration}ms):
10226
+ resolve2(`Execution successful (${duration}ms):
9447
10227
  Stdout: ${stdout}
9448
10228
  Stderr: ${stderr}`);
9449
10229
  }
@@ -10097,7 +10877,7 @@ var ToolRegistry = class extends EventEmitter {
10097
10877
  */
10098
10878
  waitForTool(toolName, timeoutMs = 6e4) {
10099
10879
  if (this.tools.has(toolName)) return Promise.resolve();
10100
- return new Promise((resolve, reject) => {
10880
+ return new Promise((resolve2, reject) => {
10101
10881
  const timer = setTimeout(() => {
10102
10882
  this.off("tool:added", handler);
10103
10883
  reject(new Error(`Timeout waiting for tool: ${toolName}`));
@@ -10106,7 +10886,7 @@ var ToolRegistry = class extends EventEmitter {
10106
10886
  if (name === toolName) {
10107
10887
  clearTimeout(timer);
10108
10888
  this.off("tool:added", handler);
10109
- resolve();
10889
+ resolve2();
10110
10890
  }
10111
10891
  };
10112
10892
  this.on("tool:added", handler);
@@ -10781,20 +11561,20 @@ var PermissionEscalator = class extends EventEmitter {
10781
11561
  resolver(decision);
10782
11562
  }
10783
11563
  waitForUserDecision(req) {
10784
- return new Promise((resolve) => {
11564
+ return new Promise((resolve2) => {
10785
11565
  let timer;
10786
11566
  const wrappedResolver = (decision) => {
10787
11567
  if (timer) clearTimeout(timer);
10788
11568
  if (decision.always) {
10789
11569
  this.taskWideCache.set(req.toolName, decision.approved);
10790
11570
  }
10791
- resolve(decision);
11571
+ resolve2(decision);
10792
11572
  };
10793
11573
  this.pendingUserDecisions.set(req.id, wrappedResolver);
10794
11574
  if (this.approvalTimeoutMs > 0 && Number.isFinite(this.approvalTimeoutMs)) {
10795
11575
  timer = setTimeout(() => {
10796
11576
  if (this.pendingUserDecisions.delete(req.id)) {
10797
- resolve({
11577
+ resolve2({
10798
11578
  requestId: req.id,
10799
11579
  approved: false,
10800
11580
  decidedBy: "USER",
@@ -10961,6 +11741,14 @@ var BudgetConfigSchema = z.object({
10961
11741
  maxTokensPerRun: z.number().int().positive().default(2e5),
10962
11742
  /** Optional hard per-task cost ceiling (USD). Unset = only the token cap applies. */
10963
11743
  maxCostPerRunUsd: z.number().positive().optional(),
11744
+ /**
11745
+ * Consecutive systemic failures against ONE model before the run stops
11746
+ * launching work that would fail the same way (dead key, wrong model id,
11747
+ * exhausted quota). Lower reacts sooner and wastes less; higher absorbs a
11748
+ * longer transient before giving up. Only failures that will certainly repeat
11749
+ * are counted — see core/run-breaker.ts.
11750
+ */
11751
+ failureThreshold: z.number().int().min(1).max(20).default(3),
10964
11752
  warnAtPct: z.number().default(80)
10965
11753
  });
10966
11754
  var WorkspaceConfigSchema = z.object({
@@ -11248,6 +12036,23 @@ var noopTelemetry = {
11248
12036
  }
11249
12037
  };
11250
12038
 
12039
+ // src/core/router/feedback-prior.ts
12040
+ var MAX_FEEDBACK_ADJUSTMENT = 0.05;
12041
+ var PRIOR_STRENGTH = 10;
12042
+ function feedbackAdjustment(counts) {
12043
+ const good = Math.max(0, Math.floor(counts.good));
12044
+ const bad = Math.max(0, Math.floor(counts.bad));
12045
+ const n = good + bad;
12046
+ if (n === 0) return 0;
12047
+ const half = PRIOR_STRENGTH / 2;
12048
+ const shrunk = (good + half) / (n + PRIOR_STRENGTH);
12049
+ return (shrunk - 0.5) * 2 * MAX_FEEDBACK_ADJUSTMENT;
12050
+ }
12051
+ function applyFeedback(score01, counts) {
12052
+ const adjusted = score01 + feedbackAdjustment(counts);
12053
+ return Math.max(0, Math.min(1, adjusted));
12054
+ }
12055
+
11251
12056
  // src/core/router/task-analyzer.ts
11252
12057
  var CODE_SIGNALS = [
11253
12058
  /\b(?:function|class|interface|async|await|import|export|const|let|var|def|return|if|else|for|while|try|catch)\b/,
@@ -11322,6 +12127,8 @@ var TASK_TYPE_TAGS = {
11322
12127
  };
11323
12128
  var TaskAnalyzer = class {
11324
12129
  tracker;
12130
+ /** Per-model thumbs counts, when the host collects them. */
12131
+ feedback;
11325
12132
  bias;
11326
12133
  lastProfile = null;
11327
12134
  lastSelectedModels = /* @__PURE__ */ new Map();
@@ -11329,6 +12136,14 @@ var TaskAnalyzer = class {
11329
12136
  this.tracker = tracker;
11330
12137
  this.bias = bias;
11331
12138
  }
12139
+ /**
12140
+ * Supply per-model rating counts. Optional by design: a host that collects no
12141
+ * feedback (the CLI, a fresh install) scores exactly as it did before, since
12142
+ * feedbackAdjustment returns 0 for an empty record.
12143
+ */
12144
+ setFeedbackSource(source) {
12145
+ this.feedback = source;
12146
+ }
11332
12147
  setTracker(tracker) {
11333
12148
  this.tracker = tracker;
11334
12149
  }
@@ -11412,7 +12227,10 @@ var TaskAnalyzer = class {
11412
12227
  const perf = this.tracker?.performanceScore(model.id, profile.type) ?? 0.5;
11413
12228
  const costEff = this.costEfficiency(model, profile.complexity);
11414
12229
  const match = this.taskMatchScore(model, profile);
11415
- const benchmark = 0.3 + 0.7 * benchmarkScore01(model, profile.type);
12230
+ const rated = this.feedback?.(model.id);
12231
+ const publicScore = benchmarkScore01(model, profile.type);
12232
+ const adjusted = rated ? applyFeedback(publicScore, rated) : publicScore;
12233
+ const benchmark = 0.3 + 0.7 * adjusted;
11416
12234
  switch (this.bias) {
11417
12235
  case "quality":
11418
12236
  return perf * match * benchmark ** 2 * (0.85 + 0.15 * costEff);
@@ -11814,10 +12632,10 @@ var DynamicTool = class extends BaseTool {
11814
12632
  `;
11815
12633
  const script = await isolate.compileScript(bootstrap);
11816
12634
  const runPromise = script.run(context, { timeout: timeoutMs, promise: true }).then((v) => String(v ?? ""));
11817
- const timeoutPromise = new Promise((resolve) => {
12635
+ const timeoutPromise = new Promise((resolve2) => {
11818
12636
  const t = setTimeout(() => {
11819
12637
  dispose();
11820
- resolve(`Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`);
12638
+ resolve2(`Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`);
11821
12639
  }, timeoutMs + 500);
11822
12640
  t.unref?.();
11823
12641
  });
@@ -11835,7 +12653,7 @@ var DynamicTool = class extends BaseTool {
11835
12653
  /** Spawn the worker, service its callTool/fetch bridge, enforce the kill timeout. */
11836
12654
  runInWorker(input, callTool) {
11837
12655
  const timeoutMs = Math.max(200, Number(process.env["CASCADE_DYNAMIC_TOOL_TIMEOUT_MS"]) || DYNAMIC_TOOL_TIMEOUT_MS);
11838
- return new Promise((resolve) => {
12656
+ return new Promise((resolve2) => {
11839
12657
  let settled = false;
11840
12658
  const worker = new Worker(HARNESS_SRC, {
11841
12659
  eval: true,
@@ -11847,7 +12665,7 @@ var DynamicTool = class extends BaseTool {
11847
12665
  settled = true;
11848
12666
  clearTimeout(timer);
11849
12667
  void worker.terminate();
11850
- resolve(value);
12668
+ resolve2(value);
11851
12669
  };
11852
12670
  const timer = setTimeout(
11853
12671
  () => finish(`Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`),
@@ -13393,6 +14211,7 @@ ${prompt}`;
13393
14211
  var Cascade = class _Cascade extends EventEmitter {
13394
14212
  router;
13395
14213
  toolRegistry;
14214
+ multimodal;
13396
14215
  mcpClient;
13397
14216
  codeIndex;
13398
14217
  config;
@@ -13431,6 +14250,10 @@ var Cascade = class _Cascade extends EventEmitter {
13431
14250
  }
13432
14251
  });
13433
14252
  this.toolRegistry = new ToolRegistry(this.config.tools, workspacePath);
14253
+ this.multimodal = new MultimodalRegistry(
14254
+ (this.config.providers ?? []).map((p) => p.type)
14255
+ );
14256
+ this.registerMediaTools(workspacePath);
13434
14257
  this.telemetry = config.telemetry?.enabled ? new Telemetry(config.telemetry, config.telemetry.distinctId ?? "anonymous") : noopTelemetry;
13435
14258
  this.worldStateDB = new WorldStateDB(this.workspacePath, this.config.workspace?.debugWorldState ?? false);
13436
14259
  this.router.setWorldStateDB(this.worldStateDB);
@@ -13484,14 +14307,14 @@ var Cascade = class _Cascade extends EventEmitter {
13484
14307
  if (this.listenerCount("mcp:approval-required") === 0) {
13485
14308
  return false;
13486
14309
  }
13487
- return await new Promise((resolve) => {
13488
- this.pendingMcpApprovals.set(server.name, resolve);
14310
+ return await new Promise((resolve2) => {
14311
+ this.pendingMcpApprovals.set(server.name, resolve2);
13489
14312
  const timeout = setTimeout(() => {
13490
- if (this.pendingMcpApprovals.delete(server.name)) resolve(false);
14313
+ if (this.pendingMcpApprovals.delete(server.name)) resolve2(false);
13491
14314
  }, 3e4);
13492
14315
  const wrap = (approved) => {
13493
14316
  clearTimeout(timeout);
13494
- resolve(approved);
14317
+ resolve2(approved);
13495
14318
  };
13496
14319
  this.pendingMcpApprovals.set(server.name, wrap);
13497
14320
  this.emit("mcp:approval-required", { server });
@@ -13565,17 +14388,17 @@ var Cascade = class _Cascade extends EventEmitter {
13565
14388
  }
13566
14389
  const t2Count = plan.sections.length;
13567
14390
  const t3Count = plan.sections.reduce((sum, s) => sum + (s.t3Subtasks?.length ?? 0), 0);
13568
- return await new Promise((resolve) => {
14391
+ return await new Promise((resolve2) => {
13569
14392
  const timeout = setTimeout(() => {
13570
14393
  if (this.pendingPlanApproval) {
13571
14394
  this.pendingPlanApproval = void 0;
13572
- resolve({ approved: true });
14395
+ resolve2({ approved: true });
13573
14396
  }
13574
14397
  }, 12e4);
13575
14398
  this.pendingPlanApproval = (decision) => {
13576
14399
  clearTimeout(timeout);
13577
14400
  this.pendingPlanApproval = void 0;
13578
- resolve(decision);
14401
+ resolve2(decision);
13579
14402
  };
13580
14403
  this.emit("plan:approval-required", {
13581
14404
  taskId,
@@ -13608,17 +14431,17 @@ var Cascade = class _Cascade extends EventEmitter {
13608
14431
  }
13609
14432
  async requestContextApproval(info) {
13610
14433
  if (this.listenerCount("context:approval-required") === 0) return true;
13611
- return await new Promise((resolve) => {
14434
+ return await new Promise((resolve2) => {
13612
14435
  const timeout = setTimeout(() => {
13613
14436
  if (this.pendingContextApproval) {
13614
14437
  this.pendingContextApproval = void 0;
13615
- resolve(true);
14438
+ resolve2(true);
13616
14439
  }
13617
14440
  }, 12e4);
13618
14441
  this.pendingContextApproval = (approved) => {
13619
14442
  clearTimeout(timeout);
13620
14443
  this.pendingContextApproval = void 0;
13621
- resolve(approved);
14444
+ resolve2(approved);
13622
14445
  };
13623
14446
  this.emit("context:approval-required", info);
13624
14447
  });
@@ -13698,6 +14521,55 @@ ${input}` }],
13698
14521
  if (this.store) t1.setStore(this.store);
13699
14522
  return t1.previewPlan(prompt);
13700
14523
  }
14524
+ /**
14525
+ * Register image / speech / transcription tools for the modalities this
14526
+ * config can actually serve.
14527
+ *
14528
+ * The sink writes bytes into the workspace, because that is what a desktop or
14529
+ * CLI run wants and it keeps a generated asset indistinguishable from any
14530
+ * other file the run produced. A host with different storage (the cloud
14531
+ * server stores a file row) constructs the tools itself via buildMediaTools
14532
+ * with its own sink.
14533
+ */
14534
+ registerMediaTools(workspacePath) {
14535
+ const allowlist = this.config.tools?.enabledTools;
14536
+ const permitted = (name) => !allowlist || allowlist.includes(name);
14537
+ const tools = buildMediaTools(
14538
+ {
14539
+ registry: this.multimodal,
14540
+ lookupProvider: (provider) => (this.config.providers ?? []).find((p) => p.type === provider),
14541
+ sink: async (asset) => {
14542
+ const fsp = await import('fs/promises');
14543
+ const nodePath = await import('path');
14544
+ const dir = nodePath.join(workspacePath, "generated");
14545
+ await fsp.mkdir(dir, { recursive: true });
14546
+ const dest = nodePath.join(dir, asset.filename);
14547
+ await fsp.writeFile(dest, asset.data);
14548
+ return nodePath.relative(workspacePath, dest);
14549
+ }
14550
+ },
14551
+ async (p) => {
14552
+ const fsp = await import('fs/promises');
14553
+ return fsp.readFile(p);
14554
+ }
14555
+ );
14556
+ for (const tool of tools) {
14557
+ if (!permitted(tool.name)) continue;
14558
+ tool.setWorkspaceRoot(workspacePath);
14559
+ this.toolRegistry.register(tool);
14560
+ }
14561
+ }
14562
+ /**
14563
+ * Supply per-model thumbs-up/down counts so Auto routing can weigh how models
14564
+ * have actually performed for this user, on top of public benchmarks.
14565
+ *
14566
+ * Optional everywhere: with no source, or an empty record, scoring is
14567
+ * byte-identical to before. The adjustment is capped and sample-size shrunk —
14568
+ * see core/router/feedback-prior.ts.
14569
+ */
14570
+ setFeedbackSource(source) {
14571
+ this.router.setFeedbackSource(source);
14572
+ }
13701
14573
  /** True when a task stopped at the budget cap and can be resumed via /continue. */
13702
14574
  hasResumableRun() {
13703
14575
  return this.lastInterruptedRun != null;
@@ -14187,6 +15059,7 @@ ${prompt}` : prompt;
14187
15059
  if (toolCreator) toolCreator.setPermissionEscalator(escalator);
14188
15060
  const rootPrompt = buildContextualPrompt(options.prompt, options.conversationHistory);
14189
15061
  let finalOutput = "";
15062
+ const runBreaker = new RunBreaker(this.config.budget?.failureThreshold);
14190
15063
  let t2Results = [];
14191
15064
  let runError = null;
14192
15065
  let identityPrompt = "";
@@ -14255,6 +15128,7 @@ ${prompt}` : prompt;
14255
15128
  this.emit("tier:status", { tierId: "t3-root", status: "COMPLETED", role: "T3" });
14256
15129
  } else if (complexity === "Moderate") {
14257
15130
  const t2 = new T2Manager(this.router, this.toolRegistry, "root");
15131
+ t2.setRunBreaker(runBreaker);
14258
15132
  t2.setPresenter(true);
14259
15133
  t2.setHierarchyContext("You are the ROOT Manager for this task. There is no T1 Administrator involved in this run. You are responsible for decomposing the task and managing T3 workers directly.");
14260
15134
  if (identityPrompt) {
@@ -14315,6 +15189,7 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
14315
15189
  }
14316
15190
  } else {
14317
15191
  const t1 = new T1Administrator(this.router, this.toolRegistry, this.config);
15192
+ t1.setRunBreaker(runBreaker);
14318
15193
  t1.setPresenter(true);
14319
15194
  t1.setHierarchyContext("You are the top-level Administrator. You are responsible for the overall plan and supervising multiple T2 Managers.");
14320
15195
  if (identityPrompt) {
@@ -14339,6 +15214,20 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
14339
15214
  finalOutput = result.output;
14340
15215
  t2Results = result.t2Results;
14341
15216
  }
15217
+ const trip = runBreaker.reason();
15218
+ if (trip) {
15219
+ finalOutput = [
15220
+ `\u26A0 **Run stopped early \u2014 ${trip.modelId} was failing every request.**`,
15221
+ "",
15222
+ trip.message,
15223
+ "",
15224
+ `Cascade stopped after ${trip.failures} consecutive failures rather than retrying every remaining subtask against the same model, which would have cost more and produced the same result.`,
15225
+ finalOutput ? `
15226
+ ---
15227
+
15228
+ ${finalOutput}` : ""
15229
+ ].join("\n").trimEnd();
15230
+ }
14342
15231
  } catch (err) {
14343
15232
  if (err instanceof CascadeCancelledError || err instanceof Error && err.name === "AbortError" || options.signal?.aborted) {
14344
15233
  this.emit("run:cancelled", {
@@ -16343,12 +17232,12 @@ var DashboardServer = class {
16343
17232
  console.warn("\u26A0 Dashboard auth is DISABLED while bound to a non-loopback interface \u2014 this allows unauthenticated remote task execution.");
16344
17233
  }
16345
17234
  }
16346
- await new Promise((resolve, reject) => {
17235
+ await new Promise((resolve2, reject) => {
16347
17236
  const onError = (err) => reject(err);
16348
17237
  this.httpServer.once("error", onError);
16349
17238
  this.httpServer.listen(this.port, this.host, () => {
16350
17239
  this.httpServer.off("error", onError);
16351
- resolve();
17240
+ resolve2();
16352
17241
  });
16353
17242
  });
16354
17243
  try {
@@ -16372,8 +17261,8 @@ var DashboardServer = class {
16372
17261
  } catch {
16373
17262
  }
16374
17263
  this.globalStore = null;
16375
- await new Promise((resolve, reject) => {
16376
- this.httpServer.close((err) => err ? reject(err) : resolve());
17264
+ await new Promise((resolve2, reject) => {
17265
+ this.httpServer.close((err) => err ? reject(err) : resolve2());
16377
17266
  });
16378
17267
  }
16379
17268
  getSocket() {
@@ -16570,8 +17459,8 @@ var DashboardServer = class {
16570
17459
  * timeout denies it.
16571
17460
  */
16572
17461
  makeApprovalCallback(sessionId) {
16573
- return (request) => new Promise((resolve) => {
16574
- this.pendingApprovals.set(request.id, { resolve, sessionId });
17462
+ return (request) => new Promise((resolve2) => {
17463
+ this.pendingApprovals.set(request.id, { resolve: resolve2, sessionId });
16575
17464
  });
16576
17465
  }
16577
17466
  /**
@@ -17883,8 +18772,8 @@ var CloudClient = class _CloudClient {
17883
18772
  }
17884
18773
  };
17885
18774
  function sleep(ms, signal) {
17886
- return new Promise((resolve, reject) => {
17887
- const t = setTimeout(resolve, ms);
18775
+ return new Promise((resolve2, reject) => {
18776
+ const t = setTimeout(resolve2, ms);
17888
18777
  signal?.addEventListener("abort", () => {
17889
18778
  clearTimeout(t);
17890
18779
  reject(new Error("aborted"));