فهرست منبع

允许 Bot 修改自身名称和角色规则并限制创建后改写其他成员

Samien 1 ماه پیش
والد
کامیت
c932d11

+ 7 - 4
docs/bot-agent-teams.md

@@ -6,7 +6,7 @@
 
 日常建群只需要名称、成员、负责人和可选群规则,创建后直接聊天。团队模板和高级协作设置默认收起。顶部的「话题」用于隔离不同聊天上下文,不代表已经启动交付目标。
 
-需要机器人自主组队时,在 Bot 模式创建或编辑 Agent,打开「允许组建团队」并保存。例如只准备团队:
+Bot 模式的组队权限由服务端统一管理,可以直接要求机器人自主组队。例如只准备团队:
 
 > 建一个调研群,优先复用已有成员,先不执行任务。
 
@@ -27,7 +27,7 @@ Web/macOS 的群聊输入区可开启「交付目标」,发送后在当前话
 - Web 服务和发起者的 Hermes 必须在同一机器/网络命名空间,以回环地址连接。远程节点和远端引用 Agent 可作为成员,不能开启发起组队权限。
 - 发起者的基础 Profile 需安装并启用既有 `yaoyao-bot-bridge` 工具桥,能力响应必须包含 `version: 1`、`ready: true`、`in_process: true`、`native_tools: true`。插件由 Hermes Bot 提供;按其安装流程部署并重新加载,Named Profile 需要各自安装、启用。
 - 服务端在首次授予权限及每轮执行前检查条件。不满足时返回具体错误,普通 Agent 对话仍不查询工具桥。
-- 先部署 Web 分支,再使用配套 iOS 分支。现有记录缺少 `canManageTeam` 时按关闭处理;新建成员默认关闭。
+- 当前版本由服务端统一开放持久 Bot 的组队与协作权限;临时助手仍受所属任务限制。
 
 ## 工具与权限
 
@@ -36,7 +36,8 @@ Web/macOS 的群聊输入区可开启「交付目标」,发送后在当前话
 | `workspace_list_sources` | 当前账号获准的基础 Profile |
 | `workspace_list_agents` | 当前账号可使用的已有成员 |
 | `workspace_list_teams` | 发起者担任管理员的团队 |
-| `workspace_create_agent` | 创建成员,禁止授予组队权限 |
+| `workspace_create_agent` | 创建成员时填写初始提示词和角色偏好,沿用服务端统一权限 |
+| `workspace_get_self_rules` / `workspace_update_self_rules` | 读取及修改自身名称、长期提示词和角色规则,更新需要当前版本 |
 | `workspace_create_team` | 默认创建平等讨论团队,也可指定负责人协调或自由协作 |
 | `workspace_start_team_task` | 在发起者管理的团队启动任务 |
 | `workspace_update_team_goal` | 提炼或调整验收要求,校验 `expectedRevision` |
@@ -45,11 +46,13 @@ Web/macOS 的群聊输入区可开启「交付目标」,发送后在当前话
 | `workspace_update_assignment` / `workspace_cancel_assignment` | 调整或停止子任务,保留原因与历史 |
 | `workspace_review_assignment` | 验收、明确返工或记录受阻 |
 | `workspace_finish_team_task` / `workspace_resume_team_task` | 记录目标结论,或从用户新指令恢复目标 |
-| `workspace_update_created_agent` / `workspace_archive_created_agent` | 管理自己创建且符合使用状态限制的成员 |
+| `workspace_update_created_agent` / `workspace_archive_created_agent` | 仅改名或归档自己创建且符合使用状态限制的成员,不能再修改对方规则 |
 | `workspace_update_team` | 调整空闲团队的规则、成员或归档状态 |
 
 所有变更工具要求 `requestId`(UUID);重试同一操作必须复用它。服务端持久保存回执;相同编号用于不同参数返回冲突,重复请求不会重复创建。原生工具调用 ID 也在本轮去重。
 
