feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand

Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.

- Extract the `Branded<B>` primitive into a new standalone type-only package
  `@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
  dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
  dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
  dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
  generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
  the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
  the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
  SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
  that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
  agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
  at the config boundary and the inner create()/resume casts disappear (only the
  genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
  Map keys and public params/exports (SessionStore, AgentRegistry + factory
  options, the ACP session-id surface + ToolPresenter CallId map, the
  persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
  the Branded type-equiv at dsh-brand, fix stale param types in the session/
  agent/bash READMEs, regenerate the cordis catalog + module graph.

Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
This commit is contained in:
Tianyi Cui
2026-06-21 07:17:25 +08:00
parent 24168aee70
commit d6a2ab30c8
75 changed files with 644 additions and 445 deletions
+23 -23
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { AgentId } from '@deepseek-ai/dsh-agent'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -53,7 +53,7 @@ describe('ReactLoopAgent', () => {
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -68,7 +68,7 @@ describe('ReactLoopAgent', () => {
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -83,7 +83,7 @@ describe('ReactLoopAgent', () => {
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -96,7 +96,7 @@ describe('ReactLoopAgent', () => {
it('inject() decides enclosure from the LOG (open turn), not agent status', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// Simulate an OPEN turn in the log while the agent is idle (status is not a
// reliable open-turn signal). inject must append into that open turn, NOT
@@ -122,7 +122,7 @@ describe('ReactLoopAgent', () => {
// A persistence-like listener whose flush rejects.
ctx.on('session/flush', () => { throw new Error('disk gone') })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// inject() is synchronous and fires a fire-and-forget flush; a rejecting
// flush must be contained (logged), never thrown into the caller.
@@ -135,7 +135,7 @@ describe('ReactLoopAgent', () => {
it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
@@ -155,7 +155,7 @@ describe('ReactLoopAgent', () => {
it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// A session/event listener that throws on the synthetic turn/end. Append
@@ -180,7 +180,7 @@ describe('ReactLoopAgent', () => {
// A non-Error rejection exercises the String() normalization branch.
ctx.on('session/flush', () => { throw 'disk gone' })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const errors: { turn: number; step: number; message: string }[] = []
ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
@@ -199,7 +199,7 @@ describe('ReactLoopAgent', () => {
it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// A non-serializable source makes the turn/start append throw BEFORE the
// event is pushed (Session.append validates before push), so NO turn opens.
@@ -214,7 +214,7 @@ describe('ReactLoopAgent', () => {
it('steer() when idle falls through to send() and starts a turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// steer while idle delegates to send
agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } })
@@ -230,7 +230,7 @@ describe('ReactLoopAgent', () => {
// Then call it twice — the second call hits the early-return branch.
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create('test')
const session = ctx.sessions.create(SessionId('test'))
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
// Start the loop to get the disposer; the agent waits for messages
@@ -249,7 +249,7 @@ describe('ReactLoopAgent', () => {
it('setting the same status does not emit agent/status again', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const statuses: string[] = []
ctx.on('agent/status', (subject, status) => {
@@ -268,7 +268,7 @@ describe('ReactLoopAgent', () => {
it('whenIdle() resolves immediately when the agent is not running', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// Fresh agent is idle — whenIdle() takes the not-running fast path and
// resolves without subscribing. await must not hang.
@@ -279,7 +279,7 @@ describe('ReactLoopAgent', () => {
it('whenIdle() waits for queued work that has not flipped status yet', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'queued')
let settled = false
@@ -297,8 +297,8 @@ describe('ReactLoopAgent', () => {
it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => {
const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const other = ctx.agentLoop.create('a2', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
// Drive `agent` into `running`, then await whenIdle() — it subscribes to
// agent/status and resolves on the first transition out of running.
@@ -333,7 +333,7 @@ describe('ReactLoopAgent', () => {
await ctx.plugin(AgentRegistry)
const adapter = new MockAdapter(['hang'])
ctx.llm.registerAdapter(['mock'], adapter)
const session = ctx.sessions.create('bare')
const session = ctx.sessions.create(SessionId('bare'))
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const dispose = agent.start()
agent.send([{ type: 'text', text: 'go' }])
@@ -357,7 +357,7 @@ describe('ReactLoopAgent', () => {
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -378,7 +378,7 @@ describe('ReactLoopAgent', () => {
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -399,7 +399,7 @@ describe('ReactLoopAgent', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/status', (_subject, status) => {
if (status === 'running') throw new Error('bad running listener')
})
@@ -417,7 +417,7 @@ describe('ReactLoopAgent', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/status', (_subject, status) => {
if (status === 'idle') throw new Error('bad idle listener')
})
@@ -434,7 +434,7 @@ describe('ReactLoopAgent', () => {
it('abort() resolves reason to "aborted" when no reason provided', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: { kind: string; reason?: string }[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))