mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 23:42:03 +00:00
646 lines
18 KiB
TypeScript
646 lines
18 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import {
|
|
APPROVAL_MODE_INFO,
|
|
APPROVAL_MODES,
|
|
AuthType,
|
|
clearCachedCredentialFile,
|
|
createDebugLogger,
|
|
QwenOAuth2Event,
|
|
qwenOAuth2Events,
|
|
MCPServerConfig,
|
|
SessionService,
|
|
tokenLimit,
|
|
type Config,
|
|
type ConversationRecord,
|
|
type DeviceAuthorizationData,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import {
|
|
AgentSideConnection,
|
|
RequestError,
|
|
ndJsonStream,
|
|
PROTOCOL_VERSION,
|
|
} from '@agentclientprotocol/sdk';
|
|
import type {
|
|
Agent,
|
|
AuthenticateRequest,
|
|
AuthMethod,
|
|
CancelNotification,
|
|
ClientCapabilities,
|
|
InitializeRequest,
|
|
InitializeResponse,
|
|
ListSessionsRequest,
|
|
ListSessionsResponse,
|
|
LoadSessionRequest,
|
|
LoadSessionResponse,
|
|
McpServer,
|
|
McpServerStdio,
|
|
NewSessionRequest,
|
|
NewSessionResponse,
|
|
PromptRequest,
|
|
PromptResponse,
|
|
SessionConfigOption,
|
|
SessionInfo,
|
|
SessionModeState,
|
|
SetSessionConfigOptionRequest,
|
|
SetSessionConfigOptionResponse,
|
|
SetSessionModelRequest,
|
|
SetSessionModelResponse,
|
|
SetSessionModeRequest,
|
|
SetSessionModeResponse,
|
|
} from '@agentclientprotocol/sdk';
|
|
import { buildAuthMethods } from './authMethods.js';
|
|
import { AcpFileSystemService } from './service/filesystem.js';
|
|
import { Readable, Writable } from 'node:stream';
|
|
import type { LoadedSettings } from '../config/settings.js';
|
|
import { SettingScope } from '../config/settings.js';
|
|
import type { ApprovalModeValue } from './session/types.js';
|
|
import { z } from 'zod';
|
|
import type { CliArgs } from '../config/config.js';
|
|
import { loadCliConfig } from '../config/config.js';
|
|
import { Session } from './session/Session.js';
|
|
import { formatAcpModelId } from '../utils/acpModelUtils.js';
|
|
import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js';
|
|
import { runExitCleanup } from '../utils/cleanup.js';
|
|
|
|
const debugLogger = createDebugLogger('ACP_AGENT');
|
|
|
|
export async function runAcpAgent(
|
|
config: Config,
|
|
settings: LoadedSettings,
|
|
argv: CliArgs,
|
|
) {
|
|
const stdout = Writable.toWeb(process.stdout) as WritableStream;
|
|
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
|
|
|
|
// Stdout is used to send messages to the client, so console.log/console.info
|
|
// messages to stderr so that they don't interfere with ACP.
|
|
console.log = console.error;
|
|
console.info = console.error;
|
|
console.debug = console.error;
|
|
|
|
const stream = ndJsonStream(stdout, stdin);
|
|
const connection = new AgentSideConnection(
|
|
(conn) => new QwenAgent(config, settings, argv, conn),
|
|
stream,
|
|
);
|
|
|
|
// Handle SIGTERM/SIGINT for graceful shutdown.
|
|
// Without this, signal handlers registered elsewhere in the CLI
|
|
// (e.g., stdin raw mode restoration) override the default exit behavior,
|
|
// causing the ACP process to ignore termination signals.
|
|
let shuttingDown = false;
|
|
const shutdownHandler = () => {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
debugLogger.debug('[ACP] Shutdown signal received, closing streams');
|
|
try {
|
|
process.stdin.destroy();
|
|
} catch {
|
|
// stdin may already be closed
|
|
}
|
|
try {
|
|
process.stdout.destroy();
|
|
} catch {
|
|
// stdout may already be closed
|
|
}
|
|
// Clean up child processes (MCP servers, etc.) and force exit.
|
|
// Without this, orphan subprocesses keep the Node.js event loop alive
|
|
// and the CLI process never terminates after the IDE disconnects.
|
|
runExitCleanup()
|
|
.catch((err) => {
|
|
debugLogger.error('[ACP] Cleanup error:', err);
|
|
})
|
|
.finally(() => {
|
|
process.exit(0);
|
|
});
|
|
};
|
|
process.on('SIGTERM', shutdownHandler);
|
|
process.on('SIGINT', shutdownHandler);
|
|
|
|
await connection.closed;
|
|
|
|
process.off('SIGTERM', shutdownHandler);
|
|
process.off('SIGINT', shutdownHandler);
|
|
}
|
|
|
|
function toStdioServer(server: McpServer): McpServerStdio | undefined {
|
|
if ('command' in server && 'args' in server && 'env' in server) {
|
|
return server as McpServerStdio;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
class QwenAgent implements Agent {
|
|
private sessions: Map<string, Session> = new Map();
|
|
private clientCapabilities: ClientCapabilities | undefined;
|
|
|
|
constructor(
|
|
private config: Config,
|
|
private settings: LoadedSettings,
|
|
private argv: CliArgs,
|
|
private connection: AgentSideConnection,
|
|
) {}
|
|
|
|
async initialize(args: InitializeRequest): Promise<InitializeResponse> {
|
|
this.clientCapabilities = args.clientCapabilities;
|
|
const authMethods = buildAuthMethods();
|
|
const version = process.env['CLI_VERSION'] || process.version;
|
|
|
|
return {
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
agentInfo: {
|
|
name: 'qwen-code',
|
|
title: 'Qwen Code',
|
|
version,
|
|
},
|
|
authMethods,
|
|
agentCapabilities: {
|
|
loadSession: true,
|
|
promptCapabilities: {
|
|
image: true,
|
|
audio: true,
|
|
embeddedContext: true,
|
|
},
|
|
sessionCapabilities: {
|
|
list: {},
|
|
resume: {},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
async authenticate({ methodId }: AuthenticateRequest): Promise<void> {
|
|
const method = z.nativeEnum(AuthType).parse(methodId);
|
|
|
|
let authUri: string | undefined;
|
|
const authUriHandler = (deviceAuth: DeviceAuthorizationData) => {
|
|
authUri = deviceAuth.verification_uri_complete;
|
|
void this.connection.extNotification('authenticate/update', {
|
|
_meta: { authUri },
|
|
});
|
|
};
|
|
|
|
if (method === AuthType.QWEN_OAUTH) {
|
|
qwenOAuth2Events.once(QwenOAuth2Event.AuthUri, authUriHandler);
|
|
}
|
|
|
|
await clearCachedCredentialFile();
|
|
try {
|
|
await this.config.refreshAuth(method);
|
|
this.settings.setValue(
|
|
SettingScope.User,
|
|
'security.auth.selectedType',
|
|
method,
|
|
);
|
|
} finally {
|
|
if (method === AuthType.QWEN_OAUTH) {
|
|
qwenOAuth2Events.off(QwenOAuth2Event.AuthUri, authUriHandler);
|
|
}
|
|
}
|
|
}
|
|
|
|
async newSession({
|
|
cwd,
|
|
mcpServers,
|
|
}: NewSessionRequest): Promise<NewSessionResponse> {
|
|
const config = await this.newSessionConfig(cwd, mcpServers);
|
|
await this.ensureAuthenticated(config);
|
|
this.setupFileSystem(config);
|
|
|
|
const session = await this.createAndStoreSession(config);
|
|
const availableModels = this.buildAvailableModels(config);
|
|
const modesData = this.buildModesData(config);
|
|
const configOptions = this.buildConfigOptions(config);
|
|
|
|
return {
|
|
sessionId: session.getId(),
|
|
models: availableModels,
|
|
modes: modesData,
|
|
configOptions,
|
|
};
|
|
}
|
|
|
|
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
|
const exists = await runWithAcpRuntimeOutputDir(
|
|
this.settings,
|
|
params.cwd,
|
|
async () => {
|
|
const sessionService = new SessionService(params.cwd);
|
|
return sessionService.sessionExists(params.sessionId);
|
|
},
|
|
);
|
|
if (!exists) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
`Session not found for id: ${params.sessionId}`,
|
|
);
|
|
}
|
|
|
|
const config = await this.newSessionConfig(
|
|
params.cwd,
|
|
params.mcpServers,
|
|
params.sessionId,
|
|
);
|
|
await this.ensureAuthenticated(config);
|
|
this.setupFileSystem(config);
|
|
|
|
const sessionData = config.getResumedSessionData();
|
|
if (!sessionData) {
|
|
throw RequestError.internalError(
|
|
undefined,
|
|
`Failed to load session data for id: ${params.sessionId}`,
|
|
);
|
|
}
|
|
|
|
await this.createAndStoreSession(config, sessionData.conversation);
|
|
|
|
const modesData = this.buildModesData(config);
|
|
const availableModels = this.buildAvailableModels(config);
|
|
const configOptions = this.buildConfigOptions(config);
|
|
|
|
return {
|
|
modes: modesData,
|
|
models: availableModels,
|
|
configOptions,
|
|
};
|
|
}
|
|
|
|
async unstable_listSessions(
|
|
params: ListSessionsRequest,
|
|
): Promise<ListSessionsResponse> {
|
|
const cwd = params.cwd || process.cwd();
|
|
const numericCursor = params.cursor ? Number(params.cursor) : undefined;
|
|
const result = await runWithAcpRuntimeOutputDir(this.settings, cwd, () => {
|
|
const sessionService = new SessionService(cwd);
|
|
return sessionService.listSessions({
|
|
cursor: Number.isNaN(numericCursor) ? undefined : numericCursor,
|
|
});
|
|
});
|
|
|
|
const sessions: SessionInfo[] = result.items.map((item) => ({
|
|
cwd: item.cwd,
|
|
sessionId: item.sessionId,
|
|
title: item.prompt || '(session)',
|
|
updatedAt: new Date(item.mtime).toISOString(),
|
|
}));
|
|
|
|
return {
|
|
sessions,
|
|
nextCursor:
|
|
result.nextCursor != null ? String(result.nextCursor) : undefined,
|
|
};
|
|
}
|
|
|
|
async setSessionMode(
|
|
params: SetSessionModeRequest,
|
|
): Promise<SetSessionModeResponse | void> {
|
|
const session = this.sessions.get(params.sessionId);
|
|
if (!session) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
`Session not found for id: ${params.sessionId}`,
|
|
);
|
|
}
|
|
return session.setMode(params);
|
|
}
|
|
|
|
async unstable_setSessionModel(
|
|
params: SetSessionModelRequest,
|
|
): Promise<SetSessionModelResponse | void> {
|
|
const session = this.sessions.get(params.sessionId);
|
|
if (!session) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
`Session not found for id: ${params.sessionId}`,
|
|
);
|
|
}
|
|
return await session.setModel(params);
|
|
}
|
|
|
|
async setSessionConfigOption(
|
|
params: SetSessionConfigOptionRequest,
|
|
): Promise<SetSessionConfigOptionResponse> {
|
|
const { sessionId, configId, value } = params;
|
|
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
`Session not found for id: ${sessionId}`,
|
|
);
|
|
}
|
|
|
|
switch (configId) {
|
|
case 'mode': {
|
|
await this.setSessionMode({
|
|
sessionId,
|
|
modeId: value as string,
|
|
});
|
|
break;
|
|
}
|
|
case 'model': {
|
|
await this.unstable_setSessionModel({
|
|
sessionId,
|
|
modelId: value as string,
|
|
});
|
|
break;
|
|
}
|
|
default:
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
`Unsupported configId: ${configId}`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
configOptions: this.buildConfigOptions(session.getConfig()),
|
|
};
|
|
}
|
|
|
|
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
|
const session = this.sessions.get(params.sessionId);
|
|
if (!session) {
|
|
throw new Error(`Session not found: ${params.sessionId}`);
|
|
}
|
|
return session.prompt(params);
|
|
}
|
|
|
|
async cancel(params: CancelNotification): Promise<void> {
|
|
const session = this.sessions.get(params.sessionId);
|
|
if (!session) {
|
|
throw new Error(`Session not found: ${params.sessionId}`);
|
|
}
|
|
await session.cancelPendingPrompt();
|
|
}
|
|
|
|
async extMethod(
|
|
method: string,
|
|
_params: Record<string, unknown>,
|
|
): Promise<Record<string, unknown>> {
|
|
throw RequestError.methodNotFound(method);
|
|
}
|
|
|
|
// --- private helpers ---
|
|
|
|
private async newSessionConfig(
|
|
cwd: string,
|
|
mcpServers: McpServer[],
|
|
sessionId?: string,
|
|
): Promise<Config> {
|
|
const mergedMcpServers = { ...this.settings.merged.mcpServers };
|
|
|
|
for (const server of mcpServers) {
|
|
const stdioServer = toStdioServer(server);
|
|
if (!stdioServer) continue;
|
|
|
|
const env: Record<string, string> = {};
|
|
for (const { name: envName, value } of stdioServer.env) {
|
|
env[envName] = value;
|
|
}
|
|
mergedMcpServers[stdioServer.name] = new MCPServerConfig(
|
|
stdioServer.command,
|
|
stdioServer.args,
|
|
env,
|
|
cwd,
|
|
);
|
|
}
|
|
|
|
const settings = { ...this.settings.merged, mcpServers: mergedMcpServers };
|
|
const argvForSession = {
|
|
...this.argv,
|
|
resume: sessionId,
|
|
continue: false,
|
|
};
|
|
|
|
const config = await loadCliConfig(settings, argvForSession, cwd);
|
|
await config.initialize();
|
|
return config;
|
|
}
|
|
|
|
private async ensureAuthenticated(config: Config): Promise<void> {
|
|
const selectedType = config.getModelsConfig().getCurrentAuthType();
|
|
if (!selectedType) {
|
|
throw RequestError.authRequired(
|
|
{ authMethods: this.pickAuthMethodsForAuthRequired() },
|
|
'Use Qwen Code CLI to authenticate first.',
|
|
);
|
|
}
|
|
|
|
try {
|
|
await config.refreshAuth(selectedType, true);
|
|
} catch (e) {
|
|
debugLogger.error(`Authentication failed: ${e}`);
|
|
throw RequestError.authRequired(
|
|
{
|
|
authMethods: this.pickAuthMethodsForAuthRequired(selectedType, e),
|
|
},
|
|
'Authentication failed: ' + (e as Error).message,
|
|
);
|
|
}
|
|
}
|
|
|
|
private pickAuthMethodsForAuthRequired(
|
|
selectedType?: AuthType | string,
|
|
error?: unknown,
|
|
): AuthMethod[] {
|
|
const authMethods = buildAuthMethods();
|
|
const errorMessage = this.extractErrorMessage(error);
|
|
if (
|
|
errorMessage?.includes('qwen-oauth') ||
|
|
errorMessage?.includes('Qwen OAuth')
|
|
) {
|
|
const qwenOAuthMethods = authMethods.filter(
|
|
(m) => m.id === AuthType.QWEN_OAUTH,
|
|
);
|
|
return qwenOAuthMethods.length ? qwenOAuthMethods : authMethods;
|
|
}
|
|
|
|
if (selectedType) {
|
|
const matched = authMethods.filter((m) => m.id === selectedType);
|
|
return matched.length ? matched : authMethods;
|
|
}
|
|
|
|
return authMethods;
|
|
}
|
|
|
|
private extractErrorMessage(error?: unknown): string | undefined {
|
|
if (error instanceof Error) return error.message;
|
|
if (
|
|
typeof error === 'object' &&
|
|
error != null &&
|
|
'message' in error &&
|
|
typeof error.message === 'string'
|
|
) {
|
|
return error.message;
|
|
}
|
|
if (typeof error === 'string') return error;
|
|
return undefined;
|
|
}
|
|
|
|
private setupFileSystem(config: Config): void {
|
|
if (!this.clientCapabilities?.fs) return;
|
|
|
|
const acpFileSystemService = new AcpFileSystemService(
|
|
this.connection,
|
|
config.getSessionId(),
|
|
this.clientCapabilities.fs,
|
|
config.getFileSystemService(),
|
|
);
|
|
config.setFileSystemService(acpFileSystemService);
|
|
}
|
|
|
|
private async createAndStoreSession(
|
|
config: Config,
|
|
conversation?: ConversationRecord,
|
|
): Promise<Session> {
|
|
const sessionId = config.getSessionId();
|
|
const geminiClient = config.getGeminiClient();
|
|
|
|
if (!geminiClient.isInitialized()) {
|
|
await geminiClient.initialize();
|
|
}
|
|
|
|
const chat = geminiClient.getChat();
|
|
|
|
const session = new Session(
|
|
sessionId,
|
|
chat,
|
|
config,
|
|
this.connection,
|
|
this.settings,
|
|
);
|
|
this.sessions.set(sessionId, session);
|
|
|
|
setTimeout(async () => {
|
|
await session.sendAvailableCommandsUpdate();
|
|
}, 0);
|
|
|
|
if (conversation && conversation.messages) {
|
|
await session.replayHistory(conversation.messages);
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
private buildAvailableModels(config: Config): NewSessionResponse['models'] {
|
|
const rawCurrentModelId = (
|
|
config.getModel() ||
|
|
this.config.getModel() ||
|
|
''
|
|
).trim();
|
|
const currentAuthType = config.getAuthType();
|
|
const allConfiguredModels = config.getAllConfiguredModels();
|
|
|
|
const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.();
|
|
const currentModelId = activeRuntimeSnapshot
|
|
? formatAcpModelId(
|
|
activeRuntimeSnapshot.id,
|
|
activeRuntimeSnapshot.authType,
|
|
)
|
|
: this.formatCurrentModelId(rawCurrentModelId, currentAuthType);
|
|
|
|
const mappedAvailableModels = allConfiguredModels.map((model) => {
|
|
const effectiveModelId =
|
|
model.isRuntimeModel && model.runtimeSnapshotId
|
|
? model.runtimeSnapshotId
|
|
: model.id;
|
|
|
|
return {
|
|
modelId: formatAcpModelId(effectiveModelId, model.authType),
|
|
name: model.label,
|
|
description: model.description ?? null,
|
|
_meta: {
|
|
contextLimit: model.contextWindowSize ?? tokenLimit(model.id),
|
|
},
|
|
};
|
|
});
|
|
|
|
return {
|
|
currentModelId,
|
|
availableModels: mappedAvailableModels,
|
|
};
|
|
}
|
|
|
|
private buildModesData(config: Config): SessionModeState {
|
|
const currentApprovalMode = config.getApprovalMode();
|
|
|
|
const availableModes = APPROVAL_MODES.map((mode) => ({
|
|
id: mode as ApprovalModeValue,
|
|
name: APPROVAL_MODE_INFO[mode].name,
|
|
description: APPROVAL_MODE_INFO[mode].description,
|
|
}));
|
|
|
|
return {
|
|
currentModeId: currentApprovalMode as ApprovalModeValue,
|
|
availableModes,
|
|
};
|
|
}
|
|
|
|
private buildConfigOptions(config: Config): SessionConfigOption[] {
|
|
const currentApprovalMode = config.getApprovalMode();
|
|
const allConfiguredModels = config.getAllConfiguredModels();
|
|
const rawCurrentModelId = (config.getModel() || '').trim();
|
|
const currentAuthType = config.getAuthType?.();
|
|
|
|
const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.();
|
|
const currentModelId = activeRuntimeSnapshot
|
|
? formatAcpModelId(
|
|
activeRuntimeSnapshot.id,
|
|
activeRuntimeSnapshot.authType,
|
|
)
|
|
: this.formatCurrentModelId(rawCurrentModelId, currentAuthType);
|
|
|
|
const modeOptions = APPROVAL_MODES.map((mode) => ({
|
|
value: mode,
|
|
name: APPROVAL_MODE_INFO[mode].name,
|
|
description: APPROVAL_MODE_INFO[mode].description,
|
|
}));
|
|
|
|
const modeConfigOption: SessionConfigOption = {
|
|
id: 'mode',
|
|
name: 'Mode',
|
|
description: 'Session permission mode',
|
|
category: 'mode',
|
|
type: 'select' as const,
|
|
currentValue: currentApprovalMode,
|
|
options: modeOptions,
|
|
};
|
|
|
|
const modelOptions = allConfiguredModels.map((model) => {
|
|
const effectiveModelId =
|
|
model.isRuntimeModel && model.runtimeSnapshotId
|
|
? model.runtimeSnapshotId
|
|
: model.id;
|
|
return {
|
|
value: formatAcpModelId(effectiveModelId, model.authType),
|
|
name: model.label,
|
|
description: model.description ?? '',
|
|
};
|
|
});
|
|
|
|
const modelConfigOption: SessionConfigOption = {
|
|
id: 'model',
|
|
name: 'Model',
|
|
description: 'AI model to use',
|
|
category: 'model',
|
|
type: 'select' as const,
|
|
currentValue: currentModelId,
|
|
options: modelOptions,
|
|
};
|
|
|
|
return [modeConfigOption, modelConfigOption];
|
|
}
|
|
|
|
private formatCurrentModelId(
|
|
baseModelId: string,
|
|
authType?: AuthType,
|
|
): string {
|
|
if (!baseModelId) return baseModelId;
|
|
return authType ? formatAcpModelId(baseModelId, authType) : baseModelId;
|
|
}
|
|
}
|