Merge remote-tracking branch 'github/master' into feat/agent-event-payload
This commit is contained in:
@@ -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>({
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user