mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-17 12:26:43 +01:00
* Add send_message server tool Adds a send_message tool that starts a new turn on an existing session or chat, reusing the same startPrompt path create_session/create_chat use to deliver their first message. Accepts a list_sessions URI or an agent-host-session:// link; a create_chat link (with ?chat=) targets that specific peer chat, otherwise the session's default chat. Requires confirmation, refuses messaging the current chat channel (self-loop guard), and has a process-wide fan-out backstop. Renders the same 'Open Session' pill (conversation icon when chat-scoped) and is unpinned from the thinking group like the other session tools. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add isSendMessageTool unit test for prefix matching Addresses PR review: cover the bare and mcp__server__-prefixed name paths for isSendMessageTool, mirroring the existing isCreateSessionTool/isCreateChatTool tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: add get_session_context tool + list_sessions single-session lookup (#325158) Add get_session_context tool and list_sessions single-session lookup Adds a read-only get_session_context server tool that returns a compacted transcript of an existing session/chat's recent turns, read directly from live chat state. Three fidelity levels: summary (per-turn message + short reply gist), digest (full reply text + tool-call names), full (+ tool-call inputs); bounded by transcriptLimit (default 10, max 50) with hasMoreHistory/truncated flags and the in-progress turn included. Deterministic only, no plan/todo heuristics. Targets a session's default chat, or a specific peer chat via a create_chat open link. Also extends list_sessions with an optional session argument for a direct single-session lookup by URI / open link (bypassing the other filters), so session metadata stays with list_sessions while get_session_context focuses purely on conversation data. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1075 lines
50 KiB
TypeScript
1075 lines
50 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import { URI } from '../../../../base/common/uri.js';
|
|
import type { Mutable } from '../../../../base/common/types.js';
|
|
import { localize } from '../../../../nls.js';
|
|
import type { IAgentCreateSessionConfig, IAgentModelInfo, IAgentSessionMetadata } from '../../common/agentService.js';
|
|
import { SessionStatus } from '../../common/state/protocol/channels-session/state.js';
|
|
import { buildChatUri, buildDefaultChatUri, parseChatUri, readSessionGitState, readSessionGitHubState, ResponsePartKind, ToolCallStatus, TurnState, type Message, type ResponsePart, type ToolCallState, type ToolDefinition, type StringOrMarkdown, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js';
|
|
import { buildOpenSessionLinkUri, CREATE_CHAT_TOOL_NAME, CREATE_SESSION_TOOL_NAME, parseOpenSessionLinkChatId, parseOpenSessionLinkUri, SEND_MESSAGE_TOOL_NAME } from '../../common/openSessionLink.js';
|
|
import { generateUuid } from '../../../../base/common/uuid.js';
|
|
import type { AgentHostStateManager } from '../agentHostStateManager.js';
|
|
import type { IServerToolDisplay, IServerToolDisplayResult, IServerToolGroup } from './agentServerToolHost.js';
|
|
|
|
export const listSessionsToolName = 'list_sessions';
|
|
export const getCurrentSessionToolName = 'get_current_session';
|
|
export const createSessionToolName = CREATE_SESSION_TOOL_NAME;
|
|
export const createChatToolName = CREATE_CHAT_TOOL_NAME;
|
|
export const sendMessageToolName = SEND_MESSAGE_TOOL_NAME;
|
|
export const getSessionContextToolName = 'get_session_context';
|
|
export const deleteSessionToolName = 'delete_session';
|
|
|
|
/**
|
|
* Maximum `create_session` recursion depth. A user/top-level session is depth 0;
|
|
* a session created by `create_session` from within a depth-N session is depth
|
|
* N+1. Once a session reaches this depth, its agent may not create further
|
|
* sessions — this bounds recursive spawn *chains* (A→B→C→…). Breadth is bounded
|
|
* separately by {@link maxCreatedSessions} plus the per-call user confirmation.
|
|
*/
|
|
const maxSessionSpawnDepth = 3;
|
|
|
|
/** Process-wide backstop against runaway spawning (breadth), independent of depth. */
|
|
const maxCreatedSessions = 25;
|
|
const maxCreatedChats = 25;
|
|
|
|
/** Process-wide backstop against runaway `send_message` fan-out. */
|
|
const maxSentMessages = 50;
|
|
|
|
const sessionConfirmationToolNames: ReadonlySet<string> = new Set([createSessionToolName, createChatToolName, sendMessageToolName, deleteSessionToolName]);
|
|
|
|
/** Whether the given session server tool requires user confirmation before it runs. */
|
|
export function sessionToolRequiresConfirmation(toolName: string): boolean {
|
|
return sessionConfirmationToolNames.has(toolName);
|
|
}
|
|
|
|
const listSessionsStatusValues = ['idle', 'inProgress', 'inputNeeded', 'error', 'archived'] as const;
|
|
|
|
const listSessionsInputSchema: ToolDefinition['inputSchema'] = {
|
|
type: 'object',
|
|
properties: {
|
|
session: { type: 'string', description: 'Return only the session with this URI or `agent-host-session://` link (a direct lookup that ignores the other filters). Use this to fetch one known session\'s metadata.' },
|
|
status: {
|
|
type: 'array',
|
|
items: { type: 'string', enum: [...listSessionsStatusValues] },
|
|
description: 'Only return sessions whose status matches one of these (e.g. `inputNeeded` for sessions awaiting a reply, `inProgress` for running ones). Omit to return every status.',
|
|
},
|
|
workspace: { type: 'string', description: 'Only return sessions whose working directory is this folder — an absolute path or a workspace URI.' },
|
|
withChanges: { type: 'boolean', description: 'When true, only return sessions that have pending worktree changes.' },
|
|
unread: { type: 'boolean', description: 'When true, only return sessions with updates the user has not seen yet.' },
|
|
withPullRequest: { type: 'boolean', description: 'When true, only return sessions that have a linked GitHub pull request.' },
|
|
includeArchived: { type: 'boolean', description: 'Whether to include archived sessions. Defaults to false; set true to also return archived sessions.' },
|
|
createdAfter: { type: 'string', description: 'Only return sessions created at or after this time (ISO-8601 timestamp, e.g. `2025-01-31T00:00:00Z`).' },
|
|
createdBefore: { type: 'string', description: 'Only return sessions created at or before this time (ISO-8601 timestamp).' },
|
|
},
|
|
};
|
|
|
|
const createSessionInputSchema: ToolDefinition['inputSchema'] = {
|
|
type: 'object',
|
|
properties: {
|
|
workspace: { type: 'string', description: 'Absolute folder path, workspace URI, or a working directory from an existing session.' },
|
|
prompt: { type: 'string', description: 'Initial prompt to send to the new session.' },
|
|
model: { type: 'string', description: 'Optional model ID or display name.' },
|
|
},
|
|
required: ['workspace', 'prompt'],
|
|
};
|
|
|
|
const getCurrentSessionInputSchema: ToolDefinition['inputSchema'] = {
|
|
type: 'object',
|
|
properties: {},
|
|
};
|
|
|
|
const createChatInputSchema: ToolDefinition['inputSchema'] = {
|
|
type: 'object',
|
|
properties: {
|
|
session: { type: 'string', description: 'Optional session to add the chat to: a session URI from `list_sessions` or an `agent-host-session://` link. Defaults to the current session when omitted.' },
|
|
prompt: { type: 'string', description: 'Initial prompt to send to the new chat.' },
|
|
title: { type: 'string', description: 'Optional title for the new chat.' },
|
|
model: { type: 'string', description: 'Optional model ID or display name. Defaults to the session\'s model.' },
|
|
},
|
|
required: ['prompt'],
|
|
};
|
|
|
|
const deleteSessionInputSchema: ToolDefinition['inputSchema'] = {
|
|
type: 'object',
|
|
properties: {
|
|
session: { type: 'string', description: 'The session to delete: a session URI from `list_sessions` or an `agent-host-session://` link (e.g. from `create_session`).' },
|
|
},
|
|
required: ['session'],
|
|
};
|
|
|
|
const sendMessageInputSchema: ToolDefinition['inputSchema'] = {
|
|
type: 'object',
|
|
properties: {
|
|
session: { type: 'string', description: 'The session or chat to message: a session URI from `list_sessions`, or an `agent-host-session://` link (from `create_session`/`create_chat`; a `create_chat` link targets that specific chat).' },
|
|
message: { type: 'string', description: 'The message to send.' },
|
|
},
|
|
required: ['session', 'message'],
|
|
};
|
|
|
|
const sessionContextDetailValues = ['summary', 'digest', 'full'] as const;
|
|
|
|
const getSessionContextInputSchema: ToolDefinition['inputSchema'] = {
|
|
type: 'object',
|
|
properties: {
|
|
session: { type: 'string', description: 'The session or chat to read: a session URI from `list_sessions`, or an `agent-host-session://` link (a `create_chat` link targets that specific chat).' },
|
|
detail: {
|
|
type: 'string',
|
|
enum: [...sessionContextDetailValues],
|
|
description: 'How much conversation detail to return. `summary` (default): status and a short per-turn gist (the message plus a compact snippet of the reply). `digest`: adds the full assistant reply text and tool-call names. `full`: adds tool-call inputs. Higher levels return more tokens.',
|
|
},
|
|
transcriptLimit: { type: 'number', description: 'Maximum number of most-recent turns to include. Defaults to 10; capped at 50.' },
|
|
},
|
|
required: ['session'],
|
|
};
|
|
|
|
/** Protocol tool definitions for the session-management server tools. */
|
|
export const sessionServerToolDefinitions: ToolDefinition[] = [
|
|
{
|
|
name: listSessionsToolName,
|
|
title: 'List Sessions',
|
|
description: 'List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.',
|
|
inputSchema: listSessionsInputSchema,
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: getCurrentSessionToolName,
|
|
title: 'Get Current Session',
|
|
description: 'Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).',
|
|
inputSchema: getCurrentSessionInputSchema,
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: createSessionToolName,
|
|
title: 'Create Session',
|
|
description: 'Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button.',
|
|
inputSchema: createSessionInputSchema,
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
{
|
|
name: createChatToolName,
|
|
title: 'Create Chat',
|
|
description: 'Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the session\'s model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button.',
|
|
inputSchema: createChatInputSchema,
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
{
|
|
name: sendMessageToolName,
|
|
title: 'Send Message',
|
|
description: 'Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link (a `create_chat` link targets that specific chat). The message is delivered asynchronously — this tool does not wait for or return the reply. The UI shows a confirmation with a button to open the target, so reply with a single short sentence and do NOT print the URL or tell the user to click a button.',
|
|
inputSchema: sendMessageInputSchema,
|
|
annotations: { readOnlyHint: false },
|
|
},
|
|
{
|
|
name: getSessionContextToolName,
|
|
title: 'Get Session Context',
|
|
description: 'Read the recent conversation of an existing session or chat: a compacted transcript of its turns (messages, replies, and tool calls). Use this to see what a session you created is doing, or to gather context before sending it a message. Returns a compacted summary by default (`detail: "summary"`); request `digest` or `full` for more detail. For session metadata (status, working directory, changes, …) use `list_sessions` with the `session` argument.',
|
|
inputSchema: getSessionContextInputSchema,
|
|
annotations: { readOnlyHint: true },
|
|
},
|
|
{
|
|
name: deleteSessionToolName,
|
|
title: 'Delete Session',
|
|
description: 'Permanently delete a session (identified by a session URI from `list_sessions`), including its stored data. This cannot be undone. Refuses to delete the current session.',
|
|
inputSchema: deleteSessionInputSchema,
|
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
},
|
|
];
|
|
|
|
/** Resolves the owning backend session URI for the channel a tool call runs on. */
|
|
export function currentSessionUri(toolCallChannel: ProtocolURI): URI {
|
|
const owning = parseChatUri(toolCallChannel) ?? undefined;
|
|
return URI.parse(owning?.session ?? toolCallChannel);
|
|
}
|
|
|
|
interface ICreateSessionArgs {
|
|
readonly workspace?: unknown;
|
|
readonly prompt?: unknown;
|
|
readonly model?: unknown;
|
|
}
|
|
|
|
export interface IResolvedCreateSessionArgs {
|
|
readonly workspace: URI;
|
|
readonly prompt: string;
|
|
readonly model?: IAgentModelInfo;
|
|
}
|
|
|
|
/** Minimal dependency surface needed by the session server-tool group. */
|
|
export interface ISessionServerToolAccessor {
|
|
readonly listSessions: () => Promise<readonly IAgentSessionMetadata[]>;
|
|
readonly createSession: (config: IAgentCreateSessionConfig) => Promise<URI>;
|
|
readonly getModels: () => readonly IAgentModelInfo[];
|
|
readonly startPrompt: (session: URI, chat: URI, prompt: string) => Promise<void>;
|
|
readonly createChat: (session: URI, chat: URI, options?: { title?: string; model?: IAgentModelInfo }) => Promise<void>;
|
|
readonly deleteSession: (session: URI) => Promise<void>;
|
|
/** Reads a point-in-time snapshot of a session's chat conversation (default chat, or a specific chat by id). */
|
|
readonly getChatContext: (session: URI, chatId?: string) => IChatContextSnapshot | undefined;
|
|
/** The spawn depth of a session (0 for a user/top-level session, N for one created N levels deep by `create_session`). */
|
|
readonly getSessionSpawnDepth: (session: URI) => number;
|
|
/** Records the spawn depth of a freshly-created session so its own `create_session` calls can enforce the recursion limit. */
|
|
readonly setSessionSpawnDepth: (session: URI, depth: number) => void;
|
|
}
|
|
|
|
/** Point-in-time snapshot of a chat's conversation, read from the host state. */
|
|
export interface IChatContextSnapshot {
|
|
/** Completed turns, oldest first. */
|
|
readonly turns: readonly Turn[];
|
|
/** The in-progress turn, if the chat is mid-response. */
|
|
readonly activeTurn?: Pick<Turn, 'message' | 'responseParts'>;
|
|
/** `true` when older completed turns exist beyond the in-memory window. */
|
|
readonly hasMoreHistory: boolean;
|
|
}
|
|
|
|
interface ISerializedGitState {
|
|
readonly branch?: string;
|
|
readonly baseBranch?: string;
|
|
readonly upstreamBranch?: string;
|
|
readonly ahead?: number;
|
|
readonly behind?: number;
|
|
readonly uncommittedChanges?: number;
|
|
}
|
|
|
|
interface ISerializedGitHubState {
|
|
readonly owner?: string;
|
|
readonly repo?: string;
|
|
readonly pullRequestUrl?: string;
|
|
}
|
|
|
|
interface ISerializedSession {
|
|
readonly session: string;
|
|
readonly title?: string;
|
|
readonly status?: string;
|
|
/** Human-readable description of what the session is currently doing. */
|
|
readonly activity?: string;
|
|
readonly workingDirectory?: string;
|
|
/** Display name of the session's project/workspace. */
|
|
readonly project?: string;
|
|
/** `true` when the session has updates the user has not yet seen. */
|
|
readonly unread?: boolean;
|
|
/** ISO-8601 timestamp of when the session was created. */
|
|
readonly createdAt?: string;
|
|
/** ISO-8601 timestamp of the session's last activity. */
|
|
readonly modifiedAt?: string;
|
|
readonly changes?: IAgentSessionMetadata['changes'];
|
|
readonly changesets?: readonly {
|
|
readonly label: string;
|
|
readonly changeKind: string;
|
|
readonly uriTemplate: string;
|
|
readonly description?: string;
|
|
}[];
|
|
readonly git?: ISerializedGitState;
|
|
readonly github?: ISerializedGitHubState;
|
|
}
|
|
|
|
function getRequiredString(value: unknown, field: string, toolName: string): string {
|
|
if (typeof value !== 'string' || value.length === 0) {
|
|
throw new Error(`Invalid ${toolName} input: ${field} must be a non-empty string.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function getOptionalString(value: unknown, field: string, toolName: string): string | undefined {
|
|
if (value === undefined) {
|
|
return undefined;
|
|
}
|
|
if (typeof value !== 'string' || value.length === 0) {
|
|
throw new Error(`Invalid ${toolName} input: ${field} must be a non-empty string.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseWorkspaceUri(workspace: string): URI | undefined {
|
|
// Absolute filesystem path (POSIX `/…` or Windows `C:\…` / `\\share`).
|
|
if (/^(\/|[a-zA-Z]:[\\/]|\\\\)/.test(workspace)) {
|
|
return URI.file(workspace);
|
|
}
|
|
try {
|
|
const parsed = URI.parse(workspace, true);
|
|
return parsed.scheme ? parsed : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function resolveWorkspace(workspace: string, sessions: readonly IAgentSessionMetadata[]): URI {
|
|
const matchingSession = sessions.find(session =>
|
|
session.workingDirectory?.toString() === workspace || session.workingDirectory?.fsPath === workspace);
|
|
if (matchingSession?.workingDirectory) {
|
|
return matchingSession.workingDirectory;
|
|
}
|
|
const parsed = parseWorkspaceUri(workspace);
|
|
if (!parsed) {
|
|
throw new Error(`Invalid ${createSessionToolName} input: workspace must match a known session workingDirectory, an absolute path, or a valid URI string.`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function resolveModel(modelName: string | undefined, models: readonly IAgentModelInfo[]): IAgentModelInfo | undefined {
|
|
if (modelName === undefined) {
|
|
return undefined;
|
|
}
|
|
const model = models.find(candidate => candidate.id === modelName || candidate.name === modelName);
|
|
if (!model) {
|
|
throw new Error(`Invalid ${createSessionToolName} input: model must match an available model id or name.`);
|
|
}
|
|
return model;
|
|
}
|
|
|
|
/** Validates and resolves create-session arguments against current sessions and models. */
|
|
export function getCreateSessionArgs(rawArgs: unknown, sessions: readonly IAgentSessionMetadata[], models: readonly IAgentModelInfo[]): IResolvedCreateSessionArgs {
|
|
const args = (rawArgs ?? {}) as ICreateSessionArgs;
|
|
const workspace = getRequiredString(args.workspace, 'workspace', createSessionToolName);
|
|
const prompt = getRequiredString(args.prompt, 'prompt', createSessionToolName);
|
|
const modelName = getOptionalString(args.model, 'model', createSessionToolName);
|
|
return {
|
|
workspace: resolveWorkspace(workspace, sessions),
|
|
prompt,
|
|
model: resolveModel(modelName, models),
|
|
};
|
|
}
|
|
|
|
/** Decodes the {@link SessionStatus} bit-flags into readable names for the agent. */
|
|
function describeSessionStatus(status: SessionStatus): string {
|
|
const names: string[] = [];
|
|
// `InputNeeded` is a superset of the `InProgress` bit, so it must be matched
|
|
// with an exact-bits check before falling back to plain `InProgress`.
|
|
if ((status & SessionStatus.InputNeeded) === SessionStatus.InputNeeded) {
|
|
names.push('inputNeeded');
|
|
} else if (status & SessionStatus.InProgress) {
|
|
names.push('inProgress');
|
|
} else if (status & SessionStatus.Idle) {
|
|
names.push('idle');
|
|
}
|
|
if (status & SessionStatus.Error) {
|
|
names.push('error');
|
|
}
|
|
if (status & SessionStatus.IsArchived) {
|
|
names.push('archived');
|
|
}
|
|
return names.join(',') || 'unknown';
|
|
}
|
|
|
|
/** Filters accepted by `list_sessions` to narrow the returned set. */
|
|
export interface IListSessionsArgs {
|
|
/** Direct lookup: return only the session with this URI / open link, ignoring all other filters. */
|
|
readonly session?: string;
|
|
readonly status?: ReadonlySet<string>;
|
|
readonly workspace?: string;
|
|
readonly withChanges?: boolean;
|
|
readonly unread?: boolean;
|
|
readonly withPullRequest?: boolean;
|
|
readonly includeArchived?: boolean;
|
|
/** Lower bound on session creation time, in epoch milliseconds. */
|
|
readonly createdAfter?: number;
|
|
/** Upper bound on session creation time, in epoch milliseconds. */
|
|
readonly createdBefore?: number;
|
|
}
|
|
|
|
function getOptionalBoolean(value: unknown, field: string, toolName: string): boolean | undefined {
|
|
if (value === undefined) {
|
|
return undefined;
|
|
}
|
|
if (typeof value !== 'boolean') {
|
|
throw new Error(`Invalid ${toolName} input: ${field} must be a boolean.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function getOptionalTimestamp(value: unknown, field: string, toolName: string): number | undefined {
|
|
if (value === undefined) {
|
|
return undefined;
|
|
}
|
|
if (typeof value !== 'string') {
|
|
throw new Error(`Invalid ${toolName} input: ${field} must be an ISO-8601 timestamp string.`);
|
|
}
|
|
const parsed = Date.parse(value);
|
|
if (Number.isNaN(parsed)) {
|
|
throw new Error(`Invalid ${toolName} input: ${field} must be a valid ISO-8601 timestamp (e.g. 2025-01-31T00:00:00Z).`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
/** Validates and normalizes the optional `list_sessions` filter arguments. */
|
|
export function getListSessionsArgs(rawArgs: unknown): IListSessionsArgs {
|
|
const args = (rawArgs ?? {}) as { session?: unknown; status?: unknown; workspace?: unknown; withChanges?: unknown; unread?: unknown; withPullRequest?: unknown; includeArchived?: unknown; createdAfter?: unknown; createdBefore?: unknown };
|
|
|
|
let status: Set<string> | undefined;
|
|
if (args.status !== undefined) {
|
|
if (!Array.isArray(args.status) || args.status.some(value => typeof value !== 'string')) {
|
|
throw new Error(`Invalid ${listSessionsToolName} input: status must be an array of status names.`);
|
|
}
|
|
const invalid = (args.status as string[]).filter(value => !(listSessionsStatusValues as readonly string[]).includes(value));
|
|
if (invalid.length > 0) {
|
|
throw new Error(`Invalid ${listSessionsToolName} input: unknown status value(s) ${invalid.join(', ')}. Valid values: ${listSessionsStatusValues.join(', ')}.`);
|
|
}
|
|
status = new Set(args.status as string[]);
|
|
}
|
|
|
|
return {
|
|
session: getOptionalString(args.session, 'session', listSessionsToolName),
|
|
status,
|
|
workspace: getOptionalString(args.workspace, 'workspace', listSessionsToolName),
|
|
withChanges: getOptionalBoolean(args.withChanges, 'withChanges', listSessionsToolName),
|
|
unread: getOptionalBoolean(args.unread, 'unread', listSessionsToolName),
|
|
withPullRequest: getOptionalBoolean(args.withPullRequest, 'withPullRequest', listSessionsToolName),
|
|
includeArchived: getOptionalBoolean(args.includeArchived, 'includeArchived', listSessionsToolName),
|
|
createdAfter: getOptionalTimestamp(args.createdAfter, 'createdAfter', listSessionsToolName),
|
|
createdBefore: getOptionalTimestamp(args.createdBefore, 'createdBefore', listSessionsToolName),
|
|
};
|
|
}
|
|
|
|
/** Whether a session has any pending worktree changes (insertions, deletions, or changed files). */
|
|
function sessionHasChanges(session: IAgentSessionMetadata): boolean {
|
|
const changes = session.changes;
|
|
return !!changes && ((changes.files ?? 0) > 0 || (changes.additions ?? 0) > 0 || (changes.deletions ?? 0) > 0);
|
|
}
|
|
|
|
/** Whether a session is archived (either the metadata flag or the status bit). */
|
|
function sessionIsArchived(session: IAgentSessionMetadata): boolean {
|
|
return session.isArchived === true || (session.status !== undefined && (session.status & SessionStatus.IsArchived) !== 0);
|
|
}
|
|
|
|
/** Whether a session's working directory matches the given folder (absolute path or URI). */
|
|
function sessionMatchesWorkspace(session: IAgentSessionMetadata, workspace: string): boolean {
|
|
const dir = session.workingDirectory;
|
|
if (!dir) {
|
|
return false;
|
|
}
|
|
if (dir.toString() === workspace || dir.fsPath === workspace) {
|
|
return true;
|
|
}
|
|
const parsed = parseWorkspaceUri(workspace);
|
|
return !!parsed && parsed.toString() === dir.toString();
|
|
}
|
|
|
|
/** Applies the {@link IListSessionsArgs} filters to a set of sessions. */
|
|
export function filterSessions(sessions: readonly IAgentSessionMetadata[], args: IListSessionsArgs): readonly IAgentSessionMetadata[] {
|
|
// A direct `session` lookup returns just that session, bypassing the other
|
|
// filters (including the default archived exclusion).
|
|
if (args.session !== undefined) {
|
|
const target = parseOpenSessionLinkUri(args.session)?.toString() ?? args.session;
|
|
return sessions.filter(session => session.session.toString() === target);
|
|
}
|
|
return sessions.filter(session => {
|
|
if (args.status) {
|
|
const names = session.status !== undefined ? describeSessionStatus(session.status).split(',') : [];
|
|
if (!names.some(name => args.status!.has(name))) {
|
|
return false;
|
|
}
|
|
}
|
|
if (args.workspace !== undefined && !sessionMatchesWorkspace(session, args.workspace)) {
|
|
return false;
|
|
}
|
|
if (args.withChanges && !sessionHasChanges(session)) {
|
|
return false;
|
|
}
|
|
if (args.unread && session.isRead !== false) {
|
|
return false;
|
|
}
|
|
if (args.withPullRequest && !readSessionGitHubState(session._meta)?.pullRequestUrl) {
|
|
return false;
|
|
}
|
|
// Archived sessions are hidden unless explicitly requested.
|
|
if (args.includeArchived !== true && sessionIsArchived(session)) {
|
|
return false;
|
|
}
|
|
if (args.createdAfter !== undefined && session.startTime < args.createdAfter) {
|
|
return false;
|
|
}
|
|
if (args.createdBefore !== undefined && session.startTime > args.createdBefore) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function serializeGitState(session: IAgentSessionMetadata): ISerializedGitState | undefined {
|
|
const git = readSessionGitState(session._meta);
|
|
if (!git) {
|
|
return undefined;
|
|
}
|
|
const result: Mutable<ISerializedGitState> = {};
|
|
if (git.branchName !== undefined) { result.branch = git.branchName; }
|
|
if (git.baseBranchName !== undefined) { result.baseBranch = git.baseBranchName; }
|
|
if (git.upstreamBranchName !== undefined) { result.upstreamBranch = git.upstreamBranchName; }
|
|
if (git.outgoingChanges !== undefined) { result.ahead = git.outgoingChanges; }
|
|
if (git.incomingChanges !== undefined) { result.behind = git.incomingChanges; }
|
|
if (git.uncommittedChanges !== undefined) { result.uncommittedChanges = git.uncommittedChanges; }
|
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
}
|
|
|
|
function serializeGitHubState(session: IAgentSessionMetadata): ISerializedGitHubState | undefined {
|
|
const github = readSessionGitHubState(session._meta);
|
|
if (!github) {
|
|
return undefined;
|
|
}
|
|
const result: Mutable<ISerializedGitHubState> = {};
|
|
if (github.owner !== undefined) { result.owner = github.owner; }
|
|
if (github.repo !== undefined) { result.repo = github.repo; }
|
|
if (github.pullRequestUrl !== undefined) { result.pullRequestUrl = github.pullRequestUrl; }
|
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
}
|
|
|
|
function serializeSession(session: IAgentSessionMetadata): ISerializedSession {
|
|
const git = serializeGitState(session);
|
|
const github = serializeGitHubState(session);
|
|
return {
|
|
session: session.session.toString(),
|
|
...(session.summary !== undefined ? { title: session.summary } : {}),
|
|
...(session.status !== undefined ? { status: describeSessionStatus(session.status) } : {}),
|
|
...(session.activity !== undefined ? { activity: session.activity } : {}),
|
|
...(session.workingDirectory !== undefined ? { workingDirectory: session.workingDirectory.toString() } : {}),
|
|
...(session.project !== undefined ? { project: session.project.displayName } : {}),
|
|
...(session.isRead === false ? { unread: true } : {}),
|
|
...(session.startTime > 0 ? { createdAt: new Date(session.startTime).toISOString() } : {}),
|
|
...(session.modifiedTime > 0 ? { modifiedAt: new Date(session.modifiedTime).toISOString() } : {}),
|
|
...(session.changes !== undefined ? { changes: session.changes } : {}),
|
|
...(session.changesets !== undefined ? {
|
|
changesets: session.changesets.map(changeset => ({
|
|
label: changeset.label,
|
|
changeKind: changeset.changeKind,
|
|
uriTemplate: changeset.uriTemplate,
|
|
...(changeset.description !== undefined ? { description: changeset.description } : {}),
|
|
})),
|
|
} : {}),
|
|
...(git !== undefined ? { git } : {}),
|
|
...(github !== undefined ? { github } : {}),
|
|
};
|
|
}
|
|
|
|
/** Serializes session metadata into the compact tool-result JSON payload. */
|
|
export function serializeSessions(sessions: readonly IAgentSessionMetadata[]): string {
|
|
return JSON.stringify({ sessions: sessions.map(serializeSession) });
|
|
}
|
|
|
|
export interface ICreateSessionResult {
|
|
readonly session: string;
|
|
readonly chat: string;
|
|
/** Clickable {@link AGENT_HOST_SESSION_LINK_SCHEME} URI that opens the session in the Agents window. */
|
|
readonly openLink: string;
|
|
}
|
|
|
|
/**
|
|
* Creates a session, sends its initial prompt, and returns the created channels.
|
|
* Enforces the {@link maxSessionSpawnDepth recursion limit} against
|
|
* {@link currentSession} (the session the tool runs in) and stamps the new
|
|
* session one level deeper so its own `create_session` calls are bounded too.
|
|
*/
|
|
export async function applyCreateSessionTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, currentSession?: URI): Promise<ICreateSessionResult> {
|
|
const parentDepth = currentSession ? accessor.getSessionSpawnDepth(currentSession) : 0;
|
|
if (parentDepth >= maxSessionSpawnDepth) {
|
|
throw new Error(`Refusing to create a session: recursion limit reached (max spawn depth ${maxSessionSpawnDepth}). This session was itself created ${parentDepth} level(s) deep.`);
|
|
}
|
|
const sessions = await accessor.listSessions();
|
|
const args = getCreateSessionArgs(rawArgs, sessions, accessor.getModels());
|
|
const config: IAgentCreateSessionConfig = {
|
|
workingDirectory: args.workspace,
|
|
...(args.model !== undefined ? { provider: args.model.provider, model: { id: args.model.id } } : {}),
|
|
};
|
|
const session = await accessor.createSession(config);
|
|
accessor.setSessionSpawnDepth(session, parentDepth + 1);
|
|
const chat = URI.parse(buildDefaultChatUri(session));
|
|
await accessor.startPrompt(session, chat, args.prompt);
|
|
return { session: session.toString(), chat: chat.toString(), openLink: buildOpenSessionLinkUri(session) };
|
|
}
|
|
|
|
/**
|
|
* Builds the model-facing `create_session` result. Keeps the machine-readable
|
|
* `agent-host-session://` link (parsed client-side to render the deterministic
|
|
* "Session Created" confirmation + button) but omits the raw backend session
|
|
* URI so the model has nothing ugly to echo, and tells it to reply briefly.
|
|
*/
|
|
export function formatCreateSessionResult(result: ICreateSessionResult): string {
|
|
return `Session created (${result.openLink}). Reply with one short sentence confirming the session was created; do not print the URL or mention a button.`;
|
|
}
|
|
|
|
interface ICreateChatArgs {
|
|
readonly session?: unknown;
|
|
readonly prompt?: unknown;
|
|
readonly title?: unknown;
|
|
readonly model?: unknown;
|
|
}
|
|
|
|
export interface ICreateChatResult {
|
|
readonly session: string;
|
|
readonly chat: string;
|
|
/** Clickable {@link AGENT_HOST_SESSION_LINK_SCHEME} URI that opens the created chat. */
|
|
readonly openLink: string;
|
|
}
|
|
|
|
/**
|
|
* Resolves a session identifier — accepting either a backend session URI
|
|
* (`copilotcli:/…` from `list_sessions`) or an `agent-host-session://…` open
|
|
* link (as returned by `create_session`/`get_current_session`) — against the
|
|
* set of known sessions. Returns `undefined` when it matches no known session.
|
|
*/
|
|
function resolveKnownSession(sessionInput: string, sessions: readonly IAgentSessionMetadata[]): URI | undefined {
|
|
// Normalize an open-session link back to its backend session URI.
|
|
const fromLink = parseOpenSessionLinkUri(sessionInput);
|
|
const candidate = fromLink?.toString() ?? sessionInput;
|
|
const match = sessions.find(s => s.session.toString() === candidate);
|
|
return match?.session;
|
|
}
|
|
|
|
/** Resolves the target session URI for `create_chat` against the known sessions. */
|
|
function resolveChatSession(sessionInput: string, sessions: readonly IAgentSessionMetadata[]): URI {
|
|
const session = resolveKnownSession(sessionInput, sessions);
|
|
if (!session) {
|
|
throw new Error(`Invalid ${createChatToolName} input: session must match the URI of a known session (see list_sessions).`);
|
|
}
|
|
return session;
|
|
}
|
|
|
|
/** Validates and resolves create-chat arguments; defaults the session to {@link currentSession} when omitted. */
|
|
export function getCreateChatArgs(rawArgs: unknown, sessions: readonly IAgentSessionMetadata[], models: readonly IAgentModelInfo[], currentSession?: URI): { session: URI; prompt: string; title?: string; model?: IAgentModelInfo } {
|
|
const args = (rawArgs ?? {}) as ICreateChatArgs;
|
|
const prompt = getRequiredString(args.prompt, 'prompt', createChatToolName);
|
|
const title = getOptionalString(args.title, 'title', createChatToolName);
|
|
const modelName = getOptionalString(args.model, 'model', createChatToolName);
|
|
const model = resolveModel(modelName, models);
|
|
const sessionInput = getOptionalString(args.session, 'session', createChatToolName);
|
|
let session: URI;
|
|
if (sessionInput !== undefined) {
|
|
session = resolveChatSession(sessionInput, sessions);
|
|
} else if (currentSession) {
|
|
session = currentSession;
|
|
} else {
|
|
throw new Error(`Invalid ${createChatToolName} input: no session provided and the current session could not be determined.`);
|
|
}
|
|
return { session, prompt, ...(title !== undefined ? { title } : {}), ...(model !== undefined ? { model } : {}) };
|
|
}
|
|
|
|
/** Adds a chat to a session, sends its initial prompt, and returns the created channels. */
|
|
export async function applyCreateChatTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, currentSession?: URI): Promise<ICreateChatResult> {
|
|
const sessions = await accessor.listSessions();
|
|
const args = getCreateChatArgs(rawArgs, sessions, accessor.getModels(), currentSession);
|
|
const chatId = generateUuid();
|
|
const chat = URI.parse(buildChatUri(args.session.toString(), chatId));
|
|
await accessor.createChat(args.session, chat, { title: args.title, model: args.model });
|
|
await accessor.startPrompt(args.session, chat, args.prompt);
|
|
return { session: args.session.toString(), chat: chat.toString(), openLink: buildOpenSessionLinkUri(args.session, chatId) };
|
|
}
|
|
|
|
/** Builds the model-facing `create_chat` result. */
|
|
export function formatCreateChatResult(result: ICreateChatResult): string {
|
|
return `Chat created (${result.openLink}). Reply with one short sentence confirming the chat was created; do not print the URL or mention a button.`;
|
|
}
|
|
|
|
interface ISendMessageArgs {
|
|
readonly session?: unknown;
|
|
readonly message?: unknown;
|
|
}
|
|
|
|
export interface IResolvedSendMessageArgs {
|
|
/** The owning backend session URI of the target chat. */
|
|
readonly session: URI;
|
|
/** The chat channel to deliver the message on (default chat, or a specific chat when the link carried one). */
|
|
readonly chat: URI;
|
|
/** The chat id when a specific chat was targeted (from a `create_chat` link). */
|
|
readonly chatId?: string;
|
|
readonly message: string;
|
|
}
|
|
|
|
/**
|
|
* Validates and resolves send-message arguments. When the `session` input is a
|
|
* `create_chat` open link (carrying a chat id), the message is targeted at that
|
|
* specific chat rather than the session's default chat.
|
|
*/
|
|
export function getSendMessageArgs(rawArgs: unknown, sessions: readonly IAgentSessionMetadata[]): IResolvedSendMessageArgs {
|
|
const args = (rawArgs ?? {}) as ISendMessageArgs;
|
|
const message = getRequiredString(args.message, 'message', sendMessageToolName);
|
|
const sessionInput = getRequiredString(args.session, 'session', sendMessageToolName);
|
|
const session = resolveKnownSession(sessionInput, sessions);
|
|
if (!session) {
|
|
throw new Error(`Invalid ${sendMessageToolName} input: session must match the URI of a known session (see list_sessions).`);
|
|
}
|
|
const chatId = parseOpenSessionLinkChatId(sessionInput);
|
|
const chat = URI.parse(chatId ? buildChatUri(session.toString(), chatId) : buildDefaultChatUri(session.toString()));
|
|
return { session, chat, message, ...(chatId !== undefined ? { chatId } : {}) };
|
|
}
|
|
|
|
/**
|
|
* Sends a message to an existing session/chat, starting a new turn there.
|
|
* Refuses to target {@link currentChannel} (the chat channel the tool runs on)
|
|
* to avoid a session trivially messaging itself in a loop.
|
|
*/
|
|
export async function applySendMessageTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, currentChannel?: ProtocolURI): Promise<string> {
|
|
const sessions = await accessor.listSessions();
|
|
const { session, chat, chatId, message } = getSendMessageArgs(rawArgs, sessions);
|
|
if (currentChannel && chat.toString() === URI.parse(currentChannel).toString()) {
|
|
throw new Error(`Invalid ${sendMessageToolName} input: refusing to send a message to the current chat.`);
|
|
}
|
|
await accessor.startPrompt(session, chat, message);
|
|
return formatSendMessageResult(buildOpenSessionLinkUri(session, chatId));
|
|
}
|
|
|
|
/** Builds the model-facing `send_message` result. */
|
|
export function formatSendMessageResult(openLink: string): string {
|
|
return `Message sent (${openLink}). Reply with one short sentence confirming the message was sent; do not print the URL or mention a button.`;
|
|
}
|
|
|
|
// --- get_session_context -----------------------------------------------------
|
|
|
|
type SessionContextDetail = (typeof sessionContextDetailValues)[number];
|
|
|
|
const defaultTranscriptLimit = 10;
|
|
const maxTranscriptLimit = 50;
|
|
|
|
/** Per-detail truncation caps (characters); a value of 0 omits the field. */
|
|
const contextCaps: Record<SessionContextDetail, { user: number; assistant: number; toolInput: number }> = {
|
|
// `summary` still carries a short assistant gist per turn so the reader sees
|
|
// what each turn actually did, not just what was asked.
|
|
summary: { user: 160, assistant: 140, toolInput: 0 },
|
|
digest: { user: 300, assistant: 800, toolInput: 0 },
|
|
full: { user: 1000, assistant: 2000, toolInput: 200 },
|
|
};
|
|
|
|
interface ISessionContextArgs {
|
|
readonly session?: unknown;
|
|
readonly detail?: unknown;
|
|
readonly transcriptLimit?: unknown;
|
|
}
|
|
|
|
export interface IResolvedSessionContextArgs {
|
|
readonly session: URI;
|
|
readonly chatId?: string;
|
|
readonly detail: SessionContextDetail;
|
|
readonly transcriptLimit: number;
|
|
}
|
|
|
|
/** Validates and resolves get-session-context arguments against the known sessions. */
|
|
export function getSessionContextArgs(rawArgs: unknown, sessions: readonly IAgentSessionMetadata[]): IResolvedSessionContextArgs {
|
|
const args = (rawArgs ?? {}) as ISessionContextArgs;
|
|
const sessionInput = getRequiredString(args.session, 'session', getSessionContextToolName);
|
|
const session = resolveKnownSession(sessionInput, sessions);
|
|
if (!session) {
|
|
throw new Error(`Invalid ${getSessionContextToolName} input: session must match the URI of a known session (see list_sessions).`);
|
|
}
|
|
let detail: SessionContextDetail = 'summary';
|
|
if (args.detail !== undefined) {
|
|
if (typeof args.detail !== 'string' || !(sessionContextDetailValues as readonly string[]).includes(args.detail)) {
|
|
throw new Error(`Invalid ${getSessionContextToolName} input: detail must be one of ${sessionContextDetailValues.join(', ')}.`);
|
|
}
|
|
detail = args.detail as SessionContextDetail;
|
|
}
|
|
let transcriptLimit = defaultTranscriptLimit;
|
|
if (args.transcriptLimit !== undefined) {
|
|
if (typeof args.transcriptLimit !== 'number' || !Number.isFinite(args.transcriptLimit) || args.transcriptLimit < 1) {
|
|
throw new Error(`Invalid ${getSessionContextToolName} input: transcriptLimit must be a positive number.`);
|
|
}
|
|
transcriptLimit = Math.min(Math.floor(args.transcriptLimit), maxTranscriptLimit);
|
|
}
|
|
const chatId = parseOpenSessionLinkChatId(sessionInput);
|
|
return { session, detail, transcriptLimit, ...(chatId !== undefined ? { chatId } : {}) };
|
|
}
|
|
|
|
/** Truncates {@link text} to {@link max} characters, appending an ellipsis when cut. */
|
|
function truncateText(text: string, max: number): { text: string; truncated: boolean } {
|
|
const trimmed = text.trim();
|
|
if (trimmed.length <= max) {
|
|
return { text: trimmed, truncated: false };
|
|
}
|
|
return { text: `${trimmed.slice(0, Math.max(0, max - 1))}…`, truncated: true };
|
|
}
|
|
|
|
/** Reads the tool-call parts of a turn, newest-emitted last. */
|
|
function toolCallsOf(parts: readonly ResponsePart[]): ToolCallState[] {
|
|
return parts.filter((p): p is Extract<ResponsePart, { kind: ResponsePartKind.ToolCall }> => p.kind === ResponsePartKind.ToolCall).map(p => p.toolCall);
|
|
}
|
|
|
|
/** Concatenated markdown text of a turn's response, in stream order. */
|
|
function assistantTextOf(parts: readonly ResponsePart[]): string {
|
|
return parts.filter((p): p is Extract<ResponsePart, { kind: ResponsePartKind.Markdown }> => p.kind === ResponsePartKind.Markdown).map(p => p.content).join('').trim();
|
|
}
|
|
|
|
/** Reads a tool call's JSON input string, which is absent while still streaming. */
|
|
function readToolInput(tc: ToolCallState): string | undefined {
|
|
return tc.status === ToolCallStatus.Streaming ? undefined : tc.toolInput;
|
|
}
|
|
|
|
interface ISerializedContextTurn {
|
|
readonly turn: number;
|
|
readonly state: string;
|
|
readonly user?: string;
|
|
readonly assistant?: string;
|
|
readonly toolCalls?: readonly (string | { readonly name: string; readonly input?: string })[];
|
|
}
|
|
|
|
/** Maps a {@link TurnState} (or the in-progress active turn) to a display string. */
|
|
function describeTurnState(state: TurnState | 'inProgress'): string {
|
|
switch (state) {
|
|
case TurnState.Complete: return 'complete';
|
|
case TurnState.Cancelled: return 'cancelled';
|
|
case TurnState.Error: return 'error';
|
|
default: return 'inProgress';
|
|
}
|
|
}
|
|
|
|
interface ISerializedSessionContext {
|
|
readonly session: string;
|
|
readonly openLink: string;
|
|
readonly detail: SessionContextDetail;
|
|
readonly transcript: readonly ISerializedContextTurn[];
|
|
readonly hasMoreHistory: boolean;
|
|
/** `true` when turns were dropped from the window or any field was shortened. */
|
|
readonly truncated: boolean;
|
|
}
|
|
|
|
/** Builds the compacted, model-facing session-context payload from a snapshot. */
|
|
export function serializeSessionContext(session: URI, chatId: string | undefined, snapshot: IChatContextSnapshot, detail: SessionContextDetail, transcriptLimit: number): string {
|
|
const caps = contextCaps[detail];
|
|
let truncated = false;
|
|
const trunc = (text: string, max: number): string | undefined => {
|
|
if (max <= 0 || !text) {
|
|
return undefined;
|
|
}
|
|
const result = truncateText(text, max);
|
|
truncated = truncated || result.truncated;
|
|
return result.text || undefined;
|
|
};
|
|
|
|
const entries: { message: Message; parts: readonly ResponsePart[]; state: TurnState | 'inProgress' }[] =
|
|
snapshot.turns.map(t => ({ message: t.message, parts: t.responseParts, state: t.state }));
|
|
if (snapshot.activeTurn) {
|
|
entries.push({ message: snapshot.activeTurn.message, parts: snapshot.activeTurn.responseParts, state: 'inProgress' });
|
|
}
|
|
if (entries.length > transcriptLimit) {
|
|
truncated = true;
|
|
}
|
|
const windowStart = Math.max(0, entries.length - transcriptLimit);
|
|
const windowed = entries.slice(windowStart);
|
|
|
|
const transcript: ISerializedContextTurn[] = windowed.map((entry, index): ISerializedContextTurn => {
|
|
const user = trunc(entry.message.text, caps.user);
|
|
const assistant = trunc(assistantTextOf(entry.parts), caps.assistant);
|
|
const toolCalls = toolCallsOf(entry.parts);
|
|
let serializedToolCalls: (string | { name: string; input?: string })[] | undefined;
|
|
if (detail !== 'summary' && toolCalls.length > 0) {
|
|
serializedToolCalls = toolCalls.map(tc => {
|
|
if (caps.toolInput > 0) {
|
|
const input = trunc(readToolInput(tc) ?? '', caps.toolInput);
|
|
return input !== undefined ? { name: tc.toolName, input } : { name: tc.toolName };
|
|
}
|
|
return tc.toolName;
|
|
});
|
|
}
|
|
return {
|
|
turn: windowStart + index + 1,
|
|
state: describeTurnState(entry.state),
|
|
...(user !== undefined ? { user } : {}),
|
|
...(assistant !== undefined ? { assistant } : {}),
|
|
...(serializedToolCalls ? { toolCalls: serializedToolCalls } : {}),
|
|
};
|
|
});
|
|
|
|
const payload: ISerializedSessionContext = {
|
|
session: session.toString(),
|
|
openLink: buildOpenSessionLinkUri(session, chatId),
|
|
detail,
|
|
transcript,
|
|
hasMoreHistory: snapshot.hasMoreHistory,
|
|
truncated,
|
|
};
|
|
return JSON.stringify(payload);
|
|
}
|
|
|
|
/** Reads and serializes the context of an existing session/chat. */
|
|
export async function applyGetSessionContextTool(accessor: ISessionServerToolAccessor, rawArgs: unknown): Promise<string> {
|
|
const sessions = await accessor.listSessions();
|
|
const { session, chatId, detail, transcriptLimit } = getSessionContextArgs(rawArgs, sessions);
|
|
const snapshot = accessor.getChatContext(session, chatId);
|
|
if (!snapshot) {
|
|
// No live conversation state (e.g. a cold/unsubscribed session): return the
|
|
// identity + an empty transcript. Metadata is available via list_sessions.
|
|
return JSON.stringify({
|
|
session: session.toString(),
|
|
openLink: buildOpenSessionLinkUri(session, chatId),
|
|
detail,
|
|
transcript: [],
|
|
hasMoreHistory: false,
|
|
truncated: false,
|
|
} satisfies ISerializedSessionContext);
|
|
}
|
|
return serializeSessionContext(session, chatId, snapshot, detail, transcriptLimit);
|
|
}
|
|
|
|
|
|
/** Serializes the current session's metadata + open link as the `get_current_session` result. */
|
|
export function serializeCurrentSession(currentSession: URI, sessions: readonly IAgentSessionMetadata[]): string {
|
|
const meta = sessions.find(s => s.session.toString() === currentSession.toString());
|
|
return JSON.stringify({
|
|
session: currentSession.toString(),
|
|
openLink: buildOpenSessionLinkUri(currentSession),
|
|
...(meta ? serializeSession(meta) : {}),
|
|
});
|
|
}
|
|
|
|
function parseListedSessionCount(resultText: string | undefined): number | undefined {
|
|
if (!resultText) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(resultText) as { sessions?: unknown };
|
|
return Array.isArray(parsed.sessions) ? parsed.sessions.length : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
interface IDeleteSessionArgs {
|
|
readonly session?: unknown;
|
|
}
|
|
|
|
/**
|
|
* Validates delete-session arguments against current sessions and refuses to
|
|
* delete {@link currentSession} (deleting the session the tool runs in would
|
|
* tear down its own conversation).
|
|
*/
|
|
export function getDeleteSessionArgs(rawArgs: unknown, sessions: readonly IAgentSessionMetadata[], currentSession?: URI): URI {
|
|
const args = (rawArgs ?? {}) as IDeleteSessionArgs;
|
|
const sessionInput = getRequiredString(args.session, 'session', deleteSessionToolName);
|
|
const session = resolveKnownSession(sessionInput, sessions);
|
|
if (!session) {
|
|
throw new Error(`Invalid ${deleteSessionToolName} input: session must match the URI of a known session (see list_sessions).`);
|
|
}
|
|
if (currentSession && session.toString() === currentSession.toString()) {
|
|
throw new Error(`Invalid ${deleteSessionToolName} input: refusing to delete the current session.`);
|
|
}
|
|
return session;
|
|
}
|
|
|
|
/** Deletes a session and returns the model-facing confirmation. */
|
|
export async function applyDeleteSessionTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, currentSession?: URI): Promise<string> {
|
|
const sessions = await accessor.listSessions();
|
|
const session = getDeleteSessionArgs(rawArgs, sessions, currentSession);
|
|
await accessor.deleteSession(session);
|
|
return `Deleted session ${session.toString()}. Reply with one short sentence confirming the session was deleted.`;
|
|
}
|
|
|
|
function getSessionToolDisplay(toolName: string, _args: unknown, result?: IServerToolDisplayResult): IServerToolDisplay | undefined {
|
|
switch (toolName) {
|
|
case listSessionsToolName: {
|
|
let pastTenseMessage: StringOrMarkdown;
|
|
const count = result ? parseListedSessionCount(result.text) : undefined;
|
|
if (count === undefined) {
|
|
pastTenseMessage = localize('toolComplete.listSessions', "Checked sessions");
|
|
} else if (count === 1) {
|
|
pastTenseMessage = localize('toolComplete.listSessions.one', "Checked 1 session");
|
|
} else {
|
|
pastTenseMessage = localize('toolComplete.listSessions.many', "Checked {0} sessions", count);
|
|
}
|
|
return {
|
|
displayName: localize('toolName.listSessions', "List Sessions"),
|
|
invocationMessage: localize('toolInvoke.listSessions', "Checking sessions"),
|
|
pastTenseMessage,
|
|
};
|
|
}
|
|
case createSessionToolName:
|
|
return {
|
|
displayName: localize('toolName.createSession', "Create Session"),
|
|
invocationMessage: localize('toolInvoke.createSession', "Creating session"),
|
|
pastTenseMessage: localize('toolComplete.createSession', "Created session"),
|
|
};
|
|
case createChatToolName:
|
|
return {
|
|
displayName: localize('toolName.createChat', "Create Chat"),
|
|
invocationMessage: localize('toolInvoke.createChat', "Creating chat"),
|
|
pastTenseMessage: localize('toolComplete.createChat', "Created chat"),
|
|
};
|
|
case sendMessageToolName:
|
|
return {
|
|
displayName: localize('toolName.sendMessage', "Send Message"),
|
|
invocationMessage: localize('toolInvoke.sendMessage', "Sending message"),
|
|
pastTenseMessage: localize('toolComplete.sendMessage', "Sent message"),
|
|
};
|
|
case getSessionContextToolName:
|
|
return {
|
|
displayName: localize('toolName.getSessionContext', "Get Session Context"),
|
|
invocationMessage: localize('toolInvoke.getSessionContext', "Reading session context"),
|
|
pastTenseMessage: localize('toolComplete.getSessionContext', "Read session context"),
|
|
};
|
|
case getCurrentSessionToolName:
|
|
return {
|
|
displayName: localize('toolName.getCurrentSession', "Get Current Session"),
|
|
invocationMessage: localize('toolInvoke.getCurrentSession', "Checking current session"),
|
|
pastTenseMessage: localize('toolComplete.getCurrentSession', "Checked current session"),
|
|
};
|
|
case deleteSessionToolName:
|
|
return {
|
|
displayName: localize('toolName.deleteSession', "Delete Session"),
|
|
invocationMessage: localize('toolInvoke.deleteSession', "Deleting session"),
|
|
pastTenseMessage: localize('toolComplete.deleteSession', "Deleted session"),
|
|
};
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates the session server-tool group with process-local recursion protection.
|
|
*
|
|
* The {@link accessor} is optional so the group can also back the pure display
|
|
* path (`getServerToolDisplay`), which only needs {@link IServerToolGroup.definitions},
|
|
* {@link IServerToolGroup.getDisplay} and {@link IServerToolGroup.requiresConfirmation}
|
|
* and never invokes {@link IServerToolGroup.execute}. `execute` throws when no
|
|
* accessor was provided.
|
|
*/
|
|
export function createSessionServerToolGroup(accessor?: ISessionServerToolAccessor): IServerToolGroup {
|
|
let createdSessionCount = 0;
|
|
let createdChatCount = 0;
|
|
let sentMessageCount = 0;
|
|
const group: IServerToolGroup = {
|
|
definitions: sessionServerToolDefinitions,
|
|
requiresConfirmation(toolName: string): boolean {
|
|
return sessionToolRequiresConfirmation(toolName);
|
|
},
|
|
getDisplay(toolName: string, args: unknown, result?: IServerToolDisplayResult): IServerToolDisplay | undefined {
|
|
return getSessionToolDisplay(toolName, args, result);
|
|
},
|
|
async execute(_stateManager: AgentHostStateManager, sessionUri: ProtocolURI, toolName: string, rawArgs: unknown): Promise<string> {
|
|
if (!accessor) {
|
|
throw new Error(`Session server tool "${toolName}" cannot run: the group was built without a session accessor.`);
|
|
}
|
|
switch (toolName) {
|
|
case listSessionsToolName:
|
|
return serializeSessions(filterSessions(await accessor.listSessions(), getListSessionsArgs(rawArgs)));
|
|
case getCurrentSessionToolName:
|
|
return serializeCurrentSession(currentSessionUri(sessionUri), await accessor.listSessions());
|
|
case createSessionToolName: {
|
|
if (createdSessionCount >= maxCreatedSessions) {
|
|
throw new Error(`Refusing to create more than ${maxCreatedSessions} sessions from server tools in this process.`);
|
|
}
|
|
const result = await applyCreateSessionTool(accessor, rawArgs, currentSessionUri(sessionUri));
|
|
createdSessionCount++;
|
|
return formatCreateSessionResult(result);
|
|
}
|
|
case createChatToolName: {
|
|
if (createdChatCount >= maxCreatedChats) {
|
|
throw new Error(`Refusing to create more than ${maxCreatedChats} chats from server tools in this process.`);
|
|
}
|
|
const result = await applyCreateChatTool(accessor, rawArgs, currentSessionUri(sessionUri));
|
|
createdChatCount++;
|
|
return formatCreateChatResult(result);
|
|
}
|
|
case sendMessageToolName: {
|
|
if (sentMessageCount >= maxSentMessages) {
|
|
throw new Error(`Refusing to send more than ${maxSentMessages} messages from server tools in this process.`);
|
|
}
|
|
const result = await applySendMessageTool(accessor, rawArgs, sessionUri);
|
|
sentMessageCount++;
|
|
return result;
|
|
}
|
|
case getSessionContextToolName:
|
|
return applyGetSessionContextTool(accessor, rawArgs);
|
|
case deleteSessionToolName:
|
|
return applyDeleteSessionTool(accessor, rawArgs, currentSessionUri(sessionUri));
|
|
default:
|
|
throw new Error(`Unknown session server tool: ${toolName}`);
|
|
}
|
|
},
|
|
};
|
|
return group;
|
|
}
|