Merge remote-tracking branch 'github/master' into feat/agent-event-payload

This commit is contained in:
_Kerman
2026-08-06 13:26:04 +08:00
354 changed files with 7317 additions and 1710 deletions

View File

@@ -28,7 +28,7 @@ import type { Scope, Scoped } from '@deepseek-ai/dsh-scope'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
import { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import { joinContextSections, renderContextSections, 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'
@@ -207,7 +207,8 @@ export class ReactLoopAgent implements Agent {
const claimed = this.inbox.claim(target, position.turn)
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted()
const context = this.runtimeContext.project(renderContextSnapshot(assembly))
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
const decision = await this.loopCtx.waterfall(
this.carrier, 'agent/pre-step', { agent: this, messages: claimed, ...position, signal },
(): Promise<PreStepDecision> => Promise.resolve<PreStepDecision>({

View File

@@ -4,6 +4,7 @@
*/
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContextSnapshotSection } 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'
@@ -57,15 +58,19 @@ export class RuntimeContextProjection {
/**
* Create an uncommitted snapshot only when the retained value differs.
* @param current - fully rendered dynamic context.
* @param sections - named contributions that formed the current snapshot.
* @returns a candidate user message, or `undefined` when no update is needed.
*/
project(current: string): UserMessage | undefined {
project(current: string, sections: readonly ContextSnapshotSection[]): 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 },
// The cleared marker has no contributions left to attribute.
source: sections.length === 0
? { kind: 'plugin', plugin: SOURCE }
: { kind: 'plugin', plugin: SOURCE, form: 'snapshot', sections },
})
}
}

View File

@@ -30,10 +30,16 @@ describe('RuntimeContextProjection', () => {
const projection = new RuntimeContextProjection(ctx, session)
expect(session.surface.nodes).toContain(retained.seq)
expect(projection.project('retained')).toBeUndefined()
expect(projection.project('retained', [])).toBeUndefined()
expect(projection.project('next', [{ name: 'sandbox:policy', text: 'policy' }])?.source).toEqual({
kind: 'plugin',
plugin: SOURCE,
form: 'snapshot',
sections: [{ name: 'sandbox:policy', text: 'policy' }],
})
const other = ctx.sessions.create(SessionId('runtime-context-other'))
other.append('user/message', contextMessage('other'), { surfaceOp: 'append' })
expect(projection.project('retained')).toBeUndefined()
expect(projection.project('retained', [])).toBeUndefined()
})
})

View File

