merge: honor Harness default agent preset

This commit is contained in:
xmanrui 2026-08-19 11:29:44 +08:00
commit 7e96c9ce55
15 changed files with 109 additions and 34 deletions

View file

@ -74,6 +74,8 @@ Feishu QR binding records the scanner as an allowed user. Manual credentials can
Each bot maintains an independent Harness workspace. A newly connected bot records the Harness Host process's current working directory (`process.cwd()`) as its default; the path is persisted and does not change when the Host is later restarted from another directory. Every bot card shows the current path and lets it be edited. Each bot maintains an independent Harness workspace. A newly connected bot records the Harness Host process's current working directory (`process.cwd()`) as its default; the path is persisted and does not change when the Host is later restarted from another directory. Every bot card shows the current path and lets it be edited.
When a channel does not explicitly configure `agentPreset`, each new IM session inherits the Harness-wide `agent-presets.default`. Harness reads that default whenever it creates a session, so changing the setting affects only sessions created afterwards; existing sessions and sessions bound with `/session` keep their own preset. An explicit channel `agentPreset` always takes precedence for newly created sessions.
## Bot commands ## Bot commands
| Command | Description | | Command | Description |

View file

@ -77,6 +77,8 @@ QQ 扫码接入使用腾讯 QQBot v2 官方流程。默认腾讯授权页会把
每个机器人维护独立的 Harness 工作区。新接入机器人会把 Harness Host 进程当时的工作目录(`process.cwd()`)记录为默认值;该路径会持久化,不会因为以后从其他目录重启 Host 而改变。设置页的机器人卡片会显示当前路径,并可直接修改。 每个机器人维护独立的 Harness 工作区。新接入机器人会把 Harness Host 进程当时的工作目录(`process.cwd()`)记录为默认值;该路径会持久化,不会因为以后从其他目录重启 Host 而改变。设置页的机器人卡片会显示当前路径,并可直接修改。
渠道没有显式配置 `agentPreset` 时,新建的 IM 会话继承 Harness 的 `agent-presets.default` 全局默认值。Harness 会在每次创建会话时读取该默认值,因此修改设置只影响之后创建的新会话;已有会话以及通过 `/session` 绑定的会话保持自身 preset。渠道显式配置的 `agentPreset` 始终优先。
## 机器人命令 ## 机器人命令
| 命令 | 作用 | | 命令 | 作用 |

File diff suppressed because one or more lines are too long

View file

