refactor agent pre-step inbox lifecycle
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
|
||||
README.md: 8dee1ed49fea6c079e9c0aa1cf6e4dd1d897793a
|
||||
README.zh.md: 5d69c5c28f67dac66bee0fb08bbb6be28153dd34
|
||||
README.md: 2029b9424975618beccf35c42e42d0bacb6743c0
|
||||
README.zh.md: e83739635539ce5ba6f070113c164d914ca4167f
|
||||
|
||||
@@ -53,11 +53,11 @@ Configured agents start automatically. A model call requires both `provider` and
|
||||
|
||||
### Internal concrete driver
|
||||
|
||||
The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
The concrete `ReactLoopAgent`, its inbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
|
||||
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A block's mandatory `discardClaimed` controls only its submitted batch; later next-step input and queued prompts remain pending for a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model.
|
||||
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. `followup()` appends to the `next-turn` FIFO and wakes the driver, `steer()` appends to the `next-step` inbox and wakes it, and `inject()` appends to that same `next-step` inbox without waking it. At a turn boundary the driver atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. Claiming removes the batch through pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message. `agent/pre-step` then returns either rejection or the complete messages entering the proposed step. Rejection leaves the claimed batch removed; input inserted after the claim remains pending, and idle injection waits until follow-up or steering wakes the driver.
|
||||
|
||||
Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, while remove publishes discard. Edit publishes `agent/inbox/update`; steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
|
||||
Every inbox mutation publishes one normalized `agent/inbox/spliced` event before changing the live projection. Insertions, edits, removals, claiming, and cancellation replay through the same standard splice coordinates. Ordinary removals carry `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; claiming uses pure deletions with no outcome, after which the loop emits `agent/inbox/claimed`. Every insertion emits `agent/inbox/inserted { message }`. `MessageId` stays unique across both pending lists, and synchronous durable-event observers can reconstruct removed values from the pre-splice projection.
|
||||
|
||||
### Loop lifecycle (`agent.ts`)
|
||||
|
||||
@@ -75,7 +75,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded
|
||||
|
||||
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
|
||||
- Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → definition-owned `finalizeContent` → `tools/result` pipeline; exact event signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md)
|
||||
- Compaction: pressure on `agent/step`; canonical overflow repair on `agent/request-error`
|
||||
- Compaction: pressure on `agent/pre-step`; canonical overflow repair on `agent/request-error`
|
||||
- Model-request recovery: `dsh-llm-retry` records and waits exact-provider normal or unbounded backoff on `agent/request-error`, emits non-surface `llm/retry` status, then returns a retry action
|
||||
- Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation
|
||||
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection.
|
||||
|
||||
@@ -53,11 +53,11 @@ interface Config {
|
||||
|
||||
### 包内部实体驱动器
|
||||
|
||||
实体 `ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。
|
||||
实体 `ReactLoopAgent`、其 inbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。
|
||||
|
||||
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。block 必须通过 `discardClaimed` 选择是否丢弃本次提交的批次;之后到达的 next-step 输入和排队提示词会继续保持待处理,等待后续获准的提示词。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。
|
||||
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`followup()` 追加到 `next-turn` FIFO 并唤醒驱动器,`steer()` 追加到 `next-step` inbox 并唤醒驱动器,`inject()` 则追加到同一个 `next-step` inbox,但不唤醒驱动器。在轮次边界,驱动器会原子领取待处理的 next-step 输入和一条排队提示词;在步骤之间则只领取 next-step 输入。领取通过纯删除 splice 移除批次,并针对每条消息发出 `agent/inbox/claimed { message, turn }`。随后 `agent/pre-step` 返回 reject,或返回拟进入步骤的完整消息。reject 后已领取批次保持已删除;领取后插入的输入仍等待后续处理,而空闲注入会一直等待,直到 follow-up 或 steering 唤醒驱动器。
|
||||
|
||||
每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`;steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。
|
||||
每次 inbox 变更都会先发布一条规范化的 `agent/inbox/spliced` 事件,再修改实时投影。因此,插入、编辑、移除、领取与取消都通过同一组标准 splice 坐标回放。普通删除携带 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`;领取使用不带 outcome 的纯删除,随后由循环发出 `agent/inbox/claimed`。每次插入都会发出 `agent/inbox/inserted { message }`。`MessageId` 在两个待处理列表之间保持唯一,同步持久事件观察方可以从 splice 前投影重建被移除的值。
|
||||
|
||||
### 循环生命周期(`agent.ts`)
|
||||
|
||||
@@ -75,7 +75,7 @@ interface Config {
|
||||
|
||||
超出「调用模型、运行工具、重复」的所有内容,都属于监听事件分类体系的插件:
|
||||
- 钩子与策略:相关的 `agent/*` 检查点,加上受守卫保护的 `tools/pre-execute` → `tools/execute` → `tools/post-execute` → 定义拥有的 `finalizeContent` → `tools/result` 流水线;确切事件签名与 mode 位于生成的[事件目录](../../../docs/cordis-catalog/events.md)
|
||||
- 压缩(compaction):在 `agent/step` 上观测压力;在 `agent/request-error` 上进行规范的溢出修复
|
||||
- 压缩(compaction):在 `agent/pre-step` 上观测压力;在 `agent/request-error` 上进行规范的溢出修复
|
||||
- 模型请求恢复:`dsh-llm-retry` 在 `agent/request-error` 上记录并等待按确切提供方配置的 normal 或无界退避,发出不进入表层的 `llm/retry` 状态,然后返回重试动作
|
||||
- 沙箱、权限、计划模式:使用 `tools/pre-execute` 提供可扩展的拒绝/询问,使用 `tools.guard()` 提供单调拥有方策略,使用 `tools/post-execute` 处理结果决定,并使用 `tools/result` 进行最终观测
|
||||
- subagent:在循环外部实现为 `ctx.subagents` 提供方;进程内提供方使用 `ctx.agents.create()` 和拥有的 `AgentHandle` 进行 teardown,而通用的 [`ctx.tasks`](../../tasks/tasks/) 与 [`dsh-tool-subagent`](../../subagent/tool-subagent/) 负责后台收集。
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
AgentStatus,
|
||||
CancelOptions,
|
||||
InboxTarget,
|
||||
PreStepDecision,
|
||||
RequestErrorAction,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { Inbox, agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -36,11 +37,6 @@ type Phase =
|
||||
| { kind: 'collecting'; abort: AbortController; lastTurn: number }
|
||||
| { kind: 'running'; abort: AbortController; turn: number; step: number }
|
||||
|
||||
type Admission =
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'admitted'; messages: UserMessage[] }
|
||||
| { kind: 'blocked' }
|
||||
|
||||
type StepEndReason = Extract<TurnEndReason, { kind: 'completed' | 'max-tokens' }>
|
||||
|
||||
/** Remove adapter-derived values before plugins propose the next request config. */
|
||||
@@ -71,7 +67,10 @@ export class ReactLoopAgent implements Agent {
|
||||
public readonly options: AgentOptions,
|
||||
public readonly session: Session,
|
||||
) {
|
||||
this.inbox = new Inbox(session)
|
||||
this.inbox = new Inbox(session, {
|
||||
inserted: (message) =>{ emitAgentEvent(loopCtx, this, 'agent/inbox/inserted', { message }) },
|
||||
discarded: (message) =>{ emitAgentEvent(loopCtx, this, 'agent/inbox/discarded', { message }) },
|
||||
})
|
||||
const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
|
||||
this.phase = { kind: 'idle', lastTurn }
|
||||
this.scope = createScope(loopCtx, this)
|
||||
@@ -93,7 +92,7 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void {
|
||||
// Waking input cannot join an aborted admission or turn, so it starts the next turn.
|
||||
// Waking input cannot join an aborted pre-step or turn, so it starts the next turn.
|
||||
const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
|
||||
const resolvedTarget = wakingAfterAbort ? 'next-turn' : target
|
||||
this.inbox.splice(resolvedTarget, Infinity, 0, [message])
|
||||
@@ -114,13 +113,13 @@ export class ReactLoopAgent implements Agent {
|
||||
|
||||
cancel(cause: AgentCancelCause, options: CancelOptions = {}): void {
|
||||
if (!options.keepInbox) {
|
||||
this.inbox.splice('next-step', 0, this.inbox.nextStep.length, [], 'canceled')
|
||||
this.inbox.splice('next-turn', 0, this.inbox.nextTurn.length, [], 'canceled')
|
||||
this.inbox.splice('next-step', 0, this.inbox.nextStep.length, [])
|
||||
this.inbox.splice('next-turn', 0, this.inbox.nextTurn.length, [])
|
||||
}
|
||||
if (this.phase.kind !== 'idle') this.phase.abort.abort(cause)
|
||||
}
|
||||
|
||||
/** Reserve a driver before deferring idle admission. */
|
||||
/** Reserve a driver before deferring idle pre-step processing. */
|
||||
private scheduleKick(): void {
|
||||
if (this.phase.kind !== 'idle') return
|
||||
const driver = Promise.withResolvers<void>()
|
||||
@@ -158,33 +157,22 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
private async admit(onTurnBoundary: boolean): Promise<Admission> {
|
||||
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": admit outside running phase`)
|
||||
private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise<PreStepDecision> {
|
||||
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`)
|
||||
const signal = this.phase.abort.signal
|
||||
const claimed = [...this.inbox.nextStep]
|
||||
const outboxLength = claimed.length
|
||||
const queued = onTurnBoundary ? this.inbox.nextTurn[0] : undefined
|
||||
if (queued !== undefined) claimed.push(queued)
|
||||
if (claimed.length === 0) return { kind: 'empty' }
|
||||
const claimed = this.inbox.claim(target)
|
||||
for (const message of claimed) {
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/claimed', { message, turn: position.turn })
|
||||
}
|
||||
const decision = await agentEvents(this.loopCtx, this).waterfall(
|
||||
'agent/prompt-submit', claimed, signal,
|
||||
() => Promise.resolve({ kind: 'allow', messages: claimed }),
|
||||
'agent/pre-step', claimed, { ...position, signal },
|
||||
() => Promise.resolve({ kind: 'enter', messages: claimed }),
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
if (decision.kind === 'allow') {
|
||||
this.inbox.splice('next-step', 0, outboxLength, [], 'admitted')
|
||||
if (queued !== undefined) this.inbox.splice('next-turn', 0, 1, [], 'admitted')
|
||||
return { kind: 'admitted', messages: decision.messages }
|
||||
}
|
||||
if (decision.discardClaimed) {
|
||||
this.inbox.splice('next-step', 0, outboxLength, [], 'canceled')
|
||||
if (queued !== undefined) this.inbox.splice('next-turn', 0, 1, [], 'canceled')
|
||||
}
|
||||
this.cancel({ kind: 'hook', reason: decision.reason }, { keepInbox: true })
|
||||
return { kind: 'blocked' }
|
||||
return decision
|
||||
}
|
||||
|
||||
/** Admitted input stays unowned until `turn/start` commits. */
|
||||
/** Claimed input stays unowned until `turn/start` commits. */
|
||||
private async turn(): Promise<boolean> {
|
||||
if (this.phase.kind === 'idle') {
|
||||
this.throwError(new Error(`agent "${this.id}": turn without driver reservation`))
|
||||
@@ -195,10 +183,10 @@ export class ReactLoopAgent implements Agent {
|
||||
const phase = { kind: 'running' as const, abort, turn: lastTurn, step: 0 }
|
||||
this.setPhase(phase)
|
||||
signal.throwIfAborted()
|
||||
let admission: Admission
|
||||
let decision: PreStepDecision
|
||||
try {
|
||||
admission = await this.admit(true)
|
||||
if (admission.kind !== 'admitted') return false
|
||||
decision = await this.preStep('next-turn', { turn: phase.turn + 1, step: 1 })
|
||||
if (decision.kind === 'reject') return false
|
||||
signal.throwIfAborted()
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) throw error
|
||||
@@ -213,15 +201,13 @@ export class ReactLoopAgent implements Agent {
|
||||
let turnEnds: TurnEndReason | null = null
|
||||
try {
|
||||
while (true) {
|
||||
if (admission.kind === 'admitted') {
|
||||
for (const message of admission.messages) {
|
||||
this.session.append('user/message', message, { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
const step = ++phase.step
|
||||
this.session.append('step/start', { turn, step })
|
||||
try {
|
||||
for (const message of decision.messages) {
|
||||
this.session.append('user/message', message, { surfaceOp: 'append' })
|
||||
}
|
||||
turnEnds = await this.step()
|
||||
} finally {
|
||||
this.session.append('step/end', { turn, step })
|
||||
@@ -231,13 +217,13 @@ export class ReactLoopAgent implements Agent {
|
||||
await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal)
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
admission = await this.admit(false)
|
||||
if (admission.kind === 'blocked') {
|
||||
decision = await this.preStep('next-step', { turn, step: phase.step + 1 })
|
||||
if (decision.kind === 'reject') {
|
||||
turnEnds = { kind: 'blocked' }
|
||||
return false
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (admission.kind === 'empty' && turnEnds) break
|
||||
if (decision.messages.length === 0 && turnEnds) break
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) {
|
||||
@@ -264,8 +250,6 @@ export class ReactLoopAgent implements Agent {
|
||||
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`)
|
||||
const { turn, step, abort: { signal } } = this.phase
|
||||
signal.throwIfAborted()
|
||||
await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal)
|
||||
signal.throwIfAborted()
|
||||
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
|
||||
signal.throwIfAborted()
|
||||
const system = renderPrompt(assembly)
|
||||
|
||||
@@ -42,7 +42,7 @@ interface GroupOutcome {
|
||||
* Ordinary completion and abort commit started-call results in order. Abort
|
||||
* drains them, records synthetic results for unstarted calls, and returns with
|
||||
* the signal still aborted after accepting started-call context through the
|
||||
* caller-supplied acceptor (the machine stages it on its outbox for the next
|
||||
* caller-supplied acceptor (the machine stages it in its next-step inbox for the
|
||||
* step boundary). An internal scheduler failure stops new dispatches, drains
|
||||
* already-started dispatches, and rejects with the first failure without
|
||||
* fabricating tool results.
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('AgentLoop initiator scope', () => {
|
||||
const { ctx } = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('signal-owner'), { provider: 'mock', model: 'mock' })
|
||||
let signals: AbortSignal[] = []
|
||||
let admissionSignals: AbortSignal[] = []
|
||||
let preStepSignals: AbortSignal[] = []
|
||||
const capture = (signal: AbortSignal | undefined): void => {
|
||||
if (signal === undefined) throw new Error('turn seam omitted its explicit signal')
|
||||
expect(ctx.agents.requireInitiator()).toBe(agent)
|
||||
@@ -164,16 +164,13 @@ describe('AgentLoop initiator scope', () => {
|
||||
if (context.agent === agent) capture(context.signal)
|
||||
return next()
|
||||
})
|
||||
ctx.on('agent/prompt-submit', async (subject, _message, signal, next) => {
|
||||
ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => {
|
||||
if (subject === agent) {
|
||||
expect(ctx.agents.requireInitiator()).toBe(agent)
|
||||
admissionSignals.push(signal)
|
||||
preStepSignals.push(signal)
|
||||
}
|
||||
return next()
|
||||
})
|
||||
ctx.on('agent/step', (subject, _turn, _step, signal) => {
|
||||
if (subject === agent) capture(signal)
|
||||
})
|
||||
ctx.on('agent/request', async (subject, _turn, _step, signal, next) => {
|
||||
if (subject === agent) capture(signal)
|
||||
return next()
|
||||
@@ -197,19 +194,19 @@ describe('AgentLoop initiator scope', () => {
|
||||
const firstSignal = signals[0]
|
||||
expect(firstSignal).toBeDefined()
|
||||
expect(new Set([...signals, ...adapter.requests.slice(0, 2).map(request => request.signal!)])).toEqual(new Set([firstSignal]))
|
||||
expect(admissionSignals).toHaveLength(1)
|
||||
expect(admissionSignals[0]).toBe(firstSignal)
|
||||
expect(preStepSignals).toHaveLength(2)
|
||||
expect(new Set(preStepSignals)).toEqual(new Set([firstSignal]))
|
||||
|
||||
signals = []
|
||||
admissionSignals = []
|
||||
preStepSignals = []
|
||||
const secondIdle = waitForIdle(ctx, agent)
|
||||
send(agent, 'second')
|
||||
await secondIdle
|
||||
const secondSignal = signals[0]
|
||||
expect(secondSignal).toBeDefined()
|
||||
expect(new Set([...signals, adapter.requests[2]!.signal!])).toEqual(new Set([secondSignal]))
|
||||
expect(admissionSignals).toHaveLength(1)
|
||||
expect(admissionSignals[0]).toBe(secondSignal)
|
||||
expect(preStepSignals).toHaveLength(1)
|
||||
expect(preStepSignals[0]).toBe(secondSignal)
|
||||
expect(secondSignal).not.toBe(firstSignal)
|
||||
expect(ctx.agents.currentInitiator()).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
@@ -50,7 +50,37 @@ describe('Agent', () => {
|
||||
.toEqual({ kind: 'plugin', plugin: '' })
|
||||
})
|
||||
|
||||
it('idle inject() rejects invalid input before append', async () => {
|
||||
it('emits exact inserted, claimed, and discarded inbox messages', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('ok')]))
|
||||
const agent = ctx.agentLoop.create(SessionId('inbox-events'), { provider: 'mock', model: 'mock' })
|
||||
const inserted: unknown[] = []
|
||||
const claimed: unknown[] = []
|
||||
const discarded: unknown[] = []
|
||||
ctx.on('agent/inbox/inserted', (subject, event) => {
|
||||
if (subject === agent) inserted.push(event)
|
||||
})
|
||||
ctx.on('agent/inbox/claimed', (subject, event) => {
|
||||
if (subject === agent) claimed.push(event)
|
||||
})
|
||||
ctx.on('agent/inbox/discarded', (subject, event) => {
|
||||
if (subject === agent) discarded.push(event)
|
||||
})
|
||||
const context = createUserMessage({
|
||||
content: [{ type: 'text', text: 'discard me' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
agent.inject(context)
|
||||
agent.inbox.remove('next-step', context.id)
|
||||
const prompt = createUserMessage({ content: [{ type: 'text', text: 'run' }], source: { kind: 'user' } })
|
||||
agent.followup(prompt)
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(inserted).toEqual([{ message: context }, { message: prompt }])
|
||||
expect(discarded).toEqual([{ message: context }])
|
||||
expect(claimed).toEqual([{ message: prompt, turn: 1 }])
|
||||
})
|
||||
|
||||
it('idle inject() rejects invalid input before enqueue', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('ok')]))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
|
||||
@@ -668,9 +668,8 @@ describe('Agent.cancel()', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
'prompt-submit',
|
||||
'pre-step',
|
||||
'system-prompt',
|
||||
'step',
|
||||
'request',
|
||||
'stopping',
|
||||
'tool',
|
||||
@@ -690,8 +689,8 @@ describe('Agent.cancel()', () => {
|
||||
}
|
||||
|
||||
switch (stage) {
|
||||
case 'prompt-submit':
|
||||
ctx.on('agent/prompt-submit', async (subject, _message, signal, next) => {
|
||||
case 'pre-step':
|
||||
ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => {
|
||||
if (subject === agent) await blockUntilAbort(signal)
|
||||
return next()
|
||||
})
|
||||
@@ -705,11 +704,6 @@ describe('Agent.cancel()', () => {
|
||||
return next()
|
||||
})
|
||||
break
|
||||
case 'step':
|
||||
ctx.on('agent/step', async (subject, _turn, _step, signal) => {
|
||||
if (subject === agent) await blockUntilAbort(signal)
|
||||
})
|
||||
break
|
||||
case 'request':
|
||||
ctx.on('agent/request', async (subject, _turn, _step, signal, next) => {
|
||||
if (subject === agent) await blockUntilAbort(signal)
|
||||
@@ -741,7 +735,7 @@ describe('Agent.cancel()', () => {
|
||||
agent.cancel({ kind: 'user' })
|
||||
await idle
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
if (stage === 'prompt-submit') {
|
||||
if (stage === 'pre-step') {
|
||||
expect(turnEnd).toBeUndefined()
|
||||
} else {
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } })
|
||||
|
||||
@@ -67,30 +67,30 @@ describe('addressable inbox operations', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('inbox-actions'), { provider: 'mock', model: 'mock' })
|
||||
const admission = Promise.withResolvers<undefined>()
|
||||
const preStep = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.on('agent/prompt-submit', async (_subject, messages, _signal, next) => {
|
||||
ctx.on('agent/pre-step', async (_subject, messages, _signal, next) => {
|
||||
if (messages[0]?.content[0]?.type === 'text' && messages[0].content[0].text === 'first') {
|
||||
admission.resolve(undefined)
|
||||
preStep.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
await admission.promise
|
||||
await preStep.promise
|
||||
send(agent, 'remove me')
|
||||
send(agent, 'edit me')
|
||||
const pending = agent.inbox.nextTurn
|
||||
expect(pending.map(inboxText)).toEqual(['first', 'remove me', 'edit me'])
|
||||
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me'])
|
||||
|
||||
const remove = pending[1]!
|
||||
const edit = pending[2]!
|
||||
expect(agent.inbox.splice('next-turn', 2, 1, [freezeMessage({
|
||||
const remove = pending[0]!
|
||||
const edit = pending[1]!
|
||||
expect(agent.inbox.splice('next-turn', 1, 1, [freezeMessage({
|
||||
...edit,
|
||||
content: [{ type: 'text', text: 'edited' }],
|
||||
})])).toEqual([edit])
|
||||
expect(agent.inbox.splice('next-turn', 1, 1, [])).toEqual([remove])
|
||||
expect(agent.inbox.splice('next-turn', 0, 1, [])).toEqual([remove])
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
release.resolve(undefined)
|
||||
@@ -316,11 +316,19 @@ describe('abort during tool execution ends the turn', () => {
|
||||
|
||||
send(agent, 'leave an unmatched historical call')
|
||||
await waitForIdle(ctx, agent)
|
||||
const disposeInjection = ctx.on('agent/step', (subject, turn) => {
|
||||
if (subject === agent && turn === 2) {
|
||||
const disposeInjection = ctx.on('agent/pre-step', async (subject, _messages, { turn }, next) => {
|
||||
const decision = await next()
|
||||
if (subject === agent && turn === 2 && decision.kind === 'enter') {
|
||||
disposeInjection()
|
||||
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'new turn context' }], source: { kind: 'plugin', plugin: 'test' } }))
|
||||
return {
|
||||
kind: 'enter' as const,
|
||||
messages: [...decision.messages, createUserMessage({
|
||||
content: [{ type: 'text', text: 'new turn context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})],
|
||||
}
|
||||
}
|
||||
return decision
|
||||
})
|
||||
send(agent, 'start a text-only turn')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -903,8 +911,7 @@ describe('turn and step boundary recovery', () => {
|
||||
expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => {
|
||||
// Disposal remains authoritative when the listener also throws.
|
||||
it('contains a pre-step throw after disposal without opening a turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
let agent!: Agent
|
||||
@@ -913,8 +920,8 @@ describe('turn and step boundary recovery', () => {
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
let threw = false
|
||||
ctx.on('agent/step', () => {
|
||||
if (threw) return
|
||||
ctx.on('agent/pre-step', (_subject, _messages, _context, next) => {
|
||||
if (threw) return next()
|
||||
threw = true
|
||||
void fiber.dispose()
|
||||
throw new Error('boom pre-step during disposal')
|
||||
@@ -928,12 +935,9 @@ describe('turn and step boundary recovery', () => {
|
||||
await agent.whenIdle()
|
||||
|
||||
const e = [...agent.session.events]
|
||||
// Balanced: one turn/start, one turn/end carrying disposed (NOT error).
|
||||
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
|
||||
const turnEnd = e.findLast(x => x.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
|
||||
expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(true)
|
||||
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(errorEmits).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -1224,9 +1228,8 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
|
||||
})
|
||||
|
||||
it('disposal during agent/step listeners ends the turn disposed', { timeout: 15000 }, async () => {
|
||||
// Start disposal, then release pre-step; awaiting disposal first would
|
||||
// deadlock on the blocked driver.
|
||||
it('disposal during pre-step prevents the turn from opening', { timeout: 15000 }, async () => {
|
||||
// Start disposal, then release pre-step; awaiting disposal first would deadlock on the blocked driver.
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
let releasePreStep!: () => void
|
||||
const blocker = new Promise<void>(r => void (releasePreStep = r))
|
||||
@@ -1241,8 +1244,9 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await mountInvariants(ctx)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
ctx.on('agent/step', async () => {
|
||||
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => {
|
||||
await blocker
|
||||
return next()
|
||||
})
|
||||
|
||||
let agent!: Agent
|
||||
@@ -1261,23 +1265,17 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await disposalDone
|
||||
await driverDone(agent)
|
||||
|
||||
// After the agent/step listeners finish, the post-listener cancel/dispose
|
||||
// check catches disposal before any LLM call.
|
||||
// The post-listener cancellation check catches disposal before any turn or LLM call.
|
||||
const e = [...agent.session.events]
|
||||
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
|
||||
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
|
||||
const turnEnd = e.findLast(x => x.type === 'turn/end')
|
||||
// Disposal wins the post-listener check — reason is `disposed`.
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
|
||||
expect(e.filter(x => x.type === 'step/start')).toHaveLength(1)
|
||||
expect(e.filter(x => x.type === 'step/end')).toHaveLength(1)
|
||||
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
||||
// The durable turn/end record is the authoritative turn-boundary signal
|
||||
// (turn boundaries have no agent/* mirror).
|
||||
expect(reasons).toEqual([])
|
||||
})
|
||||
|
||||
it('cancel during agent/step listeners ends the turn aborted', { timeout: 15000 }, async () => {
|
||||
// Release agent/step after cancellation to exercise the post-listener check.
|
||||
it('cancel during pre-step prevents the turn from opening', { timeout: 15000 }, async () => {
|
||||
// Release pre-step after cancellation to exercise the post-listener check.
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
let releasePreStep!: () => void
|
||||
const blocker = new Promise<void>(r => void (releasePreStep = r))
|
||||
@@ -1292,8 +1290,9 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await mountInvariants(ctx)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
ctx.on('agent/step', async () => {
|
||||
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => {
|
||||
await blocker
|
||||
return next()
|
||||
})
|
||||
|
||||
let agent!: Agent
|
||||
@@ -1314,14 +1313,11 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await driverDone(agent)
|
||||
|
||||
const e = [...agent.session.events]
|
||||
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
|
||||
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
|
||||
const turnEnd = e.findLast(x => x.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } })
|
||||
expect(e.filter(x => x.type === 'step/start')).toHaveLength(1)
|
||||
expect(e.filter(x => x.type === 'step/end')).toHaveLength(1)
|
||||
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
|
||||
expect(reasons).toEqual([])
|
||||
})
|
||||
|
||||
it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, {
|
||||
type Agent,
|
||||
type PromptDecision,
|
||||
type PreStepDecision,
|
||||
type SessionStartSource,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
|
||||
@@ -19,7 +19,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
/**
|
||||
* The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`,
|
||||
* The interception seams introduced by the hooks taxonomy: `agent/pre-step`,
|
||||
* `agent/session-start`, `agent/turn-stopping`, and the
|
||||
* `tools/pre-execute` / `tools/post-execute`
|
||||
* split with `additionalContexts` buffering. These verify the canonical event
|
||||
@@ -58,14 +58,14 @@ function events(agent: Agent): SessionEvent[] {
|
||||
return [...agent.session.events]
|
||||
}
|
||||
|
||||
describe('agent/prompt-submit', () => {
|
||||
it('allow (default via next) records the user/message unchanged', async () => {
|
||||
describe('agent/pre-step', () => {
|
||||
it('enter (default via next) records the user/message unchanged', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const seen: string[] = []
|
||||
ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next) => {
|
||||
ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
|
||||
seen.push(messages[0]!.content.map(b => (b.type === 'text' ? b.text : '')).join(''))
|
||||
return next()
|
||||
})
|
||||
@@ -78,15 +78,43 @@ describe('agent/prompt-submit', () => {
|
||||
expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
})
|
||||
|
||||
it('reports the request coordinates for initial and tool-continuation prompts', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'hi' }),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo',
|
||||
description: 'echo',
|
||||
parameters: { text: { type: 'string', required: true } },
|
||||
execute: async ({ text }) => [{ type: 'text', text }],
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' })
|
||||
const seen: Array<{ turn: number; step: number; messages: number }> = []
|
||||
ctx.on('agent/pre-step', async (_agent, messages, context, next) => {
|
||||
seen.push({ turn: context.turn, step: context.step, messages: messages.length })
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'hello')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(seen).toEqual([
|
||||
{ turn: 1, step: 1, messages: 1 },
|
||||
{ turn: 1, step: 2, messages: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
it('publishes frozen input without replacing its identity', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('owned-input'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<PromptDecision>()
|
||||
const decision = Promise.withResolvers<PreStepDecision>()
|
||||
const observed: UserMessage[] = []
|
||||
ctx.on('agent/prompt-submit', async (subject, messages) => {
|
||||
if (subject !== agent) return { kind: 'allow', messages }
|
||||
ctx.on('agent/pre-step', async (subject, messages) => {
|
||||
if (subject !== agent) return { kind: 'enter', messages }
|
||||
const message = messages[0]!
|
||||
expect(Object.isFrozen(message)).toBe(true)
|
||||
expect(Object.isFrozen(message.content)).toBe(true)
|
||||
@@ -115,7 +143,7 @@ describe('agent/prompt-submit', () => {
|
||||
expect(() => {
|
||||
if (input.source.kind === 'plugin') input.source.plugin = 'caller mutation'
|
||||
}).toThrow(TypeError)
|
||||
decision.resolve({ kind: 'allow', messages: [input] })
|
||||
decision.resolve({ kind: 'enter', messages: [input] })
|
||||
await idle
|
||||
|
||||
expect(observed).toHaveLength(1)
|
||||
@@ -128,14 +156,14 @@ describe('agent/prompt-submit', () => {
|
||||
expect(userMsg?.type === 'user/message' && userMsg.data).toEqual(input)
|
||||
})
|
||||
|
||||
it('allow with content REWRITES the prompt before it is recorded', async () => {
|
||||
it('enter with content rewrites the prompt before it is recorded', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (_agent, messages): Promise<PromptDecision> =>
|
||||
ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> =>
|
||||
({
|
||||
kind: 'allow',
|
||||
kind: 'enter',
|
||||
messages: [{ ...messages[0]!, content: [{ type: 'text', text: 'REWRITTEN' }] }],
|
||||
}))
|
||||
|
||||
@@ -149,14 +177,14 @@ describe('agent/prompt-submit', () => {
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
|
||||
})
|
||||
|
||||
it('allow with additionalContexts injects separate injected-context user messages into the turn', async () => {
|
||||
it('enter with additional messages records separately sourced context in the turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (_agent, messages): Promise<PromptDecision> =>
|
||||
ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> =>
|
||||
({
|
||||
kind: 'allow',
|
||||
kind: 'enter',
|
||||
messages: [...messages, createUserMessage({
|
||||
content: [{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
@@ -176,43 +204,12 @@ describe('agent/prompt-submit', () => {
|
||||
expect(sent).toContain('extra ctx')
|
||||
})
|
||||
|
||||
it('runs pre-step after prompt rewrites and injected context become durable', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (_agent, messages): Promise<PromptDecision> =>
|
||||
({
|
||||
kind: 'allow',
|
||||
messages: [{
|
||||
...messages[0]!,
|
||||
content: [{ type: 'text', text: 'REWRITTEN prompt' }],
|
||||
}, createUserMessage({
|
||||
content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' },
|
||||
})],
|
||||
}))
|
||||
|
||||
let preStepDerived: string | undefined
|
||||
ctx.on('agent/step', (subject, _turn, step) => {
|
||||
if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages())
|
||||
})
|
||||
|
||||
send(agent, 'ORIGINAL prompt')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(preStepDerived).toBeDefined()
|
||||
expect(preStepDerived).toContain('REWRITTEN prompt')
|
||||
expect(preStepDerived).toContain('injected ctx')
|
||||
expect(preStepDerived).not.toContain('ORIGINAL prompt')
|
||||
})
|
||||
|
||||
it('block drops the claimed prompt before any turn or model call', async () => {
|
||||
it('reject drops the claimed prompt before any turn or model call', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
({ kind: 'block', reason: 'blocked by policy', discardClaimed: true }))
|
||||
ctx.on('agent/pre-step', async (): Promise<PreStepDecision> => ({ kind: 'reject' }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
@@ -230,44 +227,24 @@ describe('agent/prompt-submit', () => {
|
||||
expect(reasons).toEqual([])
|
||||
})
|
||||
|
||||
it('block can retain the claimed prompt without opening a turn', async () => {
|
||||
const adapter = new MockAdapter([])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('retained-claim'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> => ({
|
||||
kind: 'block',
|
||||
reason: 'try later',
|
||||
discardClaimed: false,
|
||||
}))
|
||||
|
||||
send(agent, 'retained')
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(agent.inbox.nextTurn.map(message => message.content[0]))
|
||||
.toEqual([{ type: 'text', text: 'retained' }])
|
||||
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(adapter.requests).toEqual([])
|
||||
})
|
||||
|
||||
it('stages inject and steer during admission for the admitted turn', async () => {
|
||||
it('stages inject and steer during pre-step for the entered turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('admission-outbox'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('pre-step-outbox'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<PromptDecision>()
|
||||
const decision = Promise.withResolvers<PreStepDecision>()
|
||||
let claimed: UserMessage[] = []
|
||||
let firstAdmission = true
|
||||
ctx.on('agent/prompt-submit', async (_agent, messages) => {
|
||||
if (!firstAdmission) return { kind: 'allow', messages }
|
||||
firstAdmission = false
|
||||
let firstProposal = true
|
||||
ctx.on('agent/pre-step', async (_agent, messages) => {
|
||||
if (!firstProposal) return { kind: 'enter', messages }
|
||||
firstProposal = false
|
||||
claimed = messages
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'admitted prompt')
|
||||
send(agent, 'entered prompt')
|
||||
await entered.promise
|
||||
expect(agent.status).toBe('running')
|
||||
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
|
||||
@@ -276,15 +253,15 @@ describe('agent/prompt-submit', () => {
|
||||
content: [{ type: 'text', text: 'attached context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'admission steering' }], source: { kind: 'user' } }))
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'pre-step steering' }], source: { kind: 'user' } }))
|
||||
expect(events(agent).some(event => event.type === 'user/message')).toBe(false)
|
||||
expect(agent.inbox.nextStep.map(message => message.content[0]))
|
||||
.toEqual([
|
||||
{ type: 'text', text: 'attached context' },
|
||||
{ type: 'text', text: 'admission steering' },
|
||||
{ type: 'text', text: 'pre-step steering' },
|
||||
])
|
||||
|
||||
decision.resolve({ kind: 'allow', messages: claimed })
|
||||
decision.resolve({ kind: 'enter', messages: claimed })
|
||||
await idle
|
||||
expect(agent.inbox.hasPending).toBe(false)
|
||||
|
||||
@@ -297,27 +274,27 @@ describe('agent/prompt-submit', () => {
|
||||
'user/message',
|
||||
])
|
||||
expect(staged[1]?.type === 'user/message' && staged[1].data.content)
|
||||
.toEqual([{ type: 'text', text: 'admitted prompt' }])
|
||||
.toEqual([{ type: 'text', text: 'entered prompt' }])
|
||||
expect(staged[2]?.type === 'user/message' && staged[2].data.content)
|
||||
.toEqual([{ type: 'text', text: 'attached context' }])
|
||||
expect(staged[3]?.type === 'user/message' && staged[3].data.content)
|
||||
.toEqual([{ type: 'text', text: 'admission steering' }])
|
||||
.toEqual([{ type: 'text', text: 'pre-step steering' }])
|
||||
const firstRequest = JSON.stringify(adapter.requests[0]?.messages)
|
||||
expect(firstRequest).toContain('admitted prompt')
|
||||
expect(firstRequest).toContain('entered prompt')
|
||||
expect(firstRequest).not.toContain('attached context')
|
||||
expect(firstRequest).not.toContain('admission steering')
|
||||
expect(firstRequest).not.toContain('pre-step steering')
|
||||
const nextRequest = JSON.stringify(adapter.requests[1]?.messages)
|
||||
expect(nextRequest).toContain('attached context')
|
||||
expect(nextRequest).toContain('admission steering')
|
||||
expect(nextRequest).toContain('pre-step steering')
|
||||
})
|
||||
|
||||
it('preserves input staged after the blocked batch was claimed', async () => {
|
||||
const adapter = new MockAdapter([textResponse('retried')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('blocked-admission-outbox'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('blocked-pre-step-outbox'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<PromptDecision>()
|
||||
const disposeBlock = ctx.on('agent/prompt-submit', async () => {
|
||||
const decision = Promise.withResolvers<PreStepDecision>()
|
||||
const disposeBlock = ctx.on('agent/pre-step', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
@@ -330,7 +307,7 @@ describe('agent/prompt-submit', () => {
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'staged steering' }], source: { kind: 'user' } }))
|
||||
decision.resolve({ kind: 'block', reason: 'policy', discardClaimed: true })
|
||||
decision.resolve({ kind: 'reject' })
|
||||
await blockedIdle
|
||||
|
||||
expect(agent.inbox.nextStep.map(message => message.content[0]))
|
||||
@@ -357,24 +334,24 @@ describe('agent/prompt-submit', () => {
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering')
|
||||
})
|
||||
|
||||
it('preserves later queued work when an admission is blocked', async () => {
|
||||
it('preserves later queued work when a step is rejected', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('continued'),
|
||||
textResponse('wake reply'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('rejected-admission-order'), {
|
||||
const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-order'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next) => {
|
||||
ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
|
||||
const decision = await next()
|
||||
return messages.some(message =>
|
||||
message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))
|
||||
? { kind: 'block', reason: 'policy', discardClaimed: true }
|
||||
? { kind: 'reject' as const }
|
||||
: decision
|
||||
})
|
||||
ctx.on('agent/prompt-submit', async (subject, messages, _signal, next) => {
|
||||
ctx.on('agent/pre-step', async (subject, messages, _signal, next) => {
|
||||
if (messages.some(message =>
|
||||
message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))) {
|
||||
subject.inject(createUserMessage({
|
||||
@@ -414,13 +391,13 @@ describe('agent/prompt-submit', () => {
|
||||
expect(request).not.toContain('blocked prompt')
|
||||
})
|
||||
|
||||
it('preserves context-only injection staged after admission began', async () => {
|
||||
it('preserves context-only injection staged after pre-step began', async () => {
|
||||
const adapter = new MockAdapter([textResponse('continued')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('blocked-admission-context'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-context'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<PromptDecision>()
|
||||
const disposeBlock = ctx.on('agent/prompt-submit', async () => {
|
||||
const decision = Promise.withResolvers<PreStepDecision>()
|
||||
const disposeBlock = ctx.on('agent/pre-step', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
@@ -432,7 +409,7 @@ describe('agent/prompt-submit', () => {
|
||||
content: [{ type: 'text', text: 'independent context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
decision.resolve({ kind: 'block', reason: 'policy', discardClaimed: true })
|
||||
decision.resolve({ kind: 'reject' })
|
||||
await idle
|
||||
|
||||
const log = events(agent)
|
||||
@@ -452,7 +429,7 @@ describe('agent/prompt-submit', () => {
|
||||
it('leaves inbox state unchanged when its durable append fails', async () => {
|
||||
const adapter = new MockAdapter([])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('blocked-admission-append-failure'), {
|
||||
const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-append-failure'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
@@ -476,11 +453,11 @@ describe('agent/prompt-submit', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next): Promise<PromptDecision> => {
|
||||
ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise<PreStepDecision> => {
|
||||
const text = messages.flatMap(message => message.content)
|
||||
.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
return text === 'secret'
|
||||
? { kind: 'block', reason: 'policy: no secrets', discardClaimed: true }
|
||||
? { kind: 'reject' }
|
||||
: next()
|
||||
})
|
||||
|
||||
@@ -506,15 +483,15 @@ describe('agent/prompt-submit', () => {
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('secret')
|
||||
})
|
||||
|
||||
it('a throwing prompt-submit listener reports the driver error and retains adjacent work', async () => {
|
||||
it('a throwing pre-step listener reports the driver error and retains adjacent work', async () => {
|
||||
const adapter = new MockAdapter([textResponse('after')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threw = false
|
||||
ctx.on('agent/prompt-submit', async (_agent, messages) => {
|
||||
ctx.on('agent/pre-step', async (_agent, messages) => {
|
||||
if (!threw) { threw = true; throw new Error('prompt hook broke') }
|
||||
return { kind: 'allow' as const, messages }
|
||||
return { kind: 'enter' as const, messages }
|
||||
})
|
||||
const errors: Error[] = []
|
||||
const reasons: TurnEndReason[] = []
|
||||
@@ -538,7 +515,8 @@ describe('agent/prompt-submit', () => {
|
||||
expect(reasons).toEqual([])
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.inbox.nextTurn).toHaveLength(2)
|
||||
expect(agent.inbox.nextTurn.map(message => message.content[0]))
|
||||
.toEqual([{ type: 'text', text: 'second' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -716,16 +694,12 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
|
||||
ctx.on('agent/session-start', (agent, source) => {
|
||||
agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } }))
|
||||
})
|
||||
// 2. PromptSubmit: block a forbidden prompt, annotate the rest.
|
||||
ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next): Promise<PromptDecision> => {
|
||||
// 2. PreStep: reject a forbidden prompt, annotate the rest.
|
||||
ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise<PreStepDecision> => {
|
||||
const text = messages.flatMap(message => message.content)
|
||||
.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
if (text.includes('rm -rf')) {
|
||||
return {
|
||||
kind: 'block',
|
||||
reason: 'destructive prompt blocked',
|
||||
discardClaimed: true,
|
||||
}
|
||||
return { kind: 'reject' }
|
||||
}
|
||||
return next()
|
||||
})
|
||||
@@ -800,7 +774,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
|
||||
const agent = ctx.agentLoop.create(SessionId('a3'), { provider: 'mock', model: 'mock' })
|
||||
send(agent, 'run rm -rf /')
|
||||
await waitForIdle(ctx, agent)
|
||||
// the prompt ran (not rejected) — proving the prompt-submit listener was disposed
|
||||
// the prompt ran (not rejected) — proving the pre-step listener was disposed
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(events(agent).some(e => e.type === 'user/message')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('agent loop', () => {
|
||||
expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
|
||||
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
// Durable inbox receipt and admission bracket the turn-owned transcript.
|
||||
// Durable inbox receipt precedes the turn-owned transcript.
|
||||
expect(types[0]).toBe('agent/inbox/spliced')
|
||||
expect(types).toContain('turn/start')
|
||||
expect(types).toContain('user/message')
|
||||
@@ -313,7 +313,7 @@ describe('agent loop', () => {
|
||||
const steering = agent.session.events.find(e =>
|
||||
e.type === 'user/message' && JSON.stringify(e.data.content).includes('change of plans'))
|
||||
expect(steering).toBeDefined()
|
||||
// Steering is admitted before the second step's request derives history.
|
||||
// Steering enters history before the second step's request derives it.
|
||||
const steeringSeq = steering!.seq
|
||||
const secondStepStart = agent.session.events.filter(e => e.type === 'step/start')[1]
|
||||
expect(secondStepStart).toBeDefined()
|
||||
@@ -348,30 +348,30 @@ describe('agent loop', () => {
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('second idle steer')
|
||||
})
|
||||
|
||||
it('stops after a throwing step observer and retains steering until a later wakeup', async () => {
|
||||
it('stops after a throwing pre-step listener and retains later steering until a wakeup', async () => {
|
||||
const adapter = new MockAdapter([textResponse('recovered')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' })
|
||||
let fail = true
|
||||
ctx.on('agent/step', (subject) => {
|
||||
if (subject !== agent || !fail) return
|
||||
ctx.on('agent/pre-step', (subject, _messages, _context, next) => {
|
||||
if (subject !== agent || !fail) return next()
|
||||
fail = false
|
||||
subject.steer(createUserMessage({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } }))
|
||||
throw new Error('step failed')
|
||||
throw new Error('pre-step failed')
|
||||
})
|
||||
|
||||
send(agent, 'prompt')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0)
|
||||
expect(agent.inbox.nextStep).toHaveLength(1)
|
||||
|
||||
send(agent, 'resume')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering')
|
||||
})
|
||||
@@ -603,7 +603,7 @@ describe('agent loop', () => {
|
||||
expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config.model).toBe('other-model')
|
||||
})
|
||||
|
||||
it('agent/step fires once per step before the step is opened', async () => {
|
||||
it('agent/pre-step fires once per proposed step before the step is opened', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', {}, 'calling echo'),
|
||||
textResponse('done'),
|
||||
@@ -616,8 +616,9 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const fires: { turn: number; step: number; signal: AbortSignal }[] = []
|
||||
ctx.on('agent/step', (subject, turn, step, signal) => {
|
||||
ctx.on('agent/pre-step', (subject, _messages, { turn, step, signal }, next) => {
|
||||
if (subject === agent) fires.push({ turn, step, signal })
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
@@ -630,33 +631,33 @@ describe('agent loop', () => {
|
||||
expect(fires.every(({ signal }) => signal instanceof AbortSignal)).toBe(true)
|
||||
})
|
||||
|
||||
it('agent/step fires after its step boundary opens and before the request', async () => {
|
||||
it('agent/pre-step fires before its step boundary opens and before the request', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let boundaryOpen = false
|
||||
ctx.on('agent/step', (subject) => {
|
||||
let boundaryOpen = true
|
||||
ctx.on('agent/pre-step', (subject, _messages, _context, next) => {
|
||||
if (subject === agent) boundaryOpen = subject.session.events.at(-1)?.type === 'step/start'
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(boundaryOpen).toBe(true)
|
||||
expect(boundaryOpen).toBe(false)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a throwing agent/step listener ends the turn (error), not the loop', async () => {
|
||||
// Before step/start, a pre-step throw reaches the turn catch: no step needs
|
||||
// closing, the turn records error, and the loop remains available.
|
||||
it('a throwing agent/pre-step listener fails the proposal, not the loop', async () => {
|
||||
const adapter = new MockAdapter([textResponse('second turn ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let throwOnce = true
|
||||
ctx.on('agent/step', () => {
|
||||
ctx.on('agent/pre-step', (_agent, _messages, _context, next) => {
|
||||
if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') }
|
||||
return next()
|
||||
})
|
||||
|
||||
const errors: Error[] = []
|
||||
@@ -666,14 +667,11 @@ describe('agent loop', () => {
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
// The first turn failed at step 1 before a model call.
|
||||
// The first proposal failed before opening a turn or calling the model.
|
||||
expect(errors.map(error => error.message)).toEqual(['boom in pre-step'])
|
||||
expect(adapter.requests.length).toBe(0)
|
||||
const firstTurnEnd = agent.session.events.find(e => e.type === 'turn/end')
|
||||
expect(firstTurnEnd?.type === 'turn/end' && firstTurnEnd.data.reason).toMatchObject({ kind: 'error' })
|
||||
// The step opened-and-closed count stays balanced even though it never ran.
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
|
||||
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(agent.session.events.some(event => event.type === 'turn/end')).toBe(false)
|
||||
|
||||
// The loop survived: a second prompt runs a normal completed turn.
|
||||
send(agent, 'second')
|
||||
|
||||
@@ -532,7 +532,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('an idle inject() survives persist + resume without a synthetic turn', async () => {
|
||||
it('a pending idle inject() survives persist + resume without a synthetic turn', async () => {
|
||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
|
||||
|
||||
@@ -1066,7 +1066,7 @@ describe('agent scope lifecycle', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(reentered).toBe(true)
|
||||
|
||||
// Idle again: the reentrant admission was already claimed and settled (its
|
||||
// Idle again: the reentrant batch was already claimed and settled (its
|
||||
// prompt was blocked by nothing, so it ran) — arm a SECOND reentry that
|
||||
// fires from the disposal cancel's idle transition itself.
|
||||
reentered = false
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
|
||||
README.md: b21ec0dc49604a91e4b8b34b0f47ccc01d897e88
|
||||
README.zh.md: 2cf6960310af0524d3e0f10c2cd3cf97fbf1869f
|
||||
README.md: 8942dcd976f9c7c5a8109ad3979fefe2a913cba5
|
||||
README.zh.md: 274a37556a9c2af140c57e5346da8abf7bc49bf3
|
||||
|
||||
@@ -50,9 +50,11 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
|
||||
|
||||
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
|
||||
|
||||
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
|
||||
Most interception points are cooperative waterfalls. `agent/pre-step` receives the exclusive claimed `UserMessage[]` plus a `PreStepContext` containing the proposed `turn`, `step`, and cancellation `signal`; its batch may be empty when tools already require another request. Other turn-scoped asynchronous seams receive their explicit `AbortSignal` positionally. Listeners may cooperate with a signal but must not retain it as authority over another turn. `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery. `agent/turn-stopping` runs before an otherwise completed turn closes. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
|
||||
|
||||
`PromptDecision.allow.messages` is the complete identified, frozen batch admitted by prompt interception. A listener that wraps a downstream allow preserves that batch unless it intentionally replaces it. A block must choose `discardClaimed`; this affects only the submitted batch, while messages not claimed by that admission remain pending.
|
||||
`PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages }`. The enter branch is the complete identified, frozen batch for the proposed step. A listener that wraps downstream entry preserves that batch unless it intentionally replaces it; additions follow the waterfall's natural return order. Claiming already removed the offered messages from the inbox, so rejection does not retain them. Messages inserted after the claim remain pending for a later boundary.
|
||||
|
||||
Inbox live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`. They complement the durable `agent/inbox/spliced` projection without adding another lifecycle envelope.
|
||||
|
||||
Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
|
||||
|
||||
@@ -60,10 +62,10 @@ Turn and step boundaries and the model token stream are durable `session/event`
|
||||
|
||||
The handle every plugin programs against:
|
||||
|
||||
- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values; `splice(target, start, deleteCount, inserted, outcome?)` uses standard splice coordinates to insert, edit, remove, admit, or cancel them. `MessageId` is the only occurrence identity and must remain unique while pending.
|
||||
- `agent.followup(message)` — queue an ordinary `next-turn` message and wake the driver. It returns no completion handle; the message id identifies inbox and admission facts, not a later output or `turn/end`.
|
||||
- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values. `append`, `prepend`, `update`, `remove`, and `splice` mutate them; ordinary removals are durable cancellations and emit `agent/inbox/discarded`. `claim(target)` atomically removes the next proposed batch with pure deletion splices; the loop then emits `agent/inbox/claimed`. `MessageId` is the only occurrence identity and must remain unique while pending.
|
||||
- `agent.followup(message)` — queue an ordinary `next-turn` message and wake the driver. It returns no completion handle; the message id identifies inbox insertion, claim, and discard facts, not a later output or `turn/end`.
|
||||
- `agent.steer(message)` — queue waking `next-step` input. An idle driver schedules a turn; collecting and running drivers consume it at their next step boundary.
|
||||
- `agent.inject(message)` — queue non-waking `next-step` context. During admission or an open turn it waits for the next safe log position; otherwise it appends immediately without opening a turn.
|
||||
- `agent.inject(message)` — queue non-waking `next-step` context. A collecting or running driver claims it at the nearest later pre-step boundary; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. It may miss a request whose pre-step already claimed its batch.
|
||||
- `agent.cancel(cause, options?)` — cancel the active driver and, unless `options.keepInbox`, durably cancel all pending inbox work. Idle cancellation is a no-op.
|
||||
- `agent.whenIdle()` — observe whole-agent quiescence, including replacement work scheduled before the current driver retires. It does not settle any particular message.
|
||||
- `agent.session`, `agent.status`, `agent.options`, `agent.id`, `agent.ctx`
|
||||
@@ -82,7 +84,7 @@ The handle every plugin programs against:
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/step`, and other declared events let plugins block a prompt or add durable request material; this interface contributes no fixed prose itself.
|
||||
`send`, `steer`, and `inject` feed the owning session. `agent/pre-step` and other declared events let plugins reject a proposed step or add durable request material; this interface contributes no fixed prose itself.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -50,9 +50,11 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
|
||||
|
||||
生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器完全停稳后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。
|
||||
|
||||
大多数拦截点都是协作式 waterfall(瀑布式事件)。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时提供服务的注册项重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
|
||||
大多数拦截点都是协作式 waterfall(瀑布式事件)。`agent/pre-step` 接收独占的已领取 `UserMessage[]`,以及包含拟进入 `turn`、`step` 与取消 `signal` 的 `PreStepContext`;当工具已经要求继续请求时,该批次可以为空。其他轮次作用域异步 seam 仍按位置接收显式 `AbortSignal`。监听器可以配合信号,但不得将它保留为控制另一轮次的权限。`agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时提供服务的注册项重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`。`agent/turn-stopping` 在本可完成的轮次关闭前运行。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
|
||||
|
||||
`PromptDecision.allow.messages` 是提示词拦截所准入的完整、带标识且冻结的批次。包装下游 allow 的监听器会保留该批次,除非有意替换它。block 必须指定 `discardClaimed`;该字段仅影响本次提交的批次,未被此次接纳认领的消息会继续保持待处理。
|
||||
`PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages }`。enter 分支是拟进入步骤的完整、带标识且冻结的批次。包装下游 enter 的监听器会保留该批次,除非有意替换它;新增消息遵循 waterfall 的自然返回顺序。领取操作已经把候选消息从 inbox 删除,因此 reject 不会保留它们;领取后插入的消息仍等待后续边界。
|
||||
|
||||
inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }`、`agent/inbox/claimed { message, turn }` 与 `agent/inbox/discarded { message }`。它们补充持久 `agent/inbox/spliced` 投影,但不引入另一层生命周期封套。
|
||||
|
||||
轮次和步骤边界以及模型 token 流是持久 `session/event` 事实,而不是镜像的 `agent/*` 通知。消费方从会话事件流读取 `turn/*`、`step/*` 和 `assistant/chunk`;工具策略与结果观测属于 [`dsh-tools`](../tools/README.md) 记录的完整流水线。
|
||||
|
||||
@@ -60,10 +62,10 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
|
||||
|
||||
每个插件面向的 handle:
|
||||
|
||||
- `agent.inbox`:agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn` 与 `nextStep` 暴露待处理的 `UserMessage` 值;`splice(target, start, deleteCount, inserted, outcome?)` 使用标准 splice 坐标插入、编辑、移除、准入或取消消息。`MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。
|
||||
- `agent.followup(message)`:将一条普通 `next-turn` 消息排队并唤醒驱动器。它不返回完成 handle;消息 id 标识 inbox 和准入事实,而不标识之后的输出或 `turn/end`。
|
||||
- `agent.inbox`:agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn` 与 `nextStep` 暴露待处理的 `UserMessage` 值。`append`、`prepend`、`update`、`remove` 与 `splice` 用于变更队列;普通删除是持久取消,并发出 `agent/inbox/discarded`。`claim(target)` 通过纯删除 splice 原子移除下一个候选批次,随后由循环发出 `agent/inbox/claimed`。`MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。
|
||||
- `agent.followup(message)`:将一条普通 `next-turn` 消息排队并唤醒驱动器。它不返回完成 handle;消息 id 标识 inbox 的插入、领取与丢弃事实,而不标识之后的输出或 `turn/end`。
|
||||
- `agent.steer(message)`:将会唤醒的 `next-step` 输入排队。空闲驱动器会调度一个轮次;collecting 和 running 驱动器会在各自的下一步骤边界消费该输入。
|
||||
- `agent.inject(message)`:将不会唤醒的 `next-step` 上下文排队。在准入期间或轮次打开时,它会等待下一个安全日志位置;否则立即追加,且不打开轮次。
|
||||
- `agent.inject(message)`:将不会唤醒的 `next-step` 上下文排队。collecting 或 running 驱动器会在最近的后续 pre-step 边界领取它;idle 驱动器则会让它保持待处理,直至 `followup()` 或 `steer()` 唤醒驱动器。若某次请求的 pre-step 已经领取完批次,它可能赶不上该请求。
|
||||
- `agent.cancel(cause, options?)`:取消活跃驱动器,并在未设置 `options.keepInbox` 时持久取消全部待处理 inbox 工作。空闲取消是空操作。
|
||||
- `agent.whenIdle()`:观察整个 agent 达到完全停稳,包括当前驱动器退役前调度的替代工作。它不结算任何特定消息。
|
||||
- `agent.session`、`agent.status`、`agent.options`、`agent.id`、`agent.ctx`
|
||||
@@ -82,7 +84,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
`send`、`steer` 与 `inject` 会向所属会话提供输入。`agent/prompt-submit`、`agent/step` 和其他已声明事件让插件能够阻止提示词或添加持久请求材料;此接口本身不贡献固定文案。
|
||||
`send`、`steer` 与 `inject` 会向所属会话提供输入。`agent/pre-step` 和其他已声明事件让插件能够拒绝拟进入的步骤或添加持久请求材料;此接口本身不贡献固定文案。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* @module @deepseek-ai/dsh-agent/inbox
|
||||
*/
|
||||
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** One of the two ordered pending-message lists owned by an agent. */
|
||||
@@ -12,11 +13,22 @@ export type InboxTarget = 'next-turn' | 'next-step'
|
||||
/** Mutable state privately owned by an {@link Inbox}. */
|
||||
type InboxState = Record<InboxTarget, UserMessage[]>
|
||||
|
||||
/** Live notifications committed by inbox mutations. */
|
||||
export interface InboxNotifications {
|
||||
/** Publish one inserted message. */
|
||||
inserted(message: UserMessage): void
|
||||
/** Publish one discarded message. */
|
||||
discarded(message: UserMessage): void
|
||||
}
|
||||
|
||||
/** A replay-once projection that incrementally consumes later inbox splices. */
|
||||
export class Inbox {
|
||||
private readonly state: InboxState = { 'next-turn': [], 'next-step': [] }
|
||||
|
||||
constructor(private readonly session: Session) {
|
||||
constructor(
|
||||
private readonly session: Session,
|
||||
private readonly notifications: InboxNotifications,
|
||||
) {
|
||||
for (const event of session.events.slice(session.header.seedLength ?? 0)) {
|
||||
if (event.type !== 'agent/inbox/spliced') continue
|
||||
try {
|
||||
@@ -32,7 +44,7 @@ export class Inbox {
|
||||
return this.state['next-turn']
|
||||
}
|
||||
|
||||
/** Input awaiting admission at a step boundary. */
|
||||
/** Input awaiting the next step boundary. */
|
||||
get nextStep(): readonly UserMessage[] {
|
||||
return this.state['next-step']
|
||||
}
|
||||
@@ -42,6 +54,68 @@ export class Inbox {
|
||||
return this.nextTurn.length > 0 || this.nextStep.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and return the complete batch proposed for one step. The durable
|
||||
* splices are pure deletions; the caller publishes claimed notifications.
|
||||
* @param target - whether this boundary also consumes one queued turn.
|
||||
* @returns next-step input followed by the queued turn, when requested.
|
||||
*/
|
||||
claim(target: InboxTarget): UserMessage[] {
|
||||
const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false)
|
||||
if (target === 'next-turn') {
|
||||
claimed.push(...this.mutate('next-turn', 0, 1, [], false))
|
||||
}
|
||||
return claimed
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one message to a pending list and durably record the insertion.
|
||||
* @param target - pending list to extend.
|
||||
* @param message - message to append.
|
||||
* @throws if the message identity is already pending.
|
||||
*/
|
||||
append(target: InboxTarget, message: UserMessage): void {
|
||||
this.splice(target, this.state[target].length, 0, [message])
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend one message to a pending list and durably record the insertion.
|
||||
* @param target - pending list to extend.
|
||||
* @param message - message to prepend.
|
||||
* @throws if the message identity is already pending.
|
||||
*/
|
||||
prepend(target: InboxTarget, message: UserMessage): void {
|
||||
this.splice(target, 0, 0, [message])
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace one pending message in place and durably record the mutation.
|
||||
* @param target - pending list containing the message.
|
||||
* @param messageId - identity of the message to replace.
|
||||
* @param newMessage - replacement message.
|
||||
* @returns whether the message was still pending.
|
||||
* @throws if the replacement duplicates another pending message identity.
|
||||
*/
|
||||
update(target: InboxTarget, messageId: MessageId, newMessage: UserMessage): boolean {
|
||||
const index = this.state[target].findIndex(message => message.id === messageId)
|
||||
if (index < 0) return false
|
||||
this.splice(target, index, 1, [newMessage])
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one pending message and durably record its cancellation.
|
||||
* @param target - pending list containing the message.
|
||||
* @param messageId - identity of the message to remove.
|
||||
* @returns whether the message was still pending.
|
||||
*/
|
||||
remove(target: InboxTarget, messageId: MessageId): boolean {
|
||||
const index = this.state[target].findIndex(message => message.id === messageId)
|
||||
if (index < 0) return false
|
||||
this.splice(target, index, 1, [])
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply standard splice semantics and durably record the normalized result.
|
||||
* The durable event commits before the live projection mutates, so synchronous
|
||||
@@ -51,7 +125,6 @@ export class Inbox {
|
||||
* @param start - splice position.
|
||||
* @param deleteCount - maximum number of messages to remove.
|
||||
* @param inserted - messages to insert at the resolved position.
|
||||
* @param outcome - terminal disposition of removed messages.
|
||||
* @returns messages removed by the splice.
|
||||
*/
|
||||
splice(
|
||||
@@ -59,7 +132,17 @@ export class Inbox {
|
||||
start: number,
|
||||
deleteCount: number,
|
||||
inserted: UserMessage[],
|
||||
outcome?: 'admitted' | 'canceled',
|
||||
): UserMessage[] {
|
||||
return this.mutate(target, start, deleteCount, inserted, true)
|
||||
}
|
||||
|
||||
/** Commit one normalized mutation and publish its live notifications. */
|
||||
private mutate(
|
||||
target: InboxTarget,
|
||||
start: number,
|
||||
deleteCount: number,
|
||||
inserted: UserMessage[],
|
||||
discardRemoved: boolean,
|
||||
): UserMessage[] {
|
||||
const inbox = this.state[target]
|
||||
const truncatedStart = Math.trunc(start)
|
||||
@@ -73,17 +156,22 @@ export class Inbox {
|
||||
inbox.length - actualStart,
|
||||
)
|
||||
if (actualDeleteCount === 0 && inserted.length === 0) return []
|
||||
const resolvedOutcome = outcome ?? (actualDeleteCount > 0 ? 'canceled' : undefined)
|
||||
const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' : undefined
|
||||
const splice = {
|
||||
target,
|
||||
start: actualStart,
|
||||
...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }),
|
||||
inserted,
|
||||
...(resolvedOutcome === undefined ? {} : { outcome: resolvedOutcome }),
|
||||
...(outcome === undefined ? {} : { outcome }),
|
||||
}
|
||||
this.validate(splice)
|
||||
const event = this.session.append('agent/inbox/spliced', splice)
|
||||
return inbox.splice(actualStart, actualDeleteCount, ...event.data.inserted)
|
||||
const removed = inbox.splice(actualStart, actualDeleteCount, ...event.data.inserted)
|
||||
if (discardRemoved) {
|
||||
for (const message of removed) this.notifications.discarded(message)
|
||||
}
|
||||
for (const message of event.data.inserted) this.notifications.inserted(message)
|
||||
return removed
|
||||
}
|
||||
|
||||
/** Apply one normalized durable splice to the projection. */
|
||||
|
||||
@@ -42,21 +42,26 @@ export interface CancelOptions {
|
||||
/**
|
||||
* An agent's lifecycle state, emitted on every transition as `agent/status`:
|
||||
* `idle` means no driver is scheduled or active; `running` begins when a
|
||||
* cancellable admission is scheduled and lasts while the driver drains,
|
||||
* cancellable pre-step processing is scheduled and lasts while the driver drains,
|
||||
* closes, or checkpoints turns. Disposal removes the agent from its registry;
|
||||
* it is not a third observable status.
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running'
|
||||
|
||||
/**
|
||||
* Prompt interception result. An allowed batch replaces the submitted
|
||||
* messages; a listener wrapping `next()` preserves that batch unless it
|
||||
* intentionally replaces it. A blocked batch explicitly chooses whether to
|
||||
* discard the claimed messages; unclaimed work remains pending.
|
||||
*/
|
||||
export type PromptDecision =
|
||||
| { kind: 'allow'; messages: UserMessage[] }
|
||||
| { kind: 'block'; reason: string; discardClaimed: boolean }
|
||||
/** Coordinates and cancellation for a proposed step. */
|
||||
export interface PreStepContext {
|
||||
/** Turn that will own the step. */
|
||||
readonly turn: number
|
||||
/** Step proposed by the loop. */
|
||||
readonly step: number
|
||||
/** Current turn cancellation signal. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
|
||||
/** Whether and with which messages the loop enters a proposed step. */
|
||||
export type PreStepDecision =
|
||||
| { kind: 'reject' }
|
||||
| { kind: 'enter'; messages: UserMessage[] }
|
||||
|
||||
/** One failed model-request attempt presented to recovery listeners. */
|
||||
export interface RequestFailureContext {
|
||||
@@ -135,11 +140,11 @@ export interface Agent {
|
||||
steer(message: UserMessage): void
|
||||
|
||||
/**
|
||||
* Append model-facing context without running the model. Admission or an
|
||||
* open turn stages it at the next safe log position; outside that window it
|
||||
* appends immediately without opening a turn. If admission closes without a
|
||||
* turn, a context-only boundary appends immediately; context staged beside
|
||||
* steering remains pending with it.
|
||||
* Queue model-facing context for the next pre-step without waking the
|
||||
* driver. Collecting and running drivers claim it at the nearest later
|
||||
* step boundary; idle drivers leave it pending until follow-up or steering
|
||||
* wakes them. It may miss a request whose pre-step already claimed its
|
||||
* batch. Cancellation or disposal may discard pending context.
|
||||
* @param message - identified injected context and its producer provenance.
|
||||
*/
|
||||
inject(message: UserMessage): void
|
||||
@@ -178,6 +183,30 @@ declare module 'cordis' {
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
|
||||
/**
|
||||
* One message entered the live inbox.
|
||||
* @param agent - the agent whose inbox changed.
|
||||
* @param event - the inserted message.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/inbox/inserted'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void
|
||||
/**
|
||||
* One message left the inbox for a turn.
|
||||
* @param agent - the agent whose inbox changed.
|
||||
* @param event - the claimed message and owning turn.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/inbox/claimed'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage; turn: number }): void
|
||||
/**
|
||||
* One message was discarded from the live inbox.
|
||||
* @param agent - the agent whose inbox changed.
|
||||
* @param event - the discarded message.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/inbox/discarded'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void
|
||||
// ---- session lifecycle (emit) ----
|
||||
/**
|
||||
* The session lifecycle began, once before the first turn. Use
|
||||
@@ -193,30 +222,15 @@ declare module 'cordis' {
|
||||
|
||||
// ---- the machine's extension seams ----
|
||||
/**
|
||||
* Allow, rewrite, or block one claimed inbox batch before it becomes
|
||||
* model-visible or opens a turn. Call `next()` for the unchanged default. The
|
||||
* signal controls only this admission attempt; listeners may cooperate with
|
||||
* it but must not retain it for a later attempt or turn.
|
||||
* @param agent - the agent whose driver claimed the batch.
|
||||
* @param messages - the claimed messages.
|
||||
* @param signal - the current turn's explicit abort signal.
|
||||
* Reject a proposed step or replace the messages that enter it. Calling
|
||||
* `next()` preserves the current messages.
|
||||
* @param agent - the agent proposing the step.
|
||||
* @param messages - messages removed from the inbox for this step.
|
||||
* @param context - proposed turn and step coordinates plus cancellation.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
/**
|
||||
* Awaited serial checkpoint before EVERY request of a turn is built (the
|
||||
* first as well as each post-tools continuation). The single "between
|
||||
* steps" extension point: inject context, steer, or edit the session log
|
||||
* here — the request's history derives from the log right after this settles.
|
||||
* @param agent - the agent about to send a request.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step number about to open.
|
||||
* @param signal - the turn abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode serial
|
||||
*/
|
||||
'agent/step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void
|
||||
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
|
||||
/**
|
||||
* Replace the frozen call configuration. `await next()` yields the config
|
||||
* the machine would use (agent options on the first request, the logged
|
||||
@@ -284,7 +298,7 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
start: number
|
||||
removedCount?: number
|
||||
inserted: UserMessage[]
|
||||
outcome?: 'admitted' | 'canceled'
|
||||
outcome?: 'canceled'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
||||
id,
|
||||
options: {},
|
||||
session,
|
||||
inbox: new Inbox(session),
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
|
||||
status: 'idle',
|
||||
ctx: new Context(),
|
||||
send: () => {},
|
||||
|
||||
@@ -11,12 +11,14 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
|
||||
'agent/created': args => args[0],
|
||||
'agent/disposed': args => args[0],
|
||||
'agent/error': args => args[0],
|
||||
'agent/prompt-submit': args => args[0],
|
||||
'agent/inbox/claimed': args => args[0],
|
||||
'agent/inbox/discarded': args => args[0],
|
||||
'agent/inbox/inserted': args => args[0],
|
||||
'agent/pre-step': args => args[0],
|
||||
'agent/request': args => args[0],
|
||||
'agent/request-error': args => args[0],
|
||||
'agent/session-start': args => args[0],
|
||||
'agent/status': args => args[0],
|
||||
'agent/step': args => args[0],
|
||||
'agent/turn-stopping': args => args[0],
|
||||
'approval/request': args => (args[0] as Record<string, unknown>)['agent'],
|
||||
'goal/changed': args => args[0],
|
||||
|
||||
@@ -48,9 +48,11 @@ describe('scoped-dispatch invariants', () => {
|
||||
'agent/created': [agent],
|
||||
'agent/disposed': [agent],
|
||||
'agent/status': [agent, 'idle'],
|
||||
'agent/inbox/inserted': [agent, { message }],
|
||||
'agent/inbox/claimed': [agent, { message, turn: 1 }],
|
||||
'agent/inbox/discarded': [agent, { message }],
|
||||
'agent/session-start': [agent, 'startup'],
|
||||
'agent/step': [agent, 1, 1, signal],
|
||||
'agent/prompt-submit': [agent, [message], signal, () => Promise.resolve({ kind: 'allow', messages: [message] })],
|
||||
'agent/pre-step': [agent, [message], { turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [message] })],
|
||||
'agent/request': [agent, 1, 1, signal, () => Promise.resolve(config)],
|
||||
'agent/request-error': [
|
||||
agent,
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/session/README.md
|
||||
README.md: d2eb35108b7eba9963e62a1c0d2a8868c7b7a821
|
||||
README.zh.md: 85507abf55e82485268055f99f759b75f8cc5f01
|
||||
README.md: 2928cd22d7ecafdca951c3b9aca1fb0bdc2567dd
|
||||
README.zh.md: 209219e98b7dab7f3f142ccb0069f5a7eb654124
|
||||
|
||||
@@ -65,7 +65,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/
|
||||
|
||||
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. Its optional `adapterDefaults` map marks effective `reasoningEffort` or `maxTokens` values materialized by exact-model resolution, allowing the next request proposal to distinguish them from explicit conversation settings. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
|
||||
|
||||
A `user/message` stores the complete `UserMessage` directly, including the identity created before routing or prompt admission. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an admitted goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message`, `tool/result`, and `steering/message` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`, while an idle injection may append and flush a `user/message` between turns without running the model.
|
||||
A `user/message` stores the complete `UserMessage` directly, including the identity created before inbox routing or step entry. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an entered goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message`, `tool/result`, and `steering/message` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`; `agent.inject()` queues input until a later pre-step claims it and returns it in an enter decision.
|
||||
|
||||
`tool/result` persists one identified user-role tool-result message, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message.
|
||||
|
||||
@@ -75,7 +75,7 @@ The append-only log's event types, enumerated member by member — payloads, sur
|
||||
|
||||
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. A plugin owns the relational invariant for its merged events, including whether a log-only event may appear between turns. A producer that requires durability appends through `Session` and then awaits `ctx.sessions.flush(session)` without fabricating an execution turn.
|
||||
|
||||
Also defines `TurnEndReasonMap`, the merge-extensible `kind`-tagged sum type for turn endings. `turn/start` carries only the turn number; the following admitted `user/message` batch records its input, while `llm/retry` records request recovery.
|
||||
Also defines `TurnEndReasonMap`, the merge-extensible `kind`-tagged sum type for turn endings. `turn/start` carries only the turn number; the following entered `user/message` batch records its input, while `llm/retry` records request recovery.
|
||||
|
||||
An interrupted live turn ends with `{ kind: 'aborted', reason: AgentCancelCause }`, preserving the typed cancellation cause in the durable transcript. A turn failure carries `{ kind: 'error', error }`; crash recovery alone synthesizes `{ kind: 'interrupted' }`.
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial`、`resume` 或 `change`。其可选 `adapterDefaults` 映射会标记由精确模型解析填入的生效 `reasoningEffort` 或 `maxTokens` 值,使下一次请求提议能够将它们与显式对话设置区分开。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。
|
||||
|
||||
`user/message` 会直接存储完整的 `UserMessage`,其中包括路由或提示词准入前创建的标识。无论它是直接人类提示词、合成注入,还是已准入的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message`、`tool/result` 和 steering(中途引导)对应的 `steering/message` 也会存储完整的消息值。轮次执行仍由 `turn/start` 与 `turn/end` 包围,而空闲注入可以在轮次之间追加并刷新一条 `user/message`,无需运行模型。
|
||||
`user/message` 会直接存储完整的 `UserMessage`,其中包括路由或 pre-step 领取前创建的标识。无论它是直接人类提示词、合成注入,还是进入步骤的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message`、`tool/result` 和 steering(中途引导)对应的 `steering/message` 也会存储完整的消息值。轮次执行仍由 `turn/start` 与 `turn/end` 包围;`agent.inject()` 会把输入排队,直到某次 pre-step 返回 enter 并在轮次内记录它。
|
||||
|
||||
`tool/result` 持久保存一条带标识、user-role 的工具结果消息,以及可选内部失败标识和可选呈现元数据。工具成功时的规范 `value` 和便于人类阅读的规范失败消息只存在于执行本地;渲染后的错误内容是回放权威消息。
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
|
||||
`SessionEventMap` 可通过合并扩展:插件使用声明合并添加自身类型(压缩 seam 的 `compact/*`、有界恢复的非 surface `llm/retry`、hook(钩子)桥接层的 `hook/*`);合并成员会出现在同一目录中。插件拥有其合并事件的关系不变量,包括是否允许纯日志事件出现在轮次之间。需要持久性的生产方通过 `Session` 追加,再等待 `ctx.sessions.flush(session)`,无需虚构一个执行轮次。
|
||||
|
||||
此包还定义 `TurnEndReasonMap`,即用于轮次结束、可合并扩展且以 `kind` 为标签的和类型。`turn/start` 只携带轮次编号;之后已准入的 `user/message` 批次记录其输入,`llm/retry` 则记录请求恢复。
|
||||
此包还定义 `TurnEndReasonMap`,即用于轮次结束、可合并扩展且以 `kind` 为标签的和类型。`turn/start` 只携带轮次编号;之后进入步骤的 `user/message` 批次记录其输入,`llm/retry` 则记录请求恢复。
|
||||
|
||||
被中断的实时轮次以 `{ kind: 'aborted', reason: AgentCancelCause }` 结束,在持久 transcript(文本记录)中保留类型化取消原因。轮次失败携带 `{ kind: 'error', error }`;只有崩溃恢复会合成 `{ kind: 'interrupted' }`。
|
||||
|
||||
|
||||
@@ -167,9 +167,9 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change'
|
||||
*/
|
||||
export interface SessionEventMap {
|
||||
/**
|
||||
* Opens turn `turn`. Every turn begins when the loop admits queued input;
|
||||
* the following identified `user/message` event or batch records the
|
||||
* admitted input.
|
||||
* Opens turn `turn`. Every turn begins after the loop claims queued input
|
||||
* and accepts the pre-step result; the following identified `user/message`
|
||||
* event or batch records the messages entering the step.
|
||||
*/
|
||||
'turn/start': { turn: number }
|
||||
/**
|
||||
@@ -187,9 +187,8 @@ export interface SessionEventMap {
|
||||
* A user-role message on the model-visible surface: a direct human prompt
|
||||
* (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` tells them apart. An idle
|
||||
* injection may append this event between turns without running the model.
|
||||
* notifications, …), or an entered goal continuation round. All three
|
||||
* project their `content` verbatim; `source` tells them apart.
|
||||
*/
|
||||
'user/message': UserMessage
|
||||
/** Raw stream chunk — token-level replay fidelity. */
|
||||
|
||||
Reference in New Issue
Block a user