feat(gateway): wire safe-point runtime cancellation for agent.cancel
This commit is contained in:
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user