+持久 Bot 可维护自己的名称、描述、`instructions`、`job`、`antiJobs`、`voice`、`voiceCustom` 和 `actBias`。先读取完整规则及版本,再提交局部更新;未提交字段保持原值,可选角色偏好传 `null` 清除。新规则在后续轮次注入,当前轮次仍可继续使用已授权工具。临时助手与远端引用不开放自我修改;基础 Hermes Profile、模型、执行环境、工具权限和记忆开关不在此接口的可写字段中。创建者只在创建成员时填写对方规则,创建后由成员自身或用户维护。
+
 账号和调用 Agent 从正在运行的服务端轮次取得,模型参数不能指定 owner、token 或调用身份。创建成员只能引用当前账号获准的基础 Profile;子账号的分配规则保持有效。只有团队管理员可以启动或读取该团队的任务。当前群内的用户指令可以将尚无目标的话题升级为目标;自动回传不能重复启动目标。
 
 每轮使用独立随机凭据、独立原生工具 ID 和回环监听端口,凭据仅传给经过认证的工具桥绑定接口。权限、账号、Profile、成员归属、归档和停止状态在每次调用时校验,异步操作在写入前再次校验。绑定保留 Agent 的配置版本;关闭后重新开启权限也不能使旧轮次重新生效。完成、取消、连接结束或服务关闭时销毁监听器并解绑;最长 30 分钟,运行时每 5 分钟续租。断线恢复只核对原执行,不重新提交已有工具副作用;下一轮重新授权。

+ 2 - 0
docs/bot-collaboration-memory.md

@@ -5,6 +5,8 @@
 ## 使用方式
 
 - Bot 的组队和协作由服务端统一开放,资料中只展示权限状态;旧客户端提交的单 Bot 组队、协作开关不会覆盖服务端策略。“自动记录长期事实”仍可单独暂停。
+- 持久 Bot 可通过 `workspace_get_self_rules` 读取自己的名称、完整提示词、角色规则和版本,再用 `workspace_update_self_rules` 修改名称、描述、长期提示词、职责、边界、语气和行动偏好。更新校验 `expectedRevision`,新规则从后续轮次生效;不修改基础 Hermes Profile、模型或工具权限。
+- Bot 创建成员时可填写对方的初始提示词和角色规则。创建完成后,创建者不能再修改对方规则;原有改名、归档权限保持不变。成员仍可修改自己的规则,用户也可继续在客户端编辑。临时助手不开放自我修改,记忆维护仍遵守下文的独立权限。
 - 在聊天中要求 Bot 联系同伴。`workspace_send_to_agent` 立即返回投递凭据,同伴回复稍后唤醒原 Bot;`workspace_post_to_group` 发布到自己参加的群。界面可查看请求、结果、状态,并停止整条协作。
 - 新建群默认平等讨论,无需选择负责人;未点名时全体参与。普通消息一轮、讨论默认两轮,可通过发送选项或文字指定 1–12 轮,最多 8 位成员,逐轮轮换发言顺序。成员失败不阻止其他成员。
 - 已有群保留原模式,空闲时可切换。需要交付时再开启“交付目标”、选择有组队权限的负责人,沿用原有任务与验收流程。

+ 22 - 2
src/server/workspaceKnowledgeTools.ts

@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'
 import type { WorkspaceRuntime } from './workspaceRuntime.js'
 import type { Work } from './workspaceScheduler.js'
 import type { WorkspaceAgent, WorkspaceConversation, WorkspaceRun } from '../shared/workspace.js'
-import { parse } from './workspaceStore.js'
+import { agentPatch, parse } from './workspaceStore.js'
 import { HttpError } from './errors.js'
 
 const id = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/)
