feat: abort controller for work order processing
Add end-to-end abort support: AbortSignal in @gadget/ai providers, abortWorkOrder socket message, drone AbortController handling, Cancel button and double-Esc in frontend, and aborted turn status display.
This commit is contained in:
parent
c635209201
commit
4780b79148
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,4 +1,5 @@
|
|||||||
gadget*log
|
gadget-code.*.log
|
||||||
|
gadget-drone.*.log
|
||||||
logfetch
|
logfetch
|
||||||
|
|
||||||
.gadget
|
.gadget
|
||||||
|
|||||||
@ -49,7 +49,9 @@ const ChatTurn = memo(function ChatTurn({ turn }: ChatTurnProps) {
|
|||||||
? "text-green-500"
|
? "text-green-500"
|
||||||
: turn.status === "error"
|
: turn.status === "error"
|
||||||
? "text-red-500"
|
? "text-red-500"
|
||||||
: "text-yellow-500"
|
: turn.status === "aborted"
|
||||||
|
? "text-yellow-500"
|
||||||
|
: "text-yellow-500"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
@ -88,6 +90,17 @@ const ChatTurn = memo(function ChatTurn({ turn }: ChatTurnProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Aborted notice */}
|
||||||
|
{turn.status === "aborted" && (
|
||||||
|
<div className="max-w-[80%] ml-0 mb-2">
|
||||||
|
<div className="bg-bg-tertiary border border-border-default rounded p-3">
|
||||||
|
<div className="whitespace-pre-wrap text-sm text-text-secondary">
|
||||||
|
The turn was aborted by you.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* User Prompt */}
|
{/* User Prompt */}
|
||||||
<div className="max-w-[80%] ml-0 mb-4">
|
<div className="max-w-[80%] ml-0 mb-4">
|
||||||
<div className="bg-[#4a0000] text-white rounded p-4">
|
<div className="bg-[#4a0000] text-white rounded p-4">
|
||||||
|
|||||||
@ -408,7 +408,7 @@ export interface ChatTurn {
|
|||||||
provider: string | AiProvider;
|
provider: string | AiProvider;
|
||||||
llm: string;
|
llm: string;
|
||||||
mode: ChatSessionMode;
|
mode: ChatSessionMode;
|
||||||
status: "processing" | "finished" | "error";
|
status: "processing" | "finished" | "aborted" | "error";
|
||||||
prompts: ChatTurnPrompts;
|
prompts: ChatTurnPrompts;
|
||||||
blocks: ChatTurnBlock[];
|
blocks: ChatTurnBlock[];
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
|||||||
@ -42,6 +42,7 @@ export interface ServerToClientEvents {
|
|||||||
|
|
||||||
export interface ClientToServerEvents {
|
export interface ClientToServerEvents {
|
||||||
submitPrompt: (content: string) => void;
|
submitPrompt: (content: string) => void;
|
||||||
|
abortWorkOrder: (cb: (success: boolean, message?: string) => void) => void;
|
||||||
requestSessionLock: (
|
requestSessionLock: (
|
||||||
registration: any,
|
registration: any,
|
||||||
project: any,
|
project: any,
|
||||||
@ -353,6 +354,16 @@ class SocketClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abortWorkOrder(cb?: (success: boolean, message?: string) => void): void {
|
||||||
|
if (this._socket?.connected) {
|
||||||
|
this._socket.emit('abortWorkOrder', (success: boolean, message?: string) => {
|
||||||
|
cb?.(success, message);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
cb?.(false, 'Socket not connected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
requestSessionLock(
|
requestSessionLock(
|
||||||
registration: any,
|
registration: any,
|
||||||
project: any,
|
project: any,
|
||||||
|
|||||||
@ -50,6 +50,7 @@ export default function ChatSessionView() {
|
|||||||
const [turns, setTurns] = useState<ChatTurn[]>([]);
|
const [turns, setTurns] = useState<ChatTurn[]>([]);
|
||||||
const [promptInput, setPromptInput] = useState('');
|
const [promptInput, setPromptInput] = useState('');
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [isAborting, setIsAborting] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [sessionLocked, setSessionLocked] = useState(true);
|
const [sessionLocked, setSessionLocked] = useState(true);
|
||||||
@ -81,6 +82,8 @@ export default function ChatSessionView() {
|
|||||||
const updateRafRef = useRef<number | null>(null);
|
const updateRafRef = useRef<number | null>(null);
|
||||||
const currentTurnIdRef = useRef<string | null>(null);
|
const currentTurnIdRef = useRef<string | null>(null);
|
||||||
const streamingStateRef = useRef<Map<string, StreamingState>>(new Map());
|
const streamingStateRef = useRef<Map<string, StreamingState>>(new Map());
|
||||||
|
const escTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const escFlagRef = useRef(false);
|
||||||
const subagentStateRef = useRef<Map<string, SubagentStreamState>>(new Map());
|
const subagentStateRef = useRef<Map<string, SubagentStreamState>>(new Map());
|
||||||
const sessionRef = useRef<ChatSession | null>(null);
|
const sessionRef = useRef<ChatSession | null>(null);
|
||||||
const projectRef = useRef<Project | null>(null);
|
const projectRef = useRef<Project | null>(null);
|
||||||
@ -534,6 +537,16 @@ export default function ChatSessionView() {
|
|||||||
scheduleUpdate();
|
scheduleUpdate();
|
||||||
}, [mergePendingUpdate, scheduleUpdate]);
|
}, [mergePendingUpdate, scheduleUpdate]);
|
||||||
|
|
||||||
|
const showToast = useCallback((message: string) => {
|
||||||
|
setToast(message);
|
||||||
|
if (toastTimerRef.current) {
|
||||||
|
clearTimeout(toastTimerRef.current);
|
||||||
|
}
|
||||||
|
toastTimerRef.current = setTimeout(() => {
|
||||||
|
setToast(null);
|
||||||
|
}, 4000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleWorkOrderComplete = useCallback((turnId: string, success: boolean, message?: string) => {
|
const handleWorkOrderComplete = useCallback((turnId: string, success: boolean, message?: string) => {
|
||||||
// Backend has already flushed and persisted all streaming content
|
// Backend has already flushed and persisted all streaming content
|
||||||
// Just clean up frontend streaming state and update status
|
// Just clean up frontend streaming state and update status
|
||||||
@ -551,19 +564,38 @@ export default function ChatSessionView() {
|
|||||||
subagentStateRef.current.delete(agentId);
|
subagentStateRef.current.delete(agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTurns(prevTurns =>
|
if (success && message === 'aborted') {
|
||||||
prevTurns.map(turn =>
|
setTurns(prevTurns =>
|
||||||
turn._id === turnId
|
prevTurns.map(turn =>
|
||||||
? { ...turn, status: success ? 'finished' : 'error', errorMessage: message && !success ? message : turn.errorMessage }
|
turn._id === turnId
|
||||||
: turn
|
? { ...turn, status: 'aborted', errorMessage: 'The turn was aborted by you.' }
|
||||||
)
|
: turn
|
||||||
);
|
)
|
||||||
|
);
|
||||||
|
showToast('The turn was aborted by you.');
|
||||||
|
} else {
|
||||||
|
setTurns(prevTurns =>
|
||||||
|
prevTurns.map(turn =>
|
||||||
|
turn._id === turnId
|
||||||
|
? { ...turn, status: success ? 'finished' : 'error', errorMessage: message && !success ? message : turn.errorMessage }
|
||||||
|
: turn
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (!success) {
|
||||||
|
setError(message || 'Work order failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up abort state
|
||||||
|
if (escTimerRef.current) {
|
||||||
|
clearTimeout(escTimerRef.current);
|
||||||
|
escTimerRef.current = null;
|
||||||
|
}
|
||||||
|
escFlagRef.current = false;
|
||||||
|
setIsAborting(false);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
currentTurnIdRef.current = null;
|
currentTurnIdRef.current = null;
|
||||||
if (!success) {
|
}, [showToast]);
|
||||||
setError(message || 'Work order failed');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleWorkspaceModeChanged = useCallback((mode: string) => {
|
const handleWorkspaceModeChanged = useCallback((mode: string) => {
|
||||||
setWorkspaceMode(mode as WorkspaceMode);
|
setWorkspaceMode(mode as WorkspaceMode);
|
||||||
@ -712,16 +744,6 @@ export default function ChatSessionView() {
|
|||||||
});
|
});
|
||||||
}, [updateSubagentBlock]);
|
}, [updateSubagentBlock]);
|
||||||
|
|
||||||
const showToast = useCallback((message: string) => {
|
|
||||||
setToast(message);
|
|
||||||
if (toastTimerRef.current) {
|
|
||||||
clearTimeout(toastTimerRef.current);
|
|
||||||
}
|
|
||||||
toastTimerRef.current = setTimeout(() => {
|
|
||||||
setToast(null);
|
|
||||||
}, 4000);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleWorkspaceModeChange = async (mode: WorkspaceMode) => {
|
const handleWorkspaceModeChange = async (mode: WorkspaceMode) => {
|
||||||
if (!session || !project) return;
|
if (!session || !project) return;
|
||||||
|
|
||||||
@ -936,6 +958,64 @@ export default function ChatSessionView() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
if (isAborting) return;
|
||||||
|
setIsAborting(true);
|
||||||
|
socketClient.abortWorkOrder((success, message) => {
|
||||||
|
if (success) {
|
||||||
|
showToast('Aborting Agentic Workflow Loop...');
|
||||||
|
} else {
|
||||||
|
showToast(message || 'Failed to abort');
|
||||||
|
setIsAborting(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [isAborting, showToast]);
|
||||||
|
|
||||||
|
// Global Esc key handler for abort: first Esc shows prompt, second Esc within 3s aborts
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isProcessing) {
|
||||||
|
if (escTimerRef.current) {
|
||||||
|
clearTimeout(escTimerRef.current);
|
||||||
|
escTimerRef.current = null;
|
||||||
|
}
|
||||||
|
escFlagRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!escFlagRef.current) {
|
||||||
|
escFlagRef.current = true;
|
||||||
|
showToast('Press Esc again to abort');
|
||||||
|
escTimerRef.current = setTimeout(() => {
|
||||||
|
escFlagRef.current = false;
|
||||||
|
setToast(null);
|
||||||
|
escTimerRef.current = null;
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
if (escTimerRef.current) {
|
||||||
|
clearTimeout(escTimerRef.current);
|
||||||
|
escTimerRef.current = null;
|
||||||
|
}
|
||||||
|
escFlagRef.current = false;
|
||||||
|
setToast(null);
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
if (escTimerRef.current) {
|
||||||
|
clearTimeout(escTimerRef.current);
|
||||||
|
escTimerRef.current = null;
|
||||||
|
}
|
||||||
|
escFlagRef.current = false;
|
||||||
|
};
|
||||||
|
}, [isProcessing, showToast, handleCancel]);
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
@ -1037,13 +1117,24 @@ export default function ChatSessionView() {
|
|||||||
rows={3}
|
rows={3}
|
||||||
disabled={promptDisabled}
|
disabled={promptDisabled}
|
||||||
/>
|
/>
|
||||||
<button
|
{isProcessing ? (
|
||||||
type="submit"
|
<button
|
||||||
disabled={promptDisabled || !promptInput.trim()}
|
type="button"
|
||||||
className="px-6 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
onClick={handleCancel}
|
||||||
>
|
disabled={isAborting}
|
||||||
{isProcessing ? 'Processing...' : 'Send'}
|
className="px-6 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
</button>
|
>
|
||||||
|
{isAborting ? 'Aborting...' : 'Cancel'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={promptDisabled || !promptInput.trim()}
|
||||||
|
className="px-6 py-2 bg-brand text-white rounded hover:bg-brand/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
ChatTurnDocument,
|
ChatTurnDocument,
|
||||||
WorkspaceMode,
|
WorkspaceMode,
|
||||||
SubmitPromptCallback,
|
SubmitPromptCallback,
|
||||||
|
AbortWorkOrderCallback,
|
||||||
} from "@gadget/api";
|
} from "@gadget/api";
|
||||||
|
|
||||||
import ChatSession from "../models/chat-session.ts";
|
import ChatSession from "../models/chat-session.ts";
|
||||||
@ -55,6 +56,7 @@ export class CodeSession extends SocketSession {
|
|||||||
this.onRequestWorkspaceMode.bind(this),
|
this.onRequestWorkspaceMode.bind(this),
|
||||||
);
|
);
|
||||||
this.socket.on("submitPrompt", this.onSubmitPrompt.bind(this));
|
this.socket.on("submitPrompt", this.onSubmitPrompt.bind(this));
|
||||||
|
this.socket.on("abortWorkOrder", this.onAbortWorkOrder.bind(this));
|
||||||
this.socket.on("releaseSessionLock", this.onReleaseSessionLock.bind(this));
|
this.socket.on("releaseSessionLock", this.onReleaseSessionLock.bind(this));
|
||||||
this.socket.on("sessionHeartbeat", this.onSessionHeartbeat.bind(this));
|
this.socket.on("sessionHeartbeat", this.onSessionHeartbeat.bind(this));
|
||||||
|
|
||||||
@ -401,6 +403,23 @@ export class CodeSession extends SocketSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the IDE sends an abortWorkOrder event to cancel the
|
||||||
|
* currently running work order. Forwards to the drone.
|
||||||
|
*/
|
||||||
|
onAbortWorkOrder(cb: AbortWorkOrderCallback): void {
|
||||||
|
if (!this.selectedDrone) {
|
||||||
|
return cb(false, "No drone selected");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const droneSession = SocketService.getDroneSession(this.selectedDrone);
|
||||||
|
droneSession.socket.emit("abortWorkOrder", cb);
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to forward abortWorkOrder to drone", { error });
|
||||||
|
cb(false, "Failed to reach drone");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the IDE sends a releaseSessionLock event to release a
|
* Called when the IDE sends a releaseSessionLock event to release a
|
||||||
* previously-acquired session lock on a gadget-drone instance.
|
* previously-acquired session lock on a gadget-drone instance.
|
||||||
|
|||||||
@ -288,9 +288,14 @@ export class DroneSession extends SocketSession {
|
|||||||
|
|
||||||
const turn = await ChatTurn.findById(turnId);
|
const turn = await ChatTurn.findById(turnId);
|
||||||
if (turn) {
|
if (turn) {
|
||||||
turn.status = success ? ChatTurnStatus.Finished : ChatTurnStatus.Error;
|
if (success && message === "aborted") {
|
||||||
if (!success && message) {
|
turn.status = ChatTurnStatus.Aborted;
|
||||||
turn.errorMessage = message;
|
turn.errorMessage = "The turn was aborted by you.";
|
||||||
|
} else {
|
||||||
|
turn.status = success ? ChatTurnStatus.Finished : ChatTurnStatus.Error;
|
||||||
|
if (!success && message) {
|
||||||
|
turn.errorMessage = message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await turn.save();
|
await turn.save();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -248,6 +248,10 @@ class GadgetDrone extends GadgetProcess {
|
|||||||
this.onReleaseSessionLock.bind(this),
|
this.onReleaseSessionLock.bind(this),
|
||||||
);
|
);
|
||||||
this.socket.on("sessionHeartbeat", this.onSessionHeartbeat.bind(this));
|
this.socket.on("sessionHeartbeat", this.onSessionHeartbeat.bind(this));
|
||||||
|
this.socket.on(
|
||||||
|
"abortWorkOrder",
|
||||||
|
this.onAbortWorkOrder.bind(this),
|
||||||
|
);
|
||||||
this.socket.on(
|
this.socket.on(
|
||||||
"requestTermination",
|
"requestTermination",
|
||||||
this.onRequestTermination.bind(this),
|
this.onRequestTermination.bind(this),
|
||||||
@ -704,6 +708,16 @@ class GadgetDrone extends GadgetProcess {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onAbortWorkOrder(cb: (success: boolean, message?: string) => void): Promise<void> {
|
||||||
|
this.log.info("abortWorkOrder received from platform", {
|
||||||
|
registrationId: this.registration?._id,
|
||||||
|
isProcessing: this.isProcessingWorkOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
const aborted = AgentService.abortCurrentWorkOrder();
|
||||||
|
cb(aborted, aborted ? "Abort signaled" : "No active work order to abort");
|
||||||
|
}
|
||||||
|
|
||||||
async onRequestTermination(cb: (success: boolean) => void): Promise<void> {
|
async onRequestTermination(cb: (success: boolean) => void): Promise<void> {
|
||||||
this.log.info("requestTermination received from platform", {
|
this.log.info("requestTermination received from platform", {
|
||||||
registrationId: this.registration?._id,
|
registrationId: this.registration?._id,
|
||||||
|
|||||||
@ -79,6 +79,7 @@ class AgentService extends GadgetService {
|
|||||||
private currentWorkOrder: IAgentWorkOrder | null = null;
|
private currentWorkOrder: IAgentWorkOrder | null = null;
|
||||||
private currentSocket: DroneSocket | null = null;
|
private currentSocket: DroneSocket | null = null;
|
||||||
private currentToolCallId: string | null = null;
|
private currentToolCallId: string | null = null;
|
||||||
|
private abortController: AbortController | null = null;
|
||||||
|
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "AgentService";
|
return "AgentService";
|
||||||
@ -143,6 +144,7 @@ class AgentService extends GadgetService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.currentWorkOrder = workOrder;
|
this.currentWorkOrder = workOrder;
|
||||||
this.currentSocket = socket;
|
this.currentSocket = socket;
|
||||||
|
this.abortController = new AbortController();
|
||||||
|
|
||||||
const { turn } = workOrder;
|
const { turn } = workOrder;
|
||||||
let toolCallCount = 0;
|
let toolCallCount = 0;
|
||||||
@ -204,6 +206,7 @@ class AgentService extends GadgetService {
|
|||||||
const chatOptions: IAiChatOptions = {
|
const chatOptions: IAiChatOptions = {
|
||||||
context: messages,
|
context: messages,
|
||||||
tools: this.getToolsForMode(turn.mode),
|
tools: this.getToolsForMode(turn.mode),
|
||||||
|
signal: this.abortController.signal,
|
||||||
};
|
};
|
||||||
|
|
||||||
let response: IAiChatResponse;
|
let response: IAiChatResponse;
|
||||||
@ -302,6 +305,11 @@ class AgentService extends GadgetService {
|
|||||||
|
|
||||||
socket.emit("workOrderComplete", turn._id, true);
|
socket.emit("workOrderComplete", turn._id, true);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
|
if (cause instanceof Error && cause.name === "AbortError") {
|
||||||
|
this.log.info("work order aborted by user", { turnId: turn._id });
|
||||||
|
socket.emit("workOrderComplete", turn._id, true, "aborted");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const msg = cause instanceof Error ? cause.message : String(cause);
|
const msg = cause instanceof Error ? cause.message : String(cause);
|
||||||
this.log.error("agent loop failed, sending workOrderComplete(false)", {
|
this.log.error("agent loop failed, sending workOrderComplete(false)", {
|
||||||
turnId: turn._id,
|
turnId: turn._id,
|
||||||
@ -309,9 +317,21 @@ class AgentService extends GadgetService {
|
|||||||
});
|
});
|
||||||
socket.emit("workOrderComplete", turn._id, false, msg);
|
socket.emit("workOrderComplete", turn._id, false, msg);
|
||||||
throw cause;
|
throw cause;
|
||||||
|
} finally {
|
||||||
|
this.abortController = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals the abort controller for the currently running work order.
|
||||||
|
* Returns true if an abort was signaled, false if there was no active work order.
|
||||||
|
*/
|
||||||
|
abortCurrentWorkOrder(): boolean {
|
||||||
|
if (!this.abortController) return false;
|
||||||
|
this.abortController.abort();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
buildSessionContext(workOrder: IAgentWorkOrder): IContextChatMessage[] {
|
buildSessionContext(workOrder: IAgentWorkOrder): IContextChatMessage[] {
|
||||||
const session = workOrder.turn.session as IChatSession;
|
const session = workOrder.turn.session as IChatSession;
|
||||||
if (!session.user) {
|
if (!session.user) {
|
||||||
@ -596,6 +616,7 @@ class AgentService extends GadgetService {
|
|||||||
const chatOptions: IAiChatOptions = {
|
const chatOptions: IAiChatOptions = {
|
||||||
context: messages,
|
context: messages,
|
||||||
tools,
|
tools,
|
||||||
|
signal: this.abortController?.signal,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.log.info("subagent loop iteration", {
|
this.log.info("subagent loop iteration", {
|
||||||
|
|||||||
@ -46,6 +46,7 @@ export interface IAiInferenceStats {
|
|||||||
export interface IAiGenerateOptions {
|
export interface IAiGenerateOptions {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAiGenerateResponse {
|
export interface IAiGenerateResponse {
|
||||||
@ -89,6 +90,7 @@ export interface IAiChatOptions {
|
|||||||
userPrompt?: string;
|
userPrompt?: string;
|
||||||
context?: IContextChatMessage[];
|
context?: IContextChatMessage[];
|
||||||
tools?: IAiTool[];
|
tools?: IAiTool[];
|
||||||
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAiChatResponse {
|
export interface IAiChatResponse {
|
||||||
|
|||||||
@ -144,11 +144,16 @@ export class OllamaAiApi extends AiApi {
|
|||||||
modelId: model.modelId,
|
modelId: model.modelId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
throw new DOMException("The operation was aborted", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
const response = await this.client.generate({
|
const response = await this.client.generate({
|
||||||
model: model.modelId,
|
model: model.modelId,
|
||||||
prompt: options.prompt,
|
prompt: options.prompt,
|
||||||
system: options.systemPrompt,
|
system: options.systemPrompt,
|
||||||
stream: true,
|
stream: true,
|
||||||
|
...(options.signal ? { signal: options.signal } : {}),
|
||||||
options: {
|
options: {
|
||||||
num_ctx: model.params.numCtx,
|
num_ctx: model.params.numCtx,
|
||||||
num_predict: model.params.numPredict,
|
num_predict: model.params.numPredict,
|
||||||
@ -161,6 +166,10 @@ export class OllamaAiApi extends AiApi {
|
|||||||
};
|
};
|
||||||
let lastChunk;
|
let lastChunk;
|
||||||
for await (const chunk of response) {
|
for await (const chunk of response) {
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
throw new DOMException("The operation was aborted", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
lastChunk = chunk;
|
lastChunk = chunk;
|
||||||
|
|
||||||
if (chunk.thinking) {
|
if (chunk.thinking) {
|
||||||
@ -216,6 +225,10 @@ export class OllamaAiApi extends AiApi {
|
|||||||
modelId: model.modelId,
|
modelId: model.modelId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
throw new DOMException("The operation was aborted", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
const messages: OllamaMessage[] = [];
|
const messages: OllamaMessage[] = [];
|
||||||
|
|
||||||
if (options.systemPrompt) {
|
if (options.systemPrompt) {
|
||||||
@ -268,6 +281,7 @@ export class OllamaAiApi extends AiApi {
|
|||||||
stream: true,
|
stream: true,
|
||||||
think: model.params.reasoning,
|
think: model.params.reasoning,
|
||||||
tools: ollamaTools,
|
tools: ollamaTools,
|
||||||
|
...(options.signal ? { signal: options.signal } : {}),
|
||||||
options: {
|
options: {
|
||||||
num_ctx: model.params.numCtx,
|
num_ctx: model.params.numCtx,
|
||||||
num_predict: model.params.numPredict,
|
num_predict: model.params.numPredict,
|
||||||
@ -280,6 +294,10 @@ export class OllamaAiApi extends AiApi {
|
|||||||
const toolCalls: IToolCall[] = [];
|
const toolCalls: IToolCall[] = [];
|
||||||
|
|
||||||
for await (const chunk of response) {
|
for await (const chunk of response) {
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
throw new DOMException("The operation was aborted", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
lastChunk = chunk;
|
lastChunk = chunk;
|
||||||
|
|
||||||
if (chunk.message.thinking) {
|
if (chunk.message.thinking) {
|
||||||
|
|||||||
@ -192,6 +192,10 @@ export class OpenAiApi extends AiApi {
|
|||||||
modelId: model.modelId,
|
modelId: model.modelId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
throw new DOMException("The operation was aborted", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const response = await this.client.chat.completions.create({
|
const response = await this.client.chat.completions.create({
|
||||||
model: model.modelId,
|
model: model.modelId,
|
||||||
@ -202,6 +206,7 @@ export class OpenAiApi extends AiApi {
|
|||||||
{ role: "user" as const, content: options.prompt },
|
{ role: "user" as const, content: options.prompt },
|
||||||
],
|
],
|
||||||
stream: true,
|
stream: true,
|
||||||
|
...(options.signal ? { signal: options.signal } : {}),
|
||||||
...(model.params.maxCompletionTokens
|
...(model.params.maxCompletionTokens
|
||||||
? { max_completion_tokens: model.params.maxCompletionTokens }
|
? { max_completion_tokens: model.params.maxCompletionTokens }
|
||||||
: {}),
|
: {}),
|
||||||
@ -219,6 +224,10 @@ export class OpenAiApi extends AiApi {
|
|||||||
let accumulatedThinking = "";
|
let accumulatedThinking = "";
|
||||||
|
|
||||||
for await (const chunk of response) {
|
for await (const chunk of response) {
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
throw new DOMException("The operation was aborted", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
const delta = chunk.choices[0]?.delta;
|
const delta = chunk.choices[0]?.delta;
|
||||||
if (delta) {
|
if (delta) {
|
||||||
if (delta.content) {
|
if (delta.content) {
|
||||||
@ -282,6 +291,7 @@ export class OpenAiApi extends AiApi {
|
|||||||
messages,
|
messages,
|
||||||
tools,
|
tools,
|
||||||
streamCallback,
|
streamCallback,
|
||||||
|
options.signal,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.log.debug("OpenAI chat stream iteration finished", {
|
await this.log.debug("OpenAI chat stream iteration finished", {
|
||||||
@ -295,7 +305,7 @@ export class OpenAiApi extends AiApi {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (this.isEmptyIteration(iteration)) {
|
if (this.isEmptyIteration(iteration)) {
|
||||||
iteration = await this.readNonStreamingChatCompletion(model, messages, tools);
|
iteration = await this.readNonStreamingChatCompletion(model, messages, tools, options.signal);
|
||||||
if (streamCallback && iteration.response) {
|
if (streamCallback && iteration.response) {
|
||||||
await streamCallback({ type: "response", data: iteration.response });
|
await streamCallback({ type: "response", data: iteration.response });
|
||||||
}
|
}
|
||||||
@ -370,12 +380,18 @@ export class OpenAiApi extends AiApi {
|
|||||||
messages: ChatCompletionMessageParam[],
|
messages: ChatCompletionMessageParam[],
|
||||||
tools: ChatCompletionTool[],
|
tools: ChatCompletionTool[],
|
||||||
streamCallback?: IAiResponseStreamFn,
|
streamCallback?: IAiResponseStreamFn,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<OpenAiChatIterationResult> {
|
): Promise<OpenAiChatIterationResult> {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new DOMException("The operation was aborted", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
const response = await this.client.chat.completions.create({
|
const response = await this.client.chat.completions.create({
|
||||||
model: model.modelId,
|
model: model.modelId,
|
||||||
messages,
|
messages,
|
||||||
tools,
|
tools,
|
||||||
stream: true,
|
stream: true,
|
||||||
|
...(signal ? { signal } : {}),
|
||||||
...(model.params.maxCompletionTokens
|
...(model.params.maxCompletionTokens
|
||||||
? { max_completion_tokens: model.params.maxCompletionTokens }
|
? { max_completion_tokens: model.params.maxCompletionTokens }
|
||||||
: {}),
|
: {}),
|
||||||
@ -398,6 +414,10 @@ export class OpenAiApi extends AiApi {
|
|||||||
const toolCallMap = new Map<number, StreamingToolCallAccumulator>();
|
const toolCallMap = new Map<number, StreamingToolCallAccumulator>();
|
||||||
|
|
||||||
for await (const chunk of response) {
|
for await (const chunk of response) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new DOMException("The operation was aborted", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
chunkCount++;
|
chunkCount++;
|
||||||
finishReason = chunk.choices[0]?.finish_reason ?? finishReason;
|
finishReason = chunk.choices[0]?.finish_reason ?? finishReason;
|
||||||
const delta = chunk.choices[0]?.delta;
|
const delta = chunk.choices[0]?.delta;
|
||||||
@ -439,12 +459,14 @@ export class OpenAiApi extends AiApi {
|
|||||||
model: IAiModelConfig,
|
model: IAiModelConfig,
|
||||||
messages: ChatCompletionMessageParam[],
|
messages: ChatCompletionMessageParam[],
|
||||||
tools: ChatCompletionTool[],
|
tools: ChatCompletionTool[],
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<OpenAiChatIterationResult> {
|
): Promise<OpenAiChatIterationResult> {
|
||||||
const response = await this.client.chat.completions.create({
|
const response = await this.client.chat.completions.create({
|
||||||
model: model.modelId,
|
model: model.modelId,
|
||||||
messages,
|
messages,
|
||||||
tools,
|
tools,
|
||||||
stream: false,
|
stream: false,
|
||||||
|
...(signal ? { signal } : {}),
|
||||||
...(model.params.maxCompletionTokens
|
...(model.params.maxCompletionTokens
|
||||||
? { max_completion_tokens: model.params.maxCompletionTokens }
|
? { max_completion_tokens: model.params.maxCompletionTokens }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@ -80,6 +80,17 @@ export type ReleaseSessionLockMessage = (
|
|||||||
cb: ReleaseSessionLockCallback,
|
cb: ReleaseSessionLockCallback,
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* abortWorkOrder
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AbortWorkOrderCallback = (
|
||||||
|
success: boolean,
|
||||||
|
message?: string,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export type AbortWorkOrderMessage = (cb: AbortWorkOrderCallback) => void;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* sessionHeartbeat
|
* sessionHeartbeat
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
} from "./drone.ts";
|
} from "./drone.ts";
|
||||||
import { SessionUpdatedMessage } from "./web.ts";
|
import { SessionUpdatedMessage } from "./web.ts";
|
||||||
import {
|
import {
|
||||||
|
AbortWorkOrderMessage,
|
||||||
ReleaseSessionLockMessage,
|
ReleaseSessionLockMessage,
|
||||||
RequestSessionLockMessage,
|
RequestSessionLockMessage,
|
||||||
RequestWorkspaceModeMessage,
|
RequestWorkspaceModeMessage,
|
||||||
@ -50,6 +51,7 @@ export interface ClientToServerEvents {
|
|||||||
requestSessionLock: RequestSessionLockMessage;
|
requestSessionLock: RequestSessionLockMessage;
|
||||||
requestWorkspaceMode: RequestWorkspaceModeMessage;
|
requestWorkspaceMode: RequestWorkspaceModeMessage;
|
||||||
submitPrompt: SubmitPromptMessage;
|
submitPrompt: SubmitPromptMessage;
|
||||||
|
abortWorkOrder: AbortWorkOrderMessage;
|
||||||
releaseSessionLock: ReleaseSessionLockMessage;
|
releaseSessionLock: ReleaseSessionLockMessage;
|
||||||
sessionHeartbeat: SessionHeartbeatMessage;
|
sessionHeartbeat: SessionHeartbeatMessage;
|
||||||
|
|
||||||
@ -111,6 +113,7 @@ export interface ServerToClientEvents {
|
|||||||
requestWorkspaceMode: RequestWorkspaceModeMessage;
|
requestWorkspaceMode: RequestWorkspaceModeMessage;
|
||||||
releaseSessionLock: ReleaseSessionLockMessage;
|
releaseSessionLock: ReleaseSessionLockMessage;
|
||||||
sessionHeartbeat: SessionHeartbeatMessage;
|
sessionHeartbeat: SessionHeartbeatMessage;
|
||||||
|
abortWorkOrder: AbortWorkOrderMessage;
|
||||||
processWorkOrder: ProcessWorkOrderMessage;
|
processWorkOrder: ProcessWorkOrderMessage;
|
||||||
crashRecoveryResponse: CrashRecoveryResponseMessage;
|
crashRecoveryResponse: CrashRecoveryResponseMessage;
|
||||||
requestTermination: RequestTerminationMessage;
|
requestTermination: RequestTerminationMessage;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user