Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 160 additions & 1 deletion src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele
import { IViewsService } from '../../../../services/views/common/viewsService.js';
import { IsSessionsWindowContext } from '../../../../common/contextkeys.js';
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
import { getModeNameForTelemetry, IChatMode, IChatModeService } from '../../common/chatModes.js';
import { getModeNameForTelemetry, buildCustomAgentHandoffsInfo, getHandoffId, IChatMode, IChatModeService } from '../../common/chatModes.js';
import { chatVariableLeader } from '../../common/requestParser/chatParserTypes.js';
import { ChatStopCancellationNoopClassification, ChatStopCancellationNoopEvent, ChatStopCancellationNoopEventName, IChatService } from '../../common/chatService/chatService.js';
import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js';
Expand Down Expand Up @@ -1022,6 +1022,163 @@ export class CancelEdit extends Action2 {
}
}

// --- Handoff Discovery & Execution Commands ---

export const GetHandoffsActionId = 'workbench.action.chat.getHandoffs';

interface IGetHandoffsArgs {
/**
* Name of the custom agent (defined in an `.agent.md` file) whose handoffs
* you want to retrieve. If omitted, all
* handoffs from all agents and built-in modes are returned.
*/
sourceCustomAgent?: string;
}

/**
* Discovers the handoffs available across custom agents (and built-in modes).
*
* **Return value**: `ICustomAgentInfo[]` — an array where each element
* represents an agent/mode with its `id`, `name`, `isBuiltin`,
* `visibility`, and `handoffs` list.
*
* @see ICustomAgentInfo
* @see IHandoffInfo
*/
class GetHandoffsAction extends Action2 {

static readonly ID = GetHandoffsActionId;

constructor() {
super({
id: GetHandoffsAction.ID,
title: localize2('chat.getHandoffs.label', "Get Handoffs"),
f1: false,
category: CHAT_CATEGORY,
});
}

run(accessor: ServicesAccessor, ...args: unknown[]) {
const modeService = accessor.get(IChatModeService);
const arg = args.at(0) as IGetHandoffsArgs | undefined;

const { builtin, custom } = modeService.getModes();
let allModes: readonly IChatMode[] = [...builtin, ...custom];

if (arg?.sourceCustomAgent) {
const filterName = arg.sourceCustomAgent;
allModes = allModes.filter(m => m.name.get().toLowerCase() === filterName.toLowerCase());
}

return buildCustomAgentHandoffsInfo(allModes);
}
}

export const ExecuteHandoffActionId = 'workbench.action.chat.executeHandoff';

interface IExecuteHandoffArgs {
/**
* The stable handoff ID (from getHandoffs). Primary match key.
* IDs are unique within a given source agent; when handoffs from
* multiple source agents share the same target+label, also provide
* `sourceCustomAgent` to disambiguate.
*/
id?: string;
/** Fallback: handoff label to match. Case-insensitive. */
label?: string;
Comment thread
eleanorjboyd marked this conversation as resolved.
/**
* The chat session URI identifying which chat widget to execute in.
* If omitted, falls back to the last-focused chat widget.
*/
sessionResource?: string;
/**
* Name of the *source* custom agent (from `.agent.md`) that declares the handoff to
* execute. If omitted, falls back to the session's currently active mode/agent.
*/
sourceCustomAgent?: string;
}

interface IExecuteHandoffResult {
success: boolean;
targetMode?: string;
error?: string;
}

class ExecuteHandoffAction extends Action2 {

static readonly ID = ExecuteHandoffActionId;

constructor() {
super({
id: ExecuteHandoffAction.ID,
title: localize2('chat.executeHandoff.label', "Execute Handoff"),
f1: false,
category: CHAT_CATEGORY,
});
}

async run(accessor: ServicesAccessor, ...args: unknown[]): Promise<IExecuteHandoffResult> {
const chatWidgetService = accessor.get(IChatWidgetService);
const modeService = accessor.get(IChatModeService);

const arg = args.at(0) as IExecuteHandoffArgs | undefined;
if (!arg?.id && !arg?.label) {
return { success: false, error: 'Either id or label is required' };
}

// Resolve the target widget: explicit sessionResource, or fall back to last-focused
let widget: IChatWidget | undefined;
if (arg.sessionResource) {
let sessionResource;
try {
sessionResource = URI.parse(arg.sessionResource);
} catch {
return { success: false, error: `Invalid sessionResource URI: '${arg.sessionResource}'` };
}
widget = chatWidgetService.getWidgetBySessionResource(sessionResource);
} else {
widget = chatWidgetService.lastFocusedWidget;
}
if (!widget) {
return { success: false, error: 'No chat widget found. Provide sessionResource or focus a chat widget.' };
}

// Resolve the source custom agent whose handoffs we search (case-insensitive)
let sourceMode: IChatMode | undefined;
if (arg.sourceCustomAgent) {
const filterName = arg.sourceCustomAgent.toLowerCase();
const { builtin, custom } = modeService.getModes();
sourceMode = [...builtin, ...custom].find(m => m.name.get().toLowerCase() === filterName || m.id.toLowerCase() === filterName);
}
if (!sourceMode) {
sourceMode = widget.input.currentModeObs.get();
}

const handoffs = sourceMode?.handOffs?.get();
if (!handoffs || handoffs.length === 0) {
return { success: false, error: `No handoffs available for mode '${sourceMode?.name.get()}'` };
}

// Match by id first, then by label
let matchedHandoff = arg.id
? handoffs.find(h => getHandoffId(h) === arg.id)
: undefined;

if (!matchedHandoff && arg.label) {
const labelLower = arg.label.trim().toLowerCase();
matchedHandoff = handoffs.find(h => h.label.trim().toLowerCase() === labelLower);
}

if (!matchedHandoff) {
const identifier = arg.id ?? arg.label;
return { success: false, error: `No handoff with identifier '${identifier}' found for mode '${sourceMode?.name.get()}'` };
}

await widget.executeHandoff(matchedHandoff);
return { success: true, targetMode: matchedHandoff.agent };
}
}


