fix(agent): add model request timeouts and empty-response fallback

This commit is contained in:
William Valentin
2026-02-17 23:05:21 -08:00
parent 73c58fcbde
commit 9345a864f4
3 changed files with 158 additions and 12 deletions
+46 -10
View File
@@ -40,6 +40,8 @@ export interface NativeAgentConfig {
toolPolicyContext?: ToolPolicyContext;
/** Collector for outbound attachments queued by tools (e.g. media.send). */
attachmentCollector?: OutboundAttachmentCollector;
/** Hard timeout for each model request in milliseconds. */
modelTimeoutMs?: number;
}
// Internal message type for the tool loop — supports both text and structured content blocks.
@@ -55,6 +57,9 @@ interface PseudoToolUse {
}
export class NativeAgent {
private static readonly EMPTY_RESPONSE_FALLBACK =
'I could not generate a response for that. Please try again.';
private static readonly DEFAULT_MODEL_TIMEOUT_MS = 120_000;
private modelClient: ModelClient | ModelRouter;
private systemPrompt: string;
private session?: Session;
@@ -72,6 +77,7 @@ export class NativeAgent {
private _lastToolFingerprint?: string;
private _cancelRequested = false;
private _runInProgress = false;
private modelTimeoutMs: number;
constructor(config: NativeAgentConfig) {
this.modelClient = config.modelClient;
@@ -83,6 +89,9 @@ export class NativeAgent {
this.onToolUse = config.onToolUse;
this._toolPolicyContext = config.toolPolicyContext;
this._attachmentCollector = config.attachmentCollector;
this.modelTimeoutMs = Number.isFinite(config.modelTimeoutMs)
? Math.max(1, Math.floor(config.modelTimeoutMs as number))
: NativeAgent.DEFAULT_MODEL_TIMEOUT_MS;
}
private get history(): Message[] {
@@ -145,13 +154,15 @@ export class NativeAgent {
this._totalUsage.outputTokens += response.usage.outputTokens;
this._callCount++;
const normalizedContent = this.normalizeAssistantContent(response.content);
// Prepend thinking content if present
let finalContent = response.content;
let finalContent = normalizedContent;
if (response.thinkingContent) {
finalContent = `<thinking>\n${response.thinkingContent}\n</thinking>\n\n${response.content}`;
finalContent = `<thinking>\n${response.thinkingContent}\n</thinking>\n\n${normalizedContent}`;
}
const assistantMsg: Message = { role: 'assistant', content: response.content };
const assistantMsg: Message = { role: 'assistant', content: normalizedContent };
this.addToHistory(assistantMsg);
return finalContent;
@@ -230,13 +241,14 @@ export class NativeAgent {
&& response.toolCalls && response.toolCalls.length > 0;
if (!wantsToolUse) {
const pseudoToolUse = this.extractPseudoToolUse(response.content);
let finalContent = pseudoToolUse
const baseContent = pseudoToolUse
? this.buildPseudoToolUseWarning(response.content, pseudoToolUse)
: response.content;
: this.normalizeAssistantContent(response.content);
let finalContent = baseContent;
if (response.thinkingContent) {
finalContent = `<thinking>\n${response.thinkingContent}\n</thinking>\n\n${finalContent}`;
finalContent = `<thinking>\n${response.thinkingContent}\n</thinking>\n\n${baseContent}`;
}
const assistantMsg: Message = { role: 'assistant', content: finalContent };
const assistantMsg: Message = { role: 'assistant', content: baseContent };
this.addToHistory(assistantMsg);
return finalContent;
}
@@ -411,10 +423,27 @@ export class NativeAgent {
}
private async chatWithRouter(request: ChatRequest): Promise<ChatResponse> {
if ('getClient' in this.modelClient) {
return (this.modelClient as ModelRouter).chat(request, this.currentTier);
const requestPromise = 'getClient' in this.modelClient
? (this.modelClient as ModelRouter).chat(request, this.currentTier)
: this.modelClient.chat(request);
let timer: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
const error = new Error(`Model request timed out after ${this.modelTimeoutMs}ms`);
error.name = 'TimeoutError';
reject(error);
}, this.modelTimeoutMs);
timer.unref?.();
});
try {
return await Promise.race([requestPromise, timeoutPromise]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
return this.modelClient.chat(request);
}
private addToHistory(msg: Message): void {
@@ -560,4 +589,11 @@ export class NativeAgent {
rawContent,
].join('\n');
}
private normalizeAssistantContent(content: string): string {
if (content.trim().length > 0) {
return content;
}
return NativeAgent.EMPTY_RESPONSE_FALLBACK;
}
}