refactor(agent-loop): project runtime context before steps

This commit is contained in:
_Kerman
2026-08-01 20:39:10 +08:00
parent 1a09174987
commit d38c8bfaf3
11 changed files with 255 additions and 384 deletions
+37 -12
View File
@@ -11,7 +11,6 @@ import type {
AgentStatus,
CancelOptions,
InboxTarget,
PreStepDecision,
RequestErrorAction,
} from '@deepseek-ai/dsh-agent'
import { Inbox, agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
@@ -26,10 +25,12 @@ import {
} from '@deepseek-ai/dsh-llm'
import type { Scope } from '@deepseek-ai/dsh-scope'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { EpochHeader, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type { Context } from 'cordis'
import { RuntimeContextProjection } from './runtime-context.ts'
import { executeToolCalls } from './tool-calls.ts'
type Phase =
@@ -39,6 +40,10 @@ type Phase =
type StepEndReason = Extract<TurnEndReason, { kind: 'completed' | 'max-tokens' }>
type PreparedStep =
| { kind: 'reject' }
| { kind: 'enter'; messages: UserMessage[]; assembly: PromptAssembly }
/** Remove adapter-derived values before plugins propose the next request config. */
function requestProposal(header: EpochHeader): LlmCallConfig {
if (header.adapterDefaults === undefined) return header.config
@@ -60,6 +65,7 @@ export class ReactLoopAgent implements Agent {
/** Whether this loop instance has appended its initial/resume request anchor. */
private requestHeaderLogged = false
private readonly runtimeContext: RuntimeContextProjection
constructor(
private loopCtx: Context,
@@ -75,6 +81,7 @@ export class ReactLoopAgent implements Agent {
this.phase = { kind: 'idle', lastTurn }
this.scope = createScope(loopCtx, this)
this.ctx = this.scope.ctx.extend({ agent: this })
this.runtimeContext = new RuntimeContextProjection(this.ctx, session)
}
get status(): AgentStatus {
@@ -154,19 +161,25 @@ export class ReactLoopAgent implements Agent {
}
}
private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise<PreStepDecision> {
private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise<PreparedStep> {
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`)
const signal = this.phase.abort.signal
const claimed = this.inbox.claim(target)
for (const message of claimed) {
emitAgentEvent(this.loopCtx, this, 'agent/inbox/claimed', { message, turn: position.turn })
}
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted()
const context = this.runtimeContext.project(renderContextSnapshot(assembly))
const decision = await agentEvents(this.loopCtx, this).waterfall(
'agent/pre-step', claimed, { ...position, signal },
() => Promise.resolve({ kind: 'enter', messages: claimed }),
() => Promise.resolve({
kind: 'enter',
messages: context === undefined ? claimed : [...claimed, context],
}),
)
signal.throwIfAborted()
return decision
return decision.kind === 'reject' ? decision : { ...decision, assembly }
}
/** Claimed input stays unowned until `turn/start` commits. */
@@ -180,7 +193,7 @@ export class ReactLoopAgent implements Agent {
const phase = { kind: 'running' as const, abort, turn: lastTurn, step: 0 }
this.setPhase(phase)
signal.throwIfAborted()
let decision: PreStepDecision
let decision: PreparedStep
try {
decision = await this.preStep('next-turn', { turn: phase.turn + 1, step: 1 })
if (decision.kind === 'reject') return false
@@ -205,7 +218,7 @@ export class ReactLoopAgent implements Agent {
for (const message of decision.messages) {
this.session.append('user/message', message, { surfaceOp: 'append' })
}
turnEnds = await this.step()
turnEnds = await this.step(decision.assembly)
} finally {
this.session.append('step/end', { turn, step })
}
@@ -244,17 +257,16 @@ export class ReactLoopAgent implements Agent {
return this.inbox.hasPending
}
private async step(): Promise<StepEndReason | null> {
private async step(assembly: PromptAssembly): Promise<StepEndReason | null> {
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`)
const { turn, step, abort: { signal } } = this.phase
signal.throwIfAborted()
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted()
const system = renderPrompt(assembly)
const boundaryMessages = this.session.deriveMessages()
while (true) {
const { request, preparedCall } = await this.buildRequest(
turn, step, assembly.tools, system, this.session.deriveMessages(), signal,
turn, step, assembly.tools, system, boundaryMessages, signal,
)
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
@@ -383,6 +395,19 @@ export class ReactLoopAgent implements Agent {
} else if (baseline === undefined || !headerEquals(baseline, header)) {
this.session.append('request/header', { header, reason: 'change' })
}
const contextWindow = preparedCall?.context?.contextWindow
const requestContext: RequestContext = {
provider: config.provider,
model: config.model,
...contextWindow === undefined ? {} : { contextWindow },
}
const previousContext = session.requestContext()
if (previousContext?.provider !== requestContext.provider
|| previousContext.model !== requestContext.model
|| previousContext.contextWindow !== requestContext.contextWindow) {
session.append('request/context', requestContext)
}
signal.throwIfAborted()
const request = markAgentLoopRequest(deepFreeze({
@@ -0,0 +1,71 @@
/**
* Durable projection state for dynamic runtime context.
* @module @deepseek-ai/dsh-agent-loop/runtime-context
*/
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { Context } from 'cordis'
const SOURCE = '@deepseek-ai/dsh-system-prompt'
const CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
function isOwned(message: UserMessage): boolean {
return message.source.kind === 'plugin' && message.source.plugin === SOURCE
}
function textOf(message: UserMessage): string | undefined {
const [block] = message.content
return message.content.length === 1 && block?.type === 'text' ? block.text : undefined
}
/** Tracks the last retained runtime-context snapshot without owning its commit. */
export class RuntimeContextProjection {
/** `undefined` means no snapshot ever existed; `null` means none is retained. */
private retained: { seq: number; text: string | undefined } | null | undefined
/**
* Restore projection state once, then follow authoritative session events.
* @param ctx - agent-scoped event context.
* @param session - session receiving projected messages.
*/
constructor(ctx: Context, session: Session) {
const surface = new Set(session.surface.nodes)
for (let index = session.events.length - 1; index >= 0; index -= 1) {
const event = session.events[index]
if (event?.type !== 'user/message' || !isOwned(event.data)) continue
this.retained ??= null
if (surface.has(event.seq)) {
this.retained = { seq: event.seq, text: textOf(event.data) }
break
}
}
ctx.on('session/event', (subject, event) => {
if (subject !== session) return
if (event.type === 'user/message' && isOwned(event.data)) {
this.retained = { seq: event.seq, text: textOf(event.data) }
} else if (this.retained
&& isReplacementSurfaceEvent(event)
&& event.sourceEventSeqs?.includes(this.retained.seq) === true) {
this.retained = null
}
})
}
/**
* Create an uncommitted snapshot only when the retained value differs.
* @param current - fully rendered dynamic context.
* @returns a candidate user message, or `undefined` when no update is needed.
*/
project(current: string): UserMessage | undefined {
if (this.retained === undefined && current.length === 0) return
const snapshot = current.length === 0 ? CLEARED : current
if (this.retained?.text === snapshot) return
return createUserMessage({
content: [{ type: 'text', text: snapshot }],
source: { kind: 'plugin', plugin: SOURCE },
})
}
}
+3 -38
View File
@@ -1,8 +1,8 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
/**
* Tests for the queue-aware `Agent.cancel()` primitive. The default clears
* queued and steering work, while `keepInbox` preserves pending input and
* resumes waking turns after the active turn reaches quiescence. The suite
* queued and steering work, while `keepInbox` preserves pending input for a
* later wake after the active turn reaches quiescence. The suite
* covers every landing window plus signal reset and `whenIdle()` quiescence.
* @module dsh-agent-loop/tests/cancel
*/
@@ -284,41 +284,6 @@ describe('Agent.cancel()', () => {
expect(adapter.requests).toHaveLength(1)
})
it('cancel({ keepInbox: true }) aborts the active turn and drains the queued tail in FIFO order', async () => {
const adapter = new MockAdapter([
'hang',
textResponse('second reply'),
textResponse('third reply'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('keep-inbox-running'), { provider: 'mock', model: 'mock' })
const reasons: TurnEndReason[] = []
const discards: unknown[] = []
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
})
ctx.on('agent/inbox/discard', (subject, items) => {
if (subject === agent) discards.push(items)
})
send(agent, 'active')
await new Promise(resolve => setTimeout(resolve, 30))
send(agent, 'queued second')
send(agent, 'queued third')
const idle = agent.whenIdle()
agent.cancel({ kind: 'user' }, { keepInbox: true })
await idle
expect(discards).toEqual([])
expect(userTexts(agent)).toEqual(['active', 'queued second', 'queued third'])
expect(reasons).toEqual([
{ kind: 'aborted' },
{ kind: 'completed' },
{ kind: 'completed' },
])
expect(adapter.requests).toHaveLength(3)
})
it('cancel from an assistant/message observer skips execution but balances replay', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'danger', {}),
@@ -770,7 +735,7 @@ describe('Agent.cancel()', () => {
agent.cancel({ kind: 'user' })
await idle
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
if (stage === 'pre-step') {
if (stage === 'pre-step' || stage === 'system-prompt') {
expect(turnEnd).toBeUndefined()
} else {
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } })
@@ -1122,7 +1122,7 @@ describe('tool result call identity', () => {
})
describe('disposal and cancellation during pre-step assembly', () => {
it('disposal during system-prompt assembly closes the started step as disposed', { timeout: 30000 }, async () => {
it('disposal during system-prompt assembly prevents the turn from opening', { timeout: 30000 }, async () => {
// Start disposal, then release assembly. Do not await disposal first: it
// waits for the blocked driver to exit.
const adapter = new MockAdapter(['hang'])
@@ -1154,7 +1154,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
// Give the loop time to enter the step and reach assemble().
// Give the loop time to reach pre-step assembly.
await new Promise(r => setTimeout(r, 50))
// Release assembly before awaiting disposal because disposal joins the blocked driver.
@@ -1165,18 +1165,15 @@ describe('disposal and cancellation during pre-step assembly', () => {
await driverDone(agent)
unlisten()
// Turn boundaries are durable rows; there is no `agent/*` mirror to assert.
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
expect(e.filter(x => x.type === 'step/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'step/end')).toHaveLength(1)
expect(e.some(x => x.type === 'turn/start')).toBe(false)
expect(e.some(x => x.type === 'turn/end')).toBe(false)
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'step/end')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
})
it('cancel during system-prompt assembly closes the started step as aborted', { timeout: 30000 }, async () => {
it('cancel during system-prompt assembly prevents the turn from opening', { timeout: 30000 }, async () => {
const adapter = new MockAdapter([textResponse('should not appear')])
let releaseAssemble!: () => void
const blocker = new Promise<void>(r => void (releaseAssemble = r))
@@ -1215,16 +1212,14 @@ describe('disposal and cancellation during pre-step assembly', () => {
unlisten()
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } })
expect(e.filter(x => x.type === 'step/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'step/end')).toHaveLength(1)
expect(e.some(x => x.type === 'turn/start')).toBe(false)
expect(e.some(x => x.type === 'turn/end')).toBe(false)
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'step/end')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
expect(adapter.requests).toHaveLength(0)
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
expect(reasons).toEqual([])
})
it('disposal during pre-step prevents the turn from opening', { timeout: 15000 }, async () => {
@@ -1356,14 +1351,10 @@ describe('disposal and cancellation during pre-step assembly', () => {
await driverDone(agent)
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
// The critical assertions: after disposal, the turn has no assistant
// artifacts — the turn ended disposed before the model was invoked.
expect(e.some(x => x.type === 'turn/start')).toBe(false)
expect(e.some(x => x.type === 'turn/end')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
expect(adapter.requests).toHaveLength(0)
// The durable turn/end reason is the authoritative turn-boundary record
// (turn boundaries have no agent/* mirror).
})
})
@@ -93,8 +93,7 @@ describe('loop-level canonical tool order', () => {
expect(Object.isFrozen(adapter.requests[0])).toBe(true)
})
it('fails the turn — no model request — when toolOrder names an unregistered tool', async () => {
// Unknown tool order fails before step or request creation and returns the agent to idle.
it('fails before opening a turn when toolOrder names an unregistered tool', async () => {
const adapter = new MockAdapter([textResponse('never sent')])
const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST])
registerNamed(ctx, 'alpha')
@@ -103,12 +102,9 @@ describe('loop-level canonical tool order', () => {
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(foldRequestHeader(agent.session.events)).toBeUndefined()
const end = agent.session.events.find(e => e.type === 'turn/end')
expect(end?.type === 'turn/end' && end.data.reason).toEqual({
kind: 'error',
error: 'toolOrder lists unregistered tool "ghost"; known tools: alpha',
})
expect(agent.session.events.filter(e => e.type === 'step/start')).toHaveLength(1)
expect(agent.session.events.filter(e => e.type === 'step/end')).toHaveLength(1)
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(false)
expect(agent.session.events.some(e => e.type === 'step/start')).toBe(false)
expect(agent.session.events.some(e => e.type === 'step/end')).toBe(false)
})
})
@@ -1,286 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { type Agent, type InboxItem } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function prompt(agent: Agent, text: string): void {
agent.followup(createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
}))
}
function itemText(item: InboxItem): string {
return item.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
}
interface InboxRecording {
readonly events: string[]
readonly enqueued: InboxItem['id'][]
readonly dequeued: InboxItem['id'][]
readonly discarded: InboxItem['id'][]
}
/** Record the complete inbox lifecycle of one agent for order and identity assertions. */
function recordInbox(ctx: Context): InboxRecording {
const events: string[] = []
const enqueued: InboxItem['id'][] = []
const dequeued: InboxItem['id'][] = []
const discarded: InboxItem['id'][] = []
ctx.on('agent/inbox/enqueue', (_agent, item) => {
events.push(`enqueue:${item.placement}:${itemText(item)}`)
enqueued.push(item.id)
})
ctx.on('agent/inbox/dequeue', (_agent, item) => {
events.push(`dequeue:${itemText(item)}`)
dequeued.push(item.id)
})
ctx.on('agent/inbox/discard', (_agent, items) => {
events.push(`discard:${items.map(itemText).join(',')}`)
discarded.push(...items.map(item => item.id))
})
return { events, enqueued, dequeued, discarded }
}
/** Text of every ordinary prompt the log admitted, in durable order. */
function promptTexts(agent: Agent): string[] {
return agent.session.events.flatMap(event => event.type === 'user/message'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
}
describe('idle turn admission reservation', () => {
it('holds later waking prompts in the FIFO until release', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const inbox = recordInbox(ctx)
const release = agent.reserveTurnAdmission()
expect(release).toBeDefined()
prompt(agent, 'first prompt')
prompt(agent, 'second prompt')
expect(agent.acceptsNextStep).toBe(false)
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events).toHaveLength(0)
expect(inbox.events).toEqual([
'enqueue:queued:first prompt',
'enqueue:queued:second prompt',
])
release?.()
await agent.whenIdle()
expect(promptTexts(agent)).toEqual(['first prompt', 'second prompt'])
expect(agent.session.events.flatMap(event =>
event.type === 'turn/start' ? [event.data.turn] : [])).toEqual([1, 2])
expect(inbox.events).toEqual([
'enqueue:queued:first prompt',
'enqueue:queued:second prompt',
'dequeue:first prompt',
'dequeue:second prompt',
])
expect(inbox.dequeued).toEqual(inbox.enqueued)
expect(inbox.discarded).toEqual([])
})
it('refuses acquisition when an accepted waking prompt still owns the next turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
prompt(agent, 'accepted first')
expect(agent.status).toBe('idle')
expect(agent.reserveTurnAdmission()).toBeUndefined()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
})
it('refuses acquisition while a turn is running', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reserved: unknown[] = []
ctx.on('agent/step', () => {
reserved.push(agent.reserveTurnAdmission())
})
prompt(agent, 'running')
await agent.whenIdle()
expect(agent.status).toBe('idle')
expect(reserved).toEqual([undefined])
})
it('refuses a second reservation and releases idempotently', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const release = agent.reserveTurnAdmission()
expect(agent.reserveTurnAdmission()).toBeUndefined()
prompt(agent, 'queued behind the reservation')
release?.()
release?.()
await agent.whenIdle()
expect(promptTexts(agent)).toEqual(['queued behind the reservation'])
expect(adapter.requests).toHaveLength(1)
const second = agent.reserveTurnAdmission()
expect(second).toBeDefined()
second?.()
})
it('ignores a stale release once a later reservation owns the boundary', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const stale = agent.reserveTurnAdmission()
stale?.()
const live = agent.reserveTurnAdmission()
prompt(agent, 'held by the live reservation')
stale?.()
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
expect(adapter.requests).toHaveLength(0)
live?.()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
})
it('acquires beside quiet queued work and leaves it queued', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send(createUserMessage({
content: [{ type: 'text', text: 'quiet' }],
source: { kind: 'user' },
}), {
target: 'next-turn',
wakeup: false,
})
const release = agent.reserveTurnAdmission()
expect(release).toBeDefined()
release?.()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(0)
})
it('makes whenIdle() wait for release without spinning on a settled promise', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const machine = agent as Agent & { done: Promise<void> }
let backing = machine.done
let reads = 0
Object.defineProperty(agent, 'done', {
configurable: true,
get(): Promise<void> {
reads += 1
return backing
},
set(value: Promise<void>) {
backing = value
},
})
const release = agent.reserveTurnAdmission()
prompt(agent, 'waiting for the reservation')
let settled = false
const idle = agent.whenIdle().then(() => { settled = true })
for (let tick = 0; tick < 5; tick += 1) {
await new Promise<void>((resolve) => { setTimeout(resolve, 1) })
}
expect(settled).toBe(false)
expect(reads).toBeLessThanOrEqual(2)
release?.()
await idle
expect(settled).toBe(true)
expect(adapter.requests).toHaveLength(1)
})
it('resolves whenIdle() after release with nothing queued', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const release = agent.reserveTurnAdmission()
let settled = false
const idle = agent.whenIdle().then(() => { settled = true })
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
expect(settled).toBe(false)
release?.()
await idle
expect(agent.status).toBe('idle')
})
it('lets cancellation discard held prompts and keeps the boundary quiet', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const inbox = recordInbox(ctx)
const release = agent.reserveTurnAdmission()
prompt(agent, 'discarded while held')
agent.cancel({ kind: 'user' })
expect(inbox.events).toEqual([
'enqueue:queued:discarded while held',
'discard:discarded while held',
])
expect(inbox.discarded).toEqual(inbox.enqueued)
expect(inbox.dequeued).toEqual([])
release?.()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events).toHaveLength(0)
})
it('disposes the agent without waiting for the reservation to be released', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('a1'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const { agent } = handle
const release = agent.reserveTurnAdmission()
prompt(agent, 'discarded by disposal')
await handle.dispose()
expect(ctx.agents.list()).toEqual([])
expect(adapter.requests).toHaveLength(0)
release?.()
})
})
+22 -3
View File
@@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import type { CreateSessionOptions, EpochHeader, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
@@ -392,7 +392,7 @@ export class Session {
readonly firstLiveSeq: number
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
if (seed) {
if (seed !== undefined) {
// Validate the seed to the SAME invariants `append` enforces, so a
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
// live log that no persistence backend could store: each event's `data`
@@ -429,7 +429,7 @@ export class Session {
// captures the creation seed: no load-time write. Re-marking is skipped
// because a cold session is resumed on first touch, so repeatedly opening
// one must not grow its log per open.
if (this.firstLiveSeq > 0 && this.log.at(-1)?.type !== 'session/end-seed') {
if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
this.append('session/end-seed', {})
}
}
@@ -566,6 +566,25 @@ export class Session {
return this.headerFold
}
/** Cached fold of `request/context` events. */
private contextFold: RequestContext | undefined
private contextFoldSeq = 0
/**
* Return the latest resolved route metadata, or `undefined` before the first
* `request/context` event. Each event is folded once.
* @returns the latest immutable route metadata.
*/
requestContext(): RequestContext | undefined {
if (this.contextFoldSeq < this.log.length) {
for (const event of this.log.slice(this.contextFoldSeq)) {
if (event.type === 'request/context') this.contextFold = deepFreeze({ ...event.data })
}
this.contextFoldSeq = this.log.length
}
return this.contextFold
}
/** The derived-message cache: frozen projections, extended per unseen node. */
private derived: Message[] = []
/** Surface position (nodes projected) the cache has reached. */
+2 -1
View File
@@ -149,7 +149,8 @@ function validateEvent(
break
case 'steering/message':
case 'todo/write':
case 'request/header': {
case 'request/header':
case 'request/context': {
if (trace.openTurn === null) {
fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`)
}
+15
View File
@@ -151,6 +151,16 @@ export interface EpochHeader {
tools?: ToolSchema[]
}
/** Registration-bound metadata for one resolved model route. */
export interface RequestContext {
/** Registered provider route the metadata belongs to. */
provider: string
/** Provider-owned model id the metadata belongs to. */
model: string
/** Maximum combined request and response context in tokens, when advertised. */
contextWindow?: number
}
/**
* Why a `request/header` snapshot was appended: `'initial'` — the log's first
* header (a new conversation); `'resume'` — a loop instance's first request
@@ -233,6 +243,11 @@ export interface SessionEventMap {
* It is log-only; the latest snapshot reconstructs the request header.
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
* Route metadata for the next request, logged only when the route or capacity
* changes. It does not participate in request reconstruction or header equality.
*/
'request/context': RequestContext
/**
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
+78 -12
View File
@@ -1,5 +1,5 @@
/**
* Registry for ordered prompt sections, tool schemas, and prompt variables.
* Registry for ordered system sections, dynamic context, tool schemas, and prompt variables.
*
* @module @deepseek-ai/dsh-system-prompt
*/
@@ -17,7 +17,7 @@ declare module 'cordis' {
interface Events {
/**
* Expert waterfall over the assembled sections, tools, and variables.
* Expert waterfall over the assembled sections, contexts, tools, and variables.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners
* receive only that scope's assemblies. The returned value is authoritative.
* A supplied signal controls only this explicit assembly request and must not
@@ -65,6 +65,16 @@ export interface PromptSection {
readonly text: string | ((context: AssembleContext) => string)
}
/** Dynamic model context materialized as a durable user-role snapshot. */
export interface PromptContext {
/** Unique name — a duplicate registration throws (see {@link SystemPrompt.context}). */
readonly name: string
/** Contexts are joined in ascending order. */
readonly order: number
/** Static text or a provider evaluated for each assembly. Empty text contributes nothing. */
readonly text: string | ((context: AssembleContext) => string)
}
/** One section of an assembly: {@link PromptSection} with its text resolved. */
export interface AssembledSection {
/** The contributing section's unique name. */
@@ -73,6 +83,14 @@ export interface AssembledSection {
text: string
}
/** One resolved dynamic context contribution. */
export interface AssembledContext {
/** The contributing context's unique name. */
name: string
/** The resolved text before variable interpolation. */
text: string
}
/** Tool schemas visible in one assembly and their pre-restriction name set. */
export interface ToolProviderResult {
/** The schemas this provider contributes to THIS assembly. */
@@ -82,11 +100,12 @@ export interface ToolProviderResult {
}
/**
* Merge-extensible assembled prompt. Sections remain uninterpolated until
* {@link renderPrompt}; tools are already in canonical model-facing order.
* Merge-extensible assembled model input. Sections and contexts remain
* uninterpolated until rendered; tools are already in canonical order.
*/
export interface PromptAssembly {
sections: AssembledSection[]
contexts: AssembledContext[]
tools: ToolSchema[]
variables: Record<string, string | undefined>
}
@@ -170,14 +189,32 @@ export interface Config {
*/
export function renderPrompt(assembly: PromptAssembly): string {
return assembly.sections
.map(section => interpolate(section, assembly.variables))
.map(section => interpolate(section, assembly.variables, 'section'))
.filter(text => text.length > 0)
.join('\n\n')
}
/** Interpolate one section's `{{variable}}` references (see {@link renderPrompt}). */
function interpolate(section: AssembledSection, variables: Record<string, string | undefined>): string {
const text = section.text
/**
* Render the complete dynamic context snapshot.
* @param assembly - the assembly whose contexts and variables to render.
* @returns the current full snapshot, or `''` when no context is active.
*/
export function renderContextSnapshot(assembly: PromptAssembly): string {
const body = assembly.contexts
.map(context => interpolate(context, assembly.variables, 'context'))
.filter(text => text.length > 0)
.join('\n\n')
if (body.length === 0) return ''
return `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n${body}`
}
/** Interpolate one section or context and attribute diagnostics to its owner. */
function interpolate(
input: AssembledSection | AssembledContext,
variables: Record<string, string | undefined>,
kind: 'section' | 'context',
): string {
const text = input.text
let result = ''
let last = 0
for (let open = text.indexOf('{{'); open >= 0; open = text.indexOf('{{', last)) {
@@ -185,7 +222,7 @@ function interpolate(section: AssembledSection, variables: Record<string, string
if (group === null) {
// A later closing brace makes this malformed; otherwise it is literal prose.
if (text.indexOf('}}', open + 2) >= 0) {
throw new Error(`malformed prompt variable reference at "${text.slice(open, open + 16)}…" in section "${section.name}" (references are complete simple {{name}} groups)`)
throw new Error(`malformed prompt variable reference at "${text.slice(open, open + 16)}…" in ${kind} "${input.name}" (references are complete simple {{name}} groups)`)
}
result += text.slice(last, open + 2)
last = open + 2
@@ -194,16 +231,16 @@ function interpolate(section: AssembledSection, variables: Record<string, string
// `{{}}` yields an empty name and follows the malformed-reference path.
const name = group[0].slice(2, -2)
if (!VARIABLE_NAME.test(name)) {
throw new Error(`malformed prompt variable reference "{{${name}}}" in section "${section.name}" (variable names match ${String(VARIABLE_NAME)})`)
throw new Error(`malformed prompt variable reference "{{${name}}}" in ${kind} "${input.name}" (variable names match ${String(VARIABLE_NAME)})`)
}
// Do not resolve unregistered names through Object.prototype.
if (!Object.hasOwn(variables, name)) {
const known = Object.keys(variables)
throw new Error(`unknown prompt variable "{{${name}}}" in section "${section.name}"; registered variables: ${known.length > 0 ? known.join(', ') : '(none)'}`)
throw new Error(`unknown prompt variable "{{${name}}}" in ${kind} "${input.name}"; registered variables: ${known.length > 0 ? known.join(', ') : '(none)'}`)
}
const value = variables[name]
if (value === undefined) {
throw new Error(`prompt variable "{{${name}}}" has no value for this assembly (section "${section.name}")`)
throw new Error(`prompt variable "{{${name}}}" has no value for this assembly (${kind} "${input.name}")`)
}
result += text.slice(last, open) + value
last = open + group[0].length
@@ -220,6 +257,7 @@ type VariableProvider = (context: AssembleContext) => string | undefined
/** All prompt registrations owned by one global or scoped layer. */
class PromptLayer implements ScopeLayer {
readonly sections: NamedEntries<PromptSection>
readonly contexts: NamedEntries<PromptContext>
readonly toolProviders = new AnonymousEntries<ToolProvider>()
readonly variables: NamedEntries<VariableProvider>
@@ -231,6 +269,9 @@ class PromptLayer implements ScopeLayer {
this.sections = new NamedEntries(name => new Error(scope === undefined
? `prompt section "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${name}" is already registered in this scope`))
this.contexts = new NamedEntries(name => new Error(scope === undefined
? `prompt context "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt context "${name}" is already registered in this scope`))
this.variables = new NamedEntries(name => new Error(scope === undefined
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
: `prompt variable "${name}" is already registered in this scope`))
@@ -239,6 +280,7 @@ class PromptLayer implements ScopeLayer {
/** @returns whether this layer owns no prompt registrations. */
isEmpty(): boolean {
return this.sections.isEmpty()
&& this.contexts.isEmpty()
&& this.toolProviders.isEmpty()
&& this.variables.isEmpty()
}
@@ -297,6 +339,23 @@ export class SystemPrompt extends Service {
)
}
/**
* Register ordered dynamic context in the calling context's scope. Scoped
* entries shadow global entries with the same name.
* @param context - the context contribution to register.
* @returns the exact Cordis effect disposer.
*/
context(context: PromptContext): () => void {
if (!Number.isFinite(context.order)) {
throw new TypeError(`prompt context "${context.name}" order must be a finite number`)
}
return this.layers.effect(
this.ctx,
layer => layer.contexts.insert(context.name, context),
{ label: 'systemPrompt.context()' },
)
}
/**
* Register a tool-schema provider in the calling context's scope. Global and
* matching scoped providers both contribute; returning the reserved
@@ -352,6 +411,7 @@ export class SystemPrompt extends Service {
}
// Scoped sections shadow globals before the stable order sort.
const sectionByName = this.layers.merge(scope, layer => layer.sections)
const contextByName = this.layers.merge(scope, layer => layer.contexts)
// Validate order against pre-restriction names while collecting visible schemas.
const providers = [
...this.layers.global.toolProviders.values(),
@@ -377,6 +437,12 @@ export class SystemPrompt extends Service {
name: section.name,
text: typeof section.text === 'function' ? section.text(context) : section.text,
})),
contexts: [...contextByName.values()]
.sort((a, b) => a.order - b.order)
.map(entry => ({
name: entry.name,
text: typeof entry.text === 'function' ? entry.text(context) : entry.text,
})),
tools: orderTools(collected, this.toolOrder, knownNames),
variables,
}
@@ -22,6 +22,14 @@ function validateAssembly(assembly: PromptAssembly, fail: InvariantFailure): voi
if (typeof section.text !== 'string') fail(`assembled section ${JSON.stringify(section.name)} text must be a string`)
}
const contextNames = new Set<string>()
for (const context of assembly.contexts) {
if (context.name.length === 0) fail('assembled context names must be non-empty')
if (contextNames.has(context.name)) fail(`assembled context name ${JSON.stringify(context.name)} is duplicated`)
contextNames.add(context.name)
if (typeof context.text !== 'string') fail(`assembled context ${JSON.stringify(context.name)} text must be a string`)
}
for (const tool of assembly.tools) {
if (tool.name.length === 0) fail('assembled tool names must be non-empty')
}