feat: 按机器人选择 Agent Preset

在九个渠道的设置页卡片上选择 Host 已有预设;未选则跟随默认,只影响之后新建的会话。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
iamyaojie 2026-08-21 19:19:45 +08:00
parent c3b3a29f27
commit 3161816cc4
51 changed files with 2227 additions and 740 deletions

View file

@ -0,0 +1,74 @@
/** Matches DeepSeek Harness agent-preset directory ids. */
export const AGENT_PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
export const EMPTY_AGENT_PRESET_CATALOG = Object.freeze({
defaultId: '',
items: Object.freeze([]),
});
export function normalizeAgentPresetId(value) {
if (value == null) return null;
if (typeof value !== 'string') return null;
const id = value.trim();
return AGENT_PRESET_ID.test(id) ? id : null;
}
export function validateAgentPresetId(value) {
if (value == null || value === '') return null;
const id = normalizeAgentPresetId(value);
if (!id) {
const error = new Error('Agent Preset 无效。');
error.code = 'agent-preset-invalid';
throw error;
}
return id;
}
function catalogItem(value) {
if (typeof value === 'string') {
const id = normalizeAgentPresetId(value);
return id ? { id, label: id } : null;
}
if (!value || typeof value !== 'object') return null;
const id = normalizeAgentPresetId(value.id);
if (!id) return null;
const label = typeof value.name === 'string' && value.name.trim()
? value.name.trim().slice(0, 128)
: typeof value.label === 'string' && value.label.trim()
? value.label.trim().slice(0, 128)
: id;
return { id, label };
}
export function normalizeAgentPresetCatalog(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { defaultId: '', items: [] };
}
const items = [];
const seen = new Set();
for (const entry of Array.isArray(value.items) ? value.items : []) {
const item = catalogItem(entry);
if (!item || seen.has(item.id)) continue;
seen.add(item.id);
items.push(item);
}
return {
defaultId: normalizeAgentPresetId(value.defaultId) ?? '',
items,
};
}
export async function listAgentPresetCatalog(ctx) {
const service = typeof ctx?.get === 'function' ? ctx.get('agentPresets') : ctx?.agentPresets;
if (!service || typeof service.list !== 'function') return { defaultId: '', items: [] };
let listed = [];
try {
listed = await service.list();
} catch {
return { defaultId: '', items: [] };
}
return normalizeAgentPresetCatalog({
defaultId: typeof service.defaultId === 'string' ? service.defaultId : '',
items: Array.isArray(listed) ? listed : [],
});
}

View file

