@lazyingart/agintiflow 0.20.251 → 0.20.253

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.
@@ -26,6 +26,8 @@ The local tier policy uses the workstation's installed aliases without treating
26
26
 
27
27
  Installed aliases are not loaded during routing. A genuine coding/implementation request starts on Deep, checks the authenticated model inventory, and selects the exact `AGINTI_LOCALLLM_CODE_MODEL` value only when present. Missing or unverified capability stays on Deep; the decision and effective model are recorded so fallback sessions can re-evaluate and selected sessions resume on the same model. Explanation-only code questions and non-code writing/research/documentation/design keep their existing route. Automatic Max is off by default; set `AGINTI_LOCALLLM_ALLOW_AUTO_MAX=true` to opt in. An opted-in high-complexity non-code run starts on Deep, confirms the Max alias through authenticated `/v1/models`, and only then samples current resources. Unknown or pressured resource state stays on Deep. Explicit Max also cannot bypass the live gate: immediately before client creation, each new or resumed run rechecks at least 24 GiB available RAM, swap use at or below 75%, and 40 GiB aggregate free NVIDIA memory. A blocked explicit gate creates no model client and performs no inference.
28
28
 
29
+ Smart hosted routes fail over only toward the local trust boundary. If a hosted provider returns an authentication, quota, rate-limit, model-availability, capacity, or network failure, AgInTiFlow records `provider.handoff_requested`, patches the same durable session to `localllm-deep`, verifies LocalLLM readiness, records `provider.handoff_activated`, and continues without replaying the user request or prior side effects. Manual routing remains exact and never changes provider automatically. Retained integration profiles also keep their pinned provider identity. Set `AGINTI_PROVIDER_HANDOFF=false` to disable the smart hosted-to-local handoff or `AGINTI_PROVIDER_HANDOFF_MODEL` to select another verified local fallback model.
30
+
29
31
  Long writing tasks use an additional tool boundary: `writing_specialist`. The main model still plans the run, manages files, formats Markdown/LaTeX/Final Draft output, compiles/checks artifacts, and finishes. The specialist gets only the writing brief, canon, style guide, prior draft, target, audience, constraints, and format intent, then returns prose plus a formatter handoff. It follows the active LocalLLM provider by default even when hosted keys exist. Cross-provider writing requires both an explicit target (`AGINTI_WRITING_PROVIDER` or a per-run provider override) and `AGINTI_ALLOW_HOSTED_WRITING_SPECIALIST=true` (or the equivalent per-run permission flag); language detection and ambient credentials never grant that permission.
30
32
 
31
33
  ## CLI Commands
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.251",
3
+ "version": "0.20.253",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-aware intelligence, software automation, and industrial workflows.",
6
6
  "license": "Apache-2.0",
@@ -174,6 +174,7 @@
174
174
  "smoke:localllm-code-routing": "node scripts/smoke-localllm-code-routing.js",
175
175
  "smoke:local-failure-recovery": "node scripts/smoke-local-failure-recovery.js",
176
176
  "smoke:planning-timeout-recovery": "node scripts/smoke-planning-timeout-recovery.js",
177
+ "smoke:provider-handoff": "node scripts/smoke-provider-handoff.js",
177
178
  "smoke:local-resource-policy": "node scripts/smoke-local-resource-policy.js",
178
179
  "smoke:context-budget-recovery": "node scripts/smoke-context-budget-recovery.js",
179
180
  "smoke:localllm-model-tiers": "node scripts/smoke-localllm-model-tiers.js",
@@ -228,7 +229,7 @@
228
229
  "publish:env": "node scripts/npm-publish-from-env.js publish --access public",
229
230
  "publish:env:whoami": "node scripts/npm-publish-from-env.js whoami",
230
231
  "pretest": "npm run smoke:planning-timeout-recovery",