export function registerChatExecuteActions() {
registerAction2(ChatSubmitAction);
Expand All @@ -1041,4 +1198,6 @@ export function registerChatExecuteActions() {
registerAction2(ChatSessionPrimaryPickerAction);
registerAction2(ChangeChatModelAction);
registerAction2(CancelEdit);
registerAction2(GetHandoffsAction);
registerAction2(ExecuteHandoffAction);
}
2 changes: 2 additions & 0 deletions src/vs/workbench/contrib/chat/browser/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { IChatAgentAttachmentCapabilities, IChatAgentCommand, IChatAgentData } f
import { IChatResponseModel, IChatModelInputState } from '../common/model/chatModel.js';
import { IChatMode } from '../common/chatModes.js';
import { IParsedChatRequest } from '../common/requestParser/chatParserTypes.js';
import { IHandOff } from '../common/promptSyntax/promptFileParser.js';
import { CHAT_PROVIDER_ID } from '../common/participants/chatParticipantContribTypes.js';
import { ChatRequestQueueKind, IChatElicitationRequest, IChatLocationData, IChatSendRequestOptions } from '../common/chatService/chatService.js';
import { IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatPendingDividerViewModel } from '../common/model/chatViewModel.js';
Expand Down Expand Up @@ -437,6 +438,7 @@ export interface IChatWidget {
lockToCodingAgent(name: string, displayName: string, agentId?: string): void;
unlockFromCodingAgent(): void;
handleDelegationExitIfNeeded(sourceAgent: Pick<IChatAgentData, 'id' | 'name'> | undefined, targetAgent: IChatAgentData | undefined): Promise<void>;
executeHandoff(handoff: IHandOff, agentId?: string): Promise<void>;

delegateScrollFromMouseWheelEvent(event: IMouseWheelEvent): void;
}
Expand Down
13 changes: 12 additions & 1 deletion src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1264,14 +1264,25 @@ export class ChatWidget extends Disposable implements IChatWidget {
autoSend: Boolean(handoff.send)
});

this.executeHandoff(handoff, agentId).catch(e => {
const target = agentId ?? handoff.agent ?? 'unknown';
this.logService.error(`[Handoff] Failed to execute handoff '${handoff.label}' to '${target}'`, e);
});
Comment thread
eleanorjboyd marked this conversation as resolved.
}

