|
|
|
|
@@ -6,9 +6,9 @@
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { Context } from 'cordis'
|
|
|
|
|
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import { isDeepStrictEqual } from 'node:util'
|
|
|
|
|
import { BlockAssembler, HarnessError, assertNever, deepFreeze, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
|
|
|
|
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
|
|
|
|
|
import { canonicalHeader } from '@deepseek-ai/dsh-session'
|
|
|
|
|
@@ -28,24 +28,29 @@ function toError(error: unknown): RequestError {
|
|
|
|
|
|
|
|
|
|
/** Distinguishes final model-request failures from failures in later step processing. */
|
|
|
|
|
class TerminalModelRequestFailure extends Error {
|
|
|
|
|
constructor(readonly requestError: RequestError) {
|
|
|
|
|
super(requestError.message, { cause: requestError })
|
|
|
|
|
constructor(
|
|
|
|
|
readonly requestError: RequestError,
|
|
|
|
|
readonly failure: LlmFailure,
|
|
|
|
|
) {
|
|
|
|
|
super(failure.message, { cause: requestError })
|
|
|
|
|
this.name = 'TerminalModelRequestFailure'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */
|
|
|
|
|
function finishError(finish: FinishReason): RequestError | undefined {
|
|
|
|
|
function finishError(finish: FinishReason): { error: RequestError; failure: LlmFailure } | undefined {
|
|
|
|
|
switch (finish.kind) {
|
|
|
|
|
case 'error': {
|
|
|
|
|
const error: RequestError = new Error(finish.message)
|
|
|
|
|
if (finish.code !== undefined) error.code = finish.code
|
|
|
|
|
return error
|
|
|
|
|
}
|
|
|
|
|
case 'error':
|
|
|
|
|
case 'aborted': {
|
|
|
|
|
const error: RequestError = new Error('model stream aborted')
|
|
|
|
|
error.code = 'ABORTED'
|
|
|
|
|
return error
|
|
|
|
|
const facts = finish.failure
|
|
|
|
|
const error = new LlmError(facts.message, facts.code, {
|
|
|
|
|
...facts.status === undefined ? {} : { status: facts.status },
|
|
|
|
|
...facts.providerRetryAfterMs === undefined
|
|
|
|
|
? {}
|
|
|
|
|
: { providerRetryAfterMs: facts.providerRetryAfterMs },
|
|
|
|
|
...facts.requestId === undefined ? {} : { requestId: facts.requestId },
|
|
|
|
|
})
|
|
|
|
|
return { error, failure: error.failure }
|
|
|
|
|
}
|
|
|
|
|
// stop / tool-calls / max-tokens / plugin-added kinds → not a failure.
|
|
|
|
|
default:
|
|
|
|
|
@@ -56,9 +61,18 @@ function finishError(finish: FinishReason): RequestError | undefined {
|
|
|
|
|
/**
|
|
|
|
|
* Build the `{ message, code? }` part of an error payload, omitting the
|
|
|
|
|
* `code` key entirely when absent (exactOptionalPropertyTypes-correct).
|
|
|
|
|
* The durable message renders the full cause chain: `turn/end` is the single
|
|
|
|
|
* durable record of an in-turn failure, so a wrapper message alone (e.g.
|
|
|
|
|
* `fetch failed`) would lose the diagnosis the session log exists to keep.
|
|
|
|
|
*/
|
|
|
|
|
function errorData(err: RequestError): { message: string; code?: string } {
|
|
|
|
|
return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} }
|
|
|
|
|
return { message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Preserve cause diagnostics, falling back to adapter-normalized prose for a hostile Error. */
|
|
|
|
|
function durableFailure(err: RequestError, failure: LlmFailure): LlmFailure {
|
|
|
|
|
const message = errorChain(err)
|
|
|
|
|
return { ...failure, message: message === '<unrenderable value>' ? failure.message : message }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */
|
|
|
|
|
@@ -91,16 +105,16 @@ export interface LoopHandle {
|
|
|
|
|
cancelReason(): string
|
|
|
|
|
/** Clear the cancel marker (called once per iteration after the turn returns). */
|
|
|
|
|
clearCancel(): void
|
|
|
|
|
/** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */
|
|
|
|
|
/** Settle idle waiters before pre-running cancellation publishes idle. */
|
|
|
|
|
settleIdle(): void
|
|
|
|
|
/** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */
|
|
|
|
|
readonly withToolBatch: <T>(run: (acceptContext: (context: HookContext) => void) => Promise<T>) => Promise<T>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Drive queued batches as durable turns until disposal. Plugin failures end the
|
|
|
|
|
* current turn without terminating the driver. The caller establishes the
|
|
|
|
|
* `ctx.agents.withInitiator()` boundary before entry; package-private
|
|
|
|
|
* Drive queued messages as independent durable turns until disposal. Plugin
|
|
|
|
|
* failures end the current turn without terminating the driver. The caller
|
|
|
|
|
* establishes the `ctx.agents.withInitiator()` boundary before entry; package-private
|
|
|
|
|
* orchestration recovers that exact Agent and captures its Session locally.
|
|
|
|
|
* @param ctx - the plugin context the loop reaches its initiating Agent,
|
|
|
|
|
* events (agent/…, session/flush), and services (systemPrompt, llm, tools)
|
|
|
|
|
@@ -118,20 +132,35 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
|
|
|
|
const events = agentEvents(ctx, agent)
|
|
|
|
|
|
|
|
|
|
while (!handle.isDisposed()) {
|
|
|
|
|
await handle.inbox.waitForQueued(handle.disposed)
|
|
|
|
|
if (handle.isDisposed()) break
|
|
|
|
|
|
|
|
|
|
// Cancellation between wake and `running` skips only the cancelled work;
|
|
|
|
|
// a replacement prompt still runs and owns the eventual idle transition.
|
|
|
|
|
// An idle listener can enqueue and cancel replacement work before the next
|
|
|
|
|
// wait is installed. Consume that empty marker before parking the driver.
|
|
|
|
|
if (handle.isCancelled()) {
|
|
|
|
|
handle.clearCancel()
|
|
|
|
|
if (!handle.inbox.hasQueued) {
|
|
|
|
|
handle.settleIdle()
|
|
|
|
|
handle.setStatus('idle')
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await handle.inbox.waitForQueued(handle.disposed)
|
|
|
|
|
if (handle.isDisposed()) break
|
|
|
|
|
|
|
|
|
|
// Cancellation between wake and `running` skips only the cancelled work;
|
|
|
|
|
// a replacement prompt still runs before the eventual idle transition.
|
|
|
|
|
if (handle.isCancelled()) {
|
|
|
|
|
handle.clearCancel()
|
|
|
|
|
if (!handle.inbox.hasQueued) {
|
|
|
|
|
// Settle before publishing idle: the already-idle path has no status
|
|
|
|
|
// transition, while an idle listener can register waiters for new work.
|
|
|
|
|
handle.settleIdle()
|
|
|
|
|
handle.setStatus('idle')
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handle.setStatus('running')
|
|
|
|
|
if (handle.isDisposed()) break
|
|
|
|
|
|
|
|
|
|
// A synchronous `running` listener can cancel before `runTurn`; balance the
|
|
|
|
|
// status only when no replacement prompt was queued by that listener.
|
|
|
|
|
@@ -151,7 +180,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
// Pre-turn failure has no durable boundary to close; report it without appending outside a turn.
|
|
|
|
|
const err = toError(error)
|
|
|
|
|
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
|
|
|
|
|
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${errorChain(err)}`)
|
|
|
|
|
try {
|
|
|
|
|
events.emit('agent/error', turn, 0, err)
|
|
|
|
|
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
|
|
|
|
|
@@ -182,16 +211,15 @@ async function runTurn(
|
|
|
|
|
return messages.length > 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Drain before opening the turn, but append only after `turn/start`.
|
|
|
|
|
const queued = handle.inbox.drainQueued()
|
|
|
|
|
const first = queued[0]
|
|
|
|
|
// Claim one queued message before opening its turn, but append it only after `turn/start`.
|
|
|
|
|
const message = handle.inbox.dequeueQueued()
|
|
|
|
|
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
|
|
|
|
|
if (!first) throw new Error('runTurn invariant violated: no queued message at turn start')
|
|
|
|
|
const trigger: TurnTrigger = { kind: 'message', source: first.source }
|
|
|
|
|
if (!message) throw new Error('runTurn invariant violated: no queued message at turn start')
|
|
|
|
|
const trigger: TurnTrigger = { kind: 'message', source: message.source }
|
|
|
|
|
|
|
|
|
|
let reason: TurnEndReason = { kind: 'completed' }
|
|
|
|
|
let step = 0
|
|
|
|
|
let requestRetryAttempt = 0
|
|
|
|
|
let requestFailureHistory: readonly LlmFailure[] = Object.freeze([])
|
|
|
|
|
let stepOpen = false
|
|
|
|
|
let errorReported = false
|
|
|
|
|
let terminalStopped = false
|
|
|
|
|
@@ -204,10 +232,12 @@ async function runTurn(
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Record the durable turn failure once and contain the live error notification.
|
|
|
|
|
const failTurn = (err: RequestError): void => {
|
|
|
|
|
const failTurn = (err: RequestError, failure?: LlmFailure): void => {
|
|
|
|
|
if (errorReported) return
|
|
|
|
|
errorReported = true
|
|
|
|
|
reason = { kind: 'error', step, ...errorData(err) }
|
|
|
|
|
reason = failure === undefined
|
|
|
|
|
? { kind: 'error', step, ...errorData(err) }
|
|
|
|
|
: { kind: 'error', step, failure: durableFailure(err, failure) }
|
|
|
|
|
try {
|
|
|
|
|
events.emit('agent/error', turn, step, err)
|
|
|
|
|
} catch {
|
|
|
|
|
@@ -226,56 +256,36 @@ async function runTurn(
|
|
|
|
|
// matter what throws below; the catch + closeTurn guarantee it. A pre-commit
|
|
|
|
|
// veto leaves no turn/start in the log and therefore owes no turn/end.
|
|
|
|
|
session.append('turn/start', { turn, trigger })
|
|
|
|
|
// Each drained queued message runs the `agent/prompt-submit` waterfall before
|
|
|
|
|
// it becomes a `user/message` — a hook can rewrite the prompt or block it.
|
|
|
|
|
// The claimed message runs the `agent/prompt-submit` waterfall before it
|
|
|
|
|
// becomes a `user/message` — a hook can rewrite the prompt or block it.
|
|
|
|
|
// Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
|
|
|
|
|
// turn/end is now owed, so a throwing prompt-submit listener (the waterfall
|
|
|
|
|
// throws) is caught below and the turn still closes.
|
|
|
|
|
let anyAllowed = false
|
|
|
|
|
// Seeded with a floor (only observable if the batch were empty, which
|
|
|
|
|
// runTurn never allows — it is called with ≥1 queued message); each `block`
|
|
|
|
|
// decision carries a required `reason` and overwrites it, so a fully-blocked
|
|
|
|
|
// batch always reports the last vetoing reason.
|
|
|
|
|
let lastBlockReason = 'prompt blocked by hook'
|
|
|
|
|
for (const message of queued) {
|
|
|
|
|
const decision = await events.waterfall(
|
|
|
|
|
'agent/prompt-submit', message.content, message.source,
|
|
|
|
|
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
|
|
|
|
|
)
|
|
|
|
|
if (decision.kind === 'block') {
|
|
|
|
|
lastBlockReason = decision.reason
|
|
|
|
|
// Record the veto durably: `PromptDecision.reason` is the durable record
|
|
|
|
|
// of why a prompt was blocked, but a fully-blocked batch's `rejected`
|
|
|
|
|
// turn/end only preserves the LAST reason, and a MIXED batch (this prompt
|
|
|
|
|
// blocked, another allowed) does not end `rejected` at all — so without
|
|
|
|
|
// this append a blocked prompt would vanish from the log whenever any
|
|
|
|
|
// sibling prompt is allowed. `prompt/blocked` sits in the open turn in
|
|
|
|
|
// place of the `user/message` this prompt would have become.
|
|
|
|
|
session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason })
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
anyAllowed = true
|
|
|
|
|
const promptDecision = await events.waterfall(
|
|
|
|
|
'agent/prompt-submit', message.content, message.source,
|
|
|
|
|
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
|
|
|
|
|
)
|
|
|
|
|
if (promptDecision.kind === 'block') {
|
|
|
|
|
session.append('prompt/blocked', { content: message.content, source: message.source, reason: promptDecision.reason })
|
|
|
|
|
reason = { kind: 'rejected', reason: promptDecision.reason }
|
|
|
|
|
} else {
|
|
|
|
|
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
|
|
|
|
|
const content = decision.content ?? message.content
|
|
|
|
|
const content = promptDecision.content ?? message.content
|
|
|
|
|
session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' })
|
|
|
|
|
// 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 ?? []) {
|
|
|
|
|
// into THIS turn without flattening provenance or metadata.
|
|
|
|
|
for (const context of promptDecision.additionalContexts ?? []) {
|
|
|
|
|
agent.inject(context.content, {
|
|
|
|
|
source: context.source,
|
|
|
|
|
...context.envelope !== undefined ? { envelope: context.envelope } : {},
|
|
|
|
|
...context.meta !== undefined ? { meta: context.meta } : {},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
// A fully blocked batch closes its zero-step turn as rejected.
|
|
|
|
|
if (!anyAllowed) {
|
|
|
|
|
reason = { kind: 'rejected', reason: lastBlockReason }
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
// A blocked prompt closes its zero-step turn as rejected.
|
|
|
|
|
if (promptDecision.kind === 'block') break
|
|
|
|
|
step += 1
|
|
|
|
|
|
|
|
|
|
// Steering from the previous round's continuation listeners joins before
|
|
|
|
|
@@ -353,14 +363,14 @@ async function runTurn(
|
|
|
|
|
|
|
|
|
|
let stepOutcome:
|
|
|
|
|
| { hadToolCalls: boolean; finish: FinishReason }
|
|
|
|
|
| { requestError: RequestError }
|
|
|
|
|
| { requestError: RequestError; failure: LlmFailure }
|
|
|
|
|
| { error: RequestError }
|
|
|
|
|
try {
|
|
|
|
|
stepOutcome = await runStep(
|
|
|
|
|
ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal)
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
if (error instanceof TerminalModelRequestFailure) {
|
|
|
|
|
stepOutcome = { requestError: error.requestError }
|
|
|
|
|
stepOutcome = { requestError: error.requestError, failure: error.failure }
|
|
|
|
|
} else {
|
|
|
|
|
stepOutcome = { error: toError(error) }
|
|
|
|
|
}
|
|
|
|
|
@@ -383,12 +393,12 @@ async function runTurn(
|
|
|
|
|
try {
|
|
|
|
|
recoveryDecision = await events.waterfall(
|
|
|
|
|
'agent/request-error', turn, step, stepOutcome.requestError,
|
|
|
|
|
requestRetryAttempt, abort.signal,
|
|
|
|
|
stepOutcome.failure, requestFailureHistory, abort.signal,
|
|
|
|
|
() => Promise.resolve(defaultDecision),
|
|
|
|
|
)
|
|
|
|
|
} catch (recoveryError: unknown) {
|
|
|
|
|
ctx.logger.warn(
|
|
|
|
|
`agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${toError(recoveryError).message}`,
|
|
|
|
|
`agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
handle.setAbort(undefined)
|
|
|
|
|
@@ -404,10 +414,10 @@ async function runTurn(
|
|
|
|
|
}
|
|
|
|
|
switch (recoveryDecision.action) {
|
|
|
|
|
case 'retry':
|
|
|
|
|
requestRetryAttempt += 1
|
|
|
|
|
requestFailureHistory = Object.freeze([...requestFailureHistory, stepOutcome.failure])
|
|
|
|
|
continue
|
|
|
|
|
case 'fail':
|
|
|
|
|
failTurn(stepOutcome.requestError)
|
|
|
|
|
failTurn(stepOutcome.requestError, stepOutcome.failure)
|
|
|
|
|
break
|
|
|
|
|
/* v8 ignore next -- closed-union exhaustiveness guard */
|
|
|
|
|
default:
|
|
|
|
|
@@ -435,7 +445,7 @@ async function runTurn(
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
requestRetryAttempt = 0
|
|
|
|
|
requestFailureHistory = Object.freeze([])
|
|
|
|
|
|
|
|
|
|
// Preserve max-token completion unless a later disposal, abort, or error wins.
|
|
|
|
|
const stepReason = stepFinishReason(stepOutcome.finish)
|
|
|
|
|
@@ -552,7 +562,7 @@ async function runTurn(
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
// The turn is closed, so report the failed flush live rather than append outside a turn.
|
|
|
|
|
const err = toError(error)
|
|
|
|
|
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`)
|
|
|
|
|
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${errorChain(err)}`)
|
|
|
|
|
try {
|
|
|
|
|
events.emit('agent/error', turn, step, err)
|
|
|
|
|
} catch {
|
|
|
|
|
@@ -635,13 +645,14 @@ async function runStep(
|
|
|
|
|
assembler.push(chunk)
|
|
|
|
|
}
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
if (isLlmAdapterFailure(stream, error)) throw new TerminalModelRequestFailure(error)
|
|
|
|
|
const failure = llmFailureOf(stream, error)
|
|
|
|
|
if (failure !== undefined && error instanceof Error) throw new TerminalModelRequestFailure(error, failure)
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Normalize failure finish chunks into the same path as thrown stream errors.
|
|
|
|
|
const stepError = finishError(assembler.finish)
|
|
|
|
|
if (stepError) throw new TerminalModelRequestFailure(stepError)
|
|
|
|
|
if (stepError) throw new TerminalModelRequestFailure(stepError.error, stepError.failure)
|
|
|
|
|
|
|
|
|
|
const recordAssistantMessage = (
|
|
|
|
|
assembledContent: ContentBlock[],
|
|
|
|
|
|