diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index b0104c7e52..9a71511880 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md -2026-08-05-agent-teams.md: d108d4f9833aca2a24c84d18d895e7ec63c0bfc2 -2026-08-05-agent-teams.zh.md: abf2ce3716e44effc8e34c0911cd9561d815aa08 +2026-08-05-agent-teams.md: 056b140079bec7d7239067d415359a93e4b51fbd +2026-08-05-agent-teams.zh.md: d688122f0aab790a21becc9146b25f85a29ac81d diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index d108d4f983..056b140079 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -40,6 +40,8 @@ Fresh children have no inherited conversation. Fork children capture the Lead's Peer communication is a Lead-log mailbox. `team/message/queued` is appended and flushed before delivery. The target message carries the stable message id and sender identity in both durable source metadata and a short model-visible prefix. A target receipt is acknowledged with `team/message/delivered` only after its pending inbox item or recorded user message is flushed. Immediate admission is serialized per target in queued-log order, recovery retries queued-minus-delivered in the same order, and delivery folds live or persisted target inbox/history state before cold resume. Every current-version Team payload is runtime-validated before entering replay state. The Team runtime tracks dispatch and asynchronous acknowledgement work from synchronous admission until settlement; disposal closes admission and awaits both before removing the service. Current waiters wake only after the owning Team event flush succeeds. +Unknown mailbox content stays as decoded JSON during event projection and checkpoint admission. Validation inspects its type without rebuilding its fields, because generic object parsing can omit a valid own `__proto__` key. Known content variants keep structural validation; opaque plugin fields do not gain Team semantics. Team projection cache version 4 discards older checkpoints because parser correction alone cannot recover keys already omitted from cached state. Rebuilding uses the unchanged Session log and does not change its format version. + `send_message` always attempts Steer delivery. A running target receives the message at the nearest step boundary; an inactive target starts a turn if loaded or cold-resumes otherwise. Success means the message is already durable even when temporary delivery failure leaves it queued. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) owns the single-tool scheduling rationale. Shared tasks are complete snapshots with Team-local ids and monotonic revisions. Every mutation carries `expectedRevision`. Any member creates, reads, or claims a ready unowned task; the owner or Lead edits and transitions it, while only the Lead assigns another member. Numeric task ids remain within the safe-integer allocation range, and exhaustion fails without reusing an id. Dependencies must name non-deleted tasks and form a complete DAG. Deleted tasks are retained tombstones. `writeScopes` are normalized path prefixes that produce overlap diagnostics but never block claim or authorize a write. diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index abf2ce3716..d688122f0a 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -40,6 +40,8 @@ fresh child 不继承对话。fork child 只捕获一次 Lead 已完成 turn 前 Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/message/queued`。target message 会在持久 source metadata 与短模型可见前缀中同时携带稳定 message id 和 sender identity。只有 pending inbox 条目或已记录用户消息完成 flush,Lead 日志才写入 `team/message/delivered` acknowledgement。即时准入按 target 和 queued 日志顺序串行化,恢复按同一顺序重试 queued-minus-delivered,并在冷恢复前折叠 live 或 persisted target 的 inbox/历史状态。每个当前版本 Team payload 都会经过运行时验证后才进入 replay state。Team runtime 从同步准入到 settlement 全程跟踪 dispatch 与异步 acknowledgement 工作;dispose 会关闭准入,并在移除服务前等待两者。当前 waiter 只在所属 Team event flush 成功后被唤醒。 +事件投影和 checkpoint 准入期间,未知 mailbox 内容保持为已解码 JSON。校验只检查其 type,不重建字段,因为通用对象解析可能省略有效的自有 `__proto__` 键。已知内容变体保持结构校验;不透明的插件字段不获得 Team 语义。Team 投影缓存版本 4 丢弃旧 checkpoint,因为只修正解析器无法恢复缓存状态中已经省略的键。重建使用未修改的 Session 日志,不改变其格式版本。 + `send_message` 始终尝试 Steer 投递。running target 在最近的步骤边界收到消息,inactive target 在已加载时启动一个轮次,否则冷恢复。即使临时投递失败让消息保持 queued,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)负责单工具调度的理由。 共享 task 是带 Team-local id 与单调 revision 的完整快照。每次变更都携带 `expectedRevision`。任意 member 可以创建、读取或 claim ready 且无 owner 的任务;Owner 或 Lead 可以编辑和转换;只有 Lead 可以分配给另一个 member。数字 task id 保持在安全整数分配范围内;该范围耗尽时会失败,不会复用 id。依赖必须指向未删除任务,并形成完整 DAG。删除任务保留为 tombstone。`writeScopes` 是规范化路径前缀,只产生重叠诊断,绝不会阻止 claim 或授予写权限。 diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 74dbfe69df..aac1bccbb2 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md -README.md: 31ea956d2e39dfe25410929be2a574b4c3f07a7d -README.zh.md: 03344285ceaee965c4154f939f22b592c833840b +README.md: 5168d7f7ba3b855dd8109f3ae0c92d7946c70c4f +README.zh.md: 837547bb076e1c0a6e3284876efc0eca33736f6c diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 31ea956d2e..5168d7f7ba 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -147,6 +147,8 @@ Team events are appended to the exact live Lead Session and flushed before the o Native V4 Team event and checkpoint admission reject retired `tool-result` content before it can enter mailbox state. Historical conversion belongs to the Session-format migration; the Team projection does not convert old wrappers. +Mailbox projection and checkpoint admission preserve every decoded JSON field of unknown plugin content, including an own `__proto__` key. Known content variants retain their declared validation. Team projection cache version 4 rebuilds older checkpoints from the Session log to recover previously omitted keys; the Session format version is unchanged. + ### Disposal Disposal closes admission, aborts and awaits admitted creation and mailbox-dispatch transactions, then asks the continuation owner to release the roster's exact live direct children and their descendants; non-Team continuable children of the Lead remain untouched. Cleanup failures make disposal fail visibly, bounded by `disposalTimeoutMs`. diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 03344285ce..837547bb07 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -147,6 +147,8 @@ Team 事件追加到精确的 live Lead 会话,并在操作报告成功或唤 原生 V4 的 Team 事件及检查点准入会拒绝退役的 `tool-result` 内容,防止它进入邮箱状态。历史转换由 Session 格式迁移负责,Team 投影不转换旧包装。 +Mailbox projection 和 checkpoint 准入保留未知 plugin 内容已解码 JSON 的全部字段,包括自有 `__proto__` 键。已知内容变体保留其声明的校验。Team projection cache version 4 会从 Session 日志重建较旧的 checkpoint,恢复此前省略的键;Session format version 不变。 + ### Dispose dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch 事务,再让 continuation owner 释放 roster 中确切的 live direct child 及其后代;Lead 的非 Team continuable child 不受影响。cleanup 失败会让 dispose 明确失败,并以 `disposalTimeoutMs` 为上限。 diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts index d5dff9784c..d6583dfa9d 100644 --- a/packages/experimental/agent-team/src/projection.ts +++ b/packages/experimental/agent-team/src/projection.ts @@ -52,10 +52,12 @@ const contentBlockSchema: z.ZodType = z.lazy(() => z.union([ name: z.string(), arguments: z.string(), }).strict(), - z.object({ type: z.string().min(1) }).loose().refine( - block => !coreContentBlockTypes.has(block.type), - { message: 'known content block types must match their declared fields' }, - ), + // Unknown content is decoded JSON; validation must preserve its own extension keys. + z.custom((value) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const type = (value as { type?: unknown }).type + return typeof type === 'string' && type.length > 0 && !coreContentBlockTypes.has(type) + }), ])) as z.ZodType const teamMemberSnapshotSchema = z.object({ @@ -300,7 +302,7 @@ function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void /** Host-only Team projection selected by the projected Session identity. */ export const teamProjectionDefinition = { key: 'agentTeam', - stateVersion: 3, + stateVersion: 4, stateSchema: teamProjectionEntrySchema, init: header => emptyTeamState(header.id), apply: (state, event) => { diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index e5443d4135..8e9602c1ef 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -1,3 +1,4 @@ +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { describe, expect, it, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' @@ -325,6 +326,63 @@ describe('Agent Teams projection events', () => { expect(pending(state)[0]?.content).toEqual([extension]) }) + it('preserves opaque JSON through projection and checkpoints', () => { + const extension = JSON.parse('{"type":"plugin/custom","__proto__":{"saved":true},"constructor":{"saved":false},"content":[{"__proto__":{"nested":true},"opaque":true}]}') as ContentBlock + const content = [extension] + const queued = event('team/message/queued', { version: 2, teamId: TEAM, message: message({ content }) }, SessionSeq(0)) + const before = JSON.stringify(queued) + const projected = projectTeam(ROOT, [queued]) + expect(projected.messages[0]?.content).toEqual(content) + const checkpoint = teamProjectionDefinition.stateSchema.parse(JSON.parse(JSON.stringify(projected))) + expect(JSON.stringify(checkpoint)).toBe(JSON.stringify(projected)) + expect(JSON.stringify(queued)).toBe(before) + const block = checkpoint.messages[0]!.content[0]! + expect(Object.hasOwn(block, '__proto__')).toBe(true) + expect(Object.getPrototypeOf(block)).toBe(Object.prototype) + }) + + it.each([ + null, [], 3, + { type: '' }, + { type: null }, + { type: 'text', text: false }, + ])('rejects malformed content in events and checkpoints: %j', (block) => { + const saved = message({ content: [block] as unknown as ContentBlock[] }) + const queued = event('team/message/queued', { version: 2, teamId: TEAM, message: saved }, SessionSeq(0)) + expect(() => projectTeam(ROOT, [queued])).toThrow(/team\/message\/queued payload is invalid/) + expect(() => teamProjectionDefinition.stateSchema.parse({ ...project(ROOT, []), messages: [saved] })).toThrow() + }) + + it('rebuilds lossy version-3 Team checkpoints from the original log', async () => { + const ctx = new Context() + const store = ctx.plugin(SessionStore) + const registry = ctx.plugin(SessionProjectionRegistry) + try { + await store + await registry + ctx.sessionProjections.register(teamProjectionDefinition) + const extension = JSON.parse('{"type":"plugin/custom","__proto__":{"saved":true}}') as ContentBlock + const queued = event('team/message/queued', { + version: 2, teamId: TEAM, message: message({ content: [extension] }), + }, SessionSeq(0)) + const oldState = project(ROOT, [event('team/message/queued', { + version: 2, teamId: TEAM, message: message({ content: [{ type: 'plugin/custom' } as unknown as ContentBlock] }), + }, SessionSeq(0))]) + const restored = ctx.sessionProjections.restore( + { agentTeam: { ver: 3, seq: SessionSeq(0), val: oldState } }, + [queued], + SessionLogOffset(0), + { version: SESSION_FORMAT_VERSION, id: ROOT, createdAt: 0, isSeeded: false }, + SessionLogOffset(0), + ) + const state = teamProjectionDefinition.stateSchema.parse(restored.checkpoint['agentTeam']!.val) + expect(state.messages[0]?.content).toEqual([extension]) + } finally { + await registry.dispose() + await store.dispose() + } + }) + it('records unsupported event versions without applying them', () => { const invalid = event('team/task', { version: 1 as 2,