refactor: identify and freeze messages at creation
This commit is contained in:
345 files changed
+5220
-2901
No files matched your search
@@ -7,13 +7,11 @@
|
||||
* @module dsh-agent-loop/agent
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from 'cordis'
|
||||
import { AgentMessageId, agentCarrier, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
|
||||
import { agentCarrier, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import type {
|
||||
AgentMessage,
|
||||
Agent,
|
||||
CancelOptions,
|
||||
AgentInterruptReason,
|
||||
@@ -27,11 +25,21 @@ import type {
|
||||
SendOptions,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
BlockAssembler, LlmError, assertNever, deepFreeze, errorChain, isHarnessError, llmFailureOf, llmRetryPolicyOf, markAgentLoopRequest,
|
||||
BlockAssembler,
|
||||
LlmError,
|
||||
assertNever,
|
||||
createAssistantMessage,
|
||||
deepFreeze,
|
||||
errorChain,
|
||||
freezeMessage,
|
||||
isHarnessError,
|
||||
llmFailureOf,
|
||||
llmRetryPolicyOf,
|
||||
markAgentLoopRequest,
|
||||
} 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 { Session, SessionId, TurnEndReason, TurnTrigger, UserMessageData } from '@deepseek-ai/dsh-session'
|
||||
import type { AssistantMessage, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import { executeToolCalls } from './tool-calls.ts'
|
||||
@@ -47,9 +55,9 @@ type StepOutcome =
|
||||
*/
|
||||
export class ReactLoopAgent implements Agent {
|
||||
/** Prompts awaiting individual turns. */
|
||||
private queued: { message: AgentMessage; wakeup: boolean }[] = []
|
||||
private queued: { message: UserMessage; wakeup: boolean }[] = []
|
||||
/** Input taken into the session log at step boundaries. */
|
||||
private outbox: (UserMessageData | AgentMessage)[] = []
|
||||
private outbox: { message: UserMessage; steering: boolean }[] = []
|
||||
|
||||
/** Whether observers see a running interval; consecutive turns share it. */
|
||||
private busy = false
|
||||
@@ -93,30 +101,23 @@ export class ReactLoopAgent implements Agent {
|
||||
|
||||
/** Accept and route one unified send item. */
|
||||
send(
|
||||
input: UserMessageData,
|
||||
input: UserMessage,
|
||||
options: SendOptions,
|
||||
): AgentMessageId {
|
||||
const { content, source } = deepFreeze(structuredClone(input))
|
||||
): void {
|
||||
const message = freezeMessage(input)
|
||||
const { target, wakeup } = options
|
||||
const id = AgentMessageId(randomUUID())
|
||||
if (target === 'next-step' && !wakeup) {
|
||||
if (this.acceptsNextStep) {
|
||||
this.outbox.push({ content, source })
|
||||
return id
|
||||
this.outbox.push({ message, steering: false })
|
||||
return
|
||||
}
|
||||
this.session.append('user/message', { content, source }, { surfaceOp: 'append' })
|
||||
return id
|
||||
this.session.append('user/message', message, { surfaceOp: 'append' })
|
||||
return
|
||||
}
|
||||
|
||||
const placement: InboxPlacement = target === 'next-step' && this.acceptsNextStep ? 'steering' : 'queued'
|
||||
const message: AgentMessage = {
|
||||
id,
|
||||
content,
|
||||
source,
|
||||
}
|
||||
deepFreeze(message)
|
||||
if (placement === 'steering') {
|
||||
this.outbox.push(message)
|
||||
this.outbox.push({ message, steering: true })
|
||||
} else {
|
||||
this.queued.push({ message, wakeup })
|
||||
}
|
||||
@@ -125,28 +126,27 @@ export class ReactLoopAgent implements Agent {
|
||||
// can cancel or dispose.
|
||||
if (placement === 'queued' && wakeup) this.scheduleKick()
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', message, placement)
|
||||
return id
|
||||
}
|
||||
|
||||
/** Queue one ordinary prompt turn and wake the driver. */
|
||||
followup(input: UserMessageData): AgentMessageId {
|
||||
return this.send(input, {
|
||||
followup(input: UserMessage): void {
|
||||
this.send(input, {
|
||||
target: 'next-turn',
|
||||
wakeup: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** Steer the open turn, falling back to a waking prompt while idle. */
|
||||
steer(input: UserMessageData): AgentMessageId {
|
||||
return this.send(input, {
|
||||
steer(input: UserMessage): void {
|
||||
this.send(input, {
|
||||
target: 'next-step',
|
||||
wakeup: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** Append model-facing context without waking the driver. */
|
||||
inject(input: UserMessageData): AgentMessageId {
|
||||
return this.send(input, {
|
||||
inject(input: UserMessage): void {
|
||||
this.send(input, {
|
||||
target: 'next-step',
|
||||
wakeup: false,
|
||||
})
|
||||
@@ -171,8 +171,8 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
if (!options.keepInbox) {
|
||||
const discarded = this.queued.map(item => item.message)
|
||||
for (const message of this.outbox) {
|
||||
if ('id' in message) discarded.push(message)
|
||||
for (const item of this.outbox) {
|
||||
if (item.steering) discarded.push(item.message)
|
||||
}
|
||||
// Clear before abort observers run: replacement work belongs to the next turn.
|
||||
this.queued.length = 0
|
||||
@@ -244,19 +244,21 @@ export class ReactLoopAgent implements Agent {
|
||||
const trigger: TurnTrigger = { kind: 'message', source: message.source }
|
||||
// Admitted input stays on the stack until its turn/start commits: the
|
||||
// turn owns it only once the turn exists in the log.
|
||||
let admitted: UserMessageData[] | undefined
|
||||
let admitted: UserMessage[] | undefined
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
const decision = await this.loopCtx.waterfall(
|
||||
agentCarrier(this), 'agent/prompt-submit', this, message.content, message.source, signal,
|
||||
agentCarrier(this), 'agent/prompt-submit', this, message, signal,
|
||||
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
|
||||
if (decision.kind === 'allow') {
|
||||
admitted = [{ content: decision.content ?? message.content, source: message.source }]
|
||||
admitted = [decision.content === undefined
|
||||
? message
|
||||
: freezeMessage({ ...message, content: decision.content })]
|
||||
for (const context of decision.additionalContexts ?? []) {
|
||||
admitted.push({ content: context.content, source: context.source })
|
||||
admitted.push(freezeMessage(context))
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
@@ -301,7 +303,7 @@ export class ReactLoopAgent implements Agent {
|
||||
*/
|
||||
private async run(
|
||||
trigger: TurnTrigger,
|
||||
admitted: UserMessageData[] = [],
|
||||
admitted: UserMessage[] = [],
|
||||
inheritedOutboxLength = 0,
|
||||
priorFailures: readonly LlmFailure[] = Object.freeze([]),
|
||||
): Promise<void> {
|
||||
@@ -353,7 +355,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// one, and the agent/turn-stopping drain below is skipped for the same
|
||||
// reason.
|
||||
if (outcome.concluded) break steps
|
||||
if (outcome.continueTurn || this.outbox.some(item => 'id' in item)) continue
|
||||
if (outcome.continueTurn || this.outbox.some(item => item.steering)) continue
|
||||
break
|
||||
case 'request-failed': {
|
||||
// step() reports request failures only after step/start commits
|
||||
@@ -512,22 +514,25 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
// Truncated (max-tokens) output cannot owe tool calls.
|
||||
const assembled = assembler.message()
|
||||
const assembled = assembler.blocks()
|
||||
const content = finish.kind === 'max-tokens'
|
||||
? assembled.content.filter(block => block.type !== 'tool-call')
|
||||
: assembled.content
|
||||
? assembled.filter(block => block.type !== 'tool-call')
|
||||
: assembled
|
||||
const message: AssistantMessage = createAssistantMessage({
|
||||
content,
|
||||
source: {
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},
|
||||
},
|
||||
})
|
||||
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{
|
||||
turn,
|
||||
step,
|
||||
content,
|
||||
provenance: {
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},
|
||||
},
|
||||
message,
|
||||
...assembler.usage === undefined ? {} : { usage: assembler.usage },
|
||||
},
|
||||
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
|
||||
@@ -538,7 +543,7 @@ export class ReactLoopAgent implements Agent {
|
||||
if (toolCalls.length > 0) {
|
||||
({ concluded } = await executeToolCalls(
|
||||
this.loopCtx, turn, step, toolCalls, signal,
|
||||
context => this.outbox.push({ content: context.content, source: context.source }),
|
||||
context => this.outbox.push({ message: freezeMessage(context), steering: false }),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -631,17 +636,17 @@ export class ReactLoopAgent implements Agent {
|
||||
/** Commit the outbox and report whether it contained steering. */
|
||||
private drainOutbox(turn: number, limit = this.outbox.length): boolean {
|
||||
let steered = false
|
||||
for (const message of this.outbox.splice(0, limit)) {
|
||||
if ('id' in message) {
|
||||
for (const item of this.outbox.splice(0, limit)) {
|
||||
if (item.steering) {
|
||||
steered = true
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', message)
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.message)
|
||||
this.session.append(
|
||||
'steering/message',
|
||||
{ turn, content: message.content, source: message.source },
|
||||
{ turn, message: item.message },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
} else {
|
||||
this.session.append('user/message', message, { surfaceOp: 'append' })
|
||||
this.session.append('user/message', item.message, { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
return steered
|
||||
@@ -653,14 +658,14 @@ export class ReactLoopAgent implements Agent {
|
||||
* accepted beside it cannot split from the request it accompanies.
|
||||
*/
|
||||
private flushRejectedAdmissionContexts(): void {
|
||||
if (this.outbox.some(message => 'id' in message)) return
|
||||
if (this.outbox.some(item => item.steering)) return
|
||||
const contexts = this.outbox.splice(0)
|
||||
for (let index = 0; index < contexts.length; index += 1) {
|
||||
const context = contexts[index]
|
||||
const item = contexts[index]
|
||||
/* v8 ignore next 2 -- the steering precheck proves this batch is context-only */
|
||||
if (context === undefined || 'id' in context) throw new Error('rejected-admission context batch changed')
|
||||
if (item === undefined || item.steering) throw new Error('rejected-admission context batch changed')
|
||||
try {
|
||||
this.session.append('user/message', context, { surfaceOp: 'append' })
|
||||
this.session.append('user/message', item.message, { surfaceOp: 'append' })
|
||||
} catch (error: unknown) {
|
||||
this.outbox.unshift(...contexts.slice(index))
|
||||
throw error
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, UserMessageData } from '@deepseek-ai/dsh-session'
|
||||
import { assertNever, createToolResultMessage, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** One tool call after argument parsing, ready to schedule. */
|
||||
@@ -57,7 +57,7 @@ export async function executeToolCalls(
|
||||
step: number,
|
||||
toolCalls: ToolCallBlock[],
|
||||
signal: AbortSignal,
|
||||
acceptContext: (context: UserMessageData) => void,
|
||||
acceptContext: (context: UserMessage) => void,
|
||||
): Promise<{ concluded: boolean }> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
const { session } = agent
|
||||
@@ -119,7 +119,7 @@ async function runGroup(
|
||||
group: PlannedCall[],
|
||||
mode: ToolExecutionMode['kind'],
|
||||
signal: AbortSignal,
|
||||
acceptContext: (context: UserMessageData) => void,
|
||||
acceptContext: (context: UserMessage) => void,
|
||||
): Promise<GroupOutcome> {
|
||||
const { session } = ctx.agents.requireInitiator()
|
||||
const { maxParallelToolCalls } = ctx.agentLoop.config
|
||||
@@ -246,13 +246,14 @@ function appendToolResult(
|
||||
result: ToolExecutionResult,
|
||||
callSeq: number,
|
||||
): void {
|
||||
session.append('tool/result', {
|
||||
turn, step,
|
||||
// Correlation stays with the loop's authoritative model-transcript call id;
|
||||
// registry results deliberately do not duplicate it.
|
||||
const message = createToolResultMessage({
|
||||
callId: block.id,
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
})
|
||||
session.append('tool/result', {
|
||||
turn, step,
|
||||
message,
|
||||
...result.error?.info ? { error: result.error.info } : {},
|
||||
// The tool's private presentation payload (e.g. a result-time diff),
|
||||
// persisted so a UI bridge reproduces the card on replay.
|
||||
|
||||
Reference in New Issue
Block a user