From 1e6630a8568918cd5b49b1a58ed971b00a19b694 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:09:32 +0800 Subject: [PATCH] fix(agent-team): preserve opaque JSON through mailbox projections A valid unknown content block can own a __proto__ JSON field. Zod object reconstruction silently drops that key when Team events are projected and when checkpoints are admitted, even though the original Session log still contains it. Validate the unknown block tag without reconstructing its decoded JSON fields. Keep the current V4 local validators and the rejection of retired tool-result content. Bump only the Team projection cache stateVersion from 3 to 4 so old lossy checkpoints are rebuilt from the unchanged log; this is not a Session format or schema change. Cover direct and nested opaque JSON fields inside accepted native V4 content, plus public registry restoration from an older lossy checkpoint. Historical tool-result conversion remains owned by the Session migration and is not restored in the Team parser. Update the README and existing Team decision rationale together. Adapted from the Team JSON preservation work in b8f1da92599123e5d5bae6c174f73b5b778f3554 by Tianyi Cui. Co-authored-by: Tianyi Cui <53024+tianyicui@users.noreply.github.com> --- .../feature/2026-08-05-agent-teams.i18n.yaml | 4 +- .../feature/2026-08-05-agent-teams.md | 2 + .../feature/2026-08-05-agent-teams.zh.md | 2 + .../experimental/agent-team/README.i18n.yaml | 4 +- packages/experimental/agent-team/README.md | 2 + packages/experimental/agent-team/README.zh.md | 2 + .../experimental/agent-team/src/projection.ts | 12 ++-- .../tests/projection-events.spec.ts | 58 +++++++++++++++++++ 8 files changed, 77 insertions(+), 9 deletions(-) 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,