newmark-agent 0.5.3 → 0.5.7

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.
@@ -328670,7 +328670,7 @@ function providerStreamTimeoutError(timeoutMs) {
328670
328670
  error.message = `Stream read timeout after ${timeoutMs}ms`;
328671
328671
  return error;
328672
328672
  }
328673
- async function readProviderStreamChunk(reader, signal, timeoutMs = 3e4) {
328673
+ async function readProviderStreamChunk(reader, signal, timeoutMs = 0) {
328674
328674
  if (signal.aborted) throw providerAbortError(signal);
328675
328675
  let timer;
328676
328676
  let onAbort;
@@ -328678,9 +328678,9 @@ async function readProviderStreamChunk(reader, signal, timeoutMs = 3e4) {
328678
328678
  onAbort = () => reject(providerAbortError(signal));
328679
328679
  signal.addEventListener("abort", onAbort, { once: true });
328680
328680
  });
328681
- const timeoutPromise = new Promise((_3, reject) => {
328681
+ const timeoutPromise = timeoutMs > 0 ? new Promise((_3, reject) => {
328682
328682
  timer = setTimeout(() => reject(providerStreamTimeoutError(timeoutMs)), timeoutMs);
328683
- });
328683
+ }) : new Promise(() => void 0);
328684
328684
  try {
328685
328685
  return await Promise.race([reader.read(), abortPromise, timeoutPromise]);
328686
328686
  } catch (error) {
@@ -336933,6 +336933,10 @@ var ToolExecutor = class {
336933
336933
  async webSearch(query) {
336934
336934
  return this.wsearch(query);
336935
336935
  }
336936
+ /** OCR entry point for the runtime's final visual fallback. */
336937
+ async finalVisualFallbackOcr(dataUrl, signal) {
336938
+ return await this.localOcr.recognizeDataUrl(dataUrl, signal, "sparse-ui");
336939
+ }
336936
336940
  setHostProfile(profile) {
336937
336941
  this.hostProfile = { ...profile };
336938
336942
  }
@@ -340846,9 +340850,20 @@ async function runAgentKernel(agent) {
340846
340850
  try {
340847
340851
  const linkedPlanRevisionBeforeRun = agent.getLinkedPlan().revision;
340848
340852
  const modelBeforeKernelRun = agent.model;
340849
- let lastTurn = await runWithCompressionResume([], false);
340853
+ const preflightVisualFallback = !agent.activeModelConfig()?.vision ? await agent.finalVisualFallback("vision input not supported by the selected model", processSignal) : null;
340854
+ let lastTurn = preflightVisualFallback ? { text: preflightVisualFallback, stopReason: "stop", errorMessage: "" } : await runWithCompressionResume([], false);
340855
+ if (preflightVisualFallback) {
340856
+ tokens.push({ type: "text", text: preflightVisualFallback });
340857
+ agent.recordWorkStatus("Final visual fallback used: local mini OCR plus conservative text correction.");
340858
+ }
340850
340859
  if (modelBeforeKernelRun && modelBeforeKernelRun !== agent.model && !tokens.some((t3) => t3.text?.includes("[Model fallback]"))) {
340851
- tokens.unshift({ type: "text", text: `[Model fallback] ${modelBeforeKernelRun} unavailable; switched to ${agent.model}.` });
340860
+ const notice = `[Model fallback] ${modelBeforeKernelRun} unavailable; switched to ${agent.model}.`;
340861
+ tokens.unshift({ type: "text", text: notice });
340862
+ agent.emitWorkEvent({
340863
+ type: "status",
340864
+ content: notice,
340865
+ fallback: { from: modelBeforeKernelRun, to: agent.model, providerId: agent.activeDeployment()?.providerId }
340866
+ });
340852
340867
  }
340853
340868
  let emptyResponseRetries = 0;
340854
340869
  while (providerTurnIsEmpty(lastTurn) && emptyResponseRetries < 2) {
@@ -340869,6 +340884,11 @@ async function runAgentKernel(agent) {
340869
340884
  const notice = routeTransitionNotice(agent, previous);
340870
340885
  tokens.push({ type: "text", text: notice });
340871
340886
  agent.recordWorkStatus(notice);
340887
+ agent.emitWorkEvent({
340888
+ type: "status",
340889
+ content: notice,
340890
+ fallback: { from: previous, to: agent.model, providerId: agent.activeDeployment()?.providerId }
340891
+ });
340872
340892
  kernel2.state.model = toKernelModel(agent);
340873
340893
  const fallbackToolSurface = refreshToolSurface(true);
340874
340894
  kernel2.state.systemPrompt = [agent.buildSystemPrompt(), fallbackToolSurface.systemPromptNotice].filter(Boolean).join("\n\n");
@@ -340876,6 +340896,14 @@ async function runAgentKernel(agent) {
340876
340896
  await agent.waitForPlannedRouteRetry();
340877
340897
  lastTurn = await runWithCompressionResume([], false);
340878
340898
  }
340899
+ if (kernelTurnFailed(agent, lastTurn)) {
340900
+ const visualFallback = await agent.finalVisualFallback(lastTurn.errorMessage || lastTurn.text, processSignal);
340901
+ if (visualFallback) {
340902
+ tokens.push({ type: "text", text: visualFallback });
340903
+ agent.recordWorkStatus("Final visual fallback used: local mini OCR plus conservative text correction.");
340904
+ lastTurn = { ...lastTurn, text: visualFallback, errorMessage: "", stopReason: "stop" };
340905
+ }
340906
+ }
340879
340907
  if (kernelTurnFailed(agent, lastTurn)) {
340880
340908
  throw new ProviderRunError(normalizePublicProviderError(lastTurn.errorMessage || lastTurn.text, [agent.activeModelConfig()?.api_key]));
340881
340909
  }
@@ -343285,14 +343313,16 @@ var AutoRouter = class {
343285
343313
  retryDelayMs
343286
343314
  });
343287
343315
  }
343316
+ if (failure.allowModelFallback === false) return attempts.slice(0, remainingAttempts);
343288
343317
  const selection = decision.requestedSelection;
343289
343318
  if (selection.kind === "fixed") return attempts.slice(0, remainingAttempts);
343290
343319
  const scope = selection.kind === "auto" ? selection.scope : { kind: "provider", providerId: current.providerId };
343291
343320
  const subset = selection.kind === "auto" ? selection.subset : void 0;
343292
343321
  const currentGroup = current.logicalModelGroupId;
343322
+ const currentProviderId = current.providerId;
343293
343323
  const now2 = this.now();
343294
343324
  const attemptedDeployments = decision.attempts.map((attempt) => attempt.deployment);
343295
- const eligible = candidates.filter((candidate) => candidate.enabled && inScope(candidate.deployment, scope) && inSubset(candidate.deployment, subset) && !sameDeployment(candidate.deployment, current) && !attemptedDeployments.some((attempted) => sameDeployment(candidate.deployment, attempted)) && validationEligible(candidate, now2).length === 0 && this.passedInitialHardFilters(decision, candidate) && this.circuitState(candidate.deployment, now2, false) !== "open");
343325
+ const eligible = candidates.filter((candidate) => candidate.enabled && candidate.deployment.providerId === currentProviderId && inScope(candidate.deployment, scope) && inSubset(candidate.deployment, subset) && !sameDeployment(candidate.deployment, current) && !attemptedDeployments.some((attempted) => sameDeployment(candidate.deployment, attempted)) && validationEligible(candidate, now2).length === 0 && this.passedInitialHardFilters(decision, candidate) && this.circuitState(candidate.deployment, now2, false) !== "open");
343296
343326
  const equivalent = currentGroup ? eligible.find((candidate) => candidate.deployment.logicalModelGroupId === currentGroup && !candidate.fallbackOnly) : void 0;
343297
343327
  const fallback = eligible.find((candidate) => candidate.fallbackOnly);
343298
343328
  const rankedAlternates = decision.rankedCandidates.map((ranked) => eligible.find((candidate) => sameDeployment(candidate.deployment, ranked.deployment))).filter((candidate) => !!candidate && candidate !== equivalent && candidate !== fallback && !candidate.fallbackOnly);
@@ -346730,7 +346760,8 @@ ${String(event.toolArgs || "")}`;
346730
346760
  sequence,
346731
346761
  status: input.status,
346732
346762
  guide: !isToolEvent && input.guide ? this.normalizeGuideReceipt(input.guide) : void 0,
346733
- displayImage: isToolEvent ? this.hydrateDisplayImage(input.displayImage) : void 0
346763
+ displayImage: isToolEvent ? this.hydrateDisplayImage(input.displayImage) : void 0,
346764
+ fallback: input.fallback
346734
346765
  };
346735
346766
  if (activeRun && this.isPersistablePublicWorkEvent(event)) {
346736
346767
  activeRun.sequence = Number(sequence || activeRun.sequence + 1);
@@ -349649,9 +349680,15 @@ ${summary}`, segment, "local-summarize", true);
349649
349680
  }
349650
349681
  compressionBuildBlockStart(messages) {
349651
349682
  const activeRunId = this.currentWorkRunId();
349652
- if (!activeRunId) return 0;
349653
- const index = messages.findIndex((message) => String(message.run_id || message.runId || "") === activeRunId);
349654
- return index >= 0 ? index : 0;
349683
+ if (activeRunId) {
349684
+ const index = messages.findIndex((message) => String(message.run_id || message.runId || "") === activeRunId);
349685
+ if (index >= 0) return index;
349686
+ }
349687
+ let lastRunBoundary = -1;
349688
+ for (let index = 0; index < messages.length; index += 1) {
349689
+ if (String(messages[index]?.run_id || messages[index]?.runId || "")) lastRunBoundary = index + 1;
349690
+ }
349691
+ return lastRunBoundary >= 0 ? lastRunBoundary : messages.length;
349655
349692
  }
349656
349693
  recentContextSuffix(messages, maxMessages, tokenBudget) {
349657
349694
  if (!messages.length) return [];
@@ -350580,17 +350617,12 @@ ${msg.content}
350580
350617
  currentAttempt.sideEffectBoundary = this.routeSideEffectCommitted;
350581
350618
  if (this.routeAttemptStartedAt > 0) currentAttempt.durationMs = Math.max(0, Date.now() - this.routeAttemptStartedAt);
350582
350619
  }
350583
- if (!fallbackEnabled) {
350584
- this.lastRouteDecision.finalStatus = "failed";
350585
- this.routeAttemptStartedAt = 0;
350586
- this.persistRouteDecision(this.lastRouteDecision);
350587
- return null;
350588
- }
350589
350620
  if (!this.pendingAutoAttempts.length) {
350590
350621
  this.pendingAutoAttempts = this.autoRouter.planAttempts(this.lastRouteDecision, this.autoRouteCandidates(), {
350591
350622
  error: failure,
350592
350623
  streamCommitted: this.routeStreamCommitted,
350593
- sideEffectCommitted: this.routeSideEffectCommitted
350624
+ sideEffectCommitted: this.routeSideEffectCommitted,
350625
+ allowModelFallback: fallbackEnabled
350594
350626
  });
350595
350627
  }
350596
350628
  const next2 = this.pendingAutoAttempts.shift();
@@ -350625,7 +350657,6 @@ ${msg.content}
350625
350657
  if (!next?.name) return null;
350626
350658
  this.model = next.name;
350627
350659
  this.fixedDeployment = this.deploymentRef(next);
350628
- if (next.provider) this.config.set("models", "auto_switch_anchor_provider", next.provider);
350629
350660
  return current;
350630
350661
  }
350631
350662
  isLlmErrorText(text) {
@@ -350633,12 +350664,10 @@ ${msg.content}
350633
350664
  }
350634
350665
  scopedSwitchModels(currentModelName) {
350635
350666
  const all = this.config.allModels();
350636
- if (this.config.autoSwitchScope() !== "provider") return all;
350637
- const current = currentModelName === "auto" ? void 0 : this.config.findModel(currentModelName);
350638
- const providerId = current?.provider_id || this.config.autoSwitchAnchorProvider() || this.config.findModel(this.config.getStr("models", "default_model"))?.provider_id || all[0]?.provider_id || "";
350639
- if (!providerId) return all;
350640
- const provider = this.config.findProvider(providerId);
350641
- return all.filter((m2) => m2.provider_id === (provider?.id || providerId));
350667
+ const current = currentModelName === "auto" ? this.activeModelConfig() : this.config.findModel(currentModelName);
350668
+ const providerId = current?.provider_id || this.fixedDeployment?.providerId || (currentModelName !== "auto" ? this.activeDeployment()?.providerId : void 0);
350669
+ if (!providerId) return [];
350670
+ return all.filter((m2) => m2.provider_id === providerId);
350642
350671
  }
350643
350672
  async validateModels(selectedNames, options = {}) {
350644
350673
  if (this.modelValidationPromise) return this.modelValidationPromise;
@@ -350820,6 +350849,60 @@ ${msg.content}
350820
350849
  };
350821
350850
  return results;
350822
350851
  }
350852
+ /**
350853
+ * Final visual safety net: OCR each submitted image and ask a text-only
350854
+ * request to conservatively repair the OCR. This is intentionally callable
350855
+ * only after a visual-input refusal and after same-provider vision routing
350856
+ * has been exhausted; the original image is never sent again.
350857
+ */
350858
+ async finalVisualFallback(errorText, signal) {
350859
+ if (!/(?:vision|image|multimodal|image_url|input_image).*(?:not supported|unsupported|拒绝|不支持|failed|failure|invalid)|(?:not supported|unsupported|拒绝|不支持).*(?:vision|image|multimodal|image_url|input_image)/i.test(String(errorText || ""))) return null;
350860
+ const current = this.activeModelConfig();
350861
+ if (!current) return null;
350862
+ const alternateVision = this.config.allModels().some(
350863
+ (model) => model.enabled !== false && model.provider_id === current.provider_id && model.name !== current.name && !!model.vision && !!model.api_key && !!model.provider_url && !["unavailable", "auth_error", "invalid_config"].includes(String(model.evaluation?.status || model.validation?.status || "").toLowerCase())
350864
+ );
350865
+ if (alternateVision) return null;
350866
+ const latest = [...this.history].reverse().find((item) => item?.role === "user");
350867
+ const parts = latest?.content && Array.isArray(latest.content) ? latest.content : [];
350868
+ const images = parts.map((part) => {
350869
+ const image = part.image_url;
350870
+ return image && typeof image === "object" ? String(image.url || "") : "";
350871
+ }).filter((value) => /^data:image\/(?:png|jpeg);base64,/i.test(value)).slice(0, 4);
350872
+ if (!images.length) return null;
350873
+ const ocr = [];
350874
+ for (const [index, image] of images.entries()) {
350875
+ try {
350876
+ const result = await this.tools.finalVisualFallbackOcr(image, signal);
350877
+ if (result.ok && result.text.trim()) ocr.push({ index: index + 1, text: result.text.slice(0, 5e4), confidence: result.confidence });
350878
+ } catch {
350879
+ }
350880
+ }
350881
+ if (!ocr.length) return JSON.stringify({ ok: false, fallback: "mini_ocr_llm", error: "Local OCR returned no readable text; no visual content was fabricated." });
350882
+ const task = typeof latest?.content === "string" ? latest.content : "";
350883
+ const evidence = ocr.map((item) => `Image ${item.index} (OCR confidence ${item.confidence.toFixed(1)}):
350884
+ ${item.text}`).join("\n\n");
350885
+ const prompt = `The provider rejected image input. Answer the user's task using only this approximate OCR evidence. Correct obvious character, spacing, and line-break errors only when supported by context. Preserve [uncertain] markers for ambiguity and never invent missing visual content.
350886
+ User task:
350887
+ ${task.slice(0, 12e3)}
350888
+ OCR evidence:
350889
+ ${evidence}`;
350890
+ let corrected = "";
350891
+ try {
350892
+ const provider = this.engineModel();
350893
+ if (provider) corrected = String(await provider.chat(this.activeModelName(), [{ role: "user", content: prompt }], "You are a text-only OCR correction assistant. Be conservative and explicit about uncertainty.", 0.05, 3e3, signal) || "").trim();
350894
+ } catch {
350895
+ }
350896
+ return JSON.stringify({
350897
+ ok: !!(corrected || ocr.length),
350898
+ fallback: "mini_ocr_llm",
350899
+ approximate: true,
350900
+ warning: "\u89C6\u89C9\u8F93\u5165\u88AB\u62D2\u7EDD\uFF1B\u4EE5\u4E0B\u5185\u5BB9\u6765\u81EA\u672C\u5730 OCR\uFF0C\u5E76\u7ECF\u6587\u672C\u6A21\u578B\u4FDD\u5B88\u6821\u6B63\uFF0C\u53EF\u80FD\u4E0D\u5B8C\u6574\u3002",
350901
+ raw_ocr: ocr,
350902
+ corrected: corrected || ocr.map((item) => item.text).join("\n\n"),
350903
+ uncertainty: corrected ? "preserved" : "raw_ocr_only"
350904
+ }, null, 2);
350905
+ }
350823
350906
  engineModel() {
350824
350907
  if (this.forcedProvider) {
350825
350908
  const active = this.activeDeployment();
@@ -351202,8 +351285,10 @@ ${persisted.map((guide, index) => `Guide ${index + 1}: ${guide.text}`).join("\n"
351202
351285
  }
351203
351286
  const selectedModel = this.activeModelConfig();
351204
351287
  if (images.length && !selectedModel?.vision) {
351205
- this.status = "idle";
351206
- return [{ type: "text", text: `[Vision unavailable] ${this.activeModelName() || this.model} has not passed image-input validation. Select a validated vision model before asking about attachments.` }];
351288
+ const hasSameProviderVision = selectedModel && this.config.allModels().some(
351289
+ (model) => model.enabled !== false && model.provider_id === selectedModel.provider_id && model.name !== selectedModel.name && !!model.vision
351290
+ );
351291
+ if (hasSameProviderVision) this.switchToFallbackModel("vision input not supported by the selected model");
351207
351292
  }
351208
351293
  const now2 = this.nowLabel();
351209
351294
  const visibleUserInput = inputEnvelope?.visibleUserInput === void 0 ? text : String(inputEnvelope.visibleUserInput || "");
@@ -351964,7 +352049,10 @@ ${items.map((item) => this.formatAutomation(item)).join("\n")}`;
351964
352049
  if (!sa) return "[Subagent] Not found.";
351965
352050
  const requestedModel = this.normalizeSubagentModelSelection(sa.model && sa.model !== "default" ? sa.model : this.modelSelectionValue() || this.model);
351966
352051
  const requestedDeployment = parseDeploymentSelectionValue2(requestedModel);
351967
- const assignedModel = requestedModel === "auto" ? this.activeModelConfig() : requestedDeployment ? this.config.findDeployment(requestedDeployment) : this.config.findModel(requestedModel);
352052
+ let assignedModel = requestedModel === "auto" ? this.activeModelConfig() : requestedDeployment ? this.config.findDeployment(requestedDeployment) : this.config.findModel(requestedModel);
352053
+ if (!assignedModel && !requestedDeployment && requestedModel !== "auto") {
352054
+ assignedModel = this.activeModelConfig();
352055
+ }
351968
352056
  const model = assignedModel?.name || (requestedModel === "auto" ? this.activeModelName() : requestedModel);
351969
352057
  const activeModel = this.activeModelConfig();
351970
352058
  const activeProvider = this.engineModel();
@@ -353393,6 +353481,32 @@ var ConversationKernel = class {
353393
353481
  followUp: queued?.followUp.slice() || []
353394
353482
  };
353395
353483
  }
353484
+ /**
353485
+ * dev-0.5.6: 排队 followUp 消息 drain 时转成普通用户消息载荷。
353486
+ *
353487
+ * 入队时(enqueueSameSession)为去重/编辑保留了 clientMessageId,并把
353488
+ * runId 固定为入队时运行的 runId;若原样传给 process,写入 chatMessages
353489
+ * 的用户消息会携带旧 runId + clientMessageId,渲染端(PC renderTranscript /
353490
+ * 移动端 projectRemoteConversationItems)会把「clientMessageId + runId 命中
353491
+ * workRuns」的消息误判为 guide 消息而跳过气泡,导致排队自动发送的消息不
353492
+ * 显示在用户输入时间线。清除这两个字段后 process 走普通用户消息分支
353493
+ * (chatMessages.push,mode 取 visibleMode,runId 取当前 run)。
353494
+ */
353495
+ drainQueuedFollowUpMessage(message) {
353496
+ if (typeof message === "string") return message;
353497
+ const text = String(message.text || "");
353498
+ const images = message.images;
353499
+ const attachments = message.attachments;
353500
+ const visible = message.visibleUserInput;
353501
+ const visibleMode = message.visibleMode;
353502
+ return {
353503
+ text,
353504
+ ...images?.length ? { images } : {},
353505
+ ...attachments?.length ? { attachments } : {},
353506
+ ...visible ? { visibleUserInput: visible } : {},
353507
+ ...visibleMode ? { visibleMode } : {}
353508
+ };
353509
+ }
353396
353510
  queueItems(target) {
353397
353511
  const runtime = this.findRuntime(target);
353398
353512
  if (!runtime) return [];
@@ -354134,7 +354248,7 @@ ${batchText}`,
354134
354248
  };
354135
354249
  lastTokens = await this.runSingle(runtime, batchMessage, "steer");
354136
354250
  } else {
354137
- lastTokens = await this.runSingle(runtime, next.message, next.queueMode);
354251
+ lastTokens = await this.runSingle(runtime, this.drainQueuedFollowUpMessage(next.message), next.queueMode);
354138
354252
  }
354139
354253
  }
354140
354254
  const rootMessage = runtime.runner.subagents.readRootInbox()[0];
@@ -354593,7 +354707,11 @@ ${text}`;
354593
354707
  type: "queue_update",
354594
354708
  content: "Conversation queue updated.",
354595
354709
  conversationId: runtime.id,
354596
- queue: this.queued(runtime.target)
354710
+ queue: this.queued(runtime.target),
354711
+ // Structured rows with stable kernel ids so every consumer (PC UI and
354712
+ // the paired mobile client) can render/edit/delete the same items.
354713
+ queueItems: this.queueItems(runtime.target),
354714
+ queuePaused: runtime.queuePaused === true
354597
354715
  });
354598
354716
  }
354599
354717
  clearQueued(runtime) {
@@ -990,6 +990,13 @@ export declare class Agent {
990
990
  isModelValidationRunning(): boolean;
991
991
  modelValidationStatus(): ModelValidationProgress;
992
992
  private runModelValidation;
993
+ /**
994
+ * Final visual safety net: OCR each submitted image and ask a text-only
995
+ * request to conservatively repair the OCR. This is intentionally callable
996
+ * only after a visual-input refusal and after same-provider vision routing
997
+ * has been exhausted; the original image is never sent again.
998
+ */
999
+ finalVisualFallback(errorText: string, signal?: AbortSignal): Promise<string | null>;
993
1000
  engineModel(): LLMProvider | null;
994
1001
  /**
995
1002
  * dev-0.4.3 模型原生思考强度档位映射表(模型名 → thinking_tier_map)。
@@ -2298,6 +2298,7 @@ class Agent {
2298
2298
  status: input.status,
2299
2299
  guide: !isToolEvent && input.guide ? this.normalizeGuideReceipt(input.guide) : undefined,
2300
2300
  displayImage: isToolEvent ? this.hydrateDisplayImage(input.displayImage) : undefined,
2301
+ fallback: input.fallback,
2301
2302
  };
2302
2303
  if (activeRun && this.isPersistablePublicWorkEvent(event)) {
2303
2304
  activeRun.sequence = Number(sequence || activeRun.sequence + 1);
@@ -5587,10 +5588,22 @@ class Agent {
5587
5588
  }
5588
5589
  compressionBuildBlockStart(messages) {
5589
5590
  const activeRunId = this.currentWorkRunId();
5590
- if (!activeRunId)
5591
- return 0;
5592
- const index = messages.findIndex(message => String(message.run_id || message.runId || '') === activeRunId);
5593
- return index >= 0 ? index : 0;
5591
+ if (activeRunId) {
5592
+ const index = messages.findIndex(message => String(message.run_id || message.runId || '') === activeRunId);
5593
+ if (index >= 0)
5594
+ return index;
5595
+ }
5596
+ // dev-0.5.6: 空闲对话(无活动 run)时不得把全部历史算进当前 Build Block,
5597
+ // 否则上下文显示窗口的长期历史恒为 0。回退语义:
5598
+ // - 存在带 run_id 的消息:boundary 取最后一个 run 的起点之后(该 run 及其
5599
+ // 之前的历史属于长期历史,其后无归属的消息属于当前未命名区块);
5600
+ // - 完全没有 run_id:全部历史都是长期历史(不存在当前 Build Block)。
5601
+ let lastRunBoundary = -1;
5602
+ for (let index = 0; index < messages.length; index += 1) {
5603
+ if (String(messages[index]?.run_id || messages[index]?.runId || ''))
5604
+ lastRunBoundary = index + 1;
5605
+ }
5606
+ return lastRunBoundary >= 0 ? lastRunBoundary : messages.length;
5594
5607
  }
5595
5608
  recentContextSuffix(messages, maxMessages, tokenBudget) {
5596
5609
  if (!messages.length)
@@ -6651,17 +6664,12 @@ class Agent {
6651
6664
  if (this.routeAttemptStartedAt > 0)
6652
6665
  currentAttempt.durationMs = Math.max(0, Date.now() - this.routeAttemptStartedAt);
6653
6666
  }
6654
- if (!fallbackEnabled) {
6655
- this.lastRouteDecision.finalStatus = 'failed';
6656
- this.routeAttemptStartedAt = 0;
6657
- this.persistRouteDecision(this.lastRouteDecision);
6658
- return null;
6659
- }
6660
6667
  if (!this.pendingAutoAttempts.length) {
6661
6668
  this.pendingAutoAttempts = this.autoRouter.planAttempts(this.lastRouteDecision, this.autoRouteCandidates(), {
6662
6669
  error: failure,
6663
6670
  streamCommitted: this.routeStreamCommitted,
6664
6671
  sideEffectCommitted: this.routeSideEffectCommitted,
6672
+ allowModelFallback: fallbackEnabled,
6665
6673
  });
6666
6674
  }
6667
6675
  const next = this.pendingAutoAttempts.shift();
@@ -6702,8 +6710,11 @@ class Agent {
6702
6710
  return null;
6703
6711
  this.model = next.name;
6704
6712
  this.fixedDeployment = this.deploymentRef(next);
6705
- if (next.provider)
6706
- this.config.set('models', 'auto_switch_anchor_provider', next.provider);
6713
+ // Do NOT rewrite auto_switch_anchor_provider here. The anchor describes
6714
+ // the user's Auto-routing scope; a fixed-model recovery must never
6715
+ // repoint it (a single transient failure would otherwise silently move
6716
+ // every future Auto route — and every future fallback pool — to the
6717
+ // recovery provider, which is the cross-provider leak users observed).
6707
6718
  return current;
6708
6719
  }
6709
6720
  isLlmErrorText(text) {
@@ -6711,18 +6722,23 @@ class Agent {
6711
6722
  }
6712
6723
  scopedSwitchModels(currentModelName) {
6713
6724
  const all = this.config.allModels();
6714
- if (this.config.autoSwitchScope() !== 'provider')
6715
- return all;
6716
- const current = currentModelName === 'auto' ? undefined : this.config.findModel(currentModelName);
6717
- const providerId = current?.provider_id ||
6718
- this.config.autoSwitchAnchorProvider() ||
6719
- this.config.findModel(this.config.getStr('models', 'default_model'))?.provider_id ||
6720
- all[0]?.provider_id ||
6721
- '';
6725
+ // Fixed-model recovery is always provider-local. A same-named model on a
6726
+ // different provider is a different deployment and must never receive the
6727
+ // failed provider's credentials or become an implicit fallback.
6728
+ // Resolution order is strictly deployment-first: the pinned deployment,
6729
+ // then the exact active deployment. The global auto anchor is deliberately
6730
+ // NOT consulted here — it belongs to Auto routing only and can point at a
6731
+ // different provider (for example after a previous cross-provider switch),
6732
+ // which is exactly the same-name cross-provider leak this guard exists to
6733
+ // prevent. When no provider can be established unambiguously, return an
6734
+ // empty pool: failing the switch is safe, crossing providers is not.
6735
+ const current = currentModelName === 'auto' ? this.activeModelConfig() : this.config.findModel(currentModelName);
6736
+ const providerId = current?.provider_id
6737
+ || this.fixedDeployment?.providerId
6738
+ || (currentModelName !== 'auto' ? this.activeDeployment()?.providerId : undefined);
6722
6739
  if (!providerId)
6723
- return all;
6724
- const provider = this.config.findProvider(providerId);
6725
- return all.filter(m => m.provider_id === (provider?.id || providerId));
6740
+ return [];
6741
+ return all.filter(m => m.provider_id === providerId);
6726
6742
  }
6727
6743
  async validateModels(selectedNames, options = {}) {
6728
6744
  if (this.modelValidationPromise)
@@ -6913,6 +6929,62 @@ class Agent {
6913
6929
  };
6914
6930
  return results;
6915
6931
  }
6932
+ /**
6933
+ * Final visual safety net: OCR each submitted image and ask a text-only
6934
+ * request to conservatively repair the OCR. This is intentionally callable
6935
+ * only after a visual-input refusal and after same-provider vision routing
6936
+ * has been exhausted; the original image is never sent again.
6937
+ */
6938
+ async finalVisualFallback(errorText, signal) {
6939
+ if (!/(?:vision|image|multimodal|image_url|input_image).*(?:not supported|unsupported|拒绝|不支持|failed|failure|invalid)|(?:not supported|unsupported|拒绝|不支持).*(?:vision|image|multimodal|image_url|input_image)/i.test(String(errorText || '')))
6940
+ return null;
6941
+ const current = this.activeModelConfig();
6942
+ if (!current)
6943
+ return null;
6944
+ const alternateVision = this.config.allModels().some(model => model.enabled !== false && model.provider_id === current.provider_id &&
6945
+ model.name !== current.name && !!model.vision && !!model.api_key && !!model.provider_url &&
6946
+ !['unavailable', 'auth_error', 'invalid_config'].includes(String(model.evaluation?.status || model.validation?.status || '').toLowerCase()));
6947
+ if (alternateVision)
6948
+ return null;
6949
+ const latest = [...this.history].reverse().find(item => item?.role === 'user');
6950
+ const parts = latest?.content && Array.isArray(latest.content) ? latest.content : [];
6951
+ const images = parts.map(part => {
6952
+ const image = part.image_url;
6953
+ return image && typeof image === 'object' ? String(image.url || '') : '';
6954
+ }).filter(value => /^data:image\/(?:png|jpeg);base64,/i.test(value)).slice(0, 4);
6955
+ if (!images.length)
6956
+ return null;
6957
+ const ocr = [];
6958
+ for (const [index, image] of images.entries()) {
6959
+ try {
6960
+ const result = await this.tools.finalVisualFallbackOcr(image, signal);
6961
+ if (result.ok && result.text.trim())
6962
+ ocr.push({ index: index + 1, text: result.text.slice(0, 50_000), confidence: result.confidence });
6963
+ }
6964
+ catch { }
6965
+ }
6966
+ if (!ocr.length)
6967
+ return JSON.stringify({ ok: false, fallback: 'mini_ocr_llm', error: 'Local OCR returned no readable text; no visual content was fabricated.' });
6968
+ const task = typeof latest?.content === 'string' ? latest.content : '';
6969
+ const evidence = ocr.map(item => `Image ${item.index} (OCR confidence ${item.confidence.toFixed(1)}):\n${item.text}`).join('\n\n');
6970
+ const prompt = `The provider rejected image input. Answer the user's task using only this approximate OCR evidence. Correct obvious character, spacing, and line-break errors only when supported by context. Preserve [uncertain] markers for ambiguity and never invent missing visual content.\nUser task:\n${task.slice(0, 12_000)}\nOCR evidence:\n${evidence}`;
6971
+ let corrected = '';
6972
+ try {
6973
+ const provider = this.engineModel();
6974
+ if (provider)
6975
+ corrected = String(await provider.chat(this.activeModelName(), [{ role: 'user', content: prompt }], 'You are a text-only OCR correction assistant. Be conservative and explicit about uncertainty.', 0.05, 3000, signal) || '').trim();
6976
+ }
6977
+ catch { }
6978
+ return JSON.stringify({
6979
+ ok: !!(corrected || ocr.length),
6980
+ fallback: 'mini_ocr_llm',
6981
+ approximate: true,
6982
+ warning: '视觉输入被拒绝;以下内容来自本地 OCR,并经文本模型保守校正,可能不完整。',
6983
+ raw_ocr: ocr,
6984
+ corrected: corrected || ocr.map(item => item.text).join('\n\n'),
6985
+ uncertainty: corrected ? 'preserved' : 'raw_ocr_only',
6986
+ }, null, 2);
6987
+ }
6916
6988
  engineModel() {
6917
6989
  if (this.forcedProvider) {
6918
6990
  const active = this.activeDeployment();
@@ -7353,8 +7425,13 @@ class Agent {
7353
7425
  }
7354
7426
  const selectedModel = this.activeModelConfig();
7355
7427
  if (images.length && !selectedModel?.vision) {
7356
- this.status = 'idle';
7357
- return [{ type: 'text', text: `[Vision unavailable] ${this.activeModelName() || this.model} has not passed image-input validation. Select a validated vision model before asking about attachments.` }];
7428
+ // Give the normal route planner first chance to select another
7429
+ // same-provider vision deployment. If none is available, the kernel
7430
+ // preflight invokes the final mini-OCR + text-only correction path.
7431
+ const hasSameProviderVision = selectedModel && this.config.allModels().some(model => model.enabled !== false && model.provider_id === selectedModel.provider_id &&
7432
+ model.name !== selectedModel.name && !!model.vision);
7433
+ if (hasSameProviderVision)
7434
+ this.switchToFallbackModel('vision input not supported by the selected model');
7358
7435
  }
7359
7436
  const now = this.nowLabel();
7360
7437
  const visibleUserInput = inputEnvelope?.visibleUserInput === undefined
@@ -8163,9 +8240,17 @@ class Agent {
8163
8240
  return '[Subagent] Not found.';
8164
8241
  const requestedModel = this.normalizeSubagentModelSelection(sa.model && sa.model !== 'default' ? sa.model : this.modelSelectionValue() || this.model);
8165
8242
  const requestedDeployment = parseDeploymentSelectionValue(requestedModel);
8166
- const assignedModel = requestedModel === 'auto'
8243
+ let assignedModel = requestedModel === 'auto'
8167
8244
  ? this.activeModelConfig()
8168
8245
  : (requestedDeployment ? this.config.findDeployment(requestedDeployment) : this.config.findModel(requestedModel));
8246
+ // Imported presets (and stale records) may reference a bare model name
8247
+ // that no longer exists in the configured catalog. Resolving by name must
8248
+ // never guess a provider; instead the peer inherits the parent deployment
8249
+ // so the job can still run. Qualified deployment values keep failing
8250
+ // closed — they are exact references, not guesses.
8251
+ if (!assignedModel && !requestedDeployment && requestedModel !== 'auto') {
8252
+ assignedModel = this.activeModelConfig();
8253
+ }
8169
8254
  const model = assignedModel?.name || (requestedModel === 'auto' ? this.activeModelName() : requestedModel);
8170
8255
  const activeModel = this.activeModelConfig();
8171
8256
  const activeProvider = this.engineModel();
@@ -429,9 +429,24 @@ async function runAgentKernel(agent) {
429
429
  try {
430
430
  const linkedPlanRevisionBeforeRun = agent.getLinkedPlan().revision;
431
431
  const modelBeforeKernelRun = agent.model;
432
- let lastTurn = await runWithCompressionResume([], false);
432
+ const preflightVisualFallback = !agent.activeModelConfig()?.vision
433
+ ? await agent.finalVisualFallback('vision input not supported by the selected model', processSignal)
434
+ : null;
435
+ let lastTurn = preflightVisualFallback
436
+ ? { text: preflightVisualFallback, stopReason: 'stop', errorMessage: '' }
437
+ : await runWithCompressionResume([], false);
438
+ if (preflightVisualFallback) {
439
+ tokens.push({ type: 'text', text: preflightVisualFallback });
440
+ agent.recordWorkStatus('Final visual fallback used: local mini OCR plus conservative text correction.');
441
+ }
433
442
  if (modelBeforeKernelRun && modelBeforeKernelRun !== agent.model && !tokens.some(t => t.text?.includes('[Model fallback]'))) {
434
- tokens.unshift({ type: 'text', text: `[Model fallback] ${modelBeforeKernelRun} unavailable; switched to ${agent.model}.` });
443
+ const notice = `[Model fallback] ${modelBeforeKernelRun} unavailable; switched to ${agent.model}.`;
444
+ tokens.unshift({ type: 'text', text: notice });
445
+ agent.emitWorkEvent({
446
+ type: 'status',
447
+ content: notice,
448
+ fallback: { from: modelBeforeKernelRun, to: agent.model, providerId: agent.activeDeployment()?.providerId },
449
+ });
435
450
  }
436
451
  let emptyResponseRetries = 0;
437
452
  while (providerTurnIsEmpty(lastTurn) && emptyResponseRetries < 2) {
@@ -453,6 +468,11 @@ async function runAgentKernel(agent) {
453
468
  const notice = routeTransitionNotice(agent, previous);
454
469
  tokens.push({ type: 'text', text: notice });
455
470
  agent.recordWorkStatus(notice);
471
+ agent.emitWorkEvent({
472
+ type: 'status',
473
+ content: notice,
474
+ fallback: { from: previous, to: agent.model, providerId: agent.activeDeployment()?.providerId },
475
+ });
456
476
  kernel.state.model = toKernelModel(agent);
457
477
  const fallbackToolSurface = refreshToolSurface(true);
458
478
  kernel.state.systemPrompt = [agent.buildSystemPrompt(), fallbackToolSurface.systemPromptNotice].filter(Boolean).join('\n\n');
@@ -460,6 +480,14 @@ async function runAgentKernel(agent) {
460
480
  await agent.waitForPlannedRouteRetry();
461
481
  lastTurn = await runWithCompressionResume([], false);
462
482
  }
483
+ if (kernelTurnFailed(agent, lastTurn)) {
484
+ const visualFallback = await agent.finalVisualFallback(lastTurn.errorMessage || lastTurn.text, processSignal);
485
+ if (visualFallback) {
486
+ tokens.push({ type: 'text', text: visualFallback });
487
+ agent.recordWorkStatus('Final visual fallback used: local mini OCR plus conservative text correction.');
488
+ lastTurn = { ...lastTurn, text: visualFallback, errorMessage: '', stopReason: 'stop' };
489
+ }
490
+ }
463
491
  if (kernelTurnFailed(agent, lastTurn)) {
464
492
  throw new ProviderRunError(normalizePublicProviderError(lastTurn.errorMessage || lastTurn.text, [agent.activeModelConfig()?.api_key]));
465
493
  }
@@ -153,6 +153,7 @@ export declare class AutoRouter {
153
153
  error: RouteFailure;
154
154
  streamCommitted: boolean;
155
155
  sideEffectCommitted: boolean;
156
+ allowModelFallback?: boolean;
156
157
  }): PlannedRouteAttempt[];
157
158
  recordEndpointFailure(deployment: DeploymentRef, failure: RouteFailureType | RouteFailure): void;
158
159
  recordEndpointSuccess(deployment: DeploymentRef, latencyMs?: number, throughput?: number): void;
@@ -290,15 +290,24 @@ class AutoRouter {
290
290
  retryDelayMs,
291
291
  });
292
292
  }
293
+ if (failure.allowModelFallback === false)
294
+ return attempts.slice(0, remainingAttempts);
293
295
  const selection = decision.requestedSelection;
294
296
  if (selection.kind === 'fixed')
295
297
  return attempts.slice(0, remainingAttempts);
296
298
  const scope = selection.kind === 'auto' ? selection.scope : { kind: 'provider', providerId: current.providerId };
297
299
  const subset = selection.kind === 'auto' ? selection.subset : undefined;
298
300
  const currentGroup = current.logicalModelGroupId;
301
+ const currentProviderId = current.providerId;
299
302
  const now = this.now();
300
303
  const attemptedDeployments = decision.attempts.map(attempt => attempt.deployment);
304
+ // Fallback is a recovery operation, not a new global routing decision.
305
+ // It must never cross the provider boundary, even when the original Auto
306
+ // selection has global scope. This prevents equal model ids from silently
307
+ // switching credentials/endpoints (for example provider A/model X to
308
+ // provider B/model X).
301
309
  const eligible = candidates.filter(candidate => candidate.enabled
310
+ && candidate.deployment.providerId === currentProviderId
302
311
  && inScope(candidate.deployment, scope)
303
312
  && inSubset(candidate.deployment, subset)
304
313
  && !sameDeployment(candidate.deployment, current)
@@ -194,6 +194,18 @@ export declare class ConversationKernel {
194
194
  steering: string[];
195
195
  followUp: string[];
196
196
  };
197
+ /**
198
+ * dev-0.5.6: 排队 followUp 消息 drain 时转成普通用户消息载荷。
199
+ *
200
+ * 入队时(enqueueSameSession)为去重/编辑保留了 clientMessageId,并把
201
+ * runId 固定为入队时运行的 runId;若原样传给 process,写入 chatMessages
202
+ * 的用户消息会携带旧 runId + clientMessageId,渲染端(PC renderTranscript /
203
+ * 移动端 projectRemoteConversationItems)会把「clientMessageId + runId 命中
204
+ * workRuns」的消息误判为 guide 消息而跳过气泡,导致排队自动发送的消息不
205
+ * 显示在用户输入时间线。清除这两个字段后 process 走普通用户消息分支
206
+ * (chatMessages.push,mode 取 visibleMode,runId 取当前 run)。
207
+ */
208
+ private drainQueuedFollowUpMessage;
197
209
  queueItems(target: ConversationTargetInput): ConversationQueueItemSnapshot[];
198
210
  enqueueNext(target: ConversationTargetInput, input: {
199
211
  id: string;