Merge latest master into manual compaction
# Conflicts: # apps/cli/README.i18n.yaml # docs/architecture.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/compact/compact-basic/README.i18n.yaml # packages/pty/pty-local/tests/index.spec.ts
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
||||
LlmError,
|
||||
assertNever,
|
||||
createAssistantMessage,
|
||||
createUserMessage,
|
||||
deepFreeze,
|
||||
errorChain,
|
||||
freezeMessage,
|
||||
@@ -45,8 +46,8 @@ import {
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import type { AssistantMessage, EpochHeader, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { AssistantMessage, EpochHeader, RequestContext, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import { executeToolCalls } from './tool-calls.ts'
|
||||
|
||||
@@ -55,6 +56,47 @@ type StepOutcome =
|
||||
| { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean }
|
||||
| { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined }
|
||||
|
||||
const RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt'
|
||||
/** Clearing marker kept distinct from every prefixed {@link renderContextSnapshot} result. */
|
||||
const CLEARED_RUNTIME_CONTEXT = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
|
||||
|
||||
/** Whether one user message is owned by runtime-context materialization. */
|
||||
function isRuntimeContextMessage(message: UserMessage): boolean {
|
||||
return message.source.kind === 'plugin' && message.source.plugin === RUNTIME_CONTEXT_SOURCE
|
||||
}
|
||||
|
||||
/** Latest retained runtime-context snapshot; `found` distinguishes malformed content from absence. */
|
||||
function retainedRuntimeContext(session: Session): { found: boolean; text: string | undefined } {
|
||||
const events = session.events
|
||||
const nodes = session.surface.nodes
|
||||
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[nodes[index] as number]
|
||||
if (event?.type !== 'user/message' || !isRuntimeContextMessage(event.data)) continue
|
||||
const [block] = event.data.content
|
||||
return {
|
||||
found: true,
|
||||
text: event.data.content.length === 1 && block?.type === 'text' ? block.text : undefined,
|
||||
}
|
||||
}
|
||||
return { found: false, text: undefined }
|
||||
}
|
||||
|
||||
/** Append a full current snapshot only when it changed or compaction removed it. */
|
||||
function materializeRuntimeContext(session: Session, current: string): void {
|
||||
const previous = retainedRuntimeContext(session)
|
||||
if (!previous.found && current.length === 0) {
|
||||
const compactedPriorSnapshot = session.surface.replaceGeneration > 0
|
||||
&& session.events.some(event => event.type === 'user/message' && isRuntimeContextMessage(event.data))
|
||||
if (!compactedPriorSnapshot) return
|
||||
}
|
||||
const snapshot = current.length === 0 ? CLEARED_RUNTIME_CONTEXT : current
|
||||
if (previous.text === snapshot) return
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: snapshot }],
|
||||
source: { kind: 'plugin', plugin: RUNTIME_CONTEXT_SOURCE },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/** Remove adapter-derived values before plugins propose the next request config. */
|
||||
function requestProposal(header: EpochHeader): LlmCallConfig {
|
||||
if (header.adapterDefaults === undefined) return header.config
|
||||
@@ -568,10 +610,13 @@ export class ReactLoopAgent implements Agent {
|
||||
// this request together.
|
||||
this.drainOutbox(turn)
|
||||
|
||||
// Assemble the system prompt fresh each step (it may depend on log state).
|
||||
// Assemble request-owned prompt inputs fresh each step. Dynamic context is
|
||||
// committed at the tail before deriving history once, preserving the stable
|
||||
// system/history cache prefix while keeping every model-visible byte logged.
|
||||
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
|
||||
signal.throwIfAborted()
|
||||
const system = renderPrompt(assembly)
|
||||
materializeRuntimeContext(session, renderContextSnapshot(assembly))
|
||||
|
||||
// Snapshot the exact log prefix: the reconstruction boundary. Appends
|
||||
// after this synchronous snapshot join the next request.
|
||||
@@ -728,6 +773,24 @@ export class ReactLoopAgent implements Agent {
|
||||
session.append('request/header', { header, reason: 'change' })
|
||||
}
|
||||
|
||||
// TODO: This looks like code smell.
|
||||
// Context metadata for the route this request resolved to, recorded from the same
|
||||
// registration-bound lookup that prepared the call (no second resolve).
|
||||
// A route with unknown capacity is still recorded so it clears any older
|
||||
// denominator; an unchanged route logs nothing.
|
||||
const contextWindow = preparedCall?.context?.contextWindow
|
||||
const requestContext: RequestContext = {
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
...contextWindow === undefined ? {} : { contextWindow },
|
||||
}
|
||||
const previous = session.requestContext()
|
||||
if (previous?.provider !== requestContext.provider
|
||||
|| previous.model !== requestContext.model
|
||||
|| previous.contextWindow !== requestContext.contextWindow) {
|
||||
session.append('request/context', requestContext)
|
||||
}
|
||||
|
||||
const request = markAgentLoopRequest(deepFreeze({
|
||||
...header.config,
|
||||
messages: boundaryMessages,
|
||||
|
||||
@@ -254,7 +254,7 @@ describe('agent loop', () => {
|
||||
// NO system field at all (not an empty string).
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} }))
|
||||
ctx.on('system-prompt/assemble', async () => ({ sections: [], contexts: [], tools: [], variables: {} }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a-no-system'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'hi')
|
||||
@@ -264,6 +264,178 @@ describe('agent loop', () => {
|
||||
expect('system' in adapter.requests[0]!).toBe(false)
|
||||
})
|
||||
|
||||
it('materializes changed runtime context at the history tail without rewriting the system header', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('one'),
|
||||
textResponse('two'),
|
||||
textResponse('three'),
|
||||
textResponse('four'),
|
||||
textResponse('five'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
let mode = 'read-only'
|
||||
const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: () => `Mode: ${mode}.` })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-runtime-context'), { provider: 'mock', model: 'mock' })
|
||||
const contextEvents = () => agent.session.events.flatMap(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt'
|
||||
? [event]
|
||||
: [])
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(contextEvents()).toHaveLength(1)
|
||||
expect(contextEvents()[0]?.data.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nMode: read-only.',
|
||||
}])
|
||||
|
||||
send(agent, 'unchanged')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(contextEvents()).toHaveLength(1)
|
||||
|
||||
mode = 'danger-full-access'
|
||||
send(agent, 'changed')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(contextEvents()).toHaveLength(2)
|
||||
const changedBlock = contextEvents()[1]?.data.content[0]
|
||||
expect(changedBlock?.type).toBe('text')
|
||||
if (changedBlock?.type !== 'text') throw new Error('changed runtime context is not text')
|
||||
expect(changedBlock.text).toContain('danger-full-access')
|
||||
|
||||
dispose()
|
||||
send(agent, 'cleared')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(contextEvents()).toHaveLength(3)
|
||||
expect(contextEvents()[2]?.data.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.',
|
||||
}])
|
||||
|
||||
send(agent, 'still clear')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(contextEvents()).toHaveLength(3)
|
||||
expect(adapter.requests.map(request => request.system)).toEqual(Array(5).fill(adapter.requests[0]?.system))
|
||||
expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('re-emits unchanged runtime context when a surface replacement removed the retained snapshot', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-compacted'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
const contextEvent = agent.session.events.find(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')
|
||||
if (contextEvent?.type !== 'user/message') throw new Error('first turn did not materialize runtime context')
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'compacted summary' }],
|
||||
source: { kind: 'plugin', plugin: 'test-compaction' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: contextEvent.seq, end: contextEvent.seq },
|
||||
sourceEventSeqs: [contextEvent.seq],
|
||||
})
|
||||
|
||||
send(agent, 'after compaction')
|
||||
await waitForIdle(ctx, agent)
|
||||
const runtimeContexts = agent.session.events.flatMap(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt'
|
||||
? [event]
|
||||
: [])
|
||||
expect(runtimeContexts).toHaveLength(2)
|
||||
expect(adapter.requests[1]?.messages.some(message =>
|
||||
message.source.kind === 'plugin'
|
||||
&& message.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(true)
|
||||
})
|
||||
|
||||
it('clears compacted runtime context after the active set becomes empty', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-compacted-clear'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
const contextEvent = agent.session.events.find(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')
|
||||
if (contextEvent?.type !== 'user/message') throw new Error('first turn did not materialize runtime context')
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'summary retaining old mode: read-only' }],
|
||||
source: { kind: 'plugin', plugin: 'test-compaction' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: contextEvent.seq, end: contextEvent.seq },
|
||||
sourceEventSeqs: [contextEvent.seq],
|
||||
})
|
||||
dispose()
|
||||
|
||||
send(agent, 'after compaction')
|
||||
await waitForIdle(ctx, agent)
|
||||
const clearing = adapter.requests[1]?.messages.find(message =>
|
||||
message.source.kind === 'plugin'
|
||||
&& message.source.plugin === '@deepseek-ai/dsh-system-prompt')
|
||||
expect(clearing?.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.',
|
||||
}])
|
||||
})
|
||||
|
||||
it('does not clear runtime context after an unrelated replacement', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-unrelated-compaction'), { provider: 'mock', model: 'mock' })
|
||||
const original = agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'old context' }],
|
||||
source: { kind: 'plugin', plugin: 'test-context' },
|
||||
}), { surfaceOp: 'append' })
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'compacted summary' }],
|
||||
source: { kind: 'plugin', plugin: 'test-compaction' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
|
||||
sourceEventSeqs: [original.seq],
|
||||
})
|
||||
|
||||
send(agent, 'after compaction')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests[0]?.messages.some(message =>
|
||||
message.source.kind === 'plugin'
|
||||
&& message.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(false)
|
||||
})
|
||||
|
||||
it('replaces a malformed retained runtime-context message with the current complete snapshot', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-malformed'), { provider: 'mock', model: 'mock' })
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'broken' }, { type: 'text', text: 'snapshot' }],
|
||||
source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
send(agent, 'repair context')
|
||||
await waitForIdle(ctx, agent)
|
||||
const runtimeContexts = agent.session.events.flatMap(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt'
|
||||
? [event]
|
||||
: [])
|
||||
expect(runtimeContexts).toHaveLength(2)
|
||||
expect(runtimeContexts[1]?.data.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nMode: read-only.',
|
||||
}])
|
||||
})
|
||||
|
||||
it('records raw chunks for replay as assistant/chunk session events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('abc')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { createUserMessage, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
@@ -614,3 +614,95 @@ describe('request stability across the loop', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('request/context capacity records', () => {
|
||||
/** Adapter advertising a per-model capacity, keyed by model id. */
|
||||
function capacityAdapter(windows: Record<string, number>, script: StreamChunk[][]): MockAdapter {
|
||||
return new class extends MockAdapter {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
const contextWindow = windows[model]
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
...contextWindow === undefined ? {} : { context: { contextWindow } },
|
||||
})
|
||||
}
|
||||
}(script)
|
||||
}
|
||||
|
||||
it('records capacity once and skips it while the route is unchanged', async () => {
|
||||
const adapter = capacityAdapter({ mock: 128_000 }, [textResponse('a'), textResponse('b')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('capacity-dedup'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const records = agent.session.events.filter(event => event.type === 'request/context')
|
||||
expect(records).toHaveLength(1)
|
||||
expect(records[0]?.data).toEqual({ provider: 'mock', model: 'mock', contextWindow: 128_000 })
|
||||
// Log-only: not a SurfaceEventType, so it can never reach a model request
|
||||
// (the type system rejects a surfaceOp here; the session invariant also
|
||||
// requires the record to sit inside its open turn).
|
||||
expect(agent.session.surface.nodes).not.toContain(records[0]?.seq)
|
||||
})
|
||||
|
||||
it('records a second capacity when the route changes mid-session', async () => {
|
||||
const adapter = capacityAdapter(
|
||||
{ small: 64_000, large: 256_000 },
|
||||
[textResponse('a'), textResponse('b')],
|
||||
)
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('capacity-switch'), { provider: 'mock', model: 'small' })
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
ctx.on('agent/request', (subject, _turn, _step, _signal, next) => subject === agent
|
||||
? Promise.resolve({ provider: 'mock', model: 'large' })
|
||||
: next())
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'request/context')
|
||||
.map(event => event.data.contextWindow)).toEqual([64_000, 256_000])
|
||||
})
|
||||
|
||||
it('records and deduplicates a route whose adapter advertises no capacity', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('a'), textResponse('b')]))
|
||||
const agent = ctx.agentLoop.create(SessionId('capacity-absent'), { provider: 'mock', model: 'mock' })
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'request/context')
|
||||
.map(event => event.data)).toEqual([{ provider: 'mock', model: 'mock' }])
|
||||
})
|
||||
|
||||
it('clears a previous capacity when the next route advertises none', async () => {
|
||||
const adapter = capacityAdapter({ known: 64_000 }, [textResponse('a'), textResponse('b')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('capacity-clear'), { provider: 'mock', model: 'known' })
|
||||
let model = 'known'
|
||||
ctx.on('agent/request', (subject, _turn, _step, _signal, next) => subject === agent
|
||||
? Promise.resolve({ provider: 'mock', model })
|
||||
: next())
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
model = 'unknown'
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'request/context')
|
||||
.map(event => event.data)).toEqual([
|
||||
{ provider: 'mock', model: 'known', contextWindow: 64_000 },
|
||||
{ provider: 'mock', model: 'unknown' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user