@ -9,6 +9,7 @@ import {
} from 'node:fs/promises';
import { dirname, isAbsolute, resolve } from 'node:path';
import { validateAgentPresetId } from './agent-preset.mjs';
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
@ -49,7 +50,22 @@ function normalizeDocument(value) {
|| typeof workspace !== 'string' || !isAbsolute(workspace)) return null;
workspaces[botId] = resolve(workspace);
}
return { version: 1, workspaces };
let agentPresets = {};
if (value.agentPresets !== undefined) {
if (!value.agentPresets || typeof value.agentPresets !== 'object'
|| Array.isArray(value.agentPresets)) return null;
for (const [botId, agentPreset] of Object.entries(value.agentPresets)) {
if (!/^[A-Za-z0-9_-]{1,128}$/.test(botId)) return null;
try {
const normalized = validateAgentPresetId(agentPreset);
if (!normalized) return null;
agentPresets[botId] = normalized;
} catch {
return null;
}
}
}
return { version: 1, workspaces, agentPresets };
}
export async function validateWorkspacePath(value) {
@ -79,6 +95,7 @@ export class BotWorkspaceStore {
#path;
#defaultWorkspace;
#workspaces = {};
#agentPresets = {};
#generations = new Map();
#nextGeneration = 1;
#incarnations = new Map();
@ -100,9 +117,11 @@ export class BotWorkspaceStore {
const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
if (!normalized) throw new Error('dsh-im workspace config is invalid');
this.#workspaces = normalized.workspaces;
this.#agentPresets = normalized.agentPresets;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
this.#workspaces = {};
this.#agentPresets = {};
}
this.#generations.clear();
this.#nextGeneration = 1;
@ -130,6 +149,10 @@ export class BotWorkspaceStore {
return this.#workspaces[botIdOf(botId)] ?? this.#defaultWorkspace;
}
agentPresetFor(botId) {
return this.#agentPresets[botIdOf(botId)] ?? null;
}
generationFor(botId) {
return this.#generations.get(botIdOf(botId)) ?? null;
}
@ -207,6 +230,37 @@ export class BotWorkspaceStore {
});
}
async setAgentPreset(botId, value, { incarnation } = {}) {
const id = botIdOf(botId);
if (!this.has(id)
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const agentPreset = validateAgentPresetId(value);
return this.#enqueue(id, async () => {
if (!this.has(id)
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const previous = this.#agentPresets[id] ?? null;
if (previous === agentPreset) return agentPreset;
if (agentPreset) this.#agentPresets[id] = agentPreset;
else delete this.#agentPresets[id];
try {
await this.#persist();
} catch (error) {
if (previous) this.#agentPresets[id] = previous;
else delete this.#agentPresets[id];
throw error;
}
return agentPreset;
});
}
async bindWorkspaceSession(botId, value, {
conversationKey,
sessionId,
@ -353,7 +407,11 @@ export class BotWorkspaceStore {
async reconcile(activeBotIds) {
const active = new Set([...activeBotIds].map(botIdOf));
const candidates = new Set([...Object.keys(this.#workspaces), ...this.#dirtyRemovals]);
const candidates = new Set([
...Object.keys(this.#workspaces),
...Object.keys(this.#agentPresets),
...this.#dirtyRemovals,
]);
for (const botId of candidates) {
if (!active.has(botId)) await this.remove(botId);
}
@ -364,7 +422,11 @@ export class BotWorkspaceStore {
return {
...status,
bots: status.bots.map((bot) => bot?.botId
? { ...bot, workspace: this.workspaceFor(bot.botId) }
? {
...bot,
workspace: this.workspaceFor(bot.botId),
agentPreset: this.agentPresetFor(bot.botId),
}
: bot),
};
}
@ -392,8 +454,10 @@ export class BotWorkspaceStore {
async #retireCurrentIncarnation(id) {
const hadWorkspace = Object.hasOwn(this.#workspaces, id);
const needsCleanup = hadWorkspace || this.#dirtyRemovals.has(id);
const hadPreset = Object.hasOwn(this.#agentPresets, id);
const needsCleanup = hadWorkspace || hadPreset || this.#dirtyRemovals.has(id);
delete this.#workspaces[id];
delete this.#agentPresets[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
if (!needsCleanup) return {
@ -425,6 +489,9 @@ export class BotWorkspaceStore {
async #persist() {
const document = { version: 1, workspaces: this.#workspaces };
if (Object.keys(this.#agentPresets).length > 0) {
document.agentPresets = this.#agentPresets;
}
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
const temporary = `${this.#path}.tmp`;
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
@ -436,7 +503,8 @@ export class BotWorkspaceStore {
}
async #persistCurrentDocument() {
if (Object.keys(this.#workspaces).length > 0) {
if (Object.keys(this.#workspaces).length > 0
|| Object.keys(this.#agentPresets).length > 0) {
await this.#persist();
return;
}
@ -450,10 +518,18 @@ export class BotWorkspaceStore {
}
}
function decorateResult(workspaces, result) {
function decorateResult(workspaces, result, catalog) {
const decorate = (value) => {
const decorated = workspaces.decorateStatus(value);
if (!catalog || !decorated || typeof decorated !== 'object') return decorated;
const agentPresetCatalog = typeof catalog === 'function' ? catalog() : catalog;
return agentPresetCatalog
? { ...decorated, agentPresetCatalog }
: decorated;
};
return result && typeof result.then === 'function'
? result.then((value) => workspaces.decorateStatus(value))
: workspaces.decorateStatus(result);
? result.then(decorate)
: decorate(result);
}
function targetStatus(controller) {
@ -608,9 +684,11 @@ export function createBotWorkspaceScope(harness, { botId, workspaces, state }) {
throw error;
}
const generation = workspaces.generationFor(botId);
const agentPreset = workspaces.agentPresetFor(botId);
const sessionId = await target.createSession({
...options,
workspace: workspaces.workspaceFor(botId),
...(agentPreset == null ? {} : { agentPreset }),
});
sessionGenerations.set(sessionId, generation);
return sessionId;
@ -765,7 +843,7 @@ export function createBotScopedHarness(harness, options) {
return createBotWorkspaceScope(harness, options).harness;
}
export function createWorkspaceAwareController(controller, { workspaces, stateFor }) {
export function createWorkspaceAwareController(controller, { workspaces, stateFor, agentPresetCatalog } = {}) {
if (!controller || !workspaces || typeof stateFor !== 'function') {
throw new TypeError('controller, workspaces, and stateFor are required');
}
@ -778,6 +856,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
if (transitions.get(botId) === current) transitions.delete(botId);
});
};
const decorate = (value) => decorateResult(workspaces, value, agentPresetCatalog);
const updateWorkspace = (botId, workspace) => {
// Capture at API invocation, before even waiting for an older outer
// transition. A queued request still belongs to the incarnation that the
@ -795,7 +874,20 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
clearSessions: () => state.clearSessions(),
incarnation,
});
return workspaces.decorateStatus(await controller.status());
return decorate(await controller.status());
});
};
const updateAgentPreset = (botId, agentPreset) => {
const incarnation = workspaces.incarnationFor(botId);
return withBotTransition(botId, async () => {
const snapshot = await controller.status();
if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
await workspaces.setAgentPreset(botId, agentPreset, { incarnation });
return decorate(await controller.status());
});
};
const deleteWithWorkspace = (botId, invokeDelete) => withBotTransition(botId, async () => {
@ -822,7 +914,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
try {
const result = await invokeDelete();
await workspaces.finishRemoval(removal);
return workspaces.decorateStatus(result);
return decorate(result);
} catch (error) {
const after = await targetStatus(controller).catch(() => null);
const knownAbsent = Array.isArray(after?.bots)
@ -836,6 +928,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
return new Proxy(controller, {
get(target, property) {
if (property === 'updateWorkspace') return updateWorkspace;
if (property === 'updateAgentPreset') return updateAgentPreset;
const value = Reflect.get(target, property, target);
if (typeof value !== 'function') return value;
if (property === 'deleteBot') {
@ -848,11 +941,11 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
return async (...args) => {
const before = await target.status();
const botId = before?.bots?.[0]?.botId;
if (!botId) return decorateResult(workspaces, value.apply(target, args));
if (!botId) return decorate(value.apply(target, args));
return deleteWithWorkspace(botId, () => value.apply(target, args));
};
}
return (...args) => decorateResult(workspaces, value.apply(target, args));
return (...args) => decorate(value.apply(target, args));
},
});
}

View file

@ -592,11 +592,13 @@ export class HarnessClient {
}
async createSession(options = {}) {
await this.ensureRunning(options);
const workspaceId = await this.workspaceId(options);
const { agentPreset: requestedPreset, ...rpcOptions } = options;
await this.ensureRunning(rpcOptions);
const workspaceId = await this.workspaceId(rpcOptions);
const payload = { workspaceId };
if (this.#agentPreset !== undefined) payload.agentPreset = this.#agentPreset;
const created = await this.rpc('session.create', payload, 30_000, options);
const agentPreset = requestedPreset !== undefined ? requestedPreset : this.#agentPreset;
if (agentPreset != null) payload.agentPreset = agentPreset;
const created = await this.rpc('session.create', payload, 30_000, rpcOptions);
return created.sessionId;
}