mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
feat(arena): improve cancellation handling and simplify to in-process mode
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> - Track user-initiated cancellation separately from failures - Cancel round immediately when user denies a tool call - Add message queue to handle input during streaming - Add info messages during Arena operations (apply, stop, cleanup) - Disable tmux/iTerm2 backends (only in-process mode supported) - Polish UI: green tool count, updated warning prefix This improves the Arena UX by providing clearer feedback and properly handling user cancellations without treating them as failures.
This commit is contained in:
parent
3233d16b5c
commit
4ee94715df
13 changed files with 153 additions and 65 deletions
|
|
@ -1193,12 +1193,12 @@ const SETTINGS_SCHEMA = {
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
default: undefined as string | undefined,
|
default: undefined as string | undefined,
|
||||||
description:
|
description:
|
||||||
'Display mode for multi-agent sessions. "tmux" uses tmux panes, "iterm2" uses iTerm2 tabs, "in-process" runs in the current terminal.',
|
'Display mode for multi-agent sessions. Currently only "in-process" is supported.',
|
||||||
showInDialog: false,
|
showInDialog: false,
|
||||||
options: [
|
options: [
|
||||||
{ value: 'in-process', label: 'In-process' },
|
{ value: 'in-process', label: 'In-process' },
|
||||||
{ value: 'tmux', label: 'tmux' },
|
// { value: 'tmux', label: 'tmux' },
|
||||||
{ value: 'iterm2', label: 'iTerm2' },
|
// { value: 'iterm2', label: 'iTerm2' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
arena: {
|
arena: {
|
||||||
|
|
|
||||||
|
|
@ -249,10 +249,7 @@ function executeArenaCommand(
|
||||||
} else if (event.type === 'info') {
|
} else if (event.type === 'info') {
|
||||||
addAndRecordArenaMessage(MessageType.INFO, event.message);
|
addAndRecordArenaMessage(MessageType.INFO, event.message);
|
||||||
} else {
|
} else {
|
||||||
addAndRecordArenaMessage(
|
addAndRecordArenaMessage(MessageType.WARNING, event.message);
|
||||||
MessageType.WARNING,
|
|
||||||
`Arena warning: ${event.message}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Box, Text, useStdin } from 'ink';
|
import { Box, Text, useStdin } from 'ink';
|
||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AgentStatus,
|
AgentStatus,
|
||||||
|
isTerminalStatus,
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
APPROVAL_MODES,
|
APPROVAL_MODES,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
|
@ -38,6 +39,7 @@ import { useTextBuffer } from '../shared/text-buffer.js';
|
||||||
import { calculatePromptWidths } from '../../utils/layoutUtils.js';
|
import { calculatePromptWidths } from '../../utils/layoutUtils.js';
|
||||||
import { BaseTextInput } from '../BaseTextInput.js';
|
import { BaseTextInput } from '../BaseTextInput.js';
|
||||||
import { LoadingIndicator } from '../LoadingIndicator.js';
|
import { LoadingIndicator } from '../LoadingIndicator.js';
|
||||||
|
import { QueuedMessageDisplay } from '../QueuedMessageDisplay.js';
|
||||||
import { AgentFooter } from './AgentFooter.js';
|
import { AgentFooter } from './AgentFooter.js';
|
||||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||||
import { theme } from '../../semantic-colors.js';
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
|
@ -182,13 +184,35 @@ export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
|
||||||
[buffer, agentTabBarFocused, setAgentTabBarFocused],
|
[buffer, agentTabBarFocused, setAgentTabBarFocused],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Message queue (accumulate while streaming, flush as one prompt on idle) ──
|
||||||
|
|
||||||
|
const [messageQueue, setMessageQueue] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// When agent becomes idle (and not terminal), flush queued messages.
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
streamingState === StreamingState.Idle &&
|
||||||
|
messageQueue.length > 0 &&
|
||||||
|
status !== undefined &&
|
||||||
|
!isTerminalStatus(status)
|
||||||
|
) {
|
||||||
|
const combined = messageQueue.join('\n');
|
||||||
|
setMessageQueue([]);
|
||||||
|
interactiveAgent?.enqueueMessage(combined);
|
||||||
|
}
|
||||||
|
}, [streamingState, messageQueue, interactiveAgent, status]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed || !interactiveAgent) return;
|
if (!trimmed || !interactiveAgent) return;
|
||||||
interactiveAgent.enqueueMessage(trimmed);
|
if (streamingState === StreamingState.Idle) {
|
||||||
|
interactiveAgent.enqueueMessage(trimmed);
|
||||||
|
} else {
|
||||||
|
setMessageQueue((prev) => [...prev, trimmed]);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[interactiveAgent],
|
[interactiveAgent, streamingState],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Render ──
|
// ── Render ──
|
||||||
|
|
@ -255,6 +279,8 @@ export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<QueuedMessageDisplay messageQueue={messageQueue} />
|
||||||
|
|
||||||
{/* Input prompt — always visible, like the main Composer */}
|
{/* Input prompt — always visible, like the main Composer */}
|
||||||
<BaseTextInput
|
<BaseTextInput
|
||||||
buffer={buffer}
|
buffer={buffer}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,10 @@ export function ArenaSelectDialog({
|
||||||
mgr.getAgentStates().find((item) => item.agentId === agentId);
|
mgr.getAgentStates().find((item) => item.agentId === agentId);
|
||||||
const label = agent?.model.modelId || agentId;
|
const label = agent?.model.modelId || agentId;
|
||||||
|
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'info',
|
||||||
|
content: `Applying changes from ${label}…`,
|
||||||
|
});
|
||||||
const result = await mgr.applyAgentResult(agentId);
|
const result = await mgr.applyAgentResult(agentId);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
pushMessage({
|
pushMessage({
|
||||||
|
|
@ -111,6 +115,10 @@ export function ArenaSelectDialog({
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'Discarding Arena results and cleaning up…',
|
||||||
|
});
|
||||||
await config.cleanupArenaRuntime(true);
|
await config.cleanupArenaRuntime(true);
|
||||||
pushMessage({
|
pushMessage({
|
||||||
messageType: 'info',
|
messageType: 'info',
|
||||||
|
|
|
||||||
|
|
@ -264,7 +264,11 @@ export function ArenaStatusDialog({
|
||||||
<Text color={theme.status.error}>{failedToolCalls}</Text>
|
<Text color={theme.status.error}>{failedToolCalls}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text color={theme.text.primary}>
|
<Text
|
||||||
|
color={
|
||||||
|
toolCalls > 0 ? theme.status.success : theme.text.primary
|
||||||
|
}
|
||||||
|
>
|
||||||
{pad(String(toolCalls), colTools - 1, 'right')}
|
{pad(String(toolCalls), colTools - 1, 'right')}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -80,9 +80,17 @@ export function ArenaStopDialog({
|
||||||
sessionStatus === ArenaSessionStatus.RUNNING ||
|
sessionStatus === ArenaSessionStatus.RUNNING ||
|
||||||
sessionStatus === ArenaSessionStatus.INITIALIZING
|
sessionStatus === ArenaSessionStatus.INITIALIZING
|
||||||
) {
|
) {
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'Stopping Arena agents…',
|
||||||
|
});
|
||||||
await mgr.cancel();
|
await mgr.cancel();
|
||||||
}
|
}
|
||||||
await mgr.waitForSettled();
|
await mgr.waitForSettled();
|
||||||
|
pushMessage({
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'Cleaning up Arena resources…',
|
||||||
|
});
|
||||||
|
|
||||||
if (action === 'preserve') {
|
if (action === 'preserve') {
|
||||||
await mgr.cleanupRuntime();
|
await mgr.cleanupRuntime();
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ export const SuccessMessage: React.FC<StatusTextProps> = ({ text }) => (
|
||||||
export const WarningMessage: React.FC<StatusTextProps> = ({ text }) => (
|
export const WarningMessage: React.FC<StatusTextProps> = ({ text }) => (
|
||||||
<StatusMessage
|
<StatusMessage
|
||||||
text={text}
|
text={text}
|
||||||
prefix="⚠"
|
prefix="△"
|
||||||
prefixColor={theme.status.warning}
|
prefixColor={theme.status.warning}
|
||||||
textColor={theme.status.warning}
|
textColor={theme.status.warning}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,8 @@ export function useAgentStreamingState(
|
||||||
}, [status, hasPendingApprovals]);
|
}, [status, hasPendingApprovals]);
|
||||||
|
|
||||||
const isInputActive =
|
const isInputActive =
|
||||||
streamingState === StreamingState.Idle &&
|
(streamingState === StreamingState.Idle ||
|
||||||
|
streamingState === StreamingState.Responding) &&
|
||||||
status !== undefined &&
|
status !== undefined &&
|
||||||
!isTerminalStatus(status);
|
!isTerminalStatus(status);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1105,7 +1105,11 @@ export class ArenaManager {
|
||||||
return incoming;
|
return incoming;
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateAgentStatus(agentId: string, newStatus: AgentStatus): void {
|
private updateAgentStatus(
|
||||||
|
agentId: string,
|
||||||
|
newStatus: AgentStatus,
|
||||||
|
options?: { roundCancelledByUser?: boolean },
|
||||||
|
): void {
|
||||||
const agent = this.agents.get(agentId);
|
const agent = this.agents.get(agentId);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1130,7 +1134,11 @@ export class ArenaManager {
|
||||||
previousStatus === AgentStatus.RUNNING &&
|
previousStatus === AgentStatus.RUNNING &&
|
||||||
newStatus === AgentStatus.IDLE
|
newStatus === AgentStatus.IDLE
|
||||||
) {
|
) {
|
||||||
this.emitProgress(`Agent ${label} finished initial task.`, 'success');
|
if (options?.roundCancelledByUser) {
|
||||||
|
this.emitProgress(`Agent ${label} is cancelled by user.`, 'warning');
|
||||||
|
} else {
|
||||||
|
this.emitProgress(`Agent ${label} finished initial task.`, 'success');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit progress messages for follow-up transitions (only after
|
// Emit progress messages for follow-up transitions (only after
|
||||||
|
|
@ -1145,7 +1153,14 @@ export class ArenaManager {
|
||||||
previousStatus === AgentStatus.RUNNING &&
|
previousStatus === AgentStatus.RUNNING &&
|
||||||
newStatus === AgentStatus.IDLE
|
newStatus === AgentStatus.IDLE
|
||||||
) {
|
) {
|
||||||
this.emitProgress(`Agent ${label} finished follow-up task.`, 'success');
|
if (options?.roundCancelledByUser) {
|
||||||
|
this.emitProgress(`Agent ${label} is cancelled by user.`, 'warning');
|
||||||
|
} else {
|
||||||
|
this.emitProgress(
|
||||||
|
`Agent ${label} finished follow-up task.`,
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1317,7 +1332,10 @@ export class ArenaManager {
|
||||||
|
|
||||||
agent.syncStats = syncStats;
|
agent.syncStats = syncStats;
|
||||||
|
|
||||||
const applyStatus = (incoming: AgentStatus) => {
|
const applyStatus = (
|
||||||
|
incoming: AgentStatus,
|
||||||
|
options?: { roundCancelledByUser?: boolean },
|
||||||
|
) => {
|
||||||
const resolved = this.resolveTransition(agent.status, incoming);
|
const resolved = this.resolveTransition(agent.status, incoming);
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
if (resolved === AgentStatus.FAILED) {
|
if (resolved === AgentStatus.FAILED) {
|
||||||
|
|
@ -1327,14 +1345,16 @@ export class ArenaManager {
|
||||||
if (isSettledStatus(resolved)) {
|
if (isSettledStatus(resolved)) {
|
||||||
agent.stats.durationMs = Date.now() - agent.startedAt;
|
agent.stats.durationMs = Date.now() - agent.startedAt;
|
||||||
}
|
}
|
||||||
this.updateAgentStatus(agent.agentId, resolved);
|
this.updateAgentStatus(agent.agentId, resolved, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync stats before mapping so counters are up-to-date even when
|
// Sync stats before mapping so counters are up-to-date even when
|
||||||
// the provider omits usage_metadata events.
|
// the provider omits usage_metadata events.
|
||||||
const onStatusChange = (event: AgentStatusChangeEvent) => {
|
const onStatusChange = (event: AgentStatusChangeEvent) => {
|
||||||
syncStats();
|
syncStats();
|
||||||
applyStatus(event.newStatus);
|
applyStatus(event.newStatus, {
|
||||||
|
roundCancelledByUser: event.roundCancelledByUser,
|
||||||
|
});
|
||||||
// Write status files so external consumers get a consistent
|
// Write status files so external consumers get a consistent
|
||||||
// file-based view regardless of backend mode.
|
// file-based view regardless of backend mode.
|
||||||
this.flushInProcessStatusFiles().catch((err) =>
|
this.flushInProcessStatusFiles().catch((err) =>
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@
|
||||||
|
|
||||||
import { createDebugLogger } from '../../utils/debugLogger.js';
|
import { createDebugLogger } from '../../utils/debugLogger.js';
|
||||||
import type { Config } from '../../config/config.js';
|
import type { Config } from '../../config/config.js';
|
||||||
import { TmuxBackend } from './TmuxBackend.js';
|
// import { TmuxBackend } from './TmuxBackend.js';
|
||||||
import { InProcessBackend } from './InProcessBackend.js';
|
import { InProcessBackend } from './InProcessBackend.js';
|
||||||
import { type Backend, DISPLAY_MODE, type DisplayMode } from './types.js';
|
import { type Backend, DISPLAY_MODE, type DisplayMode } from './types.js';
|
||||||
import { isTmuxAvailable } from './tmux-commands.js';
|
// import { isTmuxAvailable } from './tmux-commands.js';
|
||||||
|
|
||||||
const debugLogger = createDebugLogger('BACKEND_DETECT');
|
const debugLogger = createDebugLogger('BACKEND_DETECT');
|
||||||
|
|
||||||
|
|
@ -35,44 +35,54 @@ export async function detectBackend(
|
||||||
preference: DisplayMode | undefined,
|
preference: DisplayMode | undefined,
|
||||||
runtimeContext: Config,
|
runtimeContext: Config,
|
||||||
): Promise<DetectBackendResult> {
|
): Promise<DetectBackendResult> {
|
||||||
// 1. User explicit preference
|
// Currently only in-process mode is supported. Other backends (tmux,
|
||||||
if (preference === DISPLAY_MODE.IN_PROCESS) {
|
// iterm2) are kept in the codebase but not wired up as entry points.
|
||||||
debugLogger.info('Using InProcessBackend (user preference)');
|
const warning =
|
||||||
return { backend: new InProcessBackend(runtimeContext) };
|
preference && preference !== DISPLAY_MODE.IN_PROCESS
|
||||||
}
|
? `Display mode "${preference}" is not currently supported. Using in-process mode instead.`
|
||||||
|
: undefined;
|
||||||
|
debugLogger.info('Using InProcessBackend');
|
||||||
|
return { backend: new InProcessBackend(runtimeContext), warning };
|
||||||
|
|
||||||
if (preference === DISPLAY_MODE.ITERM2) {
|
// --- Disabled backends (kept for future use) ---
|
||||||
throw new Error(
|
// // 1. User explicit preference
|
||||||
`Arena display mode "${DISPLAY_MODE.ITERM2}" is not implemented yet. Please use "${DISPLAY_MODE.TMUX}" or "${DISPLAY_MODE.IN_PROCESS}".`,
|
// if (preference === DISPLAY_MODE.IN_PROCESS) {
|
||||||
);
|
// debugLogger.info('Using InProcessBackend (user preference)');
|
||||||
}
|
// return { backend: new InProcessBackend(runtimeContext) };
|
||||||
|
// }
|
||||||
if (preference === DISPLAY_MODE.TMUX) {
|
//
|
||||||
debugLogger.info('Using TmuxBackend (user preference)');
|
// if (preference === DISPLAY_MODE.ITERM2) {
|
||||||
return { backend: new TmuxBackend() };
|
// throw new Error(
|
||||||
}
|
// `Arena display mode "${DISPLAY_MODE.ITERM2}" is not implemented yet. Please use "${DISPLAY_MODE.TMUX}" or "${DISPLAY_MODE.IN_PROCESS}".`,
|
||||||
|
// );
|
||||||
// 2. Auto-detect
|
// }
|
||||||
if (process.env['TMUX']) {
|
//
|
||||||
debugLogger.info('Detected $TMUX — attempting TmuxBackend');
|
// if (preference === DISPLAY_MODE.TMUX) {
|
||||||
return { backend: new TmuxBackend() };
|
// debugLogger.info('Using TmuxBackend (user preference)');
|
||||||
}
|
// return { backend: new TmuxBackend() };
|
||||||
|
// }
|
||||||
// Other terminals (including iTerm2): use tmux external session mode if available.
|
//
|
||||||
if (isTmuxAvailable()) {
|
// // 2. Auto-detect
|
||||||
debugLogger.info(
|
// if (process.env['TMUX']) {
|
||||||
'tmux is available — using TmuxBackend external session mode',
|
// debugLogger.info('Detected $TMUX — attempting TmuxBackend');
|
||||||
);
|
// return { backend: new TmuxBackend() };
|
||||||
return { backend: new TmuxBackend() };
|
// }
|
||||||
}
|
//
|
||||||
|
// // Other terminals (including iTerm2): use tmux external session mode if available.
|
||||||
// Fallback: use InProcessBackend
|
// if (isTmuxAvailable()) {
|
||||||
debugLogger.info(
|
// debugLogger.info(
|
||||||
'No PTY backend available — falling back to InProcessBackend',
|
// 'tmux is available — using TmuxBackend external session mode',
|
||||||
);
|
// );
|
||||||
return {
|
// return { backend: new TmuxBackend() };
|
||||||
backend: new InProcessBackend(runtimeContext),
|
// }
|
||||||
warning:
|
//
|
||||||
'tmux is not available. Using in-process mode (no split-pane terminal view).',
|
// // Fallback: use InProcessBackend
|
||||||
};
|
// debugLogger.info(
|
||||||
|
// 'No PTY backend available — falling back to InProcessBackend',
|
||||||
|
// );
|
||||||
|
// return {
|
||||||
|
// backend: new InProcessBackend(runtimeContext),
|
||||||
|
// warning:
|
||||||
|
// 'tmux is not available. Using in-process mode (no split-pane terminal view).',
|
||||||
|
// };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,8 @@ export interface AgentStatusChangeEvent {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
previousStatus: AgentStatus;
|
previousStatus: AgentStatus;
|
||||||
newStatus: AgentStatus;
|
newStatus: AgentStatus;
|
||||||
|
/** True when the transition to IDLE was caused by user cancelling the round. */
|
||||||
|
roundCancelledByUser?: boolean;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,7 @@ describe('AgentInteractive', () => {
|
||||||
resolveLoop!();
|
resolveLoop!();
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(agent.getStatus()).toBe('failed');
|
expect(agent.getStatus()).toBe('idle');
|
||||||
});
|
});
|
||||||
|
|
||||||
await agent.shutdown();
|
await agent.shutdown();
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,10 @@ import type { AgentCore } from './agent-core.js';
|
||||||
import type { ContextState } from './agent-headless.js';
|
import type { ContextState } from './agent-headless.js';
|
||||||
import type { GeminiChat } from '../../core/geminiChat.js';
|
import type { GeminiChat } from '../../core/geminiChat.js';
|
||||||
import type { FunctionDeclaration } from '@google/genai';
|
import type { FunctionDeclaration } from '@google/genai';
|
||||||
import type {
|
import {
|
||||||
ToolCallConfirmationDetails,
|
ToolConfirmationOutcome,
|
||||||
ToolResultDisplay,
|
type ToolCallConfirmationDetails,
|
||||||
|
type ToolResultDisplay,
|
||||||
} from '../../tools/tools.js';
|
} from '../../tools/tools.js';
|
||||||
import { AsyncMessageQueue } from '../../utils/asyncMessageQueue.js';
|
import { AsyncMessageQueue } from '../../utils/asyncMessageQueue.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -64,6 +65,7 @@ export class AgentInteractive {
|
||||||
private chat: GeminiChat | undefined;
|
private chat: GeminiChat | undefined;
|
||||||
private toolsList: FunctionDeclaration[] = [];
|
private toolsList: FunctionDeclaration[] = [];
|
||||||
private processing = false;
|
private processing = false;
|
||||||
|
private roundCancelledByUser = false;
|
||||||
|
|
||||||
// Pending tool approval requests. Keyed by callId.
|
// Pending tool approval requests. Keyed by callId.
|
||||||
// Populated by TOOL_WAITING_APPROVAL, removed by TOOL_RESULT or when
|
// Populated by TOOL_WAITING_APPROVAL, removed by TOOL_RESULT or when
|
||||||
|
|
@ -161,6 +163,7 @@ export class AgentInteractive {
|
||||||
|
|
||||||
this.setStatus(AgentStatus.RUNNING);
|
this.setStatus(AgentStatus.RUNNING);
|
||||||
this.lastRoundError = undefined;
|
this.lastRoundError = undefined;
|
||||||
|
this.roundCancelledByUser = false;
|
||||||
this.roundAbortController = new AbortController();
|
this.roundAbortController = new AbortController();
|
||||||
|
|
||||||
// Propagate master abort to round
|
// Propagate master abort to round
|
||||||
|
|
@ -199,6 +202,8 @@ export class AgentInteractive {
|
||||||
this.lastRoundError = `Terminated: ${result.terminateMode}`;
|
this.lastRoundError = `Terminated: ${result.terminateMode}`;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// User-initiated cancellation already logged by cancelCurrentRound().
|
||||||
|
if (this.roundCancelledByUser) return;
|
||||||
// Agent survives round errors — log and settle status in runLoop.
|
// Agent survives round errors — log and settle status in runLoop.
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
this.lastRoundError = errorMessage;
|
this.lastRoundError = errorMessage;
|
||||||
|
|
@ -220,6 +225,7 @@ export class AgentInteractive {
|
||||||
* Adds a visible "cancelled" info message and clears pending approvals.
|
* Adds a visible "cancelled" info message and clears pending approvals.
|
||||||
*/
|
*/
|
||||||
cancelCurrentRound(): void {
|
cancelCurrentRound(): void {
|
||||||
|
this.roundCancelledByUser = true;
|
||||||
this.roundAbortController?.abort();
|
this.roundAbortController?.abort();
|
||||||
this.pendingApprovals.clear();
|
this.pendingApprovals.clear();
|
||||||
this.addMessage('info', 'Agent round cancelled.', {
|
this.addMessage('info', 'Agent round cancelled.', {
|
||||||
|
|
@ -344,7 +350,7 @@ export class AgentInteractive {
|
||||||
* On error → FAILED (terminal).
|
* On error → FAILED (terminal).
|
||||||
*/
|
*/
|
||||||
private settleRoundStatus(): void {
|
private settleRoundStatus(): void {
|
||||||
if (this.lastRoundError) {
|
if (this.lastRoundError && !this.roundCancelledByUser) {
|
||||||
this.setStatus(AgentStatus.FAILED);
|
this.setStatus(AgentStatus.FAILED);
|
||||||
} else {
|
} else {
|
||||||
this.setStatus(AgentStatus.IDLE);
|
this.setStatus(AgentStatus.IDLE);
|
||||||
|
|
@ -361,6 +367,7 @@ export class AgentInteractive {
|
||||||
agentId: this.config.agentId,
|
agentId: this.config.agentId,
|
||||||
previousStatus,
|
previousStatus,
|
||||||
newStatus,
|
newStatus,
|
||||||
|
roundCancelledByUser: this.roundCancelledByUser || undefined,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -462,6 +469,11 @@ export class AgentInteractive {
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
} as AgentToolOutputUpdateEvent);
|
} as AgentToolOutputUpdateEvent);
|
||||||
await event.respond(outcome, payload);
|
await event.respond(outcome, payload);
|
||||||
|
// When the user denies a tool, cancel the round immediately
|
||||||
|
// so the agent doesn't waste a turn "acknowledging" the denial.
|
||||||
|
if (outcome === ToolConfirmationOutcome.Cancel) {
|
||||||
|
this.cancelCurrentRound();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
} as ToolCallConfirmationDetails;
|
} as ToolCallConfirmationDetails;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue