fix(review): contain provider-removed listener failures; pin the model-via-request path

ds-review-bot round 2, both warnings:

- subagent/provider-removed now routes through emitLifecycle (per-listener
  containment, the subagent/start|end precedent) instead of raw ctx.emit,
  whose dispatch halts on the first throw: a throwing subscriber can no
  longer starve a later mirror into keeping a stale tool, nor disrupt the
  backend fiber's teardown mid-disposer. provider-added deliberately keeps
  propagation (register-time rollback semantics, like the system-prompt
  registries); the asymmetry is documented on emitLifecycle, the event
  JSDoc, and the provider-lifecycle RFC.

- The documented model-via-agent/request fallback composes with a
  {{model}} persona via the ownership rule itself: the plugin supplying
  the model late states it early on the system-prompt/assemble waterfall.
  Declined re-ordering render after agent/request — it would break the
  agent/pre-step contract (compaction must measure the prompt the model
  sees). New loop test pins the supply path end-to-end; the RFC's
  {{model}} consequence bullet now covers supply as well as switch.
This commit is contained in:
Tianyi Cui
2026-07-06 00:34:47 +08:00
parent bffdee88e4
commit 3633cf90c8
8 changed files with 80 additions and 15 deletions

View File

@@ -218,6 +218,32 @@ describe('agent loop', () => {
expect(turnEnds[1]?.type === 'turn/end' && turnEnds[1].data.reason.kind).toBe('completed')
})
it('supports the model-via-agent/request path with a {{model}} persona: the supplier states it via the assemble waterfall', async () => {
// AgentOptions.model unset: the model arrives in the agent/request
// waterfall (the loop's documented fallback — see runStep's no-model
// error). {{model}} renders BEFORE that waterfall, so the SAME plugin
// states the fact early on system-prompt/assemble — the owner of a
// late-bound fact owns stating it wherever it is claimed.
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter, 'You run on {{model}}.')
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
assembly.variables['model'] = 'mock'
return next()
})
ctx.on('agent/request', async (_agent, _turn, _step, options, next) => {
options.model = 'mock'
return next()
})
const agent = ctx.agentLoop.create(AgentId('a-late-model'), {})
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests[0]!.model).toBe('mock')
expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou run on mock.')
})
it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => {
// The documented escape valve: a deployment that must drop the harness
// openers short-circuits the assemble waterfall; the request then carries