@@ -15,7 +15,7 @@ import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
import { deriveEventMessage, SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
import { foldRequestHeader } from './request-header.ts'
@@ -29,7 +29,7 @@ export { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOM
export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
export { foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
/**
@@ -755,50 +755,13 @@ export class Session {
}
/**
* Project a single event into the LLM message it derives to, or null when
* it produces none — a non-surface event (chunk, boundary, log-only record)
* or an empty-content assistant/message (which exists only to host usage).
* The per-node pure function {@link deriveMessages} folds over the surface;
* an external reconstructor (or the dev invariant) folds the same function
* over a log prefix's surface to rebuild the exact messages any request was
* built from (the reconstructability Agent Note). The returned message is
* the already frozen message nested in the event wrapper and shared by
* delivery, durable history, and model requests.
* Instance face of the pure per-node `deriveEventMessage` export from
* `surface.ts`.
* @param event - the event to project.
* @returns the derived message, or null when the event produces none.
*/
deriveEventMessage(event: SessionEvent): Message | null {
// Intentionally non-exhaustive: only message-producing events derive
// history; turn/step boundaries, chunks, usage, and errors are
// trace/replay data.
switch (event.type) {
// Ordinary prompts and injected context project in user role: the
// event's model-facing content stays verbatim. Do NOT
// re-add per-type framing (e.g. `<context>`) here: framing is
// caller-owned — a producer bakes it into `content`, as workspace-context
// does with `<system-reminder>` — or, if reintroduced, must be driven by
// the event `meta` map and a dedicated renderer, keeping this projection a
// verbatim pass-through. See the deferred design note in
// ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md
case 'user/message': {
return event.data
}
case 'assistant/message': {
// Skip an empty-content assistant/message: it exists only to host a
// max-tokens step's usage and must not inject a content-less assistant
// turn into the provider transcript.
if (event.data.message.content.length === 0) return null
return event.data.message
}
case 'tool/result': {
return event.data.message
}
default:
// A non-surface event (boundary, chunk, log-only record) projects to
// no message. Merge-extensible union: no assertNever here.
return null
}
return deriveEventMessage(event)
}
}

View File

@@ -8,6 +8,7 @@
* @module @deepseek-ai/dsh-session/surface
*/
import type { Message } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts'
/** Runtime counterpart of the message-producing event union. */
@@ -66,6 +67,52 @@ export function isReplacementSurfaceEvent(
return isSurfaceEvent(event) && event.surfaceOp !== 'append'
}
/**
* Project a single event into the LLM message it derives to, or null when it
* produces none — a non-surface event (chunk, boundary, log-only record) or an
* empty-content assistant/message (which exists only to host usage). This is
* THE per-node projection rule: `Session.deriveMessages` folds it over the
* live surface, external reconstructors and pure projections fold the same
* function over a log prefix's surface to rebuild the exact messages any
* request was built from. The returned message is the already frozen message
* nested in the event wrapper and shared by delivery, durable history, and
* model requests.
* @param event - the event to project.
* @returns the derived message, or null when the event produces none.
*/
export function deriveEventMessage(event: SessionEvent): Message | null {
// Intentionally non-exhaustive: only message-producing events derive
// history; turn/step boundaries, chunks, usage, and errors are trace/replay
// data.
switch (event.type) {
// Ordinary prompts and injected context project in user role: the event's
// model-facing content stays verbatim. Do NOT re-add per-type framing
// (e.g. `<context>`) here: framing is caller-owned — a producer bakes it
// into `content`, as workspace-context does with `<system-reminder>` — or,
// if reintroduced, must be driven by the event `meta` map and a dedicated
// renderer, keeping this projection a verbatim pass-through. See the
// deferred design note in
// ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md
case 'user/message': {
return event.data
}
case 'assistant/message': {
// Skip an empty-content assistant/message: it exists only to host a
// max-tokens step's usage and must not inject a content-less assistant
// turn into the provider transcript.
if (event.data.message.content.length === 0) return null
return event.data.message
}
case 'tool/result': {
return event.data.message
}
default:
// A non-surface event (boundary, chunk, log-only record) projects to
// no message. Merge-extensible union: no assertNever here.
return null
}
}
/** One replacement operation observed while folding a session surface. */
export interface SurfaceFoldReplacement {
/** Seq of the event that replaced the prior surface range. */

View File

@@ -8,7 +8,7 @@ import { Context, Service } from 'cordis'
import z from 'schemastery'
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import type { ContextSnapshotSection, ToolSchema } from '@deepseek-ai/dsh-llm'
declare module 'cordis' {
interface Context {
@@ -200,15 +200,39 @@ export function renderPrompt(assembly: PromptAssembly): string {
* @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')
return joinContextSections(renderContextSections(assembly))
}
/**
* The model-facing snapshot text for an already-rendered section list.
*
* A caller that also needs the sections renders them once and joins here, so a
* request does not interpolate every context twice.
* @param sections - sections from {@link renderContextSections}.
* @returns the current full snapshot, or `''` when no context is active.
*/
export function joinContextSections(sections: readonly ContextSnapshotSection[]): string {
const body = sections.map(section => section.text).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. */
/**
* The same snapshot, kept as the named contributions it was assembled from.
*
* {@link renderContextSnapshot} joins these for the model; a consumer that
* presents the snapshot uses them to attribute each part to the subsystem that
* contributed it, without re-splitting the joined prose.
* @param assembly - the assembly whose contexts and variables to render.
* @returns one entry per contributing context that rendered to non-empty text.
*/
export function renderContextSections(assembly: PromptAssembly): ContextSnapshotSection[] {
return assembly.contexts
.map(context => ({ name: context.name, text: interpolate(context, assembly.variables, 'context') }))
.filter(section => section.text.length > 0)
}
/** Interpolate one section or context and attribute diagnostics to its owning input. */
function interpolate(
input: AssembledSection | AssembledContext,
variables: Record<string, string | undefined>,