231
- "test": "npm run check && npm run smoke:integration-production-mount && npm run smoke:integration-production-runtime-bundle && npm run smoke:integration-retained-idempotency-store && npm run smoke:integration-storage-authority && npm run smoke:integration-retained-durable-common && npm run smoke:integration-retained-file-lock && npm run smoke:integration-retained-event-ledger && npm run smoke:integration-retained-event-ledger-bundle && npm run smoke:integration-retained-repository-kernel && npm run smoke:integration-retained-session-state-store && npm run smoke:integration-retained-native-session-repository-state && npm run smoke:integration-retained-native-execution-evidence && npm run smoke:integration-retained-runtime-repository-surface && npm run smoke:integration-retained-runtime-repository-phase-b && npm run smoke:integration-runtime-repository-contract && npm run smoke:integration-runtime-authority && npm run smoke:localllm-provider && npm run smoke:localllm-model-tiers && npm run smoke:localllm-code-routing && npm run smoke:local-failure-recovery && npm run smoke:localllm-auto-max && npm run smoke:local-resource-policy && npm run smoke:context-budget-recovery && npm run smoke:session-runtime && npm run smoke:runtime-core && npm run smoke:progressive-tools && npm run smoke:truthful-completion && npm run smoke:document-artifact-quality && npm run smoke:writing-specialist-routing && npm run smoke:workspace-secret-false-positive && npm run eval:local-first-agent && npm run eval:provider-attribution && npm run smoke:runtime-compat && npm run smoke:autoupdate && npm run smoke:web-api && npm run smoke:math-rendering && npm run smoke:web-ui && npm run smoke:web-autostart && npm run smoke:webapp-command && npm run smoke:web-port-fallback && npm run smoke:docker-command && npm run smoke:coding-tools && npm run smoke:dynamic-step-budget && npm run smoke:execution-policy && npm run smoke:aaps-adapter && npm run smoke:auxiliary-tools && npm run smoke:perception-research && npm run smoke:deep-research && npm run smoke:public-research && npm run smoke:safe-chat && npm run smoke:auth && npm run smoke:agentlink && npm run smoke:canvas-artifacts && npm run smoke:capabilities && npm run smoke:mcp && npm run smoke:model-roles && npm run smoke:platform && npm run smoke:permission-modes && npm run smoke:skills && npm run smoke:skillmesh && npm run smoke:supervision-ledger && npm run smoke:tmux-tools && npm run smoke:long-jobs && npm run smoke:run-stdin && npm run smoke:cli-chat && npm run smoke:inbox",
232
+ "test": "npm run check && npm run smoke:integration-production-mount && npm run smoke:integration-production-runtime-bundle && npm run smoke:integration-retained-idempotency-store && npm run smoke:integration-storage-authority && npm run smoke:integration-retained-durable-common && npm run smoke:integration-retained-file-lock && npm run smoke:integration-retained-event-ledger && npm run smoke:integration-retained-event-ledger-bundle && npm run smoke:integration-retained-repository-kernel && npm run smoke:integration-retained-session-state-store && npm run smoke:integration-retained-native-session-repository-state && npm run smoke:integration-retained-native-execution-evidence && npm run smoke:integration-retained-runtime-repository-surface && npm run smoke:integration-retained-runtime-repository-phase-b && npm run smoke:integration-runtime-repository-contract && npm run smoke:integration-runtime-authority && npm run smoke:localllm-provider && npm run smoke:localllm-model-tiers && npm run smoke:localllm-code-routing && npm run smoke:local-failure-recovery && npm run smoke:localllm-auto-max && npm run smoke:local-resource-policy && npm run smoke:context-budget-recovery && npm run smoke:session-runtime && npm run smoke:runtime-core && npm run smoke:progressive-tools && npm run smoke:truthful-completion && npm run smoke:document-artifact-quality && npm run smoke:writing-specialist-routing && npm run smoke:workspace-secret-false-positive && npm run smoke:provider-handoff && npm run eval:local-first-agent && npm run eval:provider-attribution && npm run smoke:runtime-compat && npm run smoke:autoupdate && npm run smoke:web-api && npm run smoke:math-rendering && npm run smoke:web-ui && npm run smoke:web-autostart && npm run smoke:webapp-command && npm run smoke:web-port-fallback && npm run smoke:docker-command && npm run smoke:coding-tools && npm run smoke:dynamic-step-budget && npm run smoke:execution-policy && npm run smoke:aaps-adapter && npm run smoke:auxiliary-tools && npm run smoke:perception-research && npm run smoke:deep-research && npm run smoke:public-research && npm run smoke:safe-chat && npm run smoke:auth && npm run smoke:agentlink && npm run smoke:canvas-artifacts && npm run smoke:capabilities && npm run smoke:mcp && npm run smoke:model-roles && npm run smoke:platform && npm run smoke:permission-modes && npm run smoke:skills && npm run smoke:skillmesh && npm run smoke:supervision-ledger && npm run smoke:tmux-tools && npm run smoke:long-jobs && npm run smoke:run-stdin && npm run smoke:cli-chat && npm run smoke:inbox",
232
233
  "pack:dry-run": "npm pack --dry-run",
233
234
  "smoke:capabilities": "node scripts/smoke-capabilities.js"
234
235
  },
@@ -341,6 +341,129 @@ assert.ok(
341
341
  "DeepSeek runtime compaction exceeded the bounded retry target"
342
342
  );
343
343
 
