refactor(agent-loop): separate injected context from turns

This commit is contained in:
_Kerman
2026-07-24 16:05:52 +08:00
parent 712448a2d2
commit 45fc7fda3d
45 changed files with 470 additions and 873 deletions

View File

@@ -11,9 +11,9 @@ import { isAbsolute } from 'node:path'
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, PromptMessageData, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts'
import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
@@ -29,15 +29,6 @@ export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from '
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
/**
* Return the human-facing prompt blocks from a durable prompt message.
* @param data - ordinary or steering prompt event data.
* @returns the effective direct prompt, excluding baked prefix context.
*/
export function displayPromptContent(data: PromptMessageData): ContentBlock[] {
return data.envelope?.displayContent ?? data.content
}
/**
* Find the latest closed message-triggered turn, excluding injection and
* plugin-owned zero-step turns.
@@ -534,9 +525,7 @@ export class Session {
switch (event.type) {
// Ordinary prompts, injected context, and mid-turn steering project
// identically in user role: the event's model-facing content stays
// verbatim. A prompt envelope is model-hidden display metadata; its
// prefix bytes are already present in content. The message's `source`/`meta`
// and steering's `turn` are also log-only. Do NOT
// verbatim. The message's `source` and steering's `turn` are log-only. Do NOT
// re-add per-type framing (e.g. `<context>`/`<steering>`) 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

View File

@@ -66,8 +66,8 @@ function validateEvent(
let nextStep = trace.nextStep
let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
// SessionEventMap is merge-extensible, so the default enforces turn
// enclosure for package-added events as well as the built-in variants.
// Model input may be appended between turns without running the model.
// Merge-extensible package events remain turn-enclosed by default.
switch (event.type) {
case 'turn/start': {
if (trace.openTurn !== null) {
@@ -141,6 +141,8 @@ function validateEvent(
pendingCalls = { kind: 'delete', callId: event.data.callId }
break
}
case 'user/message':
break
default: {
if (trace.openTurn === null) {
fail(`${event.type} appended outside any open turn (every event must be turn-enclosed)`)

View File

@@ -183,25 +183,6 @@ export interface EpochHeader {
*/
export type RequestHeaderReason = 'initial' | 'resume' | 'change'
/** Durable model-hidden annotation for one context baked into a prompt message. */
export interface PromptPrefixContext {
/** Producer provenance retained for transcript presentation and inspection. */
source: MessageSource
}
/**
* Human-facing view of a prompt whose exact model content includes prefixed
* context. `content` on the owning event remains the reconstructable model
* input; this envelope prevents transcript, title, and re-reference consumers
* from treating the baked context as direct human text.
*/
export interface PromptMessageEnvelope {
/** Effective user prompt after interception rewrites, without baked context. */
displayContent: ContentBlock[]
/** Ordered descriptors for contexts already baked into the event content. */
prefixContexts: PromptPrefixContext[]
}
/**
* Shared payload for user, injected-context, and steering prompt messages. A
* direct human prompt, a synthetic `agent.inject()` context, and mid-turn
@@ -210,12 +191,10 @@ export interface PromptMessageEnvelope {
* not by event type.
*/
export interface PromptMessageData {
/** Exact model-facing blocks, including any baked prompt-prefix contexts. */
/** Exact model-facing blocks. */
content: ContentBlock[]
/** Producer provenance for the direct prompt. */
/** Producer provenance. */
source: MessageSource
/** Present only when prompt-prefix contexts were baked into `content`. */
envelope?: PromptMessageEnvelope
}
/**
@@ -226,10 +205,7 @@ export interface PromptMessageData {
*/
export interface SessionEventMap {
/**
* Opens turn `turn`. `trigger` records what started it — one claimed queued
* message or an idle-time injection. The turn is the durability/replay
* boundary: every event sits between a `turn/start` and its matching
* `turn/end` (the turn-enclosure invariant).
* Opens turn `turn`. `trigger` records what started the model loop.
*/
'turn/start': { turn: number; trigger: TurnTrigger }
/**
@@ -248,9 +224,8 @@ export interface SessionEventMap {
* (the queued message claimed for this turn), a synthetic `agent.inject()`
* context (file-change notices, subdir AGENTS.md, skill content, cron
* notifications, …), or an admitted goal continuation round. All three
* project their `content` verbatim; `source` (with a non-`user` kind marking
* injected context) is the only channel that tells them apart. An idle
* injection wraps this event in a one-shot turn so the log stays turn-enclosed.
* project their `content` verbatim; `source` tells them apart. An idle
* injection may append this event between turns without running the model.
*/
'user/message': PromptMessageData
/**