feat(gateway): wire safe-point runtime cancellation for agent.cancel

This commit is contained in:
William Valentin
2026-02-13 08:51:14 -08:00
parent 9f81c01603
commit 46099664f0
7 changed files with 182 additions and 26 deletions
+76 -20
View File
@@ -53,6 +53,8 @@ export class NativeAgent {
private _attachmentCollector?: OutboundAttachmentCollector;
private _thinking: boolean = false;
private _lastToolFingerprint?: string;
private _cancelRequested = false;
private _runInProgress = false;
constructor(config: NativeAgentConfig) {
this.modelClient = config.modelClient;
@@ -71,31 +73,48 @@ export class NativeAgent {
}
async process(userMessage: string, attachments?: Attachment[]): Promise<string> {
this._cancelRequested = false;
this._runInProgress = true;
// Detect and strip !!think prefix for per-message thinking mode
if (userMessage.startsWith('!!think ') || userMessage === '!!think') {
this._thinking = true;
userMessage = userMessage.replace(/^!!think\s*/, '').trim() || 'Think about this.';
} else {
this._thinking = false;
try {
if (userMessage.startsWith('!!think ') || userMessage === '!!think') {
this._thinking = true;
userMessage = userMessage.replace(/^!!think\s*/, '').trim() || 'Think about this.';
} else {
this._thinking = false;
}
const userMsg = buildUserMessage(userMessage, attachments);
if (this.session) {
this.session.addMessage(userMsg);
} else {
this.inMemoryHistory.push(userMsg);
}
// If no tools configured, use the simple single-turn path
if (!this.toolRegistry || !this.toolExecutor) {
return await this.singleTurn();
}
return await this.toolLoop();
} catch (error) {
if (this.isAbortError(error)) {
const cancelledMsg = 'Operation cancelled by user.';
this.addToHistory({ role: 'assistant', content: cancelledMsg });
return cancelledMsg;
}
throw error;
} finally {
this._runInProgress = false;
this._cancelRequested = false;
}
const userMsg = buildUserMessage(userMessage, attachments);
if (this.session) {
this.session.addMessage(userMsg);
} else {
this.inMemoryHistory.push(userMsg);
}
// If no tools configured, use the simple single-turn path
if (!this.toolRegistry || !this.toolExecutor) {
return this.singleTurn();
}
return this.toolLoop();
}
private async singleTurn(): Promise<string> {
this.throwIfCancelled();
const request: ChatRequest = {
messages: this.history,
system: this.systemPrompt,
@@ -103,6 +122,7 @@ export class NativeAgent {
};
const response = await this.chatWithRouter(request);
this.throwIfCancelled();
this._totalUsage.inputTokens += response.usage.inputTokens;
this._totalUsage.outputTokens += response.usage.outputTokens;
@@ -159,6 +179,8 @@ export class NativeAgent {
for (let iteration = 0; iteration < this.maxIterations; iteration++) {
try {
this.throwIfCancelled();
// Build request — cast loopMessages to Message[] because the underlying
// model client will pass them through to the API which accepts structured content.
const request = {
@@ -169,6 +191,7 @@ export class NativeAgent {
};
const response = await this.chatWithRouter(request);
this.throwIfCancelled();
this._totalUsage.inputTokens += response.usage.inputTokens;
this._totalUsage.outputTokens += response.usage.outputTokens;
@@ -234,6 +257,8 @@ export class NativeAgent {
const toolResultBlocks: unknown[] = [];
lastToolResults = [];
for (const tc of toolCalls) {
this.throwIfCancelled();
const internalName = this.toolRegistry!.getByApiName(tc.name)?.name ?? tc.name;
this.onToolUse?.({ type: 'start', tool: internalName, args: tc.args });
@@ -280,6 +305,13 @@ export class NativeAgent {
return breakMsg;
}
} catch (error) {
if (this.isAbortError(error)) {
const cancelledMsg = 'Operation cancelled by user.';
const assistantMsg: Message = { role: 'assistant', content: cancelledMsg };
this.addToHistory(assistantMsg);
return cancelledMsg;
}
const errorMsg = `Error in tool loop (iteration ${iteration + 1}): ${error instanceof Error ? error.message : String(error)}`;
const assistantMsg: Message = { role: 'assistant', content: errorMsg };
this.addToHistory(assistantMsg);
@@ -363,4 +395,28 @@ export class NativeAgent {
getAttachmentCollector(): OutboundAttachmentCollector | undefined {
return this._attachmentCollector;
}
cancel(): void {
if (this._runInProgress) {
this._cancelRequested = true;
}
}
isCancellable(): boolean {
return this._runInProgress;
}
private throwIfCancelled(): void {
if (!this._cancelRequested) {
return;
}
const error = new Error('Operation cancelled by user.');
error.name = 'AbortError';
throw error;
}
private isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
}