@falai/agent 2.4.1 → 2.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/core/PersistenceManager.d.ts +13 -0
- package/dist/cjs/core/PersistenceManager.d.ts.map +1 -1
- package/dist/cjs/core/PersistenceManager.js +29 -6
- package/dist/cjs/core/PersistenceManager.js.map +1 -1
- package/dist/cjs/core/ResponseModal.d.ts.map +1 -1
- package/dist/cjs/core/ResponseModal.js +15 -3
- package/dist/cjs/core/ResponseModal.js.map +1 -1
- package/dist/cjs/core/SessionManager.d.ts.map +1 -1
- package/dist/cjs/core/SessionManager.js +7 -0
- package/dist/cjs/core/SessionManager.js.map +1 -1
- package/dist/cjs/core/SignalProcessor.d.ts.map +1 -1
- package/dist/cjs/core/SignalProcessor.js +28 -11
- package/dist/cjs/core/SignalProcessor.js.map +1 -1
- package/dist/cjs/core/StreamingToolExecutor.d.ts.map +1 -1
- package/dist/cjs/core/StreamingToolExecutor.js +11 -0
- package/dist/cjs/core/StreamingToolExecutor.js.map +1 -1
- package/dist/cjs/core/ToolLoopExecutor.d.ts +16 -2
- package/dist/cjs/core/ToolLoopExecutor.d.ts.map +1 -1
- package/dist/cjs/core/ToolLoopExecutor.js +115 -54
- package/dist/cjs/core/ToolLoopExecutor.js.map +1 -1
- package/dist/cjs/core/ToolManager.d.ts.map +1 -1
- package/dist/cjs/core/ToolManager.js +8 -31
- package/dist/cjs/core/ToolManager.js.map +1 -1
- package/dist/cjs/core/toolGates.d.ts +24 -0
- package/dist/cjs/core/toolGates.d.ts.map +1 -0
- package/dist/cjs/core/toolGates.js +52 -0
- package/dist/cjs/core/toolGates.js.map +1 -0
- package/dist/cjs/providers/AnthropicProvider.d.ts.map +1 -1
- package/dist/cjs/providers/AnthropicProvider.js +24 -3
- package/dist/cjs/providers/AnthropicProvider.js.map +1 -1
- package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
- package/dist/cjs/providers/GeminiProvider.js +24 -3
- package/dist/cjs/providers/GeminiProvider.js.map +1 -1
- package/dist/cjs/providers/OpenAICompatibleProvider.d.ts.map +1 -1
- package/dist/cjs/providers/OpenAICompatibleProvider.js +25 -3
- package/dist/cjs/providers/OpenAICompatibleProvider.js.map +1 -1
- package/dist/cjs/types/signals.d.ts +6 -0
- package/dist/cjs/types/signals.d.ts.map +1 -1
- package/dist/cjs/utils/index.d.ts +2 -2
- package/dist/cjs/utils/index.d.ts.map +1 -1
- package/dist/cjs/utils/index.js +2 -1
- package/dist/cjs/utils/index.js.map +1 -1
- package/dist/cjs/utils/retry.d.ts +21 -0
- package/dist/cjs/utils/retry.d.ts.map +1 -1
- package/dist/cjs/utils/retry.js +37 -0
- package/dist/cjs/utils/retry.js.map +1 -1
- package/dist/core/PersistenceManager.d.ts +13 -0
- package/dist/core/PersistenceManager.d.ts.map +1 -1
- package/dist/core/PersistenceManager.js +29 -6
- package/dist/core/PersistenceManager.js.map +1 -1
- package/dist/core/ResponseModal.d.ts.map +1 -1
- package/dist/core/ResponseModal.js +15 -3
- package/dist/core/ResponseModal.js.map +1 -1
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +7 -0
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/SignalProcessor.d.ts.map +1 -1
- package/dist/core/SignalProcessor.js +28 -11
- package/dist/core/SignalProcessor.js.map +1 -1
- package/dist/core/StreamingToolExecutor.d.ts.map +1 -1
- package/dist/core/StreamingToolExecutor.js +11 -0
- package/dist/core/StreamingToolExecutor.js.map +1 -1
- package/dist/core/ToolLoopExecutor.d.ts +16 -2
- package/dist/core/ToolLoopExecutor.d.ts.map +1 -1
- package/dist/core/ToolLoopExecutor.js +115 -54
- package/dist/core/ToolLoopExecutor.js.map +1 -1
- package/dist/core/ToolManager.d.ts.map +1 -1
- package/dist/core/ToolManager.js +8 -31
- package/dist/core/ToolManager.js.map +1 -1
- package/dist/core/toolGates.d.ts +24 -0
- package/dist/core/toolGates.d.ts.map +1 -0
- package/dist/core/toolGates.js +49 -0
- package/dist/core/toolGates.js.map +1 -0
- package/dist/providers/AnthropicProvider.d.ts.map +1 -1
- package/dist/providers/AnthropicProvider.js +25 -4
- package/dist/providers/AnthropicProvider.js.map +1 -1
- package/dist/providers/GeminiProvider.d.ts.map +1 -1
- package/dist/providers/GeminiProvider.js +25 -4
- package/dist/providers/GeminiProvider.js.map +1 -1
- package/dist/providers/OpenAICompatibleProvider.d.ts.map +1 -1
- package/dist/providers/OpenAICompatibleProvider.js +26 -4
- package/dist/providers/OpenAICompatibleProvider.js.map +1 -1
- package/dist/types/signals.d.ts +6 -0
- package/dist/types/signals.d.ts.map +1 -1
- package/dist/utils/index.d.ts +2 -2
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/retry.d.ts +21 -0
- package/dist/utils/retry.d.ts.map +1 -1
- package/dist/utils/retry.js +36 -0
- package/dist/utils/retry.js.map +1 -1
- package/docs/reference/signals.md +3 -0
- package/package.json +1 -1
- package/src/core/PersistenceManager.ts +30 -6
- package/src/core/ResponseModal.ts +16 -3
- package/src/core/SessionManager.ts +7 -0
- package/src/core/SignalProcessor.ts +34 -11
- package/src/core/StreamingToolExecutor.ts +12 -0
- package/src/core/ToolLoopExecutor.ts +133 -58
- package/src/core/ToolManager.ts +8 -32
- package/src/core/toolGates.ts +72 -0
- package/src/providers/AnthropicProvider.ts +35 -8
- package/src/providers/GeminiProvider.ts +37 -4
- package/src/providers/OpenAICompatibleProvider.ts +36 -8
- package/src/types/signals.ts +6 -0
- package/src/utils/index.ts +2 -2
- package/src/utils/retry.ts +55 -0
package/src/core/ToolManager.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { Agent } from "./Agent";
|
|
|
24
24
|
import { Flow } from "./Flow";
|
|
25
25
|
import { Step } from "./Step";
|
|
26
26
|
import { StreamingToolExecutor } from "./StreamingToolExecutor";
|
|
27
|
+
import { evaluateToolGates } from "./toolGates";
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Error thrown when tool creation fails
|
|
@@ -715,38 +716,13 @@ export class ToolManager<TContext = unknown, TData = unknown> {
|
|
|
715
716
|
|
|
716
717
|
logger.debug(`[ToolManager] Executing tool: ${tool.id} with args:`, toolArguments);
|
|
717
718
|
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
success: false,
|
|
726
|
-
error: `Validation failed: ${validation.error || 'Invalid input'}`,
|
|
727
|
-
metadata: { toolId: tool.id, executionTime, gate: 'validateInput' }
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
// Tool permission gate (Req 6.7, 6.8)
|
|
733
|
-
// When denied: do not invoke handler, do not process directives, do not apply state writes
|
|
734
|
-
if (typeof tool.checkPermissions === 'function' && toolArguments) {
|
|
735
|
-
const permission = await tool.checkPermissions(toolArguments, toolContext);
|
|
736
|
-
if (!permission.allowed) {
|
|
737
|
-
const executionTime = Date.now() - startTime;
|
|
738
|
-
logger.warn(`[ToolExecutionError] Tool "${tool.id}" permission denied: ${permission.reason}. The tool's checkPermissions hook rejected this call.`);
|
|
739
|
-
return {
|
|
740
|
-
success: false,
|
|
741
|
-
error: `Permission denied: ${permission.reason || 'Not allowed'}`,
|
|
742
|
-
metadata: {
|
|
743
|
-
toolId: tool.id,
|
|
744
|
-
executionTime,
|
|
745
|
-
gate: 'checkPermissions',
|
|
746
|
-
canOverride: permission.canOverride
|
|
747
|
-
}
|
|
748
|
-
};
|
|
749
|
-
}
|
|
719
|
+
// Pre-execution gates (validateInput → checkPermissions). Shared with the
|
|
720
|
+
// streaming executor (StreamingToolExecutor) so authorization/validation
|
|
721
|
+
// behave identically on every transport. When a gate denies, the handler
|
|
722
|
+
// is NOT invoked.
|
|
723
|
+
const gateDenial = await evaluateToolGates(tool, toolArguments, toolContext, startTime);
|
|
724
|
+
if (gateDenial) {
|
|
725
|
+
return gateDenial;
|
|
750
726
|
}
|
|
751
727
|
|
|
752
728
|
// Execute tool with timeout protection
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared pre-execution gates for tool calls.
|
|
3
|
+
*
|
|
4
|
+
* A tool's `validateInput` and `checkPermissions` hooks must be applied
|
|
5
|
+
* identically regardless of how the call is executed. Previously the gates
|
|
6
|
+
* lived only in `ToolManager.executeTool` (the sequential `generate()`/
|
|
7
|
+
* `respond()` path), so the streaming path (`StreamingToolExecutor`, used by
|
|
8
|
+
* `stream()`) invoked handlers without them — a documented authorization gate
|
|
9
|
+
* ("when denied, handler is NOT invoked") was silently a no-op on one
|
|
10
|
+
* transport. Centralizing the logic here keeps both executors in lockstep.
|
|
11
|
+
*/
|
|
12
|
+
import type { Tool, ToolContext, ToolExecutionResult } from "../types/tool";
|
|
13
|
+
import { logger } from "../utils";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Run a tool's pre-execution gates in order: `validateInput`, then
|
|
17
|
+
* `checkPermissions`. Returns a failed `ToolExecutionResult` when a gate
|
|
18
|
+
* blocks the call (the handler must NOT be invoked), or `null` when both
|
|
19
|
+
* gates pass.
|
|
20
|
+
*
|
|
21
|
+
* @param startTime - optional execution start time (ms from `Date.now()`).
|
|
22
|
+
* When provided, denial metadata includes `executionTime`, matching the
|
|
23
|
+
* sequential executor's result shape; omit it on the streaming path.
|
|
24
|
+
*/
|
|
25
|
+
export async function evaluateToolGates<TContext, TData>(
|
|
26
|
+
tool: Tool<TContext, TData>,
|
|
27
|
+
toolArguments: Record<string, unknown> | undefined,
|
|
28
|
+
toolContext: ToolContext<TContext, TData>,
|
|
29
|
+
startTime?: number
|
|
30
|
+
): Promise<ToolExecutionResult | null> {
|
|
31
|
+
// Tool validation gate (Req 6.1, 6.7)
|
|
32
|
+
if (typeof tool.validateInput === "function" && toolArguments) {
|
|
33
|
+
const validation = await tool.validateInput(toolArguments, toolContext);
|
|
34
|
+
if (!validation.valid) {
|
|
35
|
+
logger.warn(
|
|
36
|
+
`[DataValidationError] Tool "${tool.id}" input validation failed: ${validation.error}. Fix the tool call arguments to match the expected schema.`
|
|
37
|
+
);
|
|
38
|
+
return {
|
|
39
|
+
success: false,
|
|
40
|
+
error: `Validation failed: ${validation.error || "Invalid input"}`,
|
|
41
|
+
metadata: {
|
|
42
|
+
toolId: tool.id,
|
|
43
|
+
...(startTime !== undefined ? { executionTime: Date.now() - startTime } : {}),
|
|
44
|
+
gate: "validateInput",
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Tool permission gate (Req 6.7, 6.8)
|
|
51
|
+
// When denied: do not invoke handler, do not process directives, do not apply state writes
|
|
52
|
+
if (typeof tool.checkPermissions === "function" && toolArguments) {
|
|
53
|
+
const permission = await tool.checkPermissions(toolArguments, toolContext);
|
|
54
|
+
if (!permission.allowed) {
|
|
55
|
+
logger.warn(
|
|
56
|
+
`[ToolExecutionError] Tool "${tool.id}" permission denied: ${permission.reason}. The tool's checkPermissions hook rejected this call.`
|
|
57
|
+
);
|
|
58
|
+
return {
|
|
59
|
+
success: false,
|
|
60
|
+
error: `Permission denied: ${permission.reason || "Not allowed"}`,
|
|
61
|
+
metadata: {
|
|
62
|
+
toolId: tool.id,
|
|
63
|
+
...(startTime !== undefined ? { executionTime: Date.now() - startTime } : {}),
|
|
64
|
+
gate: "checkPermissions",
|
|
65
|
+
canOverride: permission.canOverride,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
@@ -17,7 +17,7 @@ import type {
|
|
|
17
17
|
} from "../types";
|
|
18
18
|
import type { ProviderCapabilities } from "../types/ai";
|
|
19
19
|
import type { HistoryItem } from "../types/history";
|
|
20
|
-
import { withTimeoutAndRetry, logger } from "../utils";
|
|
20
|
+
import { withTimeoutAndRetry, withStreamRetry, logger } from "../utils";
|
|
21
21
|
import {
|
|
22
22
|
classifyProviderError,
|
|
23
23
|
getErrorMessage,
|
|
@@ -106,8 +106,12 @@ export class AnthropicProvider implements AiProvider {
|
|
|
106
106
|
this.backupModels = backupModels;
|
|
107
107
|
this.config = config;
|
|
108
108
|
this.retryConfig = {
|
|
109
|
+
// `||` is intentional: a 0ms timeout is degenerate (aborts every call
|
|
110
|
+
// immediately), so fall back to the default.
|
|
109
111
|
timeout: retryConfig?.timeout || DEFAULT_RETRY_CONFIG.timeout,
|
|
110
|
-
retries:
|
|
112
|
+
// `??` so an explicit `retries: 0` (disable retries) is honored instead of
|
|
113
|
+
// being clobbered to the default by a falsy-zero check.
|
|
114
|
+
retries: retryConfig?.retries ?? DEFAULT_RETRY_CONFIG.retries,
|
|
111
115
|
};
|
|
112
116
|
}
|
|
113
117
|
|
|
@@ -377,6 +381,17 @@ export class AnthropicProvider implements AiProvider {
|
|
|
377
381
|
} as AgentStructuredResponse;
|
|
378
382
|
}
|
|
379
383
|
|
|
384
|
+
// A parsed-but-blank structured message with no tool calls is just as
|
|
385
|
+
// empty as no text at all — throw so withTimeoutAndRetry retries instead
|
|
386
|
+
// of returning {"message":""}.
|
|
387
|
+
if (
|
|
388
|
+
toolCalls.length === 0 &&
|
|
389
|
+
typeof structured?.message === "string" &&
|
|
390
|
+
!structured.message.trim()
|
|
391
|
+
) {
|
|
392
|
+
throw new Error("No response from Anthropic");
|
|
393
|
+
}
|
|
394
|
+
|
|
380
395
|
return {
|
|
381
396
|
message,
|
|
382
397
|
metadata: {
|
|
@@ -407,9 +422,9 @@ export class AnthropicProvider implements AiProvider {
|
|
|
407
422
|
): AsyncGenerator<GenerateMessageStreamChunk<TStructured>> {
|
|
408
423
|
// Try primary model first
|
|
409
424
|
try {
|
|
410
|
-
yield*
|
|
411
|
-
this.primaryModel,
|
|
412
|
-
|
|
425
|
+
yield* withStreamRetry(
|
|
426
|
+
() => this.generateStreamWithModel<TContext, TStructured>(this.primaryModel, input),
|
|
427
|
+
{ maxRetries: this.retryConfig.retries, operationName: `Anthropic ${this.primaryModel} stream` }
|
|
413
428
|
);
|
|
414
429
|
} catch (primaryError: unknown) {
|
|
415
430
|
const primaryErrMsg = getErrorMessage(primaryError);
|
|
@@ -433,9 +448,9 @@ export class AnthropicProvider implements AiProvider {
|
|
|
433
448
|
);
|
|
434
449
|
|
|
435
450
|
try {
|
|
436
|
-
yield*
|
|
437
|
-
backupModel,
|
|
438
|
-
|
|
451
|
+
yield* withStreamRetry(
|
|
452
|
+
() => this.generateStreamWithModel<TContext, TStructured>(backupModel, input),
|
|
453
|
+
{ maxRetries: this.retryConfig.retries, operationName: `Anthropic ${backupModel} stream` }
|
|
439
454
|
);
|
|
440
455
|
logger.debug(`[ANTHROPIC] Backup model ${backupModel} succeeded`);
|
|
441
456
|
return;
|
|
@@ -597,6 +612,18 @@ export class AnthropicProvider implements AiProvider {
|
|
|
597
612
|
} as AgentStructuredResponse;
|
|
598
613
|
}
|
|
599
614
|
|
|
615
|
+
// Empty-completion guard — mirror of the non-streaming path. A blank
|
|
616
|
+
// effective message (structured message under a schema, else accumulated
|
|
617
|
+
// text) with no tool calls means the model produced nothing usable; throw
|
|
618
|
+
// so withStreamRetry/generateStreamWithBackup retry instead of silently
|
|
619
|
+
// emitting an empty message.
|
|
620
|
+
const messageText = (
|
|
621
|
+
typeof structured?.message === "string" ? structured.message : accumulated
|
|
622
|
+
).trim();
|
|
623
|
+
if (!messageText && toolCalls.length === 0) {
|
|
624
|
+
throw new Error("No response from Anthropic");
|
|
625
|
+
}
|
|
626
|
+
|
|
600
627
|
// Yield final chunk
|
|
601
628
|
yield {
|
|
602
629
|
delta: "",
|
|
@@ -21,7 +21,7 @@ import type {
|
|
|
21
21
|
} from "../types";
|
|
22
22
|
import type { ProviderCapabilities } from "../types/ai";
|
|
23
23
|
import type { HistoryItem } from "../types/history";
|
|
24
|
-
import { withTimeoutAndRetry } from "../utils/retry";
|
|
24
|
+
import { withTimeoutAndRetry, withStreamRetry } from "../utils/retry";
|
|
25
25
|
import { tryParseJSONResponse } from "../utils/json";
|
|
26
26
|
import { logger } from "../utils/logger";
|
|
27
27
|
import {
|
|
@@ -107,8 +107,12 @@ export class GeminiProvider implements AiProvider {
|
|
|
107
107
|
this.backupModels = backupModels;
|
|
108
108
|
this.config = config;
|
|
109
109
|
this.retryConfig = {
|
|
110
|
+
// `||` is intentional: a 0ms timeout is degenerate (aborts every call
|
|
111
|
+
// immediately), so fall back to the default.
|
|
110
112
|
timeout: retryConfig?.timeout || DEFAULT_RETRY_CONFIG.timeout,
|
|
111
|
-
retries:
|
|
113
|
+
// `??` so an explicit `retries: 0` (disable retries) is honored instead of
|
|
114
|
+
// being clobbered to the default by a falsy-zero check.
|
|
115
|
+
retries: retryConfig?.retries ?? DEFAULT_RETRY_CONFIG.retries,
|
|
112
116
|
};
|
|
113
117
|
}
|
|
114
118
|
|
|
@@ -538,6 +542,17 @@ export class GeminiProvider implements AiProvider {
|
|
|
538
542
|
} as AgentStructuredResponse;
|
|
539
543
|
}
|
|
540
544
|
|
|
545
|
+
// A parsed-but-blank structured message with no tool calls is just as
|
|
546
|
+
// empty as no text at all — throw so withTimeoutAndRetry retries instead
|
|
547
|
+
// of returning {"message":""}.
|
|
548
|
+
if (
|
|
549
|
+
toolCalls.length === 0 &&
|
|
550
|
+
typeof structured?.message === "string" &&
|
|
551
|
+
!structured.message.trim()
|
|
552
|
+
) {
|
|
553
|
+
throw new Error("No response from Gemini");
|
|
554
|
+
}
|
|
555
|
+
|
|
541
556
|
return {
|
|
542
557
|
message,
|
|
543
558
|
metadata: {
|
|
@@ -566,7 +581,10 @@ export class GeminiProvider implements AiProvider {
|
|
|
566
581
|
): AsyncGenerator<GenerateMessageStreamChunk<TStructured>> {
|
|
567
582
|
// Try primary model first
|
|
568
583
|
try {
|
|
569
|
-
yield*
|
|
584
|
+
yield* withStreamRetry(
|
|
585
|
+
() => this.generateStreamWithModel(this.primaryModel, input),
|
|
586
|
+
{ maxRetries: this.retryConfig.retries, operationName: `Gemini ${this.primaryModel} stream` }
|
|
587
|
+
);
|
|
570
588
|
} catch (primaryError: unknown) {
|
|
571
589
|
const primaryErrMsg = getErrorMessage(primaryError);
|
|
572
590
|
logger.warn(
|
|
@@ -589,7 +607,10 @@ export class GeminiProvider implements AiProvider {
|
|
|
589
607
|
);
|
|
590
608
|
|
|
591
609
|
try {
|
|
592
|
-
yield*
|
|
610
|
+
yield* withStreamRetry(
|
|
611
|
+
() => this.generateStreamWithModel(backupModel, input),
|
|
612
|
+
{ maxRetries: this.retryConfig.retries, operationName: `Gemini ${backupModel} stream` }
|
|
613
|
+
);
|
|
593
614
|
logger.debug(`[GEMINI] Backup model ${backupModel} succeeded`);
|
|
594
615
|
return;
|
|
595
616
|
} catch (backupError: unknown) {
|
|
@@ -747,6 +768,18 @@ export class GeminiProvider implements AiProvider {
|
|
|
747
768
|
} as AgentStructuredResponse;
|
|
748
769
|
}
|
|
749
770
|
|
|
771
|
+
// Empty-completion guard — mirror of the non-streaming path. The effective
|
|
772
|
+
// message is the parsed structured message when a schema is used, else the
|
|
773
|
+
// accumulated text. A blank message with no tool calls means the model
|
|
774
|
+
// produced nothing usable; throw so withStreamRetry/generateStreamWithBackup
|
|
775
|
+
// retry instead of silently emitting an empty message.
|
|
776
|
+
const messageText = (
|
|
777
|
+
typeof structured?.message === "string" ? structured.message : accumulated
|
|
778
|
+
).trim();
|
|
779
|
+
if (!messageText && toolCalls.length === 0) {
|
|
780
|
+
throw new Error("No response from Gemini");
|
|
781
|
+
}
|
|
782
|
+
|
|
750
783
|
// Yield final chunk
|
|
751
784
|
yield {
|
|
752
785
|
delta: "",
|
|
@@ -27,7 +27,7 @@ import type {
|
|
|
27
27
|
} from "../types";
|
|
28
28
|
import type { ProviderCapabilities } from "../types/ai";
|
|
29
29
|
import type { HistoryItem } from "../types/history";
|
|
30
|
-
import { withTimeoutAndRetry, logger } from "../utils";
|
|
30
|
+
import { withTimeoutAndRetry, withStreamRetry, logger } from "../utils";
|
|
31
31
|
import {
|
|
32
32
|
classifyProviderError,
|
|
33
33
|
getErrorMessage,
|
|
@@ -100,8 +100,12 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
|
|
|
100
100
|
this.backupModels = init.backupModels ?? [];
|
|
101
101
|
this.config = init.config;
|
|
102
102
|
this.retryConfig = {
|
|
103
|
+
// `||` is intentional: a 0ms timeout is degenerate (aborts every call
|
|
104
|
+
// immediately), so fall back to the default.
|
|
103
105
|
timeout: init.retryConfig?.timeout || DEFAULT_RETRY_CONFIG.timeout,
|
|
104
|
-
retries:
|
|
106
|
+
// `??` so an explicit `retries: 0` (disable retries) is honored instead of
|
|
107
|
+
// being clobbered to the default by a falsy-zero check.
|
|
108
|
+
retries: init.retryConfig?.retries ?? DEFAULT_RETRY_CONFIG.retries,
|
|
105
109
|
};
|
|
106
110
|
}
|
|
107
111
|
|
|
@@ -408,9 +412,9 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
|
|
|
408
412
|
): AsyncGenerator<GenerateMessageStreamChunk<TStructured>> {
|
|
409
413
|
// Try primary model first
|
|
410
414
|
try {
|
|
411
|
-
yield*
|
|
412
|
-
this.primaryModel,
|
|
413
|
-
|
|
415
|
+
yield* withStreamRetry(
|
|
416
|
+
() => this.generateStreamWithModel<TContext, TStructured>(this.primaryModel, input),
|
|
417
|
+
{ maxRetries: this.retryConfig.retries, operationName: `${this.logLabel} ${this.primaryModel} stream` }
|
|
414
418
|
);
|
|
415
419
|
} catch (primaryError: unknown) {
|
|
416
420
|
const primaryErrMsg = getErrorMessage(primaryError);
|
|
@@ -435,9 +439,9 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
|
|
|
435
439
|
);
|
|
436
440
|
|
|
437
441
|
try {
|
|
438
|
-
yield*
|
|
439
|
-
backupModel,
|
|
440
|
-
|
|
442
|
+
yield* withStreamRetry(
|
|
443
|
+
() => this.generateStreamWithModel<TContext, TStructured>(backupModel, input),
|
|
444
|
+
{ maxRetries: this.retryConfig.retries, operationName: `${this.logLabel} ${backupModel} stream` }
|
|
441
445
|
);
|
|
442
446
|
logger.debug(`[${this.logLabel}] Backup model ${backupModel} succeeded`);
|
|
443
447
|
return;
|
|
@@ -580,6 +584,17 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
|
|
|
580
584
|
} as AgentStructuredResponse;
|
|
581
585
|
}
|
|
582
586
|
|
|
587
|
+
// A parsed-but-blank structured message with no tool calls is just as empty
|
|
588
|
+
// as no text at all — throw so withTimeoutAndRetry retries instead of
|
|
589
|
+
// returning {"message":""}.
|
|
590
|
+
if (
|
|
591
|
+
toolCalls.length === 0 &&
|
|
592
|
+
typeof structured?.message === "string" &&
|
|
593
|
+
!structured.message.trim()
|
|
594
|
+
) {
|
|
595
|
+
throw new Error(`No response from ${this.displayName}`);
|
|
596
|
+
}
|
|
597
|
+
|
|
583
598
|
return {
|
|
584
599
|
message,
|
|
585
600
|
metadata: {
|
|
@@ -720,6 +735,19 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
|
|
|
720
735
|
} as TStructured;
|
|
721
736
|
}
|
|
722
737
|
|
|
738
|
+
// Empty-completion guard — mirror of the non-streaming path. A blank
|
|
739
|
+
// effective message (structured message under a schema, else accumulated
|
|
740
|
+
// text) with no tool calls means the model produced nothing usable; throw
|
|
741
|
+
// so withStreamRetry/generateStreamWithBackup retry instead of silently
|
|
742
|
+
// emitting an empty message.
|
|
743
|
+
const structuredMessage = (structured as AgentStructuredResponse | undefined)?.message;
|
|
744
|
+
const messageText = (
|
|
745
|
+
typeof structuredMessage === "string" ? structuredMessage : accumulated
|
|
746
|
+
).trim();
|
|
747
|
+
if (!messageText && toolCalls.length === 0) {
|
|
748
|
+
throw new Error(`No response from ${this.displayName}`);
|
|
749
|
+
}
|
|
750
|
+
|
|
723
751
|
// Yield final chunk
|
|
724
752
|
yield {
|
|
725
753
|
delta: "",
|
package/src/types/signals.ts
CHANGED
|
@@ -327,6 +327,12 @@ export interface SignalFiring<TContext = unknown, TData = unknown> {
|
|
|
327
327
|
directive?: ResolvedSignalDirective<TContext, TData>;
|
|
328
328
|
/** Error message if the handler threw. */
|
|
329
329
|
handlerError?: string;
|
|
330
|
+
/**
|
|
331
|
+
* Set when the signal matched in extraction mode (`extract` defined) but the
|
|
332
|
+
* classifier returned no extracted payload — the handler ran with
|
|
333
|
+
* `extracted: undefined`. Independent of `handlerError`.
|
|
334
|
+
*/
|
|
335
|
+
extractionError?: string;
|
|
330
336
|
/** Wall-clock duration of the handler invocation in milliseconds. */
|
|
331
337
|
durationMs?: number;
|
|
332
338
|
}
|
package/src/utils/index.ts
CHANGED
|
@@ -55,8 +55,8 @@ export {
|
|
|
55
55
|
export { LoggerLevel, logger } from "./logger";
|
|
56
56
|
|
|
57
57
|
// Retry utilities
|
|
58
|
-
export type { RetryOptions } from "./retry";
|
|
59
|
-
export { retry, withTimeoutAndRetry } from "./retry";
|
|
58
|
+
export type { RetryOptions, StreamRetryOptions } from "./retry";
|
|
59
|
+
export { retry, withTimeoutAndRetry, withStreamRetry } from "./retry";
|
|
60
60
|
|
|
61
61
|
// Condition utilities
|
|
62
62
|
export {
|
package/src/utils/retry.ts
CHANGED
|
@@ -95,3 +95,58 @@ export const withTimeoutAndRetry = async <T>(
|
|
|
95
95
|
},
|
|
96
96
|
});
|
|
97
97
|
};
|
|
98
|
+
|
|
99
|
+
export interface StreamRetryOptions {
|
|
100
|
+
/** Maximum number of retries after the first attempt. Defaults to 3. */
|
|
101
|
+
maxRetries?: number;
|
|
102
|
+
/** Backoff before the next attempt, in ms. Defaults to capped exponential. */
|
|
103
|
+
delay?: (attempt: number) => number;
|
|
104
|
+
/** Label used in retry logs. */
|
|
105
|
+
operationName?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Streaming analog of {@link withTimeoutAndRetry}. Re-runs an async-generator
|
|
110
|
+
* factory as long as it fails *before yielding its first chunk* — e.g. an
|
|
111
|
+
* empty completion that throws "No response", or an error while establishing
|
|
112
|
+
* the stream. Once any chunk has been yielded the stream is committed and
|
|
113
|
+
* further errors propagate, so a retry can never double-emit deltas that the
|
|
114
|
+
* consumer has already received.
|
|
115
|
+
*
|
|
116
|
+
* This mirrors the non-streaming path, where the provider throws on an empty
|
|
117
|
+
* completion inside `withTimeoutAndRetry` and is retried on the same model
|
|
118
|
+
* before the caller falls through to backup models.
|
|
119
|
+
*/
|
|
120
|
+
export async function* withStreamRetry<T>(
|
|
121
|
+
factory: () => AsyncGenerator<T>,
|
|
122
|
+
options: StreamRetryOptions = {}
|
|
123
|
+
): AsyncGenerator<T> {
|
|
124
|
+
const {
|
|
125
|
+
maxRetries = 3,
|
|
126
|
+
delay = (attempt: number) => Math.min(1000 * Math.pow(2, attempt), 5000),
|
|
127
|
+
operationName = "AI stream",
|
|
128
|
+
} = options;
|
|
129
|
+
|
|
130
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
131
|
+
let yielded = false;
|
|
132
|
+
try {
|
|
133
|
+
for await (const chunk of factory()) {
|
|
134
|
+
yielded = true;
|
|
135
|
+
yield chunk;
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
} catch (error: unknown) {
|
|
139
|
+
// Can't retry once deltas are out, and don't retry past the budget.
|
|
140
|
+
if (yielded || attempt === maxRetries) {
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
144
|
+
logger.error(`[${operationName}] Failed attempt ${attempt + 1}:`, message);
|
|
145
|
+
const delayMs = delay(attempt);
|
|
146
|
+
logger.debug(
|
|
147
|
+
`[${operationName}] Retrying in ${delayMs}ms... (attempt ${attempt + 2}/${maxRetries + 1})`
|
|
148
|
+
);
|
|
149
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|