344
+ const staleReadAfterMutationState = {
345
+ goal: "Repair service_ctl.py from current source and verify the service lifecycle tests.",
346
+ plan: "Use current source state and latest test evidence.",
347
+ messages: [
348
+ { role: "system", content: "Preserve current source truth across compaction." },
349
+ { role: "user", content: "Continue the current service recovery task." },
350
+ {
351
+ role: "assistant",
352
+ content: "",
353
+ tool_calls: [
354
+ {
355
+ id: "stale-source-read",
356
+ type: "function",
357
+ function: {
358
+ name: "read_file",
359
+ arguments: JSON.stringify({ path: "service_ctl.py", startLine: 1, lineLimit: 40 }),
360
+ },
361
+ },
362
+ ],
363
+ },
364
+ {
365
+ role: "tool",
366
+ tool_call_id: "stale-source-read",
367
+ content: JSON.stringify({
368
+ ok: true,
369
+ toolName: "read_file",
370
+ path: "service_ctl.py",
371
+ startLine: 1,
372
+ lineLimit: 40,
373
+ lineCount: 80,
374
+ bytes: 3200,
375
+ sha256: "1111111111111111111111111111111111111111111111111111111111111111",
376
+ contentTruncated: false,
377
+ content: "STALE-SERVICE-CONTENT command = f'python gateway_service.py'",
378
+ }),
379
+ },
380
+ {
381
+ role: "assistant",
382
+ content: "",
383
+ tool_calls: [
384
+ {
385
+ id: "current-source-mutation",
386
+ type: "function",
387
+ function: {
388
+ name: "apply_patch",
389
+ arguments: JSON.stringify({
390
+ path: "service_ctl.py",
391
+ search: "command = f'python gateway_service.py'",
392
+ replace: "command = [sys.executable, 'gateway_service.py']",
393
+ }),
394
+ },
395
+ },
396
+ ],
397
+ },
398
+ {
399
+ role: "tool",
400
+ tool_call_id: "current-source-mutation",
401
+ content: JSON.stringify({
402
+ ok: true,
403
+ toolName: "apply_patch",
404
+ path: "service_ctl.py",
405
+ summary: "1 file change applied",
406
+ }),
407
+ },
408
+ {
409
+ role: "assistant",
410
+ content: "",
411
+ tool_calls: [
412
+ {
413
+ id: "fresh-source-read",
414
+ type: "function",
415
+ function: {
416
+ name: "read_file",
417
+ arguments: JSON.stringify({ path: "service_ctl.py", startLine: 41, lineLimit: 40 }),
418
+ },
419
+ },
420
+ ],
421
+ },
422
+ {
423
+ role: "tool",
424
+ tool_call_id: "fresh-source-read",
425
+ content: JSON.stringify({
426
+ ok: true,
427
+ toolName: "read_file",
428
+ path: "service_ctl.py",
429
+ startLine: 41,
430
+ lineLimit: 40,
431
+ lineCount: 80,
432
+ bytes: 3300,
433
+ sha256: "2222222222222222222222222222222222222222222222222222222222222222",
434
+ contentTruncated: false,
435
+ content: "FRESH-SERVICE-CONTENT shell=False and lifecycle helpers are current",
436
+ }),
437
+ },
438
+ ...Array.from({ length: 8 }, (_, index) => readOnlyDiagnosticPair(index + 30)).flat(),
439
+ ],
440
+ };
441
+ const staleReadAfterMutationMessages = buildContextBudgetCompactionMessages(
442
+ staleReadAfterMutationState,
443
+ config,
444
+ { title: "", url: "" },
445
+ 12,
446
+ { reason: "discard stale pre-mutation source reads" }
447
+ );
448
+ const staleReadAfterMutationText = staleReadAfterMutationMessages
449
+ .map((message) => message.content || "")
450
+ .join("\n");
451
+ assert.ok(
452
+ !staleReadAfterMutationText.includes("STALE-SERVICE-CONTENT"),
453
+ "compaction retained a source read that predates a successful mutation of the same file"
454
+ );
455
+ assert.ok(
456
+ staleReadAfterMutationText.includes("FRESH-SERVICE-CONTENT"),
457
+ "compaction discarded the bounded source read made after the successful mutation"
458
+ );
459
+ assert.ok(
460
+ staleReadAfterMutationMessages.some((message) =>
461
+ Array.isArray(message.tool_calls) &&
462
+ message.tool_calls.some((call) => call?.function?.name === "apply_patch")
463
+ ),
464
+ "compaction discarded the successful mutation while invalidating its stale predecessor read"
465
+ );
466
+
344
467
  function noisyFullReadPair(index, generation) {
345
468
  const id = `validator-${generation}-${index}`;
346
469
  const file = `tmp/validator-${generation}-${index}.py`;
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import assert from "node:assert/strict";
3
+ import crypto from "node:crypto";
3
4
  import fs from "node:fs/promises";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
@@ -11,7 +12,11 @@ import {
11
12
  decideLocalFailureRecovery,
12
13
  localFailureRecoveryInstruction,
13
14
  } from "../src/local-failure-recovery.js";
14
- import { nextStepRuntimeConfig, runAgent } from "../src/agent-runner.js";
15
+ import {
16
+ nextStepRuntimeConfig,
17
+ repeatedSuccessfulMutationBlock,
18
+ runAgent,
19
+ } from "../src/agent-runner.js";
15
20
  import { resolveRuntimeConfig } from "../src/config.js";
16
21
  import { SessionStore } from "../src/session-store.js";
17
22
 
@@ -190,6 +195,89 @@ assert.match(instruction, /preserve successful work/i);
190
195
  assert.match(instruction, /Do not repeat the failing call/i);
191
196
  assert.match(instruction, /rerun the smallest relevant verification/i);
192
197
 
198
+ const durablePatchArgs = {
199
+ path: "service_ctl.py",
200
+ search: "command = f'python gateway_service.py'",
201
+ replace: "command = [sys.executable, 'gateway_service.py']",
202
+ };
203
+ const digest = (value) => crypto.createHash("sha256").update(value).digest("hex");
204
+ const durableMutation = {
205
+ revision: 4,
206
+ goalRevision: 7,
207
+ toolName: "apply_patch",
208
+ paths: ["service_ctl.py"],
209
+ patch: {
210
+ path: "./service_ctl.py",
211
+ searchHash: digest(durablePatchArgs.search),
212
+ replaceHash: digest(durablePatchArgs.replace),
213
+ },
214
+ };
215
+ const durablePatchState = {
216
+ meta: {
217
+ goalContract: { revision: 7 },
218
+ toolLoop: {
219
+ recent: Array.from({ length: 20 }, (_, index) =>
220
+ failed("apply_patch", `later-failure-${index}`)
221
+ ),
222
+ stagnationEpoch: 23,
223
+ },
224
+ projectVerification: {
225
+ mutationRevision: 4,
226
+ mutationHistory: [durableMutation],
227
+ },
228
+ },
229
+ };
230
+ assert.equal(
231
+ repeatedSuccessfulMutationBlock(durablePatchState, "apply_patch", durablePatchArgs, {
232
+ commandCwd: process.cwd(),
233
+ })?.category,
234
+ "repeated-successful-mutation",
235
+ "a successful exact patch must remain blocked after it falls out of the short tool-loop window"
236
+ );
237
+ assert.equal(
238
+ repeatedSuccessfulMutationBlock(
239
+ {
240
+ ...durablePatchState,
241
+ meta: {
242
+ ...durablePatchState.meta,
243
+ goalContract: { revision: 8 },
244
+ },
245
+ },
246
+ "apply_patch",
247
+ durablePatchArgs,
248
+ { commandCwd: process.cwd() }
249
+ ),
250
+ null,
251
+ "a genuine user continuation must permit deliberate reconsideration of an earlier patch"
252
+ );
253
+ assert.equal(
254
+ repeatedSuccessfulMutationBlock(
255
+ {
256
+ ...durablePatchState,
257
+ meta: {
258
+ ...durablePatchState.meta,
259
+ projectVerification: {
260
+ mutationRevision: 5,
261
+ mutationHistory: [
262
+ durableMutation,
263
+ {
264
+ revision: 5,
265
+ goalRevision: 7,
266
+ toolName: "write_file",
267
+ paths: ["service_ctl.py"],
268
+ },
269
+ ],
270
+ },
271
+ },
272
+ },
273
+ "apply_patch",
274
+ durablePatchArgs,
275
+ { commandCwd: process.cwd() }
276
+ ),
277
+ null,
278
+ "an intervening successful mutation must permit the same exact patch when source state changed"
279
+ );
280
+
193
281
  function assistant(content, toolCalls = []) {
194
282
  return {
195
283
  choices: [{ message: { role: "assistant", content, ...(toolCalls.length ? { tool_calls: toolCalls } : {}) } }],
@@ -93,6 +93,7 @@ import { longJobStatus, startLongJob } from "./long-job-tools.js";
93
93
  import { executeAgentLinkTool, isAgentLinkTool } from "./agentlink.js";
94
94
  import { classifyGoalIntent, isDirectAnswerIntent } from "./goal-intent.js";
95
95
  import { normalizeProviderBaseURL, normalizeProviderId, providerRequiresApiKey } from "./provider-contract.js";
96
+ import { resolveProviderHandoff } from "./provider-handoff.js";
96
97
  import { ProviderReadinessError, probeProviderRuntime } from "./provider-runtime.js";
97
98
  import { probeLocalMaxResources } from "./local-resource-policy.js";
98
99
  import {
@@ -876,11 +877,22 @@ function summarizeRetainedSourceEvidence(messages = [], limit = 28) {
876
877
  const payload = retained?.payload || safeParseToolContent(message.content);
877
878
  if (!payload || payload.ok === false || payload.blocked || payload.skipped) continue;
878
879
  const toolName = String(retained?.name || payload.toolName || payload.name || "");
880
+ const args = retained?.args || payload.args || {};
881
+ const sourcePath = String(payload.path || args.path || "").trim();
882
+ if (["apply_patch", "write_file"].includes(toolName) && sourcePath) {
883
+ for (const [key, record] of bySource.entries()) {
884
+ if (
885
+ record.toolName === "read_file" &&
886
+ retainedPathMatchesAny(record.sourcePath, [sourcePath])
887
+ ) {
888
+ bySource.delete(key);
889
+ }
890
+ }
891
+ continue;
892
+ }
879
893
  if (!["read_file", "list_files", "search_files", "inspect_project", "run_command"].includes(toolName)) {
880
894
  continue;
881
895
  }
882
- const args = retained?.args || payload.args || {};
883
- const sourcePath = String(payload.path || args.path || "").trim();
884
896
  const command = String(args.command || "").trim();
885
897
  const readRange = toolName === "read_file" ? retainedReadRange(payload, args) : null;
886
898
  const key = `${toolName}:${sourcePath || command}${readRange ? `:${readRange.key}` : ""}`;
@@ -936,9 +948,15 @@ function summarizeRetainedSourceEvidence(messages = [], limit = 28) {
936
948
  if (payload.stdout) parts.push(`stdout=${compactSingleLine(payload.stdout, 260)}`);
937
949
  if (payload.stderr) parts.push(`stderr=${compactSingleLine(payload.stderr, 220)}`);
938
950
  }
939
- bySource.set(key, redactSensitiveText(parts.join(" | ")));
951
+ bySource.set(key, {
952
+ toolName,
953
+ sourcePath,
954
+ text: redactSensitiveText(parts.join(" | ")),
955
+ });
940
956
  }
941
- return [...bySource.values()].slice(-Math.max(1, Number(limit) || 28));
957
+ return [...bySource.values()]
958
+ .slice(-Math.max(1, Number(limit) || 28))
959
+ .map((record) => record.text);
942
960
  }
943
961
 
944
962
  const COMPACTION_STATE_TOOL_NAMES = new Set([
@@ -1075,6 +1093,16 @@ function retainedToolStateMessages(messages = [], limit = 12, outputPaths = [],
1075
1093
  if (!COMPACTION_STATE_TOOL_NAMES.has(name)) return;
1076
1094
  if (!payload || payload.ok === false || payload.blocked || payload.skipped) return;
1077
1095
  const sourcePath = String(payload.path || args?.path || "").trim();
1096
+ if (["apply_patch", "write_file"].includes(name) && sourcePath) {
1097
+ for (const [key, record] of recordsByKey.entries()) {
1098
+ if (
1099
+ record.name === "read_file" &&
1100
+ retainedPathMatchesAny(record.sourcePath, [sourcePath])
1101
+ ) {
1102
+ recordsByKey.delete(key);
1103
+ }
1104
+ }
1105
+ }
1078
1106
  const command = String(args?.command || payload.args?.command || "").trim();
1079
1107
  const durableIdentity = String(
1080
1108
  sourcePath || command || payload.researchId || args?.researchId || args?.query || name
@@ -1084,6 +1112,7 @@ function retainedToolStateMessages(messages = [], limit = 12, outputPaths = [],
1084
1112
  recordsByKey.set(key, {
1085
1113
  ordinal: ordinal += 1,
1086
1114
  name,
1115
+ sourcePath,
1087
1116
  args: redactValue(args),
1088
1117
  payload: compactRetainedToolPayload(name, payload, args),
1089
1118
  });
@@ -6199,7 +6228,7 @@ export function repeatedSuccessfulMutationBlock(state, toolName, args = {}, conf
6199
6228
  commandCwd: config.commandCwd,
6200
6229
  });
6201
6230
  const stagnationEpoch = Math.max(0, Number(toolLoop.stagnationEpoch || 0));
6202
- const alreadyApplied = (Array.isArray(toolLoop.recent) ? toolLoop.recent : []).some(
6231
+ const recentlyApplied = (Array.isArray(toolLoop.recent) ? toolLoop.recent : []).some(
6203
6232
  (entry) =>
6204
6233
  entry?.signature === signature &&
6205
6234
  entry?.toolName === "apply_patch" &&
@@ -6208,6 +6237,41 @@ export function repeatedSuccessfulMutationBlock(state, toolName, args = {}, conf
6208
6237
  entry?.successfulMutation === true &&
6209
6238
  Number(entry?.stagnationEpoch || 0) === stagnationEpoch
6210
6239
  );
6240
+ const verification = state.meta?.projectVerification || {};
6241
+ const history = Array.isArray(verification.mutationHistory)
6242
+ ? verification.mutationHistory
6243
+ : verification.lastMutation
6244
+ ? [verification.lastMutation]
6245
+ : [];
6246
+ const targetPath = typeof args.path === "string" ? safeRecoveryEvidencePath(args.path) : "";
6247
+ const searchHash = typeof args.search === "string" ? hashForLog(args.search) : "";
6248
+ const replaceHash = typeof args.replace === "string" ? hashForLog(args.replace) : "";
6249
+ const currentGoalRevision = Math.max(0, Number(state.meta?.goalContract?.revision || 0));
6250
+ let matchingHistoryIndex = -1;
6251
+ if (targetPath && searchHash && replaceHash) {
6252
+ for (let index = history.length - 1; index >= 0; index -= 1) {
6253
+ const mutation = history[index];
6254
+ if (
6255
+ mutation?.toolName === "apply_patch" &&
6256
+ Number(mutation?.goalRevision || 0) === currentGoalRevision &&
6257
+ safeRecoveryEvidencePath(mutation?.patch?.path) === targetPath &&
6258
+ String(mutation?.patch?.searchHash || "") === searchHash &&
6259
+ String(mutation?.patch?.replaceHash || "") === replaceHash
6260
+ ) {
6261
+ matchingHistoryIndex = index;
6262
+ break;
6263
+ }
6264
+ }
6265
+ }
6266
+ const persistentlyApplied =
6267
+ matchingHistoryIndex >= 0 &&
6268
+ !history.slice(matchingHistoryIndex + 1).some(
6269
+ (mutation) =>
6270
+ Number(mutation?.goalRevision || 0) === currentGoalRevision &&
6271
+ Number(mutation?.revision || 0) >
6272
+ Number(history[matchingHistoryIndex]?.revision || 0)
6273
+ );
6274
+ const alreadyApplied = recentlyApplied || persistentlyApplied;
6211
6275
  if (!alreadyApplied) return null;
6212
6276
  return {
6213
6277
  reason:
@@ -11058,7 +11122,78 @@ async function recordPreInferenceFailure({ error, config, state, store, observer
11058
11122
  emitConsole(config, result, { kind: "error", error: true });
11059
11123
  }
11060
11124
 
11061
- export async function runAgent(config) {
11125
+ class ProviderHandoffSignal extends Error {
11126
+ constructor({ sessionId, expectedRuntimeRevision, decision }) {
11127
+ super(`Provider handoff requested for ${decision.sourceProvider} -> ${decision.targetProvider}.`);
11128
+ this.name = "ProviderHandoffSignal";
11129
+ this.sessionId = sessionId;
11130
+ this.expectedRuntimeRevision = expectedRuntimeRevision;
11131
+ this.decision = decision;
11132
+ }
11133
+ }
11134
+
11135
+ async function prepareProviderHandoff({ error, config, state, store, observers, sessionId, stage = "runtime" }) {
11136
+ const decision = resolveProviderHandoff(error, config, { stage });
11137
+ if (!decision) return null;
11138
+
11139
+ state.meta = state.meta || {};
11140
+ const priorAttempts = Number(state.meta.providerHandoff?.attempts || 0);
11141
+ if (priorAttempts >= 1) return null;
11142
+
11143
+ const at = new Date().toISOString();
11144
+ const detail = {
11145
+ version: 1,
11146
+ attempts: priorAttempts + 1,
11147
+ status: "pending",
11148
+ sourceProvider: decision.sourceProvider,
11149
+ sourceModel: decision.sourceModel,
11150
+ targetProvider: decision.targetProvider,
11151
+ targetModel: decision.targetModel,
11152
+ failureCode: decision.failureCode,
11153
+ httpStatus: decision.status || 0,
11154
+ requestedAt: at,
11155
+ };
11156
+ state.meta.providerHandoff = detail;
11157
+ state.updatedAt = at;
11158
+ updateGoalStatus(state, "active", "provider_handoff", at);
11159
+ await store.saveState(state);
11160
+ await store.appendEvent("provider.handoff_requested", detail);
11161
+ observers.event("provider.handoff_requested", { ...detail, sessionId });
11162
+ emitConsole(
11163
+ config,
11164
+ `${decision.sourceProvider}/${decision.sourceModel} is unavailable (${decision.failureCode}); continuing the same session with ${decision.targetProvider}/${decision.targetModel}.`,
11165
+ { kind: "meta" }
11166
+ );
11167
+ return new ProviderHandoffSignal({
11168
+ sessionId,
11169
+ expectedRuntimeRevision: Number(state.meta.runtimeConfig?.revision || 1),
11170
+ decision,
11171
+ });
11172
+ }
11173
+
11174
+ async function activatePendingProviderHandoff({ config, state, store, observers, sessionId }) {
11175
+ const handoff = state.meta?.providerHandoff;
11176
+ if (
11177
+ !handoff ||
11178
+ handoff.status !== "pending" ||
11179
+ normalizeProviderId(handoff.targetProvider, "") !== normalizeProviderId(config.provider, "") ||
11180
+ String(handoff.targetModel || "") !== String(config.model || "")
11181
+ ) {
11182
+ return;
11183
+ }
11184
+ const detail = {
11185
+ ...handoff,
11186
+ status: "active",
11187
+ activatedAt: new Date().toISOString(),
11188
+ };
11189
+ state.meta.providerHandoff = detail;
11190
+ state.updatedAt = detail.activatedAt;
11191
+ await store.saveState(state);
11192
+ await store.appendEvent("provider.handoff_activated", detail);
11193
+ observers.event("provider.handoff_activated", { ...detail, sessionId });
11194
+ }
11195
+
11196
+ async function runAgentOnce(config) {
11062
11197
  assertIntegrationRunAgentInvocation(config);
11063
11198
  const incomingConfig = config;
11064
11199
  const sessionId = config.resume || config.sessionId || `web-agent-${crypto.randomUUID()}`;
@@ -11087,6 +11222,11 @@ export async function runAgent(config) {
11087
11222
  state.meta = state.meta || {};
11088
11223
  state.meta.runtimeConfig = runtime.snapshot;
11089
11224
  config = rebuildResumedRuntimeConfig(incomingConfig, runtime.runtimeOverrides, sessionId);
11225
+ // Keep compatibility fields aligned with the authoritative runtime
11226
+ // snapshot. Provider/model patches otherwise leave stale top-level values
11227
+ // that can mislead later diagnostics and legacy session readers.
11228
+ state.provider = config.provider;
11229
+ state.model = config.model;
11090
11230
  const patchedRuntimeFields = runtime.patched
11091
11231
  ? Object.keys(incomingConfig.runtimePatch || {}).filter((field) => isSessionRuntimeField(field))
11092
11232
  : [];
@@ -11261,10 +11401,22 @@ export async function runAgent(config) {
11261
11401
  }
11262
11402
  client = config.clientFactory ? await config.clientFactory(config) : createClient(config);
11263
11403
  } catch (error) {
11404
+ const handoff = await prepareProviderHandoff({
11405
+ error,
11406
+ config,
11407
+ state,
11408
+ store,
11409
+ observers,
11410
+ sessionId,
11411
+ stage: "preflight",
11412
+ });
11413
+ if (handoff) throw handoff;
11264
11414
  await recordPreInferenceFailure({ error, config, state, store, observers, sessionId });
11265
11415
  throw error;
11266
11416
  }
11267
11417
 
11418
+ await activatePendingProviderHandoff({ config, state, store, observers, sessionId });
11419
+
11268
11420
  ensureChatState(state);
11269
11421
 
11270
11422
  const initialRepair = repairModelMessageHistory(state, config);
@@ -12937,6 +13089,9 @@ export async function runAgent(config) {
12937
13089
  ...goalRunMetadata(state),
12938
13090
  };
12939
13091
  } catch (error) {
13092
+ if (error instanceof ProviderHandoffSignal) throw error;
13093
+ const handoff = await prepareProviderHandoff({ error, config, state, store, observers, sessionId });
13094
+ if (handoff) throw handoff;
12940
13095
  if (isModelTimeoutError(error)) {
12941
13096
  const detail = {
12942
13097
  reason: "model_timeout",
@@ -13000,3 +13155,20 @@ export async function runAgent(config) {
13000
13155
  await flushHousekeeping();
13001
13156
  }
13002
13157
  }
13158
+
13159
+ export async function runAgent(config) {
13160
+ try {
13161
+ return await runAgentOnce(config);
13162
+ } catch (error) {
13163
+ if (!(error instanceof ProviderHandoffSignal)) throw error;
13164
+ const decision = error.decision;
13165
+ return runAgentOnce({
13166
+ ...config,
13167
+ goal: "",
13168
+ resume: error.sessionId,
13169
+ sessionId: error.sessionId,
13170
+ runtimePatch: decision.runtimePatch,
13171
+ expectedRuntimeRevision: error.expectedRuntimeRevision,
13172
+ });
13173
+ }
13174
+ }
@@ -148,6 +148,22 @@ function shouldRetryWithoutReasoningEffort(error, payload = {}) {
148
148
  return /reasoning[_\s.-]?effort|unsupported parameter|unknown parameter|unrecognized request argument/i.test(message);
149
149
  }
150
150
 
151
+ function annotateProviderRequestError(error, config = {}, label = "model request") {
152
+ if (!error || (typeof error !== "object" && typeof error !== "function")) return error;
153
+ try {
154
+ Object.defineProperties(error, {
155
+ agintiProviderRequest: { value: true, configurable: true },
156
+ agintiProvider: { value: String(config.provider || ""), configurable: true },
157
+ agintiProviderModel: { value: String(config.model || ""), configurable: true },
158
+ agintiProviderRequestLabel: { value: String(label || "model request"), configurable: true },
159
+ });
160
+ } catch {
161
+ // Some SDK errors can be non-extensible. Classification still has the
162
+ // explicit preflight path, while an unmarked runtime error fails normally.
163
+ }
164
+ return error;
165
+ }
166
+
151
167
  export async function createChatCompletion(client, payload, config, label = "model request") {
152
168
  const preparedPayload = withChatReasoningEffort(payload, config);
153
169
  const timeout = resolveModelTimeoutMs(config);
@@ -157,9 +173,13 @@ export async function createChatCompletion(client, payload, config, label = "mod
157
173
  } catch (error) {
158
174
  if (shouldRetryWithoutReasoningEffort(error, preparedPayload)) {
159
175
  const { reasoning_effort: _reasoningEffort, ...retryPayload } = preparedPayload;
160
- return client.chat.completions.create(retryPayload, requestOptions(config));
176
+ try {
177
+ return await client.chat.completions.create(retryPayload, requestOptions(config));
178
+ } catch (retryError) {
179
+ throw annotateProviderRequestError(retryError, config, label);
180
+ }
161
181
  }
162
- throw error;
182
+ throw annotateProviderRequestError(error, config, label);
163
183
  }
164
184
  }
165
185
 
@@ -196,18 +216,22 @@ export async function createChatCompletion(client, payload, config, label = "mod
196
216
  } catch (error) {
197
217
  if (shouldRetryWithoutReasoningEffort(error, preparedPayload)) {
198
218
  const { reasoning_effort: _reasoningEffort, ...retryPayload } = preparedPayload;
199
- return await client.chat.completions.create(retryPayload, {
200
- ...requestOptions(config),
201
- signal: controller.signal,
202
- });
219
+ try {
220
+ return await client.chat.completions.create(retryPayload, {
221
+ ...requestOptions(config),
222
+ signal: controller.signal,
223
+ });
224
+ } catch (retryError) {
225
+ throw annotateProviderRequestError(retryError, config, label);
226
+ }
203
227
  }
204
228
  if (timedOut && error?.name !== "ModelTimeoutError") {
205
229
  const timeoutError = new Error(`${label} timed out after ${timeout}ms`);
206
230
  timeoutError.name = "ModelTimeoutError";
207
231
  timeoutError.cause = error;
208
- throw timeoutError;
232
+ throw annotateProviderRequestError(timeoutError, config, label);
209
233
  }
210
- throw error;
234
+ throw annotateProviderRequestError(error, config, label);
211
235
  } finally {
212
236
  if (timer) clearTimeout(timer);
213
237
  if (config.abortSignal) {
@@ -0,0 +1,126 @@
1
+ import { LOCALLLM_MODEL_TIERS } from "./model-routing.js";
2
+ import { normalizeProviderId } from "./provider-contract.js";
3
+
4
+ const NETWORK_ERROR_CODES = new Set([
5
+ "ECONNREFUSED",
6
+ "ECONNRESET",
7
+ "EHOSTUNREACH",
8
+ "ENETUNREACH",
9
+ "ENOTFOUND",
10
+ "EAI_AGAIN",
11
+ ]);
12
+
13
+ function enabled(value, fallback = true) {
14
+ if (value === undefined || value === null || String(value).trim() === "") return fallback;
15
+ return !/^(?:0|false|off|no)$/i.test(String(value).trim());
16
+ }
17
+
18
+ function providerErrorText(error) {
19
+ return [
20
+ error?.code,
21
+ error?.type,
22
+ error?.message,
23
+ error?.error?.code,
24
+ error?.error?.type,
25
+ error?.error?.message,
26
+ error?.response?.data?.error?.code,
27
+ error?.response?.data?.error?.type,
28
+ error?.response?.data?.error?.message,
29
+ ]
30
+ .filter(Boolean)
31
+ .join(" ")
32
+ .toLowerCase();
33
+ }
34
+
35
+ export function classifyProviderHandoffError(error) {
36
+ const status = Number(error?.status || error?.response?.status || 0);
37
+ const code = String(error?.code || "").trim().toUpperCase();
38
+ const text = providerErrorText(error);
39
+
40
+ if (
41
+ status === 402 ||
42
+ /insufficient[_ -]?(?:quota|balance)|quota exceeded|billing|account balance|credits? exhausted/.test(text)
43
+ ) {
44
+ return { eligible: true, code: "provider_quota", status };
45
+ }
46
+ if (
47
+ code === "API_KEY_REQUIRED" ||
48
+ status === 401 ||
49
+ status === 403 ||
50
+ /authentication|unauthorized|invalid[_ -]?api[_ -]?key|permission denied/.test(text)
51
+ ) {
52
+ return { eligible: true, code: "provider_auth", status };
53
+ }
54
+ if (status === 429 || /rate[_ -]?limit|too many requests/.test(text)) {
55
+ return { eligible: true, code: "provider_rate_limited", status };
56
+ }
57
+ if (
58
+ status === 404 &&
59
+ /model|deployment|endpoint|not found|does not exist|unavailable/.test(text)
60
+ ) {
61
+ return { eligible: true, code: "provider_model_unavailable", status };
62
+ }
63
+ if (
64
+ status === 408 ||
65
+ status === 503 ||
66
+ status === 529 ||
67
+ status >= 500 ||
68
+ NETWORK_ERROR_CODES.has(code) ||
69
+ /overload|overloaded|capacity|temporarily unavailable|connection refused|network unreachable/.test(text)
70
+ ) {
71
+ return { eligible: true, code: "provider_unavailable", status };
72
+ }
73
+ return { eligible: false, code: "provider_request_failed", status };
74
+ }
75
+
76
+ export function resolveProviderHandoff(error, config = {}, { stage = "runtime" } = {}) {
77
+ const sourceProvider = normalizeProviderId(config.provider, "");
78
+ const targetProvider = normalizeProviderId(
79
+ config.providerHandoffProvider || process.env.AGINTI_PROVIDER_HANDOFF_PROVIDER || "localllm",
80
+ ""
81
+ );
82
+ if (!enabled(config.allowProviderHandoff ?? process.env.AGINTI_PROVIDER_HANDOFF, true)) return null;
83
+ if (!sourceProvider || sourceProvider === "mock" || sourceProvider === "localllm") return null;
84
+ if (!targetProvider || targetProvider !== "localllm" || targetProvider === sourceProvider) return null;
85
+ if (String(config.routingMode || "smart").trim().toLowerCase() === "manual") return null;
86
+ if (String(config.integrationSessionProfile || "").trim()) return null;
87
+ if (stage !== "preflight" && error?.agintiProviderRequest !== true) return null;
88
+
89
+ const failure = classifyProviderHandoffError(error);
90
+ if (!failure.eligible) return null;
91
+
92
+ const routeModel =
93
+ String(config.providerHandoffRouteModel || process.env.AGINTI_LOCALLLM_ROUTE_MODEL || "").trim() ||
94
+ LOCALLLM_MODEL_TIERS.fast.model;
95
+ const mainModel =
96
+ String(
97
+ config.providerHandoffModel ||
98
+ process.env.AGINTI_PROVIDER_HANDOFF_MODEL ||
99
+ process.env.AGINTI_LOCALLLM_MAIN_MODEL ||
100
+ ""
101
+ ).trim() || LOCALLLM_MODEL_TIERS.deep.model;
102
+
103
+ return {
104
+ version: 1,
105
+ sourceProvider,
106
+ sourceModel: String(config.model || ""),
107
+ targetProvider,
108
+ targetModel: mainModel,
109
+ routeModel,
110
+ mainModel,
111
+ spareModel: mainModel,
112
+ failureCode: failure.code,
113
+ status: failure.status,
114
+ runtimePatch: {
115
+ provider: targetProvider,
116
+ model: mainModel,
117
+ routingMode: "manual",
118
+ routeProvider: targetProvider,
119
+ routeModel,
120
+ mainProvider: targetProvider,
121
+ mainModel,
122
+ spareProvider: targetProvider,
123
+ spareModel: mainModel,
124
+ },
125
+ };
126
+ }