feat: create draft framework for cli & sdk

This commit is contained in:
mingholy.lmh
2025-11-23 19:36:31 +08:00
parent 6729980b47
commit e1ffaec499
58 changed files with 8982 additions and 668 deletions

View File

@@ -65,6 +65,7 @@ import { ideContextStore } from '../ide/ideContext.js';
import { InputFormat, OutputFormat } from '../output/types.js';
import { PromptRegistry } from '../prompts/prompt-registry.js';
import { SubagentManager } from '../subagents/subagent-manager.js';
import type { SubagentConfig } from '../subagents/types.js';
import {
DEFAULT_OTLP_ENDPOINT,
DEFAULT_TELEMETRY_TARGET,
@@ -333,9 +334,11 @@ export interface ConfigParameters {
eventEmitter?: EventEmitter;
useSmartEdit?: boolean;
output?: OutputSettings;
skipStartupContext?: boolean;
inputFormat?: InputFormat;
outputFormat?: OutputFormat;
skipStartupContext?: boolean;
sdkMode?: boolean;
sessionSubagents?: SubagentConfig[];
}
function normalizeConfigOutputFormat(
@@ -383,8 +386,10 @@ export class Config {
private readonly toolDiscoveryCommand: string | undefined;
private readonly toolCallCommand: string | undefined;
private readonly mcpServerCommand: string | undefined;
private readonly mcpServers: Record<string, MCPServerConfig> | undefined;
private mcpServers: Record<string, MCPServerConfig> | undefined;
private sessionSubagents: SubagentConfig[];
private userMemory: string;
private sdkMode: boolean;
private geminiMdFileCount: number;
private approvalMode: ApprovalMode;
private readonly showMemoryUsage: boolean;
@@ -487,6 +492,8 @@ export class Config {
this.toolCallCommand = params.toolCallCommand;
this.mcpServerCommand = params.mcpServerCommand;
this.mcpServers = params.mcpServers;
this.sessionSubagents = params.sessionSubagents ?? [];
this.sdkMode = params.sdkMode ?? false;
this.userMemory = params.userMemory ?? '';
this.geminiMdFileCount = params.geminiMdFileCount ?? 0;
this.approvalMode = params.approvalMode ?? ApprovalMode.DEFAULT;
@@ -842,6 +849,46 @@ export class Config {
return this.mcpServers;
}
setMcpServers(servers: Record<string, MCPServerConfig>): void {
if (this.initialized) {
throw new Error('Cannot modify mcpServers after initialization');
}
this.mcpServers = servers;
}
addMcpServers(servers: Record<string, MCPServerConfig>): void {
if (this.initialized) {
throw new Error('Cannot modify mcpServers after initialization');
}
this.mcpServers = { ...this.mcpServers, ...servers };
}
getSessionSubagents(): SubagentConfig[] {
return this.sessionSubagents;
}
setSessionSubagents(subagents: SubagentConfig[]): void {
if (this.initialized) {
throw new Error('Cannot modify sessionSubagents after initialization');
}
this.sessionSubagents = subagents;
}
addSessionSubagents(subagents: SubagentConfig[]): void {
if (this.initialized) {
throw new Error('Cannot modify sessionSubagents after initialization');
}
this.sessionSubagents = [...this.sessionSubagents, ...subagents];
}
getSdkMode(): boolean {
return this.sdkMode;
}
setSdkMode(value: boolean): void {
this.sdkMode = value;
}
getUserMemory(): string {
return this.userMemory;
}

View File

@@ -916,7 +916,10 @@ export class CoreToolScheduler {
async handleConfirmationResponse(
callId: string,
originalOnConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>,
originalOnConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>,
outcome: ToolConfirmationOutcome,
signal: AbortSignal,
payload?: ToolConfirmationPayload,
@@ -925,9 +928,7 @@ export class CoreToolScheduler {
(c) => c.request.callId === callId && c.status === 'awaiting_approval',
);
if (toolCall && toolCall.status === 'awaiting_approval') {
await originalOnConfirm(outcome);
}
await originalOnConfirm(outcome, payload);
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
await this.autoApproveCompatiblePendingTools(signal, callId);
@@ -936,11 +937,10 @@ export class CoreToolScheduler {
this.setToolCallOutcome(callId, outcome);
if (outcome === ToolConfirmationOutcome.Cancel || signal.aborted) {
this.setStatusInternal(
callId,
'cancelled',
'User did not allow tool call',
);
// Use custom cancel message from payload if provided, otherwise use default
const cancelMessage =
payload?.cancelMessage || 'User did not allow tool call';
this.setStatusInternal(callId, 'cancelled', cancelMessage);
} else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) {
const waitingToolCall = toolCall as WaitingToolCall;
if (isModifiableDeclarativeTool(waitingToolCall.tool)) {
@@ -998,7 +998,8 @@ export class CoreToolScheduler {
): Promise<void> {
if (
toolCall.confirmationDetails.type !== 'edit' ||
!isModifiableDeclarativeTool(toolCall.tool)
!isModifiableDeclarativeTool(toolCall.tool) ||
!payload.newContent
) {
return;
}

View File

@@ -8,6 +8,7 @@ import { EventEmitter } from 'events';
import type {
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
ToolResultDisplay,
} from '../tools/tools.js';
import type { Part } from '@google/genai';
@@ -74,7 +75,7 @@ export interface SubAgentToolResultEvent {
success: boolean;
error?: string;
responseParts?: Part[];
resultDisplay?: string;
resultDisplay?: ToolResultDisplay;
durationMs?: number;
timestamp: number;
}

View File

@@ -77,6 +77,15 @@ export class SubagentManager {
): Promise<void> {
this.validator.validateOrThrow(config);
// Prevent creating session-level agents
if (options.level === 'session') {
throw new SubagentError(
`Cannot create session-level subagent "${config.name}". Session agents are read-only and provided at runtime.`,
SubagentErrorCode.INVALID_CONFIG,
config.name,
);
}
// Determine file path
const filePath =
options.customPath || this.getSubagentPath(config.name, options.level);
@@ -142,6 +151,11 @@ export class SubagentManager {
return BuiltinAgentRegistry.getBuiltinAgent(name);
}
if (level === 'session') {
const sessionSubagents = this.subagentsCache?.get('session') || [];
return sessionSubagents.find((agent) => agent.name === name) || null;
}
return this.findSubagentByNameAtLevel(name, level);
}
@@ -191,6 +205,15 @@ export class SubagentManager {
);
}
// Prevent updating session-level agents
if (existing.level === 'session') {
throw new SubagentError(
`Cannot update session-level subagent "${name}"`,
SubagentErrorCode.INVALID_CONFIG,
name,
);
}
// Merge updates with existing configuration
const updatedConfig = this.mergeConfigurations(existing, updates);
@@ -236,8 +259,8 @@ export class SubagentManager {
let deleted = false;
for (const currentLevel of levelsToCheck) {
// Skip builtin level for deletion
if (currentLevel === 'builtin') {
// Skip builtin and session levels for deletion
if (currentLevel === 'builtin' || currentLevel === 'session') {
continue;
}
@@ -277,6 +300,38 @@ export class SubagentManager {
const subagents: SubagentConfig[] = [];
const seenNames = new Set<string>();
// In SDK mode, only load session-level subagents
if (this.config.getSdkMode()) {
const sessionSubagents = this.config.getSessionSubagents();
if (sessionSubagents && sessionSubagents.length > 0) {
this.loadSessionSubagents(sessionSubagents);
}
const levelsToCheck: SubagentLevel[] = options.level
? [options.level]
: ['session'];
for (const level of levelsToCheck) {
const levelSubagents = this.subagentsCache?.get(level) || [];
for (const subagent of levelSubagents) {
// Apply tool filter if specified
if (
options.hasTool &&
(!subagent.tools || !subagent.tools.includes(options.hasTool))
) {
continue;
}
subagents.push(subagent);
seenNames.add(subagent.name);
}
}
return subagents;
}
// Normal mode: load from project, user, and builtin levels
const levelsToCheck: SubagentLevel[] = options.level
? [options.level]
: ['project', 'user', 'builtin'];
@@ -322,8 +377,8 @@ export class SubagentManager {
comparison = a.name.localeCompare(b.name);
break;
case 'level': {
// Project comes before user, user comes before builtin
const levelOrder = { project: 0, user: 1, builtin: 2 };
// Project comes before user, user comes before builtin, session comes last
const levelOrder = { project: 0, user: 1, builtin: 2, session: 3 };
comparison = levelOrder[a.level] - levelOrder[b.level];
break;
}
@@ -339,6 +394,27 @@ export class SubagentManager {
return subagents;
}
/**
* Loads session-level subagents into the cache.
* Session subagents are provided directly via config and are read-only.
*
* @param subagents - Array of session subagent configurations
*/
loadSessionSubagents(subagents: SubagentConfig[]): void {
if (!this.subagentsCache) {
this.subagentsCache = new Map();
}
const sessionSubagents = subagents.map((config) => ({
...config,
level: 'session' as SubagentLevel,
filePath: `<session:${config.name}>`,
}));
this.subagentsCache.set('session', sessionSubagents);
this.notifyChangeListeners();
}
/**
* Refreshes the subagents cache by loading all subagents from disk.
* This method is called automatically when cache is null or when force=true.
@@ -693,6 +769,10 @@ export class SubagentManager {
return `<builtin:${name}>`;
}
if (level === 'session') {
return `<session:${name}>`;
}
const baseDir =
level === 'project'
? path.join(

View File

@@ -11,8 +11,9 @@ import type { Content, FunctionDeclaration } from '@google/genai';
* - 'project': Stored in `.qwen/agents/` within the project directory
* - 'user': Stored in `~/.qwen/agents/` in the user's home directory
* - 'builtin': Built-in agents embedded in the codebase, always available
* - 'session': Session-level agents provided at runtime, read-only
*/
export type SubagentLevel = 'project' | 'user' | 'builtin';
export type SubagentLevel = 'project' | 'user' | 'builtin' | 'session';
/**
* Core configuration for a subagent as stored in Markdown files.

View File

@@ -10,6 +10,7 @@ import type {
ToolInvocation,
ToolMcpConfirmationDetails,
ToolResult,
ToolConfirmationPayload,
} from './tools.js';
import {
BaseDeclarativeTool,
@@ -98,7 +99,10 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation<
serverName: this.serverName,
toolName: this.serverToolName, // Display original tool name in confirmation
toolDisplayName: this.displayName, // Display global registry name exposed to model and user
onConfirm: async (outcome: ToolConfirmationOutcome) => {
onConfirm: async (
outcome: ToolConfirmationOutcome,
_payload?: ToolConfirmationPayload,
) => {
if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) {
DiscoveredMCPToolInvocation.allowlist.add(serverAllowListKey);
} else if (outcome === ToolConfirmationOutcome.ProceedAlwaysTool) {

View File

@@ -17,6 +17,7 @@ import type {
ToolResultDisplay,
ToolCallConfirmationDetails,
ToolExecuteConfirmationDetails,
ToolConfirmationPayload,
} from './tools.js';
import {
BaseDeclarativeTool,
@@ -102,7 +103,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
title: 'Confirm Shell Command',
command: this.params.command,
rootCommand: commandsToConfirm.join(', '),
onConfirm: async (outcome: ToolConfirmationOutcome) => {
onConfirm: async (
outcome: ToolConfirmationOutcome,
_payload?: ToolConfirmationPayload,
) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
commandsToConfirm.forEach((command) => this.allowlist.add(command));
}

View File

@@ -531,13 +531,18 @@ export interface ToolEditConfirmationDetails {
export interface ToolConfirmationPayload {
// used to override `modifiedProposedContent` for modifiable tools in the
// inline modify flow
newContent: string;
newContent?: string;
// used to provide custom cancellation message when outcome is Cancel
cancelMessage?: string;
}
export interface ToolExecuteConfirmationDetails {
type: 'exec';
title: string;
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
command: string;
rootCommand: string;
}
@@ -548,7 +553,10 @@ export interface ToolMcpConfirmationDetails {
serverName: string;
toolName: string;
toolDisplayName: string;
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
}
export interface ToolInfoConfirmationDetails {
@@ -573,6 +581,11 @@ export interface ToolPlanConfirmationDetails {
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
}
/**
* TODO:
* 1. support explicit denied outcome
* 2. support proceed with modified input
*/
export enum ToolConfirmationOutcome {
ProceedOnce = 'proceed_once',
ProceedAlways = 'proceed_always',