simplify(llm): drop unconsumed adapter-change event and assembled call surfaces

The LLM service exposed three call surfaces (stream/streamBlocks/generate) but
the only production consumer — the agent loop — uses stream() exclusively,
feeding raw chunks through its own BlockAssembler for replay fidelity. Drop the
speculative convenience surfaces and the registry-change event that no listener
consumed, leaving stream() as the single model-call contract for both
production and tests.

- Remove LlmService.streamBlocks() and generate(), the llm/generate waterfall,
  and GenerateResult.
- Remove the llm/adapter-change event (declaration + emits) and the
  listener-throw rollback ordering that existed only to protect it; keep the
  HMR rollback disposer.
- Remove BlockAssembler.flushReady()/flushRemaining()/result() and the flushed
  cursor — the streaming-flush slice existed only for streamBlocks().
- Adapter tests drive a stream()+BlockAssembler helper (tests/assemble.ts)
  instead of generate(), exercising the same path production uses.
- Land the AGENTS.md "RFCs are proposals, not golden truth" principle and move
  both RFCs proposed -> implemented.

Implements:
- docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md
- docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md
This commit is contained in:
Tianyi Cui
2026-06-21 01:27:41 +08:00
parent 18f1c010ca
commit 30cd67b8a1
26 changed files with 164 additions and 428 deletions

View File

@@ -1,14 +1,13 @@
/**
* LLM service: adapter registry with waterfall-interceptable streaming and
* non-streaming call surfaces. Exports the `LlmService` default, the abstract
* `LlmAdapter` for provider backends, and `BlockAssembler` for chunk assembly.
* LLM service: adapter registry with a waterfall-interceptable streaming call
* surface. Exports the `LlmService` default, the abstract `LlmAdapter` for
* provider backends, and `BlockAssembler` for chunk assembly.
*
* @module @deepseek-ai/dsh-llm
*/
import { Context, Service } from 'cordis'
import type { ContentBlock, GenerateOptions, GenerateResult, StreamChunk } from './types.ts'
import { BlockAssembler } from './assembler.ts'
import type { GenerateOptions, StreamChunk } from './types.ts'
import { HarnessError } from './error.ts'
export * from './brand.ts'
@@ -30,17 +29,6 @@ declare module 'cordis' {
* @mode waterfall
*/
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
/**
* Waterfall around every non-streaming model call. Bound to the
* {@link LlmService}; call `next()` to delegate to the adapter.
* @mode waterfall
*/
'llm/generate'(this: LlmService, options: GenerateOptions, next: () => Promise<GenerateResult>): Promise<GenerateResult>
/**
* An adapter was registered or unregistered (the model→adapter map changed).
* @mode emit
*/
'llm/adapter-change'(): void
}
}
@@ -88,8 +76,7 @@ export class LlmService extends Service {
/**
* Register an adapter for the given model names. Throws `LlmError` with code
* `DUPLICATE_ADAPTER` if any model already has an adapter (all-or-nothing).
* Emits `llm/adapter-change` on registration and disposal. Disposed with the
* fiber.
* Disposed with the fiber.
*/
registerAdapter(models: string[], adapter: LlmAdapter): () => void {
const dispose = this.ctx.effect(function* (this: LlmService) {
@@ -99,17 +86,9 @@ export class LlmService extends Service {
}
}
for (const model of models) this.adapters.set(model, adapter)
// Yield the rollback BEFORE emitting the change event: a generator effect
// collects each yielded disposer before running the next step, so a
// throwing `llm/adapter-change` listener rolls the mutation back instead
// of leaking the entry (which would wedge the duplicate check until
// restart). The duplicate throws above fire before any mutation, so they
// correctly leak nothing.
yield () => {
for (const model of models) this.adapters.delete(model)
this.ctx.emit('llm/adapter-change')
}
this.ctx.emit('llm/adapter-change')
}.bind(this), 'llm.registerAdapter()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
@@ -137,36 +116,6 @@ export class LlmService extends Service {
return this.adapter(options.model).stream(options)
})
}
/**
* Stream one model call as completed content blocks — a convenience view
* for consumers that don't care about token-level deltas. Blocks are
* yielded strictly in stream order as soon as they (and everything before
* them) complete; blocks left open at end of stream (delta-only protocols)
* are assembled and flushed last, so the sequence always equals
* `generate()`'s `message.content`.
*/
async * streamBlocks(options: GenerateOptions): AsyncIterable<ContentBlock> {
const assembler = new BlockAssembler()
for await (const chunk of this.stream(options)) {
assembler.push(chunk)
yield * assembler.flushReady()
}
yield * assembler.flushRemaining()
}
/**
* One model call, fully assembled (drains the chunk stream). Dispatches
* through the `llm/generate` waterfall (and the inner stream through
* `llm/stream`). Same completion guarantees as `streamBlocks()`.
*/
generate(options: GenerateOptions): Promise<GenerateResult> {
return this.ctx.waterfall(this, 'llm/generate', options, async () => {
const assembler = new BlockAssembler()
for await (const chunk of this.stream(options)) assembler.push(chunk)
return assembler.result()
})
}
}
export default LlmService