Merge branch 'worktree/agent-execution-context-rfc' into worktree/explicit-turn-signal

This commit is contained in:
Yichen Jiang
2026-07-18 21:33:17 +08:00
683 changed files with 37770 additions and 8712 deletions

View File

@@ -8,7 +8,7 @@
import type { Context } from 'cordis'
import { agentEvents, normalizeAgentCancelCause } from '@deepseek-ai/dsh-agent'
import type { AgentCancelCause, AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
import type { AgentCancelCause, AgentId, AgentOptions, AgentStatus, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
@@ -223,14 +223,20 @@ export class ReactLoopAgent implements Agent {
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
}
inject(content: ContentBlock[], options?: SendOptions): void {
inject(content: ContentBlock[], options?: InjectOptions): void {
this.assertNotDisposed()
const source = this.resolveSource(options)
const context = {
content,
source,
...options?.envelope !== undefined ? { envelope: options.envelope } : {},
...options?.meta !== undefined ? { meta: options.meta } : {},
}
if (isTurnOpen(this.session)) {
// A turn is open in the LOG (decided from the log, not agent status —
// status can be `running` with no turn open): the context/message is
// turn-enclosed by that turn, so append it directly.
this.session.append('context/message', { content, source }, { surfaceOp: 'append' })
this.session.append('context/message', context, { surfaceOp: 'append' })
return
}
// No turn open: wrap the injection in a one-shot turn so every event stays
@@ -242,7 +248,7 @@ export class ReactLoopAgent implements Agent {
// are contained by Session and cannot create a false append failure.
try {
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
this.session.append('context/message', { content, source }, { surfaceOp: 'append' })
this.session.append('context/message', context, { surfaceOp: 'append' })
} finally {
// Close the turn if turn/start made it into the log. A pre-commit veto
// must escape rather than being mistaken for a committed turn/end.

View File

@@ -340,6 +340,7 @@ export class AgentLoop extends Service implements AgentFactory {
static Config = z.object({
agents: z.array(z.object({
id: z.string().required(),
provider: z.string(),
model: z.string(),
cwd: z.string(),
resumeSessionId: z.string(),
@@ -356,6 +357,7 @@ export class AgentLoop extends Service implements AgentFactory {
this.runtime = { ctx }
ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')
ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
ctx.systemPrompt.variable('provider', context => context.agent?.options.provider)
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)

View File

@@ -6,7 +6,8 @@
*/
import type { Context } from 'cordis'
import type { FinishReason, GenerateOptions, LlmCallConfig, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import { isDeepStrictEqual } from 'node:util'
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
import { assertNever, BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
@@ -95,23 +96,6 @@ function interruptionTurnEndReason(handle: LoopHandle, signal: AbortSignal): Tur
}
}
/** Append the durable assembled assistant message when it carries content or usage. */
function appendAssistantMessage(
session: Session,
turn: number,
step: number,
message: Message,
usage: TokenUsage | undefined,
chunkSeqs: number[],
): void {
if (message.content.length === 0 && usage === undefined) return
session.append(
'assistant/message',
{ turn, step, content: message.content, ...usage === undefined ? {} : { usage } },
{ surfaceOp: 'append', ...(chunkSeqs.length > 0 ? { sourceEventSeqs: chunkSeqs } : {}) },
)
}
/** Mutable agent controls supplied to the loop driver. */
export interface LoopHandle {
/** Native-private agent inbox handed to the driver only at internal startup. */
@@ -288,10 +272,15 @@ async function runTurn(
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
const content = decision.content ?? message.content
session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' })
// `allow.additionalContext` is a SEPARATE context/message the next request
// also sees. The turn is open, so inject() appends it into THIS turn.
if (decision.additionalContext) {
agent.inject(decision.additionalContext.content, { source: decision.additionalContext.source })
// Every `allow.additionalContexts` entry is a separate context/message the
// next request also sees. The turn is open, so inject() appends each one
// into THIS turn without flattening provenance, framing, or metadata.
for (const context of decision.additionalContexts ?? []) {
agent.inject(context.content, {
source: context.source,
...context.envelope !== undefined ? { envelope: context.envelope } : {},
...context.meta !== undefined ? { meta: context.meta } : {},
})
}
}
@@ -485,13 +474,13 @@ async function runStep(
const seedConfig: LlmCallConfig = deepFreeze(structuredClone(transmission.loggedHeader
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
? session.requestHeader()!.config
: { model: options.model ?? '' }))
: { provider: options.provider ?? '', model: options.model ?? '' }))
// Listener replacements are recorded in the request header before dispatch.
const config = await events.waterfall('agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig))
interruptionCheckpoint(signal)
if (!config.model) {
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
if (!config.provider || !config.model) {
throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
@@ -508,6 +497,7 @@ async function runStep(
// Freeze the logged header plus boundary snapshot; the prefix precedes derived history.
const request: GenerateOptions = deepFreeze({
provider: header.config.provider,
model: header.config.model,
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
...header.system !== undefined ? { system: header.system } : {},
@@ -535,20 +525,28 @@ async function runStep(
if (stepError) throw stepError
if (assembler.finish.kind === 'max-tokens') {
let message: Message = withoutToolCalls(assembler.message())
message = withoutToolCalls(await events.waterfall('agent/step-result', turn, step, message, signal, () => Promise.resolve(message)))
interruptionCheckpoint(signal)
const assembled = assembler.message()
const assembledContent = structuredClone(assembled.content)
let message: Message = withoutToolCalls(assembled)
message = withoutToolCalls(await processStepResult(
events, session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs, signal,
))
// Preserve usage even when max-token truncation produced no content.
appendAssistantMessage(session, turn, step, message, assembler.usage, chunkSeqs)
recordAssistantMessage(session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs)
return { hadToolCalls: false, finish: assembler.finish }
}
// Record the post-waterfall message that tool dispatch uses.
let message: Message = assembler.message()
message = await events.waterfall('agent/step-result', turn, step, message, signal, () => Promise.resolve(message))
interruptionCheckpoint(signal)
const assembled = assembler.message()
const assembledContent = structuredClone(assembled.content)
let message: Message = assembled
message = await processStepResult(
events, session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs, signal,
)
appendAssistantMessage(session, turn, step, message, assembler.usage, chunkSeqs)
// Every successful call records its completion anchor, including explicit
// empty chunk provenance for a contentless, usage-less provider response.
recordAssistantMessage(session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs)
// Tool execution stays sequential; recheck abort around each normalized result.
const toolCalls = message.content.filter(block => block.type === 'tool-call')
@@ -583,18 +581,95 @@ async function runStep(
// Persist tool-owned presentation data for replay.
...result.meta !== undefined ? { meta: result.meta } : {},
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
if (result.additionalContext) pendingContext.push(result.additionalContext)
pendingContext.push(...result.additionalContexts ?? [])
interruptionCheckpoint(signal)
}
// Append buffered context after the complete result batch.
for (const context of pendingContext) {
agent.inject(context.content, { source: context.source })
agent.inject(context.content, {
source: context.source,
...context.envelope !== undefined ? { envelope: context.envelope } : {},
...context.meta !== undefined ? { meta: context.meta } : {},
})
}
return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish }
}
/** Preserve successful-call accounting without retaining output that result processing rejected. */
async function processStepResult(
events: AgentEventDispatch,
session: Session,
turn: number,
step: number,
config: LlmCallConfig,
assembledContent: ContentBlock[],
message: Message,
assembler: BlockAssembler,
chunkSeqs: number[],
signal: AbortSignal,
): Promise<Message> {
try {
const processed = await events.waterfall(
'agent/step-result', turn, step, message, signal, () => Promise.resolve(message),
)
interruptionCheckpoint(signal)
return processed
} catch (error: unknown) {
recordAssistantMessage(
session,
turn,
step,
config,
assembledContent,
{ ...message, content: [] },
assembler,
chunkSeqs,
false,
)
throw error
}
}
/** Record one content-or-usage assistant message with replay-safe provenance. */
function recordAssistantMessage(
session: Session,
turn: number,
step: number,
config: LlmCallConfig,
assembledContent: ContentBlock[],
message: Message,
assembler: BlockAssembler,
chunkSeqs: number[],
preserveReplayState = true,
): void {
session.append(
'assistant/message',
{
turn,
step,
content: message.content,
provenance: assistantProvenance(
config,
assembler.replayState,
preserveReplayState && isDeepStrictEqual(message.content, assembledContent),
),
...assembler.usage === undefined ? {} : { usage: assembler.usage },
},
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
)
}
/** Build durable assistant provenance, dropping replay state after any content rewrite. */
function assistantProvenance(config: LlmCallConfig, replayState: unknown, contentUnchanged: boolean): NonNullable<Message['provenance']> {
return {
provider: config.provider,
model: config.model,
...contentUnchanged && replayState !== undefined ? { replayState } : {},
}
}
function withoutToolCalls(message: Message): Message {
return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
}

View File

@@ -1,11 +1,12 @@
/**
* Per-loop-instance request-header bookkeeping for reconstructability. The
* comparison baseline is the header folded from the session log, so a fresh
* loop instance needs no special resume or fork state.
* comparison baseline is folded from the session log; a fresh instance anchors
* it with an initial/resume snapshot and later logs full changed snapshots.
*
* @module dsh-agent-loop/request-log
*/
import { diffHeader, headerEquals, applyHeaderDelta } from '@deepseek-ai/dsh-session'
import { headerEquals } from '@deepseek-ai/dsh-session'
import type { EpochHeader, Session } from '@deepseek-ai/dsh-session'
import type { Message } from '@deepseek-ai/dsh-llm'
@@ -32,10 +33,8 @@ export function createTransmissionLog(): TransmissionLog {
}
/**
* Append whatever header event makes the log reproduce this request's header.
* The first request from an instance always records a full `initial` or `resume`
* snapshot. Later requests record nothing when unchanged, a round-tripping
* delta when expressible, or a full `fallback` snapshot otherwise.
* Append the full header snapshot owed by this request: initial/resume for the
* instance's first request, nothing when unchanged, or change otherwise.
*
* @param session - the session whose log explains the request.
* @param state - this loop instance's bookkeeping (mutated on first log).
@@ -52,12 +51,5 @@ export function recordRequestHeader(session: Session, state: TransmissionLog, he
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const baseline = session.requestHeader()!
if (headerEquals(baseline, header)) return
const delta = diffHeader(baseline, header)
/* v8 ignore next -- headerEquals false ⟹ diffHeader defined: both compare the same four parts */
if (delta === undefined) return
if (headerEquals(applyHeaderDelta(baseline, delta), header)) {
session.append('request/header-delta', delta)
} else {
session.append('request/header', { header, reason: 'fallback' })
}
session.append('request/header', { header, reason: 'change' })
}