@ -84,7 +84,7 @@ export async function createProductionController(ctx, config = {}, internals = {
const harness = new Harness({ const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: defaultWorkspace, workspace: defaultWorkspace,
agentPreset: config.agentPreset ?? 'standard', ...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
autostart: false, autostart: false,
dshBin: config.dshBin ?? 'dsh', dshBin: config.dshBin ?? 'dsh',
...(commandExecutor ? { commandExecutor } : {}), ...(commandExecutor ? { commandExecutor } : {}),

View file

@ -110,7 +110,7 @@ export async function createProductionController(ctx, config = {}, internals = {
const harness = new Harness({ const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: defaultWorkspace, workspace: defaultWorkspace,
agentPreset: config.agentPreset ?? 'standard', ...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
// This plugin is already hosted by a running DSH process. Starting a // This plugin is already hosted by a running DSH process. Starting a
// second DSH would create a competing server and lifecycle. // second DSH would create a competing server and lifecycle.
autostart: false, autostart: false,

View file

@ -75,7 +75,7 @@ export async function createProductionController(ctx, config = {}, internals = {
const harness = new Harness({ const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: defaultWorkspace, workspace: defaultWorkspace,
agentPreset: config.agentPreset ?? 'standard', ...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
autostart: false, autostart: false,
dshBin: config.dshBin ?? 'dsh', dshBin: config.dshBin ?? 'dsh',
...(commandExecutor ? { commandExecutor } : {}), ...(commandExecutor ? { commandExecutor } : {}),

View file

@ -69,7 +69,7 @@ export async function createTokenProductionController(ctx, config, internals, de
const harness = new ResolvedHarness({ const harness = new ResolvedHarness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: defaultWorkspace, workspace: defaultWorkspace,
agentPreset: config.agentPreset ?? 'standard', ...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
autostart: false, autostart: false,
dshBin: config.dshBin ?? 'dsh', dshBin: config.dshBin ?? 'dsh',
...(commandExecutor ? { commandExecutor } : {}), ...(commandExecutor ? { commandExecutor } : {}),

View file

@ -54,7 +54,7 @@ export async function createProductionController(ctx, config = {}, internals = {
const harness = new ResolvedHarness({ const harness = new ResolvedHarness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: defaultWorkspace, workspace: defaultWorkspace,
agentPreset: config.agentPreset ?? 'standard', ...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
autostart: false, autostart: false,
dshBin: config.dshBin ?? 'dsh', dshBin: config.dshBin ?? 'dsh',
...(commandExecutor ? { commandExecutor } : {}), ...(commandExecutor ? { commandExecutor } : {}),

View file

@ -78,7 +78,7 @@ export async function createProductionController(ctx, config = {}, internals = {
const harness = new Harness({ const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: defaultWorkspace, workspace: defaultWorkspace,
agentPreset: config.agentPreset ?? 'standard', ...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
autostart: false, autostart: false,
dshBin: config.dshBin ?? 'dsh', dshBin: config.dshBin ?? 'dsh',
...(commandExecutor ? { commandExecutor } : {}), ...(commandExecutor ? { commandExecutor } : {}),

View file

@ -78,7 +78,7 @@ export async function createProductionController(ctx, config = {}, internals = {
const harness = new Harness({ const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: defaultWorkspace, workspace: defaultWorkspace,
agentPreset: config.agentPreset ?? 'standard', ...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
autostart: false, autostart: false,
dshBin: config.dshBin ?? 'dsh', dshBin: config.dshBin ?? 'dsh',
...(commandExecutor ? { commandExecutor } : {}), ...(commandExecutor ? { commandExecutor } : {}),

View file

@ -81,7 +81,7 @@ export async function createProductionController(ctx, config = {}, internals = {
const harness = new Harness({ const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
workspace: defaultWorkspace, workspace: defaultWorkspace,
agentPreset: config.agentPreset ?? 'standard', ...(config.agentPreset == null ? {} : { agentPreset: config.agentPreset }),
autostart: false, autostart: false,
dshBin: config.dshBin ?? 'dsh', dshBin: config.dshBin ?? 'dsh',
...(commandExecutor ? { commandExecutor } : {}), ...(commandExecutor ? { commandExecutor } : {}),

View file

@ -327,7 +327,7 @@ export class HarnessClient {
constructor({ constructor({
baseUrl, baseUrl,
workspace, workspace,
agentPreset = 'standard', agentPreset,
autostart = false, autostart = false,
dshBin = 'dsh', dshBin = 'dsh',
fetchImpl = fetch, fetchImpl = fetch,
@ -354,7 +354,8 @@ export class HarnessClient {
} }
this.#baseUrl = new URL(baseUrl); this.#baseUrl = new URL(baseUrl);
this.#workspace = workspace; this.#workspace = workspace;
this.#agentPreset = agentPreset; // Keep an omitted preset absent so session.create resolves the Host's current default.
this.#agentPreset = agentPreset ?? undefined;
this.#autostart = autostart; this.#autostart = autostart;
this.#dshBin = dshBin; this.#dshBin = dshBin;
this.#fetch = fetchImpl; this.#fetch = fetchImpl;
@ -458,10 +459,9 @@ export class HarnessClient {
async createSession(options = {}) { async createSession(options = {}) {
await this.ensureRunning(options); await this.ensureRunning(options);
const workspaceId = await this.workspaceId(options); const workspaceId = await this.workspaceId(options);
const created = await this.rpc('session.create', { const payload = { workspaceId };
workspaceId, if (this.#agentPreset !== undefined) payload.agentPreset = this.#agentPreset;
agentPreset: this.#agentPreset, const created = await this.rpc('session.create', payload, 30_000, options);
}, 30_000, options);
return created.sessionId; return created.sessionId;
} }

View file

@ -58,6 +58,7 @@ test('production assembly keeps secrets in credentials and creates per-bot runti
assert.equal(seen.controllerOptions.credentials, credentials); assert.equal(seen.controllerOptions.credentials, credentials);
assert.equal(seen.harnessOptions.baseUrl.href, 'http://127.0.0.1:3080/'); assert.equal(seen.harnessOptions.baseUrl.href, 'http://127.0.0.1:3080/');
assert.equal(seen.harnessOptions.autostart, false); assert.equal(seen.harnessOptions.autostart, false);
assert.equal(Object.hasOwn(seen.harnessOptions, 'agentPreset'), false);
const runtime = await seen.controllerOptions.createRuntime({ const runtime = await seen.controllerOptions.createRuntime({
botId: 'dt_abc', botId: 'dt_abc',
config: { botId: 'dt_abc', clientId: 'dingabc' }, config: { botId: 'dt_abc', clientId: 'dingabc' },
@ -71,4 +72,21 @@ test('production assembly keeps secrets in credentials and creates per-bot runti
assert.equal(seen.supervisorClosed, true); assert.equal(seen.supervisorClosed, true);
assert.equal(seen.controllerClosed, true); assert.equal(seen.controllerClosed, true);
assert.equal(seen.harnessStopped, true); assert.equal(seen.harnessStopped, true);
const productionWithPreset = await createProductionController({
credentials,
webServer: { port: 3080 },
logger: () => console,
}, { dataDir: directory, agentPreset: 'router-standard' }, {
ConfigStore,
DeviceAuth,
StateStore,
HarnessClient: Harness,
Controller,
Runtime,
createConnectionSupervisor: () => supervisor,
});
assert.equal(seen.harnessOptions.agentPreset, 'router-standard');
await productionWithPreset.close();
}); });

View file

@ -45,6 +45,34 @@ test('all legacy channel clients now use the shared Harness RPC transport', asyn
} }
}); });
test('HarnessClient lets the Host resolve an omitted agent preset and forwards an explicit override', async () => {
const createPayload = async (options = {}) => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/default-workspace',
...options,
});
let payload;
client.ensureRunning = async () => true;
client.workspaceId = async () => 'workspace-one';
client.rpc = async (method, value) => {
assert.equal(method, 'session.create');
payload = value;
return { sessionId: 'session-one' };
};
assert.equal(await client.createSession(), 'session-one');
return payload;
};
assert.deepEqual(await createPayload(), { workspaceId: 'workspace-one' });
assert.deepEqual(await createPayload({ agentPreset: 'router-standard' }), {
workspaceId: 'workspace-one',
agentPreset: 'router-standard',
});
assert.deepEqual(await createPayload({ agentPreset: null }), { workspaceId: 'workspace-one' });
});
test('HarnessClient lists only absolute workspace paths', async () => { test('HarnessClient lists only absolute workspace paths', async () => {
const client = new HarnessClient({ const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080', baseUrl: 'http://127.0.0.1:3080',

View file

@ -9,6 +9,17 @@ import {
} from '../src/channels/shared/harness-client.mjs'; } from '../src/channels/shared/harness-client.mjs';
import { createHarnessCommandExecutor } from '../plugin-src/host/harness-command-executor.mjs'; import { createHarnessCommandExecutor } from '../plugin-src/host/harness-command-executor.mjs';
const PRODUCTION_FILES = [
'plugin-src/host/channels/feishu/production.mjs',
'plugin-src/host/channels/weixin/production.mjs',
'plugin-src/host/channels/dingtalk/production.mjs',
'plugin-src/host/channels/wecom/production.mjs',
'plugin-src/host/channels/qq/production.mjs',
'plugin-src/host/channels/slack/production.mjs',
'plugin-src/host/channels/shared/production.mjs',
'plugin-src/host/channels/whatsapp/production.mjs',
];
function state(sessionId = 'session-one') { function state(sessionId = 'session-one') {
return { sessionFor: () => sessionId }; return { sessionFor: () => sessionId };
} }
@ -142,17 +153,7 @@ test('Host command executor invokes the commands Typert endpoint with the Sessio
}); });
test('all nine production channels receive the Host command executor', async () => { test('all nine production channels receive the Host command executor', async () => {
const productionFiles = [ for (const path of PRODUCTION_FILES) {
'plugin-src/host/channels/feishu/production.mjs',
'plugin-src/host/channels/weixin/production.mjs',
'plugin-src/host/channels/dingtalk/production.mjs',
'plugin-src/host/channels/wecom/production.mjs',
'plugin-src/host/channels/qq/production.mjs',
'plugin-src/host/channels/slack/production.mjs',
'plugin-src/host/channels/shared/production.mjs',
'plugin-src/host/channels/whatsapp/production.mjs',
];
for (const path of productionFiles) {
const source = await readFile(new URL(`../${path}`, import.meta.url), 'utf8'); const source = await readFile(new URL(`../${path}`, import.meta.url), 'utf8');
assert.match(source, /createHarnessCommandExecutor\(ctx, internals\.commandExecutor\)/, path); assert.match(source, /createHarnessCommandExecutor\(ctx, internals\.commandExecutor\)/, path);
assert.match(source, /commandExecutor \? \{ commandExecutor \} : \{\}/, path); assert.match(source, /commandExecutor \? \{ commandExecutor \} : \{\}/, path);
@ -169,3 +170,27 @@ test('all nine production channels receive the Host command executor', async ()
assert.match(source, /'typertGateway'/, channel); assert.match(source, /'typertGateway'/, channel);
} }
}); });
test('all nine production channels defer an omitted agent preset to the Harness Host', async () => {
for (const path of PRODUCTION_FILES) {
const source = await readFile(new URL(`../${path}`, import.meta.url), 'utf8');
assert.doesNotMatch(source, /agentPreset:\s*config\.agentPreset\s*\?\?\s*['"]standard['"]/, path);
assert.match(
source,
/\.\.\.\(config\.agentPreset == null \? \{\} : \{ agentPreset: config\.agentPreset \}\)/,
path,
);
}
for (const channel of ['telegram', 'discord']) {
const source = await readFile(
new URL(`../plugin-src/host/channels/${channel}/production.mjs`, import.meta.url),
'utf8',
);
assert.match(
source,
/createTokenProductionController\(ctx, config, internals,/,
`${channel} must delegate to the shared production assembly`,
);
}
});