@@ -15,7 +15,16 @@ export const memoryWriteInput = memoryScopeInput.extend({ requestId, id: id.opti
 export const memoryForgetInput = memoryScopeInput.extend({ requestId, id, expectedRevision: z.number().int().positive() }).strict()
 const peerSend = z.object({ requestId, agentId: z.string().uuid(), content: z.string().trim().min(1).max(16000), fileIds: z.array(z.string().uuid()).max(8).default([]), replyTo: z.string().uuid().optional(), priority: z.boolean().default(false) }).strict()
 const groupSend = peerSend.omit({ agentId: true, replyTo: true, priority: true }).extend({ groupId: z.string().uuid(), taskId: z.string().uuid().optional() }).strict()
+const selfRulesPatch = agentPatch.pick({ name: true, instructions: true, description: true, job: true, antiJobs: true, voice: true, voiceCustom: true, actBias: true })
+  .extend({ requestId, expectedRevision: z.number().int().positive() }).strict()
+  .refine(({ requestId: _request, expectedRevision: _revision, ...patch }) => Object.values(patch).some(value => value !== undefined), '至少提供一项名称、提示词或角色规则')
+function selfRules(agent: WorkspaceAgent) {
+  const { id, name, revision, instructions, description, job, antiJobs, voice, voiceCustom, actBias } = agent
+  return { id, name, revision, instructions, description, job, antiJobs, voice, voiceCustom, actBias }
+}
 const definitions = [
+  ['workspace_get_self_rules', '读取自己的名称、完整长期提示词、角色规则和当前 revision。修改前先读取;这里只是当前 Bot 的独立规则,不是基础 Hermes Profile。', z.object({}).strict()],
+  ['workspace_update_self_rules', '修改自己的名称、长期提示词、角色规则、职责边界、语气和行动偏好。先读取自身规则,expectedRevision 使用当前 revision;只提交要修改的字段,null 清除可选角色偏好。新规则从后续轮次生效,不能修改其他 Bot、基础 Hermes Profile 或工具权限。requestId 使用新的 UUID,重试同一操作复用。', selfRulesPatch],
   ['workspace_list_peers', '查询同一账号内可直接联系的 Bot。私信无需先建群。', z.object({}).strict()],
   ['workspace_send_to_agent', '向同伴异步发送具体请求或回复,立即返回投递凭据,不返回对方答案,不要轮询。回复使用本轮收到的请求 ID 作为 replyTo。只共享必要信息,不转发用户私人聊天全文;纯确认不发送。', peerSend],
   ['workspace_post_to_group', '向自己参加的群异步发布具体消息,群成员按该群规则参与。不要重复派发已有子任务或当前讨论轮次。', groupSend],
@@ -38,6 +47,7 @@ export class WorkspaceKnowledgeTools {
   catalog(owner: string, workId: string, memoryReady: boolean) {
     const { agent, root } = this.context(owner, workId)
     return definitions.filter(([name]) => {
+      if (name.endsWith('_self_rules')) return !agent.temporaryGoalId && !agent.remoteAgentId
       if (name.includes('memory_')) return memoryReady && !agent.temporaryGoalId
       if (name.includes('project')) return !agent.temporaryGoalId
       return !root.discussion
@@ -50,6 +60,16 @@ export class WorkspaceKnowledgeTools {
     const definition = definitions.find(([id]) => id === name)!
     const args = parse(definition[2] as z.ZodType<any>, input)
     const actor = { agentId: agent.id }, knowledge = this.runtime.knowledge
+    if (name === 'workspace_get_self_rules') return { agent: selfRules(agent) }
+    if (name === 'workspace_update_self_rules') {
+      const { requestId, ...patch } = args
+      return this.runtime.store.command(owner, requestId, { agentId: agent.id, name, patch }, () => {
+        const updated = this.runtime.store.updateAgent(owner, agent.id, patch)
+        // Keep this turn's existing grant after its own rules edit; never revive a stale grant.
+        if (work.teamManagementRevision === agent.revision) this.runtime.store.put(owner, 'turn', work.id, { ...work, teamManagementRevision: updated.revision })
+        return { agent: selfRules(updated), message: '自身规则已保存,从后续轮次生效。' }
+      })
+    }
     if (name === 'workspace_list_peers') return { agents: this.runtime.collaboration.peers(owner, workId), groups: this.runtime.store.list<WorkspaceConversation>(owner, 'conversation').filter(c => c.kind === 'group' && !c.archived && c.memberIds.includes(agent.id)).map(({ id, name }) => ({ id, name })) }
     if (name === 'workspace_send_to_agent' || name === 'workspace_post_to_group') return { receipt: this.runtime.collaboration.send(owner, workId, args), message: '已投递,结果会异步返回;不要轮询。' }
     if (name === 'workspace_list_projects') return { projects: knowledge.projects(owner).filter(p => p.memberIds.includes(agent.id) && !p.archived) }
@@ -70,4 +90,4 @@ export class WorkspaceKnowledgeTools {
   }
 }
 
-export const BOT_KNOWLEDGE_RULES = `Bot 协作通过显式工具异步投递:发送后继续工作或结束本轮,回复到达才唤醒;不要轮询,不要反复确认,不要把同一任务通过私信、群 @ 和子任务重复派发。向同伴只提供任务必要信息。长期记忆按 Bot、用户、当前项目隔离。基础事实用 profile,历史用 log,短暂事实用 note。保存事实需要原始消息 ID 和原话依据。用户记忆仅保存用户明确表达的长期信息,项目记忆仅保存当前项目的已确认决策与结果。当前用户的明确要求优先于记忆;自己的记忆与用户共享记忆冲突时,以自己的记忆为准。修改前查询当前版本,用户维护的记录不可覆盖。`
+export const BOT_KNOWLEDGE_RULES = `持久 Bot 可以先用 workspace_get_self_rules 读取自己的名称、完整规则与版本,再用 workspace_update_self_rules 修改自己的名称、长期提示词和角色规则,从后续轮次生效。创建其他 Bot 时可以填写其初始提示词和角色偏好,创建完成后不能再修改对方规则;基础 Hermes Profile 和工具权限不属于可修改范围。Bot 协作通过显式工具异步投递:发送后继续工作或结束本轮,回复到达才唤醒;不要轮询,不要反复确认,不要把同一任务通过私信、群 @ 和子任务重复派发。向同伴只提供任务必要信息。长期记忆按 Bot、用户、当前项目隔离。基础事实用 profile,历史用 log,短暂事实用 note。保存事实需要原始消息 ID 和原话依据。用户记忆仅保存用户明确表达的长期信息,项目记忆仅保存当前项目的已确认决策与结果。当前用户的明确要求优先于记忆;自己的记忆与用户共享记忆冲突时,以自己的记忆为准。修改前查询当前版本,用户维护的记录不可覆盖。`

+ 3 - 3
src/server/workspaceTeamTools.ts

@@ -28,7 +28,7 @@ const startTask = z.object({
   acceptanceCriteria: z.array(z.string().trim().min(1).max(1000)).max(12).default([]),
 }).strict()
 const getTask = z.object({ teamId: uuid, taskId: uuid }).strict()
-const editCreatedAgent = z.object({requestId:uuid,agentId:uuid,name:z.string().trim().min(1).max(100).optional(),instructions:z.string().max(24000).optional()}).strict()
+const editCreatedAgent = z.object({requestId:uuid,agentId:uuid,name:z.string().trim().min(1).max(100)}).strict()
 const archiveCreatedAgent = z.object({requestId:uuid,agentId:uuid,confirmName:z.string().min(1).max(100)}).strict()
 const editTeam = z.object({requestId:uuid,teamId:uuid,name:z.string().trim().min(1).max(100).optional(),instructions:z.string().max(24000).optional(),memberIds:z.array(uuid).min(2).max(8).optional(),archived:z.boolean().optional()}).strict()
 
@@ -36,7 +36,7 @@ const definitions = [
   ['workspace_list_sources', '查看当前账号获准使用的基础机器人。创建成员前先查询。', empty],
   ['workspace_list_agents', '查看当前账号可复用的成员,优先复用已有机器人,避免重复创建。', empty],
   ['workspace_list_teams', '查看由你担任管理员的团队及其任务。', empty],
-  ['workspace_create_agent', '创建持久保存的 Bot 机器人,复用已授权的基础 Profile。新成员沿用统一的设备与审批权限。requestId 使用新的 UUID;重试同一操作必须复用它。', createAgent],
+  ['workspace_create_agent', '创建持久保存的 Bot 机器人,复用已授权的基础 Profile。创建时可填写 instructions 长期提示词与角色规则,以及职责、边界、语气和行动偏好;创建完成后不能再修改该成员的规则。新成员沿用统一的设备与审批权限。requestId 使用新的 UUID;重试同一操作必须复用它。', createAgent],
   ['workspace_create_helper','为当前团队目标创建临时隔离助手并立即分派一个具体子任务。助手只参与此任务、不创建单聊、不获得组队权限;目标结束后退役。每个目标最多 8 个有效临时助手。优先用于一次性工作。',createHelper],
   ['workspace_create_team', '只建立持久团队,自动把你加入并设为管理员,不启动交付目标、不生成子任务或验收要求。其他成员使用真实机器人 ID,总人数最多 8 人。优先复用已有团队。requestId 使用新的 UUID,重试时复用。', createTeam],
   ['workspace_start_team_task', '仅当用户明确要求完成并交付具体结果时启动目标。简单问答、方案讨论、一次性咨询或仅创建群聊时不要调用。在当前群聊中可将当前话题升级为目标,在其他团队中启动独立话题;提炼少量可核对的验收要求。管理员可直接完成,不强制分工。立即返回不表示完成,requestId 重试时复用。', startTask],
@@ -48,7 +48,7 @@ const definitions = [
   ['workspace_cancel_assignment', '停止一个子任务,必须记录原因。执行停止尚未确认时显示正在停止,不能当作已经撤销外部副作用。其他子任务和团队不受影响。', assignmentCancel],
   ['workspace_finish_team_task', '根据实际结果记录目标完成、受阻或等待用户。无需为完成而创建子任务;已有有效子任务须验收通过。完成时传入当前 acceptanceRevision 并逐项提供依据;结果会自动送回原始会话。', finishGoalInput],
   ['workspace_resume_team_task', '仅在当前用户的新指令中恢复受阻、等待或停止的目标。旧执行状态不确定时拒绝恢复;自动回传不能自行重新激活旧目标。', resumeGoalInput],
-  ['workspace_update_created_agent', '调整你创建的成员的名称或职责,不能修改基础 Profile 或权限。变更从后续轮次生效。', editCreatedAgent],
+  ['workspace_update_created_agent', '仅修改你创建的成员的名称。不能修改已创建成员的提示词、角色规则、基础 Profile 或权限。', editCreatedAgent],
   ['workspace_archive_created_agent', '归档你创建且已不再被有效团队引用的空闲成员,保留历史;confirmName 必须与当前名称完全一致。不能归档其他人创建的成员。', archiveCreatedAgent],
   ['workspace_update_team', '调整你管理的空闲团队名称、规则或成员,或归档已结束的团队。不能更换管理员或修改正在执行的团队。', editTeam],
 ] as const

+ 39 - 0
tests/server/workspace.test.ts

@@ -725,6 +725,45 @@ describe('Web-owned workspace', () => {
     expect(changed.memberRoles).toEqual({ [a.id]: { name: '调研负责人', description: '汇总可信结论' } })
     expect(() => store.updateConversation(owner, c.id, { memberRoles: { [b.id]: { name: '已移除', description: '' } } })).toThrow('群成员或管理员无效')
   })
+  it('applies self-edited names and rules on the next turn without changing a shared Hermes Profile', async () => {
+    const a = agent('原来的自己'), b = agent('同 Profile 的同伴')
+    const target = nodes.target(owner,'local'), request = target.session.request.bind(target.session)
+    const bindings = new Map<string,any>()
+    vi.spyOn(target.session,'request').mockImplementation(async(path,options)=>{
+      if (!path.startsWith('/api/plugins/yaoyao-bot-bridge/')) return request(path,options)
+      if (path.endsWith('/bind')) bindings.set(String((options?.body as any).session_id),options?.body)
+      return {status:200,body:Buffer.from(JSON.stringify({ok:true,version:1,ready:true,in_process:true,native_tools:true})),headers:new Headers()}
+    })
+    let edited = false, toolError: unknown
+    reply = async(socket,params)=>{
+      if (!edited) {
+        edited = true
+        try {
+          const binding = bindings.get(params.session_id)
+          const toolRequest = async(path:string,body:unknown)=>(await fetch(binding.bridge_url+path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${binding.token}`},body:JSON.stringify(body)})).json()
+          const catalog = await toolRequest('/tools/list',{})
+          const invoke = (name:string,args:unknown,callId:string)=>toolRequest('/tools/call',{toolId:catalog.tools.find((tool:any)=>tool.name===name).id,arguments:args,callId})
+          const current = await invoke('workspace_get_self_rules',{},'read-self')
+          const updated = await invoke('workspace_update_self_rules',{requestId:randomUUID(),expectedRevision:current.structuredContent.agent.revision,name:'自己的新名字',instructions:'后续回答先给依据',job:'独立核验事实'},'update-self')
+          expect(updated.structuredContent.agent).toMatchObject({id:a.id,revision:2})
+          expect((await toolRequest('/tools/list',{})).tools.length).toBeGreaterThan(0)
+        } catch (error) { toolError = error }
+      }
+      socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:params.session_id,payload:{text:'完成',status:'complete'}}}))
+    }
+    await finished(runtime.send(owner,direct(a.id).id,{requestId:randomUUID(),content:'修改自己的名字和规则'}).id)
+    expect(toolError).toBeUndefined()
+    const initial = requests.find(frame=>frame.method==='prompt.submit')!.params.text
+    expect(initial).toContain('你是 原来的自己')
+    expect(initial).not.toContain('后续回答先给依据')
+    await finished(runtime.send(owner,direct(a.id).id,{requestId:randomUUID(),content:'继续'}).id)
+    const next = requests.filter(frame=>frame.method==='prompt.submit').at(-1)!.params.text
+    expect(next).toContain('你是 自己的新名字')
+    expect(next).toContain('后续回答先给依据')
+    expect(next).toContain('独立核验事实')
+    expect(store.require(owner,'agent',b.id)).toEqual(b)
+    expect(requests.some(frame=>frame.method==='profiles.configure'||frame.method==='config.set')).toBe(false)
+  })
   it('isolates roles over the same profile and applies edited rules on the next turn', async () => {
     const a = agent('编辑'),
       b = agent('审查'),

+ 88 - 7
tests/server/workspaceTeamTools.test.ts

@@ -19,6 +19,7 @@ let denied: Set<string>, target: GatewayTarget, binding: Record<string, any> | u
 let leases: WorkspaceToolLease[], upstream: Array<{ path: string; body: any }>
 const owner = 'account-one'
 const call = (tool: string, args: unknown = {}) => runtime.teamTools.call(owner, work.id, `workspace_${tool}`, args) as Promise<any>
+const selfCall = (tool: 'get' | 'update', args: unknown = {}) => runtime.knowledgeTools.call(owner, work.id, `workspace_${tool}_self_rules`, args, false) as Promise<any>
 
 beforeEach(() => {
   home = mkdtempSync(join(tmpdir(), 'yaoyao-team-tools-'))
@@ -88,16 +89,21 @@ describe('Agent-owned team tools', () => {
     await expect(call('start_team_task',{...input,requestId:randomUUID()})).rejects.toMatchObject({code:'goal_exists'})
   })
   it('records creator authority and archives only an unused member it created',async()=>{
-    const child=(await call('create_agent',{requestId:randomUUID(),name:'临时项目成员',profile:'default'})).agent
+    const initialRules = { instructions:'核验任务结果', description:'负责事实核验', job:'核验资料', antiJobs:['不猜测结论'], voice:'rigorous', actBias:'ask_key_then_act' }
+    const child=(await call('create_agent',{requestId:randomUUID(),name:'临时项目成员',profile:'default',...initialRules})).agent
     expect(child.createdByAgentId).toBe(manager.id)
-    const edited=await call('update_created_agent',{requestId:randomUUID(),agentId:child.id,instructions:'核验任务结果'})
-    expect(edited.agent.instructions).toBe('核验任务结果')
+    expect(store.require(owner,'agent',child.id)).toMatchObject(initialRules)
+    for (const [field, value] of Object.entries(initialRules)) await expect(call('update_created_agent',{requestId:randomUUID(),agentId:child.id,name:'改名成员',[field]:value})).rejects.toMatchObject({code:'invalid_workspace_request'})
+    expect(store.require(owner,'agent',child.id)).toMatchObject({...initialRules,revision:1})
+    const edited=await call('update_created_agent',{requestId:randomUUID(),agentId:child.id,name:'改名成员'})
+    expect(edited.agent.name).toBe('改名成员')
+    expect(store.require(owner,'agent',child.id)).toMatchObject(initialRules)
     const other=store.createAgent(owner,{name:'用户成员',profile:'default'})
     await expect(call('archive_created_agent',{requestId:randomUUID(),agentId:other.id,confirmName:other.name})).rejects.toMatchObject({status:403})
     const team=(await call('create_team',{requestId:randomUUID(),name:'成员管理测试',memberIds:[child.id]})).team
-    await expect(call('archive_created_agent',{requestId:randomUUID(),agentId:child.id,confirmName:child.name})).rejects.toMatchObject({code:'agent_in_use'})
+    await expect(call('archive_created_agent',{requestId:randomUUID(),agentId:child.id,confirmName:edited.agent.name})).rejects.toMatchObject({code:'agent_in_use'})
     await call('update_team',{requestId:randomUUID(),teamId:team.id,archived:true})
-    expect((await call('archive_created_agent',{requestId:randomUUID(),agentId:child.id,confirmName:child.name})).agent.archived).toBe(true)
+    expect((await call('archive_created_agent',{requestId:randomUUID(),agentId:child.id,confirmName:edited.agent.name})).agent.archived).toBe(true)
   })
   it('creates durable members, a managed team and one idempotent task with client-visible events', async () => {
     const requestId = randomUUID(), args = {requestId,name:'研究员',profile:'writer',instructions:'研究事实并给出处'}
@@ -202,13 +208,72 @@ describe('Agent-owned team tools', () => {
   })
 })
 
+describe('Bot self rules', () => {
+  it('reads full rules, edits only itself and keeps its current tools usable across retries', async () => {
+    const instructions = '完整长期角色规则。'.repeat(400)
+    store.put(owner,'agent',manager.id,{...manager,instructions,voice:'custom',voiceCustom:'原先语气'})
+    const before = (await selfCall('get')).agent
+    expect(before).toMatchObject({id:manager.id,revision:1,instructions})
+    const request = {requestId:randomUUID(),expectedRevision:before.revision,name:'核验助手',instructions:'先核验再回答',description:'事实助手',job:'核对来源',antiJobs:['不编造来源'],voice:'rigorous',voiceCustom:null,actBias:'ask_key_then_act'}
+    const updated = await selfCall('update',request)
+    expect(updated.agent).toMatchObject({id:manager.id,name:request.name,instructions:request.instructions,description:request.description,job:request.job,antiJobs:request.antiJobs,voice:request.voice,actBias:request.actBias,revision:2})
+    expect(updated.agent.voiceCustom).toBeUndefined()
+    expect(await selfCall('update',request)).toEqual(updated)
+    expect((await selfCall('get')).agent).toEqual(updated.agent)
+    expect(store.require<WorkspaceAgent>(owner,'agent',manager.id)).toMatchObject({profile:'default',nodeId:'local',revision:2,memoryEnabled:true})
+    expect(store.require<WorkspaceConversation>(owner,'conversation',work.conversationId).name).toBe(request.name)
+    expect(store.require<Work>(owner,'turn',work.id).teamManagementRevision).toBe(2)
+    expect((await call('list_agents')).agents[0].name).toBe(request.name)
+    await expect(selfCall('update',{...request,instructions:'同一编号改写'})).rejects.toMatchObject({code:'idempotency_conflict'})
+    expect(upstream).toEqual([])
+  })
+
+  it('rejects stale revisions and empty edits without overwriting a user edit', async () => {
+    const before = (await selfCall('get')).agent
+    store.updateAgent(owner,manager.id,{instructions:'用户刚保存的规则'})
+    await expect(selfCall('update',{requestId:randomUUID(),expectedRevision:before.revision,instructions:'旧版本覆盖'})).rejects.toMatchObject({code:'agent_revision_conflict'})
+    await expect(selfCall('update',{requestId:randomUUID(),expectedRevision:2})).rejects.toMatchObject({code:'invalid_workspace_request'})
+    expect(store.require<WorkspaceAgent>(owner,'agent',manager.id)).toMatchObject({instructions:'用户刚保存的规则',revision:2})
+    expect(store.require<Work>(owner,'turn',work.id).teamManagementRevision).toBe(1)
+    await expect(call('list_agents')).rejects.toMatchObject({code:'team_tools_forbidden'})
+  })
+
+  it.each([
+    ['agentId',randomUUID()], ['profile','writer'], ['nodeId','other'], ['approvalPolicy','allow'],
+    ['canManageTeam',true], ['canCollaborate',true], ['memoryEnabled',false], ['envs',{vm:true}], ['modelSettings',null],
+  ])('rejects the protected %s field', async (field, value) => {
+    await expect(selfCall('update',{requestId:randomUUID(),expectedRevision:1,instructions:'新规则',[field as string]:value})).rejects.toMatchObject({code:'invalid_workspace_request'})
+    expect(store.require<WorkspaceAgent>(owner,'agent',manager.id)).toEqual(manager)
+  })
+
+  it('keeps self edits available to a persistent assigned Bot without granting team management', async () => {
+    store.put(owner,'run',work.runId,{...store.require<WorkspaceRun>(owner,'run',work.runId),assignmentId:randomUUID()})
+    store.put(owner,'turn',work.id,{...work,teamManagementRevision:undefined})
+    const catalog = runtime.knowledgeTools.catalog(owner,work.id,false).map(tool => tool.name)
+    expect(catalog).toEqual(expect.arrayContaining(['workspace_get_self_rules','workspace_update_self_rules']))
+    expect(catalog).not.toContain('workspace_memory_write')
+    await selfCall('update',{requestId:randomUUID(),expectedRevision:1,instructions:'自己的后续规则'})
+    expect(store.require<Work>(owner,'turn',work.id).teamManagementRevision).toBeUndefined()
+    await expect(call('list_agents')).rejects.toMatchObject({code:'team_tools_forbidden'})
+  })
+
+  it('keeps temporary helpers and remote references from editing their rules', async () => {
+    for (const restricted of [{temporaryGoalId:randomUUID()},{remoteAgentId:randomUUID()}]) {
+      store.put(owner,'agent',manager.id,{...manager,...restricted})
+      if ('temporaryGoalId' in restricted) expect(()=>runtime.knowledgeTools.catalog(owner,work.id,false)).toThrow()
+      else expect(runtime.knowledgeTools.catalog(owner,work.id,false).map(tool => tool.name)).not.toContain('workspace_update_self_rules')
+      await expect(selfCall('update',{requestId:randomUUID(),expectedRevision:1,instructions:'不能修改'})).rejects.toMatchObject({code:'knowledge_tool_forbidden'})
+    }
+  })
+})
+
 async function mount(signal = new AbortController().signal) {
   const lease = await createWorkspaceToolLease({
     target, profile:manager.profile, workId:work.id,
     session:()=>({runtimeId:'runtime',storedId:'stored'}),signal,
     assertActive:()=>{runtime.teamTools.assertTurn(owner,work.id)},
-    catalog:()=>runtime.teamTools.catalog(owner,work.id),
-    call:(id,args)=>runtime.teamTools.call(owner,work.id,id,args),onFailure:()=>{},
+    catalog:()=>[...runtime.teamTools.catalog(owner,work.id),...runtime.knowledgeTools.catalog(owner,work.id,false)],
+    call:(id,args)=>runtime.knowledgeTools.handles(id)?runtime.knowledgeTools.call(owner,work.id,id,args,false):runtime.teamTools.call(owner,work.id,id,args),onFailure:()=>{},
   })
   leases.push(lease); await lease.bind()
   return lease
@@ -218,6 +283,22 @@ function http(path:string, body:unknown, options:RequestInit={}) {
 }
 
 describe('loopback tool lease', () => {
+  it('updates self rules through the bridge without ending the current tool lease', async () => {
+    await mount()
+    const catalog = await (await http('/tools/list',{})).json()
+    const readId = catalog.tools.find((tool:any)=>tool.name==='workspace_get_self_rules').id
+    const writeId = catalog.tools.find((tool:any)=>tool.name==='workspace_update_self_rules').id
+    const current = await (await http('/tools/call',{callId:'read-self',toolId:readId,arguments:{}})).json()
+    const request = {callId:'update-self',toolId:writeId,arguments:{requestId:randomUUID(),expectedRevision:current.structuredContent.agent.revision,name:'自己的新名字',instructions:'自己的新规则'}}
+    const updated = await (await http('/tools/call',request)).json()
+    expect(updated.structuredContent.agent).toMatchObject({id:manager.id,name:'自己的新名字',instructions:'自己的新规则',revision:2})
+    expect(await (await http('/tools/call',request)).json()).toEqual(updated)
+    expect((await http('/tools/list',{})).status).toBe(200)
+    const after = await (await http('/tools/call',{callId:'read-updated',toolId:readId,arguments:{}})).json()
+    expect(after.structuredContent.agent).toEqual(updated.structuredContent.agent)
+    store.updateAgent(owner,manager.id,{instructions:'用户再修改'})
+    expect((await http('/tools/list',{})).status).toBe(403)
+  })
   it.each(['initializing','agent_initialization_failed','binding_busy','bridge_catalog_unreachable'])('preserves the Profile and the %s binding failure without reflecting upstream secrets',async code=>{
     manager.profile='yaoer'
     vi.mocked(target.session.request).mockImplementation(async path=>({status:path.endsWith('/bind')?409:200,