async executeHandoff(handoff: IHandOff, agentId?: string): Promise<void> {
this.chatSuggestNextWidget.hide();

const promptToUse = handoff.prompt;

// If agentId is provided (from chevron dropdown), delegate to that chat session
// Otherwise, switch to the handoff agent
if (agentId) {
// Delegate to chat session (e.g., @background or @cloud)
this.input.setValue(`@${agentId} ${promptToUse}`, false);
this.input.focus();
// Auto-submit for delegated chat sessions
this.acceptInput().catch(e => this.logService.error('Failed to handle handoff continueOn', e));
this.acceptInput().catch(e => this.logService.error(`[Handoff] Failed to submit delegated handoff to '@${agentId}'`, e));
} else if (handoff.agent) {
// Regular handoff to specified agent
this._switchToAgentByName(handoff.agent);
Expand Down
79 changes: 79 additions & 0 deletions src/vs/workbench/contrib/chat/common/chatModes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,3 +563,82 @@ export function getModeNameForTelemetry(mode: IChatMode): string {
}
return mode.name.get();
}

/**
* Generates a stable identifier for a handoff by combining the target agent
* name with a slugified version of the display label.
*
* Within a single source agent, the combination of `agent` + `label` must be
* unique for IDs to be unambiguous.
*
* @example
* ```
* getHandoffId({ agent: 'agent', label: 'Continue', prompt: '...' })
* // => 'agent:continue'
* ```
*/
export function getHandoffId(handoff: IHandOff): string {
const slug = handoff.label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
Comment thread
eleanorjboyd marked this conversation as resolved.
return `${handoff.agent}:${slug}`;
}
Comment thread
eleanorjboyd marked this conversation as resolved.

/**
* Describes a single handoff defined in a custom agent's `.agent.md` file.
*/
export interface IHandoffInfo {
/** Stable identifier for programmatic matching (format: `<agent>:<slugified-label>`). */
readonly id: string;
readonly label: string;
readonly agent: string;
readonly prompt: string;
readonly send?: boolean;
readonly showContinueOn?: boolean;
readonly model?: string;
}

/**
* Describes a custom agent (or built-in mode) and the handoffs it defines.
*/
export interface ICustomAgentInfo {
readonly id: string;
readonly name: string;
readonly isBuiltin: boolean;
readonly visibility: {
readonly userInvocable: boolean;
readonly agentInvocable: boolean;
};
readonly handoffs: IHandoffInfo[];
}

/**
* Builds an array of {@link ICustomAgentInfo} with handoff metadata for the given agents/modes.
*
* @param modes - The set of agents/modes to include. Pass all modes to get a
* complete picture, or a filtered subset to scope the result.
* @returns One entry per agent/mode, each containing the agent's metadata and
* its declared handoffs.
*/
export function buildCustomAgentHandoffsInfo(modes: readonly IChatMode[]): ICustomAgentInfo[] {
return modes.map(mode => {
const handoffs = mode.handOffs?.get() ?? [];
const visibility = mode.visibility?.get();
return {
id: mode.id,
name: mode.name.get(),
isBuiltin: mode.isBuiltin,
visibility: {
userInvocable: visibility?.userInvocable ?? true,
agentInvocable: visibility?.agentInvocable ?? true,
},
handoffs: handoffs.map(h => ({
id: getHandoffId(h),
label: h.label,
agent: h.agent,
prompt: h.prompt,
...(h.send !== undefined ? { send: h.send } : {}),
...(h.showContinueOn !== undefined ? { showContinueOn: h.showContinueOn } : {}),
...(h.model !== undefined ? { model: h.model } : {}),
})),
};
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,7 @@ export class PromptValidator {
report(toMarker(localize('promptValidator.handoffsMustBeArray', "The 'handoffs' attribute must be an array."), attribute.value.range, MarkerSeverity.Error));
return;
}
const seenLabels = new Map<string, Range>();
for (const item of attribute.value.items) {
if (item.type !== 'map') {
report(toMarker(localize('promptValidator.eachHandoffMustBeObject', "Each handoff in the 'handoffs' attribute must be an object with 'label', 'agent', 'prompt' and optional 'send'."), item.range, MarkerSeverity.Error));
Expand All @@ -695,6 +696,8 @@ export class PromptValidator {
case 'label':
if (prop.value.type !== 'scalar' || prop.value.value.trim().length === 0) {
report(toMarker(localize('promptValidator.handoffLabelMustBeNonEmptyString', "The 'label' property in a handoff must be a non-empty string."), prop.value.range, MarkerSeverity.Error));
} else if (!/[a-zA-Z0-9]/.test(prop.value.value)) {
report(toMarker(localize('promptValidator.handoffLabelMustContainAlphanumeric', "The 'label' property in a handoff must contain at least one alphanumeric character."), prop.value.range, MarkerSeverity.Error));
}
break;
case 'agent':
Expand Down Expand Up @@ -732,6 +735,17 @@ export class PromptValidator {
if (required.size > 0) {
report(toMarker(localize('promptValidator.missingHandoffProperties', "Missing required properties {0} in handoff object.", Array.from(required).map(s => `'${s}'`).join(', ')), item.range, MarkerSeverity.Error));
}

// Detect duplicate labels (case-insensitive, consistent with ExecuteHandoffAction lookup)
const labelProp = item.properties.find(p => p.key.value === 'label');
if (labelProp?.value.type === 'scalar') {
const normalizedLabel = labelProp.value.value.toLowerCase();
if (normalizedLabel && seenLabels.has(normalizedLabel)) {
report(toMarker(localize('promptValidator.duplicateHandoffLabel', "Duplicate handoff label '{0}'. Each handoff must have a unique label.", labelProp.value.value), labelProp.value.range, MarkerSeverity.Error));
} else if (normalizedLabel) {
seenLabels.set(normalizedLabel, labelProp.value.range);
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ export class PromptHeader {
model = prop.value.value;
}
}
if (agent && label && prompt !== undefined) {
if (agent && label?.trim() && prompt !== undefined) {
const handoff: IHandOff = {
agent,
label,
Expand Down
Loading
Loading