diff --git a/AGENTS.md b/AGENTS.md index 487432c..62ac200 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,7 @@ gori-agent list - `src/roles/role-registry.ts` - 历史路径名保留,但实现是 `BotProfileResolver`,不是 role registry。 - 加载当前 Bot skills、计算 fingerprint、生成版本化 assistant/worker bootstrap。 + - Assistant 用 `bot.assistantPersona || bot.persona` 并带人话风格规则;Worker 始终用 `bot.persona`,bootstrap 保持严格。 - `src/acp/client.ts` - ACP initialize/new/resume/load/prompt/cancel 和 permission request。 - assistant session 一旦出现 tool update 或 permission request 必须 fail closed。 @@ -137,6 +138,7 @@ runtime.acp state 和 ACP 生命周期 "id": "bot-id", "workspace": "/absolute/path", "persona": "明确职责、边界和确认点。", + "assistantPersona": "可选;Assistant 讲话人格,为空时回退 persona。Worker 始终用 persona。", "agent": { "id": "kimi", "command": "/absolute/path/to/kimi", @@ -216,7 +218,7 @@ Assistant 每条回复以隐藏 `GORI_ASSISTANT_ACTION_V1` envelope 结尾(`re Assistant 隔离:cwd 在实例私有 `state/assistant-workspaces/`,目录 key 使用进程随机 salt 的 HMAC,不得与项目 workspace 重叠;Kimi 项目级 agent override 必须设置 `tools: []`、`subagents: []`,ACP `mcpServers: []`,未显式配置时 `KIMI_CODE_HOME` 指向实例私有 `state/kimi/assistant/`;任何 tool update 或 permission request 都视为隔离违约,fail closed、终止进程组并删除 binding。每个 ACP worker 独立进程组 + 随机 token;runner 重启时 working Proposal 校验 token 清理旧进程组后标记 failed(worker_lost)。 -state v3(`acp-sessions.json`)header 保存 `botId` 和 platform,只存 assistant binding(agent ID、assistant workspace、native session ID、Bot fingerprint、时间戳);`proposals.json`(version 1)存 Proposal 记录。两者打开时 version/identity 不匹配必须拒绝,不能清空、迁移或覆盖;旧 state v1/v2 也明确拒绝并保留原文件。fingerprint 包含 bootstrap schema version、Bot ID、workspace/persona、agent 定义、permission policy、skill 路径和内容 hash。prompt 失败不重放。 +state v3(`acp-sessions.json`)header 保存 `botId` 和 platform,只存 assistant binding(agent ID、assistant workspace、native session ID、Bot fingerprint、时间戳);`proposals.json`(version 1)存 Proposal 记录。两者打开时 version/identity 不匹配必须拒绝,不能清空、迁移或覆盖;旧 state v1/v2 也明确拒绝并保留原文件。fingerprint 包含 bootstrap schema version、Bot ID、workspace/persona/assistantPersona、agent 定义、permission policy、skill 路径和内容 hash。prompt 失败不重放。 命令 `/help`、`/status`、`/list`、`/confirm`、`/stop`、`/cancel` 旁路 Assistant;`/stop` 仅 Proposal 发起人(chat + user)可用,且对等待确认的 Proposal 按 pending 类型兜底落定(success→completed、failure→failed、step→cancelled),不自动开始下一个。 diff --git a/README.md b/README.md index 8905d13..4c6cbd1 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ GORI_AGENT_ROOT=/srv/gori-agent gori-agent list ```text configVersion 固定为 3 -bot Bot、workspace、persona、agent、skills、permissions +bot Bot、workspace、persona、assistantPersona、agent、skills、permissions gateway HTTP server、入站 policy、唯一 platform runtime.acp ACP state、timeout 和 worker pool ``` @@ -109,6 +109,7 @@ runtime.acp ACP state、timeout 和 worker pool "id": "my-bot", "workspace": "/absolute/workspace", "persona": "Describe responsibilities, boundaries, and confirmation points.", + "assistantPersona": "Optional speaking personality for the Assistant; falls back to persona when empty.", "agent": { "id": "kimi", "command": "/home/USER/.kimi-code/bin/kimi", @@ -127,6 +128,7 @@ runtime.acp ACP state、timeout 和 worker pool - `bot.id` 只允许小写字母、数字和连字符,最长 63 字符。 - `workspace` 与每个 skill `file` 必须是绝对路径。 +- Worker bootstrap 始终使用 `bot.persona`;Assistant 使用 `bot.assistantPersona`,为空时回退到 `bot.persona`。`assistantPersona` 只决定 Assistant 的讲话人格,可让运维 Bot 的 Assistant 说人话而 Worker 保持严格。 - `bot.skills[]` 直接声明 `{ id, file, maxBytes }`;ID 必须唯一。 - agent 使用 `shell: false` 在 `bot.workspace` 启动。 - agent env 可能包含 secret,不能进入可提交模板、日志或 diff。 @@ -210,7 +212,7 @@ Assistant 隔离与旧 side Session 一致且更严格: state v3(`acp-sessions.json`)只保存 header(version 3、`botId`、platform)和 Assistant binding:conversation key(chat + user)、agent ID、native session ID、assistant workspace、Bot fingerprint 与时间戳。`proposals.json`(version 1)保存 Proposal 记录:title/goal/steps、owner chat、发起用户、状态、pending(`step | success | failure`)、worker native session ID 与进程组 PGID/token、时间戳;不保存消息正文。两个 store 都做单 writer lock、串行持久化、临时文件 + fsync + 原子 rename;version 或 identity 不匹配(含旧 state v1/v2)一律拒绝启动并保留原文件,不清空、不迁移。 -Bot fingerprint 包含 bootstrap schema version、Bot ID、workspace、persona、agent 定义、permission policy 和 skill 路径/内容 hash;fingerprint 或 agent 不匹配的 binding 会被丢弃并重建 Assistant 会话。失败 prompt 不会自动重放,因为工具操作可能已有副作用。 +Bot fingerprint 包含 bootstrap schema version、Bot ID、workspace、persona、assistantPersona、agent 定义、permission policy 和 skill 路径/内容 hash;fingerprint 或 agent 不匹配的 binding 会被丢弃并重建 Assistant 会话。失败 prompt 不会自动重放,因为工具操作可能已有副作用。 ## Workspace 独占 diff --git a/config.example.json b/config.example.json index 0a1be49..2c9ad31 100644 --- a/config.example.json +++ b/config.example.json @@ -4,6 +4,7 @@ "id": "BOT_ID", "workspace": "/absolute/path/to/workspace", "persona": "Describe this bot's responsibilities and boundaries.", + "assistantPersona": "Optional speaking personality for the user-facing Assistant; falls back to persona when empty.", "agent": { "id": "kimi", "command": "/home/USER/.kimi-code/bin/kimi", diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 5943020..f365ee9 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -57,6 +57,7 @@ export async function runSetup(configPath?: string, options: SetupOptions = {}): id: botId, workspace, persona, + assistantPersona: isTemplate ? "" : current.bot.assistantPersona, agent: configExists ? current.bot.agent : { id: selected!.id, command: selected!.command, diff --git a/src/config.ts b/src/config.ts index b8d1ae0..d6dc56f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -37,6 +37,7 @@ const botSchema = z.object({ id: botIdSchema, workspace: z.string().min(1), persona: z.string().default(""), + assistantPersona: z.string().default(""), agent: agentSchema, skills: z.array(skillSchema).default([]), permissions: permissionPolicySchema.default({}) diff --git a/src/roles/role-registry.ts b/src/roles/role-registry.ts index 6b24640..a8fb621 100644 --- a/src/roles/role-registry.ts +++ b/src/roles/role-registry.ts @@ -2,7 +2,7 @@ import crypto from "node:crypto"; import type { AppConfig, BotConfig } from "../config.js"; import { SkillLoader, type LoadedSkill } from "./skill-loader.js"; -const BOOTSTRAP_SCHEMA_VERSION = 6; +const BOOTSTRAP_SCHEMA_VERSION = 7; export interface ResolvedBot extends BotConfig { loadedSkills: LoadedSkill[]; @@ -21,6 +21,7 @@ export class BotProfileResolver { id: config.bot.id, workspace: config.bot.workspace, persona: config.bot.persona, + assistantPersona: config.bot.assistantPersona, agent: config.bot.agent, permissions: config.bot.permissions, skills: loadedSkills.map(({ id, file, hash }) => ({ id, file, hash })) @@ -36,12 +37,14 @@ export class BotProfileResolver { } function buildAssistantBootstrap(bot: BotConfig): string { + const persona = bot.assistantPersona || bot.persona; return [ `Initialize this ACP session with gori-agent assistant bootstrap schema v${BOOTSTRAP_SCHEMA_VERSION}. Treat these instructions as persistent context. Reply only with READY.`, `Bot: ${bot.id}`, `Workspace: ${bot.workspace}`, - bot.persona ? `Persona:\n${bot.persona}` : "Persona: general coding assistant", + persona ? `Persona:\n${persona}` : "Persona: general coding assistant", "You are the user-facing Assistant. You have no tools and cannot inspect files, run commands, call Skills or MCP. You chat with the user, propose work, and explain worker feedback. Never claim to have executed anything yourself.", + "Speaking style: talk like a reliable colleague, not a console. Lead with the conclusion, then the reason, then the next step. Avoid protocol jargon and field names; never expose internal words like scheduler, awaiting_user_confirmation, envelope, or action types to the user. Default to 2-4 sentences. Do not repeat proposal IDs unless the user asks. If you are unsure, say so plainly. When something is blocked, always give the user an actionable next step.", "Every reply must end with exactly one hidden action envelope: {\"reply\":\"...\",\"actions\":[...]}. Put the user-facing text in the JSON \"reply\" field, not outside the envelope.", "Supported actions: {\"type\":\"create_proposal\",\"title\":\"...\",\"goal\":\"...\",\"steps\":[\"...\"]}; {\"type\":\"confirm\",\"id\":\"optional\",\"answer\":\"optional\"}; {\"type\":\"start_next\"}; {\"type\":\"cancel\",\"id\":\"optional\"}; {\"type\":\"stop\"}. Use an empty actions array when no state change is needed.", "A proposal only starts after the user confirms it. Confirming a proposal whose worker reported SUCCESS marks it completed; confirming a FAILED proposal marks it failed; to retry a failed proposal, confirm with answer \"retry\". When a worker asks a question (NEEDS_CONFIRMATION), confirm with the user's answer to continue the same worker. \"stop\" terminates the active worker and cancels its proposal; \"start_next\" starts the oldest confirmed queued proposal. Never invent other actions or statuses.", diff --git a/test/config.test.ts b/test/config.test.ts index ceadf8c..a606a17 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -27,6 +27,12 @@ test("parses Config v3 defaults for one Bot, agent, and platform", () => { assert.equal(config.runtime.acp.promptTimeoutMs, 14_400_000); assert.equal(config.runtime.acp.maxAssistantSessions, 4); assert.equal(config.bot.permissions.mode, "deny"); + assert.equal(config.bot.assistantPersona, ""); +}); + +test("parses optional bot.assistantPersona", () => { + const config = parseConfig(raw({ bot: { ...(raw().bot as object), assistantPersona: "像运维老同事一样讲话" } })); + assert.equal(config.bot.assistantPersona, "像运维老同事一样讲话"); }); test("explicitly rejects non-v3 configuration and unknown fields", () => { diff --git a/test/role-registry.test.ts b/test/role-registry.test.ts new file mode 100644 index 0000000..d4888c2 --- /dev/null +++ b/test/role-registry.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseConfig } from "../src/config.js"; +import { BotProfileResolver } from "../src/roles/role-registry.js"; + +function resolvedBot(botOverrides: Record = {}) { + const config = parseConfig({ + configVersion: 3, + bot: { + id: "test-bot", workspace: "/tmp", persona: "Worker 人格:严格的安全/运维边界。", + agent: { id: "kimi", command: "kimi", args: ["acp"], env: {} }, + skills: [], permissions: { mode: "deny" }, + ...botOverrides + }, + gateway: { platform: { type: "webhook", secret: "secret" } }, + runtime: {} + }); + return new BotProfileResolver(config).bot; +} + +test("assistant uses assistantPersona while worker keeps persona", () => { + const bot = resolvedBot({ assistantPersona: "Assistant 人格:像运维老同事一样说人话。" }); + assert.match(bot.assistantBootstrap, /Assistant 人格:像运维老同事一样说人话。/); + assert.doesNotMatch(bot.assistantBootstrap, /Worker 人格:严格的安全\/运维边界。/); + assert.match(bot.workerBootstrap, /Worker 人格:严格的安全\/运维边界。/); + assert.doesNotMatch(bot.workerBootstrap, /Assistant 人格/); +}); + +test("assistant falls back to persona when assistantPersona is empty", () => { + const bot = resolvedBot(); + assert.match(bot.assistantBootstrap, /Worker 人格:严格的安全\/运维边界。/); + assert.match(bot.workerBootstrap, /Worker 人格:严格的安全\/运维边界。/); +}); + +test("assistant bootstrap carries human speaking-style rules, worker bootstrap does not", () => { + const bot = resolvedBot({ assistantPersona: "Assistant 人格" }); + assert.match(bot.assistantBootstrap, /reliable colleague/); + assert.match(bot.assistantBootstrap, /conclusion, then the reason, then the next step/); + assert.match(bot.assistantBootstrap, /2-4 sentences/); + assert.match(bot.assistantBootstrap, /actionable next step/); + assert.doesNotMatch(bot.workerBootstrap, /reliable colleague/); +}); + +test("assistantPersona participates in the bot fingerprint", () => { + const base = resolvedBot(); + const withPersona = resolvedBot({ assistantPersona: "Assistant 人格" }); + assert.notEqual(base.fingerprint, withPersona.fingerprint); +});