@juspay/neurolink 9.81.2 → 9.82.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/CHANGELOG.md +18 -0
- package/README.md +4 -0
- package/dist/browser/neurolink.min.js +355 -355
- package/dist/cli/commands/proxy.js +5 -0
- package/dist/cli/factories/commandFactory.d.ts +2 -1
- package/dist/cli/factories/commandFactory.js +36 -16
- package/dist/cli/utils/audioPlayer.d.ts +37 -0
- package/dist/cli/utils/audioPlayer.js +138 -0
- package/dist/constants/contextWindows.js +10 -0
- package/dist/core/constants.d.ts +16 -0
- package/dist/core/constants.js +16 -0
- package/dist/core/modules/GenerationHandler.js +7 -1
- package/dist/core/modules/ToolsManager.d.ts +5 -0
- package/dist/core/modules/ToolsManager.js +62 -6
- package/dist/index.d.ts +6 -1
- package/dist/index.js +14 -6
- package/dist/lib/constants/contextWindows.js +10 -0
- package/dist/lib/core/constants.d.ts +16 -0
- package/dist/lib/core/constants.js +16 -0
- package/dist/lib/core/modules/GenerationHandler.js +7 -1
- package/dist/lib/core/modules/ToolsManager.d.ts +5 -0
- package/dist/lib/core/modules/ToolsManager.js +62 -6
- package/dist/lib/index.d.ts +6 -1
- package/dist/lib/index.js +14 -6
- package/dist/lib/neurolink.js +21 -3
- package/dist/lib/providers/googleNativeGemini3.d.ts +43 -0
- package/dist/lib/providers/googleNativeGemini3.js +73 -1
- package/dist/lib/providers/googleVertex.js +495 -80
- package/dist/lib/proxy/proxyDispatcher.d.ts +5 -0
- package/dist/lib/proxy/proxyDispatcher.js +61 -0
- package/dist/lib/types/generate.d.ts +5 -1
- package/dist/lib/utils/async/index.d.ts +1 -1
- package/dist/lib/utils/async/index.js +1 -1
- package/dist/lib/utils/async/withTimeout.d.ts +12 -0
- package/dist/lib/utils/async/withTimeout.js +45 -0
- package/dist/lib/utils/mcpErrorText.d.ts +11 -0
- package/dist/lib/utils/mcpErrorText.js +23 -0
- package/dist/lib/utils/systemMessages.d.ts +29 -0
- package/dist/lib/utils/systemMessages.js +45 -0
- package/dist/neurolink.js +21 -3
- package/dist/providers/googleNativeGemini3.d.ts +43 -0
- package/dist/providers/googleNativeGemini3.js +73 -1
- package/dist/providers/googleVertex.js +495 -80
- package/dist/proxy/proxyDispatcher.d.ts +5 -0
- package/dist/proxy/proxyDispatcher.js +60 -0
- package/dist/types/generate.d.ts +5 -1
- package/dist/utils/async/index.d.ts +1 -1
- package/dist/utils/async/index.js +1 -1
- package/dist/utils/async/withTimeout.d.ts +12 -0
- package/dist/utils/async/withTimeout.js +45 -0
- package/dist/utils/mcpErrorText.d.ts +11 -0
- package/dist/utils/mcpErrorText.js +23 -0
- package/dist/utils/systemMessages.d.ts +29 -0
- package/dist/utils/systemMessages.js +44 -0
- package/docs/assets/dashboards/neurolink-proxy-observability-dashboard.json +1258 -0
- package/package.json +3 -2
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tuned global undici dispatcher for the proxy's upstream forwards.
|
|
3
|
+
*
|
|
4
|
+
* The Claude passthrough (`claudeProxyRoutes.ts`) forwards every request to
|
|
5
|
+
* Anthropic via the global `fetch` → undici global dispatcher. undici keep-alives
|
|
6
|
+
* by default, but with a ~4s idle timeout: between Claude Code turns (the user
|
|
7
|
+
* reads output, a tool runs, the model thinks) the idle socket closes, so the
|
|
8
|
+
* next request opens a brand-new TCP connection. Under sustained use that is a
|
|
9
|
+
* high rate of short-lived outbound flows.
|
|
10
|
+
*
|
|
11
|
+
* On hosts running a socket content-filter (CFIL) — e.g. SentinelOne or
|
|
12
|
+
* GlobalProtect network extensions — every new flow allocates per-flow kernel
|
|
13
|
+
* state, so a high flow rate amplifies any leak in that path. Reusing connections
|
|
14
|
+
* cuts the flow rate sharply, so we install a dispatcher with a longer keep-alive
|
|
15
|
+
* and a bounded, reused connection pool.
|
|
16
|
+
*
|
|
17
|
+
* Everything is overridable via env so it can be tuned (or disabled) per host:
|
|
18
|
+
* NEUROLINK_PROXY_KEEPALIVE=off disable; keep undici defaults
|
|
19
|
+
* NEUROLINK_PROXY_KEEPALIVE_MS idle keep-alive timeout (default 30000)
|
|
20
|
+
* NEUROLINK_PROXY_KEEPALIVE_MAX_MS keep-alive upper bound (default 600000)
|
|
21
|
+
* NEUROLINK_PROXY_MAX_CONNECTIONS max pooled connections per origin (default 64)
|
|
22
|
+
*/
|
|
23
|
+
import { Agent, setGlobalDispatcher } from "undici";
|
|
24
|
+
import { logger } from "../utils/logger.js";
|
|
25
|
+
function readPositiveInt(name, fallback) {
|
|
26
|
+
const raw = process.env[name];
|
|
27
|
+
if (!raw) {
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
|
30
|
+
const parsed = Number.parseInt(raw, 10);
|
|
31
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
32
|
+
}
|
|
33
|
+
let configured = false;
|
|
34
|
+
/**
|
|
35
|
+
* Install the tuned global undici dispatcher. Idempotent — safe to call once at
|
|
36
|
+
* proxy startup. No-op when `NEUROLINK_PROXY_KEEPALIVE` is off/false/0.
|
|
37
|
+
*/
|
|
38
|
+
export function configureProxyKeepAliveDispatcher() {
|
|
39
|
+
if (configured) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
configured = true;
|
|
43
|
+
const toggle = (process.env.NEUROLINK_PROXY_KEEPALIVE ?? "").toLowerCase();
|
|
44
|
+
if (toggle === "off" || toggle === "false" || toggle === "0") {
|
|
45
|
+
logger.debug("[proxy] keep-alive dispatcher disabled via env");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const keepAliveTimeout = readPositiveInt("NEUROLINK_PROXY_KEEPALIVE_MS", 30_000);
|
|
49
|
+
const keepAliveMaxTimeout = readPositiveInt("NEUROLINK_PROXY_KEEPALIVE_MAX_MS", 600_000);
|
|
50
|
+
const connections = readPositiveInt("NEUROLINK_PROXY_MAX_CONNECTIONS", 64);
|
|
51
|
+
setGlobalDispatcher(new Agent({
|
|
52
|
+
keepAliveTimeout,
|
|
53
|
+
keepAliveMaxTimeout,
|
|
54
|
+
connections,
|
|
55
|
+
pipelining: 1,
|
|
56
|
+
}));
|
|
57
|
+
logger.debug(`[proxy] tuned undici dispatcher installed ` +
|
|
58
|
+
`(keepAliveTimeout=${keepAliveTimeout}ms, ` +
|
|
59
|
+
`keepAliveMaxTimeout=${keepAliveMaxTimeout}ms, connections=${connections})`);
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=proxyDispatcher.js.map
|
|
@@ -602,13 +602,17 @@ export type AdditionalMemoryUser = {
|
|
|
602
602
|
*
|
|
603
603
|
* - `completed` — the model finished on its own (text answer or final_result)
|
|
604
604
|
* - `step-cap` — the `maxSteps` budget ran out while the model still wanted tools
|
|
605
|
+
* - `context-cap` — the in-loop context guard stopped the tool loop because the
|
|
606
|
+
* accumulated conversation approached the model's context window (and the
|
|
607
|
+
* terminal synthesis could not produce an answer); without the guard these
|
|
608
|
+
* turns died mid-loop on a provider 400 "prompt is too long"
|
|
605
609
|
* - `time-limit` — the `turnTimeoutMs` wall-clock deadline passed
|
|
606
610
|
* - `stalled` — no progress (no chunk, no tool start/finish) for `stallTimeoutMs`
|
|
607
611
|
* - `aborted` — the caller's `abortSignal` ended the turn
|
|
608
612
|
* - `provider-error` — the provider/model failed the turn (e.g. persistent
|
|
609
613
|
* MALFORMED_FUNCTION_CALL after retry); usually worth a caller-side retry
|
|
610
614
|
*/
|
|
611
|
-
export type GenerateStopReason = "completed" | "step-cap" | "time-limit" | "stalled" | "aborted" | "provider-error";
|
|
615
|
+
export type GenerateStopReason = "completed" | "step-cap" | "context-cap" | "time-limit" | "stalled" | "aborted" | "provider-error";
|
|
612
616
|
/**
|
|
613
617
|
* Generate function result type - Primary output format
|
|
614
618
|
* Future-ready for multi-modal outputs while maintaining text focus
|
|
@@ -20,4 +20,4 @@
|
|
|
20
20
|
*/
|
|
21
21
|
export { delay, sleep } from "./delay.js";
|
|
22
22
|
export { calculateBackoff, createRetry, DEFAULT_RETRY_OPTIONS, RetryExhaustedError, retry, } from "./retry.js";
|
|
23
|
-
export { TimeoutError, withTimeout, withTimeoutFn } from "./withTimeout.js";
|
|
23
|
+
export { TimeoutError, raceWithAbort, withTimeout, withTimeoutFn, } from "./withTimeout.js";
|
|
@@ -20,5 +20,5 @@
|
|
|
20
20
|
*/
|
|
21
21
|
export { delay, sleep } from "./delay.js";
|
|
22
22
|
export { calculateBackoff, createRetry, DEFAULT_RETRY_OPTIONS, RetryExhaustedError, retry, } from "./retry.js";
|
|
23
|
-
export { TimeoutError, withTimeout, withTimeoutFn } from "./withTimeout.js";
|
|
23
|
+
export { TimeoutError, raceWithAbort, withTimeout, withTimeoutFn, } from "./withTimeout.js";
|
|
24
24
|
//# sourceMappingURL=index.js.map
|
|
@@ -49,6 +49,18 @@ export declare class TimeoutError extends Error {
|
|
|
49
49
|
* ```
|
|
50
50
|
*/
|
|
51
51
|
export declare function withTimeout<T>(promise: Promise<T>, ms: number, message?: string): Promise<T>;
|
|
52
|
+
/**
|
|
53
|
+
* Race a promise against an AbortSignal so the caller observes an abort
|
|
54
|
+
* IMMEDIATELY instead of waiting for the operation to settle (or for a
|
|
55
|
+
* separate timeout to expire). Rejects with an abort-shaped error
|
|
56
|
+
* (`name === "AbortError"`) so provider loops route it to their existing
|
|
57
|
+
* cancellation handling.
|
|
58
|
+
*
|
|
59
|
+
* The underlying operation is NOT cancelled — it continues as a bounded
|
|
60
|
+
* ghost (the same tradeoff as {@link withTimeout}); its eventual settlement
|
|
61
|
+
* is swallowed so it can never surface as an unhandled rejection.
|
|
62
|
+
*/
|
|
63
|
+
export declare function raceWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T>;
|
|
52
64
|
/**
|
|
53
65
|
* Execute a function with timeout protection.
|
|
54
66
|
*
|
|
@@ -72,6 +72,51 @@ export async function withTimeout(promise, ms, message) {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Race a promise against an AbortSignal so the caller observes an abort
|
|
77
|
+
* IMMEDIATELY instead of waiting for the operation to settle (or for a
|
|
78
|
+
* separate timeout to expire). Rejects with an abort-shaped error
|
|
79
|
+
* (`name === "AbortError"`) so provider loops route it to their existing
|
|
80
|
+
* cancellation handling.
|
|
81
|
+
*
|
|
82
|
+
* The underlying operation is NOT cancelled — it continues as a bounded
|
|
83
|
+
* ghost (the same tradeoff as {@link withTimeout}); its eventual settlement
|
|
84
|
+
* is swallowed so it can never surface as an unhandled rejection.
|
|
85
|
+
*/
|
|
86
|
+
export async function raceWithAbort(promise, signal) {
|
|
87
|
+
if (!signal || typeof signal.addEventListener !== "function") {
|
|
88
|
+
return promise;
|
|
89
|
+
}
|
|
90
|
+
const makeAbortError = () => {
|
|
91
|
+
const e = new Error("Operation aborted");
|
|
92
|
+
e.name = "AbortError";
|
|
93
|
+
return e;
|
|
94
|
+
};
|
|
95
|
+
if (signal.aborted) {
|
|
96
|
+
promise.catch(() => {
|
|
97
|
+
// Swallow the abandoned settlement — it must never surface as an
|
|
98
|
+
// unhandled rejection after the race has already been decided.
|
|
99
|
+
});
|
|
100
|
+
return Promise.reject(makeAbortError());
|
|
101
|
+
}
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const onAbort = () => {
|
|
104
|
+
promise.catch(() => {
|
|
105
|
+
// Swallow the abandoned settlement — it must never surface as an
|
|
106
|
+
// unhandled rejection after the race has already been decided.
|
|
107
|
+
});
|
|
108
|
+
reject(makeAbortError());
|
|
109
|
+
};
|
|
110
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
111
|
+
promise.then((value) => {
|
|
112
|
+
signal.removeEventListener("abort", onAbort);
|
|
113
|
+
resolve(value);
|
|
114
|
+
}, (error) => {
|
|
115
|
+
signal.removeEventListener("abort", onAbort);
|
|
116
|
+
reject(error);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
75
120
|
/**
|
|
76
121
|
* Execute a function with timeout protection.
|
|
77
122
|
*
|
|
@@ -24,3 +24,14 @@ export declare function extractMcpErrorText(raw: unknown): string;
|
|
|
24
24
|
* but no readable text is present, falls back to a generic message.
|
|
25
25
|
*/
|
|
26
26
|
export declare function extractMcpToolErrorMessage(result: unknown): string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Detect a tool result that REPORTS failure without throwing, for the native
|
|
29
|
+
* loops' consecutive-failure breaker. Covers the two shapes NeuroLink itself
|
|
30
|
+
* produces or forwards:
|
|
31
|
+
* - MCP CallToolResult with `isError: true` (e.g. proxy-blocked tools) —
|
|
32
|
+
* delegated to {@link extractMcpToolErrorMessage}
|
|
33
|
+
* - our own `{ error: "..." }` payloads
|
|
34
|
+
* Plain strings are never classified (too false-positive-prone).
|
|
35
|
+
* Returns the error text, or null when the result looks like a success.
|
|
36
|
+
*/
|
|
37
|
+
export declare function extractToolFailureText(result: unknown): string | null;
|
|
@@ -69,4 +69,27 @@ export function extractMcpToolErrorMessage(result) {
|
|
|
69
69
|
? `MCP tool returned isError: ${text}`
|
|
70
70
|
: "MCP tool returned isError: true";
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Detect a tool result that REPORTS failure without throwing, for the native
|
|
74
|
+
* loops' consecutive-failure breaker. Covers the two shapes NeuroLink itself
|
|
75
|
+
* produces or forwards:
|
|
76
|
+
* - MCP CallToolResult with `isError: true` (e.g. proxy-blocked tools) —
|
|
77
|
+
* delegated to {@link extractMcpToolErrorMessage}
|
|
78
|
+
* - our own `{ error: "..." }` payloads
|
|
79
|
+
* Plain strings are never classified (too false-positive-prone).
|
|
80
|
+
* Returns the error text, or null when the result looks like a success.
|
|
81
|
+
*/
|
|
82
|
+
export function extractToolFailureText(result) {
|
|
83
|
+
const mcpError = extractMcpToolErrorMessage(result);
|
|
84
|
+
if (mcpError) {
|
|
85
|
+
return mcpError;
|
|
86
|
+
}
|
|
87
|
+
if (result !== null &&
|
|
88
|
+
typeof result === "object" &&
|
|
89
|
+
typeof result.error === "string" &&
|
|
90
|
+
result.error.length > 0) {
|
|
91
|
+
return result.error;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
72
95
|
//# sourceMappingURL=mcpErrorText.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ModelMessage, SystemModelMessage } from "../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Partition a built message array into system messages and the rest, so the
|
|
4
|
+
* system prompt can ride `generateText`'s top-level `system` option instead of
|
|
5
|
+
* the `messages` array.
|
|
6
|
+
*
|
|
7
|
+
* The AI SDK deprecates system-role entries inside `messages` (warns by default
|
|
8
|
+
* from ai@6.0.170 via the `allowSystemInMessages` option, rejected by default
|
|
9
|
+
* in v7, flagged as a prompt-injection risk). The `system` option accepts full
|
|
10
|
+
* SystemModelMessage objects, so per-message providerOptions (e.g. the anthropic
|
|
11
|
+
* cacheControl breakpoint set by buildMessagesArray) survive the move. See
|
|
12
|
+
* issue #1024.
|
|
13
|
+
*
|
|
14
|
+
* Order is preserved within both partitions and the input is not mutated.
|
|
15
|
+
*
|
|
16
|
+
* Guard: the partition only runs when it leaves a non-empty `messages` array.
|
|
17
|
+
* If every message is a system message (a system-only priming call) — or there
|
|
18
|
+
* are no system messages at all — the original array is returned untouched with
|
|
19
|
+
* `system: undefined`. The AI SDK rejects an empty `messages` array, and a
|
|
20
|
+
* system-only call has no hoisted form (the SDK also requires a non-system
|
|
21
|
+
* message), so that degenerate case is deliberately left on the old
|
|
22
|
+
* system-in-`messages` path rather than made to throw — it keeps whatever
|
|
23
|
+
* behaviour it had before #1024. The normal system-plus-conversation path is
|
|
24
|
+
* always hoisted.
|
|
25
|
+
*/
|
|
26
|
+
export declare function extractSystemMessages(messages: ModelMessage[]): {
|
|
27
|
+
system: SystemModelMessage[] | undefined;
|
|
28
|
+
messages: ModelMessage[];
|
|
29
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Partition a built message array into system messages and the rest, so the
|
|
3
|
+
* system prompt can ride `generateText`'s top-level `system` option instead of
|
|
4
|
+
* the `messages` array.
|
|
5
|
+
*
|
|
6
|
+
* The AI SDK deprecates system-role entries inside `messages` (warns by default
|
|
7
|
+
* from ai@6.0.170 via the `allowSystemInMessages` option, rejected by default
|
|
8
|
+
* in v7, flagged as a prompt-injection risk). The `system` option accepts full
|
|
9
|
+
* SystemModelMessage objects, so per-message providerOptions (e.g. the anthropic
|
|
10
|
+
* cacheControl breakpoint set by buildMessagesArray) survive the move. See
|
|
11
|
+
* issue #1024.
|
|
12
|
+
*
|
|
13
|
+
* Order is preserved within both partitions and the input is not mutated.
|
|
14
|
+
*
|
|
15
|
+
* Guard: the partition only runs when it leaves a non-empty `messages` array.
|
|
16
|
+
* If every message is a system message (a system-only priming call) — or there
|
|
17
|
+
* are no system messages at all — the original array is returned untouched with
|
|
18
|
+
* `system: undefined`. The AI SDK rejects an empty `messages` array, and a
|
|
19
|
+
* system-only call has no hoisted form (the SDK also requires a non-system
|
|
20
|
+
* message), so that degenerate case is deliberately left on the old
|
|
21
|
+
* system-in-`messages` path rather than made to throw — it keeps whatever
|
|
22
|
+
* behaviour it had before #1024. The normal system-plus-conversation path is
|
|
23
|
+
* always hoisted.
|
|
24
|
+
*/
|
|
25
|
+
export function extractSystemMessages(messages) {
|
|
26
|
+
const system = [];
|
|
27
|
+
const rest = [];
|
|
28
|
+
for (const message of messages) {
|
|
29
|
+
if (message.role === "system") {
|
|
30
|
+
system.push(message);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
rest.push(message);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Only hoist when a non-empty messages array remains. A system-only call (or
|
|
37
|
+
// a no-system array) passes through unchanged: hoisting would empty
|
|
38
|
+
// `messages`, which the SDK rejects, and a system-only call has no compliant
|
|
39
|
+
// hoisted form.
|
|
40
|
+
if (system.length === 0 || rest.length === 0) {
|
|
41
|
+
return { system: undefined, messages };
|
|
42
|
+
}
|
|
43
|
+
return { system, messages: rest };
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=systemMessages.js.map
|
package/dist/neurolink.js
CHANGED
|
@@ -28,7 +28,7 @@ import { emergencyContentTruncation } from "./context/emergencyTruncation.js";
|
|
|
28
28
|
import { getContextOverflowProvider, isContextOverflowError, parseProviderOverflowDetails, } from "./context/errorDetection.js";
|
|
29
29
|
import { ContextBudgetExceededError } from "./context/errors.js";
|
|
30
30
|
import { repairToolPairs } from "./context/toolPairRepair.js";
|
|
31
|
-
import { SYSTEM_LIMITS, DEFAULT_TOOL_ROUTING_TIMEOUT_MS, } from "./core/constants.js";
|
|
31
|
+
import { SYSTEM_LIMITS, DEFAULT_TOOL_ROUTING_TIMEOUT_MS, MIN_RECOVERY_TURN_BUDGET_MS, } from "./core/constants.js";
|
|
32
32
|
import { ConversationMemoryManager } from "./core/conversationMemoryManager.js";
|
|
33
33
|
import { buildToolRoutingCatalog, buildRoutingQueryFromHistory, resolveToolRoutingExclusions, } from "./core/toolRouting.js";
|
|
34
34
|
import { ToolRoutingCache } from "./core/toolRoutingCache.js";
|
|
@@ -4277,7 +4277,7 @@ Current user's request: ${currentInput}`;
|
|
|
4277
4277
|
return result;
|
|
4278
4278
|
}
|
|
4279
4279
|
async handleGenerateTextInternalFailure(options, context, error) {
|
|
4280
|
-
const recoveredResult = await this.tryRecoverGenerateTextOverflow(options, context.functionTag, error);
|
|
4280
|
+
const recoveredResult = await this.tryRecoverGenerateTextOverflow(options, context.functionTag, error, context.generateInternalStartTime);
|
|
4281
4281
|
if (recoveredResult) {
|
|
4282
4282
|
return recoveredResult;
|
|
4283
4283
|
}
|
|
@@ -4317,7 +4317,7 @@ Current user's request: ${currentInput}`;
|
|
|
4317
4317
|
}
|
|
4318
4318
|
return null;
|
|
4319
4319
|
}
|
|
4320
|
-
async tryRecoverGenerateTextOverflow(options, functionTag, error) {
|
|
4320
|
+
async tryRecoverGenerateTextOverflow(options, functionTag, error, attemptStartTimeMs) {
|
|
4321
4321
|
// Reviewer Finding #3: drop the `!this.conversationMemory` gate so
|
|
4322
4322
|
// inline-conversationMessages callers also benefit from post-provider
|
|
4323
4323
|
// recovery when their pre-dispatch estimate happens to undershoot
|
|
@@ -4467,16 +4467,34 @@ Current user's request: ${currentInput}`;
|
|
|
4467
4467
|
breakdown: verifiedBudget?.breakdown ?? {},
|
|
4468
4468
|
});
|
|
4469
4469
|
}
|
|
4470
|
+
// Whole-turn budget semantics: the retry inherits the REMAINING
|
|
4471
|
+
// turnTimeoutMs, not a fresh clock — attempt 1 already spent part of
|
|
4472
|
+
// the caller's budget, and a fresh clock let one generate() run ~2×
|
|
4473
|
+
// the configured deadline (the 66-minute-turn incident shape).
|
|
4474
|
+
// Floored so a compacted retry still gets a workable window — but the
|
|
4475
|
+
// floor never exceeds the caller's own turnTimeoutMs (a 10s caller
|
|
4476
|
+
// budget must not receive a 30s retry).
|
|
4477
|
+
let retryTurnTimeoutMs = options.turnTimeoutMs;
|
|
4478
|
+
if (typeof options.turnTimeoutMs === "number" &&
|
|
4479
|
+
Number.isFinite(options.turnTimeoutMs) &&
|
|
4480
|
+
attemptStartTimeMs !== undefined) {
|
|
4481
|
+
const elapsedMs = Date.now() - attemptStartTimeMs;
|
|
4482
|
+
retryTurnTimeoutMs = Math.max(options.turnTimeoutMs - elapsedMs, Math.min(MIN_RECOVERY_TURN_BUDGET_MS, options.turnTimeoutMs));
|
|
4483
|
+
}
|
|
4470
4484
|
logger.info(`[${functionTag}] Smart recovery verified, retrying generation`, {
|
|
4471
4485
|
tokensSaved: lastCompactionResult.tokensSaved,
|
|
4472
4486
|
compactionTarget,
|
|
4473
4487
|
verifiedTokens: verifiedBudget.estimatedInputTokens,
|
|
4474
4488
|
verifiedBudget: verifiedBudget.availableInputTokens,
|
|
4475
4489
|
recoveredFraction,
|
|
4490
|
+
retryTurnTimeoutMs,
|
|
4476
4491
|
});
|
|
4477
4492
|
return this.directProviderGeneration({
|
|
4478
4493
|
...options,
|
|
4479
4494
|
conversationMessages: compactedMessages,
|
|
4495
|
+
...(retryTurnTimeoutMs !== undefined && {
|
|
4496
|
+
turnTimeoutMs: retryTurnTimeoutMs,
|
|
4497
|
+
}),
|
|
4480
4498
|
});
|
|
4481
4499
|
}
|
|
4482
4500
|
catch (retryError) {
|
|
@@ -230,6 +230,13 @@ export declare function isAbortError(error: unknown): boolean;
|
|
|
230
230
|
* this text (a killed 23-step turn once claimed "reached the 200-step limit").
|
|
231
231
|
*/
|
|
232
232
|
export declare function buildToolLoopCapMessage(maxSteps: number, toolCallCount: number): string;
|
|
233
|
+
/**
|
|
234
|
+
* Honest message for a turn stopped by the in-loop context guard
|
|
235
|
+
* (stopReason "context-cap") without a synthesized answer. Sibling of
|
|
236
|
+
* {@link buildToolLoopCapMessage} — context exits must never claim a step
|
|
237
|
+
* limit was reached.
|
|
238
|
+
*/
|
|
239
|
+
export declare function buildContextCapMessage(toolCallCount: number): string;
|
|
233
240
|
/**
|
|
234
241
|
* Honest message for a turn ended by the `turnTimeoutMs` wall-clock deadline
|
|
235
242
|
* (stopReason "time-limit"). Sibling of {@link buildToolLoopCapMessage} —
|
|
@@ -266,6 +273,8 @@ export declare function resolveTurnStopReason(params: {
|
|
|
266
273
|
wasAborted: boolean;
|
|
267
274
|
/** Step budget ran out AND no clean/forced answer was produced. */
|
|
268
275
|
cappedWithoutAnswer: boolean;
|
|
276
|
+
/** Context guard stopped the loop AND no clean/forced answer was produced. */
|
|
277
|
+
contextCappedWithoutAnswer?: boolean;
|
|
269
278
|
/** The turn's resolved unified finishReason. */
|
|
270
279
|
finishReason?: string;
|
|
271
280
|
}): GenerateStopReason;
|
|
@@ -307,6 +316,40 @@ export declare function createTurnClock(params: {
|
|
|
307
316
|
shouldNudgeWrapup(): boolean;
|
|
308
317
|
dispose(): void;
|
|
309
318
|
};
|
|
319
|
+
/**
|
|
320
|
+
* In-loop context guard for native agentic turns.
|
|
321
|
+
*
|
|
322
|
+
* The provider reports the ACTUAL prompt size of every model call
|
|
323
|
+
* (usage.input_tokens + cache reads/writes on Anthropic;
|
|
324
|
+
* usageMetadata.promptTokenCount on Gemini). The guard tracks that number
|
|
325
|
+
* plus an estimate of what the current step appends (assistant output, tool
|
|
326
|
+
* results), and tells the loop to stop calling tools once the projected next
|
|
327
|
+
* prompt crosses `thresholdRatio` of the model's context window — the turn
|
|
328
|
+
* then synthesizes a final answer from what it has instead of stepping into
|
|
329
|
+
* a provider 400 ("prompt is too long") that destroys all completed work.
|
|
330
|
+
*
|
|
331
|
+
* Fail-open: until the first usage report arrives, `shouldStop()` is false.
|
|
332
|
+
*/
|
|
333
|
+
export declare function createContextGuard(contextWindowTokens: number, thresholdRatio?: number): {
|
|
334
|
+
/** Tokens at which the guard trips (ratio × window). */
|
|
335
|
+
readonly thresholdTokens: number;
|
|
336
|
+
/** Last observed prompt size plus estimated growth since. */
|
|
337
|
+
readonly projectedNextPromptTokens: number;
|
|
338
|
+
/**
|
|
339
|
+
* Record a model call's reported usage. `promptTokens` must be the FULL
|
|
340
|
+
* prompt size (uncached input + cache read + cache creation for
|
|
341
|
+
* Anthropic). The response's own output is counted as growth — it is
|
|
342
|
+
* appended to the conversation for the next call.
|
|
343
|
+
*/
|
|
344
|
+
noteUsage(promptTokens: number, outputTokens: number): void;
|
|
345
|
+
/**
|
|
346
|
+
* Add growth for content appended since the last model call (tool
|
|
347
|
+
* results, nudge text) using the ~4 chars/token heuristic.
|
|
348
|
+
*/
|
|
349
|
+
noteAppendedChars(chars: number): void;
|
|
350
|
+
/** True when issuing another model call risks crossing the threshold. */
|
|
351
|
+
shouldStop(): boolean;
|
|
352
|
+
};
|
|
310
353
|
/**
|
|
311
354
|
* Push model response parts to conversation history, preserving thoughtSignature
|
|
312
355
|
* for Gemini 3 multi-turn tool calling.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { randomUUID } from "node:crypto";
|
|
12
12
|
import { existsSync, readFileSync } from "node:fs";
|
|
13
13
|
import { extname } from "node:path";
|
|
14
|
-
import { DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIES, DEFAULT_WRAPUP_TIME_LEAD_MS, } from "../core/constants.js";
|
|
14
|
+
import { DEFAULT_CONTEXT_GUARD_RATIO, DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIES, DEFAULT_WRAPUP_TIME_LEAD_MS, } from "../core/constants.js";
|
|
15
15
|
import { logger } from "../utils/logger.js";
|
|
16
16
|
import { convertZodToJsonSchema, ensureNestedSchemaTypes, inlineJsonSchema, isZodSchema, normalizeJsonSchemaObject, } from "../utils/schemaConversion.js";
|
|
17
17
|
import { createNativeThinkingConfig } from "../utils/thinkingConfig.js";
|
|
@@ -853,6 +853,20 @@ export function buildToolLoopCapMessage(maxSteps, toolCallCount) {
|
|
|
853
853
|
return (`${calls}reached the ${maxSteps}-step limit for a single turn before I could finish. ` +
|
|
854
854
|
`Please narrow the request or break it into smaller asks and I'll continue.`);
|
|
855
855
|
}
|
|
856
|
+
/**
|
|
857
|
+
* Honest message for a turn stopped by the in-loop context guard
|
|
858
|
+
* (stopReason "context-cap") without a synthesized answer. Sibling of
|
|
859
|
+
* {@link buildToolLoopCapMessage} — context exits must never claim a step
|
|
860
|
+
* limit was reached.
|
|
861
|
+
*/
|
|
862
|
+
export function buildContextCapMessage(toolCallCount) {
|
|
863
|
+
const calls = toolCallCount > 0
|
|
864
|
+
? `I gathered information across ${toolCallCount} tool call${toolCallCount === 1 ? "" : "s"} but `
|
|
865
|
+
: "I ";
|
|
866
|
+
return (`${calls}had to stop because the gathered material filled this turn's ` +
|
|
867
|
+
`context window before I could finish. Please narrow the request or ` +
|
|
868
|
+
`break it into smaller asks and I'll continue.`);
|
|
869
|
+
}
|
|
856
870
|
/** Format an elapsed duration as "Xm Ys" (or "Ys" under a minute). */
|
|
857
871
|
function formatElapsed(elapsedMs) {
|
|
858
872
|
const totalSeconds = Math.max(0, Math.round(elapsedMs / 1000));
|
|
@@ -923,6 +937,9 @@ export function resolveTurnStopReason(params) {
|
|
|
923
937
|
if (params.wasAborted) {
|
|
924
938
|
return "aborted";
|
|
925
939
|
}
|
|
940
|
+
if (params.contextCappedWithoutAnswer) {
|
|
941
|
+
return "context-cap";
|
|
942
|
+
}
|
|
926
943
|
if (params.cappedWithoutAnswer) {
|
|
927
944
|
return "step-cap";
|
|
928
945
|
}
|
|
@@ -1024,6 +1041,61 @@ export function createTurnClock(params) {
|
|
|
1024
1041
|
},
|
|
1025
1042
|
};
|
|
1026
1043
|
}
|
|
1044
|
+
/**
|
|
1045
|
+
* In-loop context guard for native agentic turns.
|
|
1046
|
+
*
|
|
1047
|
+
* The provider reports the ACTUAL prompt size of every model call
|
|
1048
|
+
* (usage.input_tokens + cache reads/writes on Anthropic;
|
|
1049
|
+
* usageMetadata.promptTokenCount on Gemini). The guard tracks that number
|
|
1050
|
+
* plus an estimate of what the current step appends (assistant output, tool
|
|
1051
|
+
* results), and tells the loop to stop calling tools once the projected next
|
|
1052
|
+
* prompt crosses `thresholdRatio` of the model's context window — the turn
|
|
1053
|
+
* then synthesizes a final answer from what it has instead of stepping into
|
|
1054
|
+
* a provider 400 ("prompt is too long") that destroys all completed work.
|
|
1055
|
+
*
|
|
1056
|
+
* Fail-open: until the first usage report arrives, `shouldStop()` is false.
|
|
1057
|
+
*/
|
|
1058
|
+
export function createContextGuard(contextWindowTokens, thresholdRatio = DEFAULT_CONTEXT_GUARD_RATIO) {
|
|
1059
|
+
const thresholdTokens = Math.floor(contextWindowTokens * thresholdRatio);
|
|
1060
|
+
let observedPromptTokens = 0;
|
|
1061
|
+
let projectedGrowthTokens = 0;
|
|
1062
|
+
return {
|
|
1063
|
+
/** Tokens at which the guard trips (ratio × window). */
|
|
1064
|
+
get thresholdTokens() {
|
|
1065
|
+
return thresholdTokens;
|
|
1066
|
+
},
|
|
1067
|
+
/** Last observed prompt size plus estimated growth since. */
|
|
1068
|
+
get projectedNextPromptTokens() {
|
|
1069
|
+
return observedPromptTokens + projectedGrowthTokens;
|
|
1070
|
+
},
|
|
1071
|
+
/**
|
|
1072
|
+
* Record a model call's reported usage. `promptTokens` must be the FULL
|
|
1073
|
+
* prompt size (uncached input + cache read + cache creation for
|
|
1074
|
+
* Anthropic). The response's own output is counted as growth — it is
|
|
1075
|
+
* appended to the conversation for the next call.
|
|
1076
|
+
*/
|
|
1077
|
+
noteUsage(promptTokens, outputTokens) {
|
|
1078
|
+
if (promptTokens > 0) {
|
|
1079
|
+
observedPromptTokens = promptTokens;
|
|
1080
|
+
projectedGrowthTokens = Math.max(0, outputTokens);
|
|
1081
|
+
}
|
|
1082
|
+
},
|
|
1083
|
+
/**
|
|
1084
|
+
* Add growth for content appended since the last model call (tool
|
|
1085
|
+
* results, nudge text) using the ~4 chars/token heuristic.
|
|
1086
|
+
*/
|
|
1087
|
+
noteAppendedChars(chars) {
|
|
1088
|
+
if (chars > 0) {
|
|
1089
|
+
projectedGrowthTokens += Math.ceil(chars / 4);
|
|
1090
|
+
}
|
|
1091
|
+
},
|
|
1092
|
+
/** True when issuing another model call risks crossing the threshold. */
|
|
1093
|
+
shouldStop() {
|
|
1094
|
+
return (observedPromptTokens > 0 &&
|
|
1095
|
+
observedPromptTokens + projectedGrowthTokens >= thresholdTokens);
|
|
1096
|
+
},
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1027
1099
|
/**
|
|
1028
1100
|
* Push model response parts to conversation history, preserving thoughtSignature
|
|
1029
1101
|
* for Gemini 3 multi-turn tool calling.
|