fix inbox lifecycle downstream contracts
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/goal/command-goal/README.md
|
||||
README.md: 8e1a5b417467c8701ea935e25acfece11c5a70d4
|
||||
README.zh.md: 66f237ac7ea8ef5d158917998b0dfd49189289f1
|
||||
README.md: 47f81a5ae303d3587c0af1a26407f0f1f0ba0d88
|
||||
README.zh.md: f5d22fa4889da8b7a1e2ac73e78ebe76714b8d42
|
||||
|
||||
@@ -17,7 +17,7 @@ Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin r
|
||||
|
||||
Control words are case-insensitive only when they occupy the complete input. Every other non-empty suffix is an objective, so `/goal pause after verification` creates that literal objective. The goal domain trims and validates objectives. Because the generic command plane has no modal editor or confirmation primitive, `edit` takes its replacement inline and an unfinished replacement returns a direct error instructing the user to edit or clear.
|
||||
|
||||
Expected domain rejections become stable direct command errors without exposing branded ids or revisions. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; every accepted mutation is persisted and made model-visible by `dsh-goal` rather than by this plugin.
|
||||
Expected domain rejections become stable direct command errors without exposing branded ids or revisions. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; `dsh-goal` persists every accepted mutation through a durable inbox insertion and independently queues its model-facing context.
|
||||
|
||||
## Composition
|
||||
|
||||
@@ -40,15 +40,15 @@ The TUI app enables the complete persisted-goal stack and this command by defaul
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The slash input and direct status/error output are absent from model requests. An accepted mutation later appears through the goal domain's raw `<goal_state>` snapshot or clear tombstone; this preserves the model-visible-is-logged invariant without logging presentation text.
|
||||
The slash input and direct status/error output are absent from model requests. An accepted mutation queues the goal domain's raw `<goal_state>` snapshot or clear tombstone; the model sees it only if a later pre-step admits that context. The mutation remains durable if the queued message is discarded, and presentation text is never logged.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Reading status or receiving a direct command error adds no model tokens. Each accepted mutation adds the goal domain's retained full snapshot, and an enabled same-session driver may add later goal-round prompts.
|
||||
Reading status or receiving a direct command error adds no model tokens. An admitted mutation context adds the goal domain's retained full snapshot, while one discarded before admission adds none; an enabled same-session driver may add later goal-round prompts.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Command discovery and direct output do not affect the cache. A mutation appends after the reusable history prefix; later compaction may replace the derived-history suffix.
|
||||
Command discovery and direct output do not affect the cache. An admitted mutation context appends after the reusable history prefix; later compaction may replace the derived-history suffix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
只有控制词占据完整输入时才不区分大小写。其他任何非空后缀都属于目标,因此 `/goal pause after verification` 会创建该字面目标。goal 领域会去除目标首尾空白并进行验证。由于通用命令平面没有模态编辑器或确认原语,`edit` 会内联接收替换内容;若试图替换未完成的 goal,则直接返回错误,提示用户执行 edit 或 clear。
|
||||
|
||||
可预期的领域拒绝会变成稳定的直接命令错误,不公开带品牌类型的 id 或 revision。意外实现失败仍会 reject 分发,使适配器能将其报告为命令失败。通用命令文本和输出仍属于实时 UI 状态;每项已接受变更都由 `dsh-goal` 持久化并提供给模型,而不是由此插件完成。
|
||||
可预期的领域拒绝会变成稳定的直接命令错误,不公开带品牌类型的 id 或 revision。意外实现失败仍会 reject 分发,使适配器能将其报告为命令失败。通用命令文本和输出仍属于实时 UI 状态;`dsh-goal` 通过持久 inbox 插入项持久化每项已接受变更,并单独将其面向模型的上下文排队。
|
||||
|
||||
## 组合
|
||||
|
||||
@@ -40,15 +40,15 @@ TUI 应用默认启用完整的持久 goal 栈和此命令。ACP(Agent Client
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
斜杠输入与直接状态/错误输出不会进入模型请求。已接受的变更稍后会通过 goal 领域的原始 `<goal_state>` 快照或 clear tombstone 出现;这样既满足模型可见内容必须记录日志的不变量,也无需记录呈现文本。
|
||||
斜杠输入与直接状态/错误输出不会进入模型请求。已接受的变更会将 goal 领域的原始 `<goal_state>` 快照或 clear tombstone 排队;只有后续 pre-step 准入该上下文时,模型才会看到它。如果已排队的消息被丢弃,变更仍然持久;呈现文本绝不会记录到日志中。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
读取状态或收到直接命令错误不会增加模型 token。每项已接受变更都会增加 goal 领域保留的完整快照;已启用的同会话驱动器还可能增加后续 Goal Round 提示词。
|
||||
读取状态或收到直接命令错误不会增加模型 token。获准的变更上下文会增加 goal 领域保留的完整快照,准入前被丢弃的上下文则不会增加;已启用的同会话驱动器还可能增加后续 Goal Round 提示词。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
命令发现与直接输出不会影响缓存。变更会追加到可复用历史前缀之后;后续压缩可能替换派生历史后缀。
|
||||
命令发现与直接输出不会影响缓存。获准的变更上下文会追加到可复用历史前缀之后;后续压缩可能替换派生历史后缀。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import GoalService from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef } from '@deepseek-ai/dsh-goal'
|
||||
import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
|
||||
|
||||
interface Harness {
|
||||
@@ -16,31 +16,23 @@ interface Harness {
|
||||
readonly plugin: Awaited<ReturnType<Context['plugin']>>
|
||||
}
|
||||
|
||||
/** Commit one injected message as an already admitted turn for the Agent test double. */
|
||||
function appendInjection(session: Session, input: UserMessage): void {
|
||||
const lastStart = session.events.findLast(event => event.type === 'turn/start')
|
||||
const turn = (lastStart?.data.turn ?? 0) + 1
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
/** Build a live idle agent accepted by the exact-identity goal service. */
|
||||
function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
|
||||
// Store-created: the command executor durably logs lifecycle events on it.
|
||||
const session = ctx.sessions.create(SessionId(id))
|
||||
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
|
||||
let status: AgentStatus = 'idle'
|
||||
const agent: Agent = {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
|
||||
inbox,
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input) { appendInjection(session, input) },
|
||||
inject(input) { inbox.append('next-step', input) },
|
||||
cancel() { status = 'idle' },
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
@@ -131,7 +123,7 @@ describe('/goal human command', () => {
|
||||
expect(created.text).toContain('Rounds: 0/256')
|
||||
expect(created.text).toContain('Activation: armed')
|
||||
expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release')
|
||||
expect(domainEvents(test.session).map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
|
||||
expect(domainEvents(test.session).map(event => event.type)).toEqual(['agent/inbox/spliced'])
|
||||
|
||||
const count = domainEvents(test.session).length
|
||||
await expect(run(test, ' replacement')).resolves.toEqual({
|
||||
|
||||
@@ -125,7 +125,8 @@ export function apply(ctx: Context): void {
|
||||
|
||||
/** Preserve claimed step context when this driver drops only its own round. */
|
||||
function restoreOtherClaimed(agent: Agent, messages: UserMessage[], messageId: MessageId): void {
|
||||
const retained = messages.filter(message => message.id !== messageId)
|
||||
const retained = messages.filter(message => message.id !== messageId
|
||||
&& !(message.source.kind === 'goal' && message.source.round === 0))
|
||||
for (const message of retained.toReversed()) {
|
||||
if (agent.inbox.nextStep.some(candidate => candidate.id === message.id)
|
||||
|| agent.inbox.nextTurn.some(candidate => candidate.id === message.id)) continue
|
||||
|
||||
@@ -101,15 +101,14 @@ async function harness(script: ScriptEntry[]): Promise<Harness> {
|
||||
return { ctx, adapter, agent, driver }
|
||||
}
|
||||
|
||||
/** Observe inserted inbox messages after the session append boundary closes. */
|
||||
/** Observe inserted inbox messages after the insertion call completes. */
|
||||
function onInboxMessage(
|
||||
ctx: Context,
|
||||
agent: Agent,
|
||||
listener: (message: UserMessage) => void,
|
||||
): () => void {
|
||||
return ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session || event.type !== 'agent/inbox/spliced') return
|
||||
for (const message of event.data.inserted) queueMicrotask(() => { listener(message) })
|
||||
return ctx.on('agent/inbox/inserted', (subject, { message }) => {
|
||||
if (subject === agent) queueMicrotask(() => { listener(message) })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -388,6 +387,56 @@ describe('same-session goal driving', () => {
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('restores non-goal step context when a claimed reservation becomes stale', async () => {
|
||||
const test = await harness([textResponse('side contexts'), textResponse('revised goal')])
|
||||
const claimedContext = createUserMessage({
|
||||
content: [{ type: 'text', text: 'claimed context to restore' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
const queuedStepContext = createUserMessage({
|
||||
content: [{ type: 'text', text: 'context already queued for the next step' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
const queuedTurnContext = createUserMessage({
|
||||
content: [{ type: 'text', text: 'context already queued for the next turn' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
let staged = false
|
||||
const stopInserted = onInboxMessage(test.ctx, test.agent, (message) => {
|
||||
if (message.source.kind !== 'goal' || message.source.round <= 0 || staged) return
|
||||
staged = true
|
||||
test.agent.inbox.prepend('next-step', claimedContext)
|
||||
})
|
||||
let edited = false
|
||||
test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => {
|
||||
const decision = await next()
|
||||
if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0) || edited) return decision
|
||||
edited = true
|
||||
agent.inbox.prepend('next-step', queuedStepContext)
|
||||
agent.inbox.append('next-turn', queuedTurnContext)
|
||||
const goal = test.ctx.goals.get(agent)
|
||||
if (goal === undefined) throw new Error('missing claimed goal')
|
||||
test.ctx.goals.edit(agent, goal, { objective: 'revised after claim' })
|
||||
return decision.kind === 'reject' ? decision : {
|
||||
kind: 'enter' as const,
|
||||
messages: [...decision.messages, queuedStepContext, queuedTurnContext],
|
||||
}
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'stale before admission', maxGoalRounds: 1 })
|
||||
|
||||
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
|
||||
stopInserted()
|
||||
|
||||
expect(goal).toMatchObject({ objective: 'revised after claim', roundsStarted: 1 })
|
||||
expect(test.adapter.requests).toHaveLength(2)
|
||||
expect(requestText(test.adapter.requests[0]!)).toContain('claimed context to restore')
|
||||
expect(requestText(test.adapter.requests[0]!)).toContain('context already queued for the next step')
|
||||
expect(requestText(test.adapter.requests[0]!)).toContain('context already queued for the next turn')
|
||||
expect(requestText(test.adapter.requests[0]!)).not.toContain('<goal_round>')
|
||||
expect(requestText(test.adapter.requests[1]!)).toContain('revised after claim')
|
||||
expect(requestText(test.adapter.requests[1]!)).not.toContain('stale before admission')
|
||||
})
|
||||
|
||||
it('disarms without dispatch when a durability checkpoint fails', async () => {
|
||||
const test = await harness([])
|
||||
test.ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable')))
|
||||
@@ -704,7 +753,7 @@ describe('same-session goal driving', () => {
|
||||
it('falls back to disarming when a cancelled reservation cannot be paused', async () => {
|
||||
const test = await harness([])
|
||||
const cancel = onInboxMessage(test.ctx, test.agent, (message) => {
|
||||
if (message.source.kind !== 'goal') return
|
||||
if (message.source.kind !== 'goal' || message.source.round <= 0) return
|
||||
cancel()
|
||||
vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => {
|
||||
throw new Error('pause failed')
|
||||
|
||||
@@ -41,11 +41,15 @@ function view(roundsStarted: number): GoalView {
|
||||
}
|
||||
|
||||
function appendChange(session: Session): void {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
const message = createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
}), { surfaceOp: 'append' })
|
||||
})
|
||||
session.append('agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, inserted: [message],
|
||||
})
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', message, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
|
||||
@@ -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/goal/goal/README.md
|
||||
README.md: cba28b2de6cb59932f25ad57a32bc25e0d5e287a
|
||||
README.zh.md: aebb3a1e773038888a109dc040d3389988231953
|
||||
README.md: f72efe2306f11dfa5f30ac927bb1b900f691a7be
|
||||
README.zh.md: 4ca86a6c1aea228e069fbf25b22ec248297fae03
|
||||
|
||||
@@ -21,13 +21,13 @@ Event-sourced same-session goal state. The service retains one current completio
|
||||
|
||||
At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation.
|
||||
|
||||
Every mutation queues a complete versioned snapshot through `agent.inject()`; clear uses a revisioned tombstone. A later entering pre-step records it as a model-visible `user/message`, whose content and typed `{ kind: 'goal', change }` source must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
|
||||
Every mutation passes a complete versioned snapshot through `agent.inject()`; clear uses a revisioned tombstone. The mutation commits when injection records the message in the durable `agent/inbox/spliced` insertion, even if that context remains queued and never reaches the model. Removing or discarding the queued message does not roll back the mutation. If the same message is later admitted as a model-visible `user/message`, replay verifies that its id, content, and typed `{ kind: 'goal', change }` source agree with the insertion without applying the mutation again.
|
||||
|
||||
Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained.
|
||||
Strict replay derives mutations only from inbox insertions and rejects malformed shapes, reused message ids with different changes, source/content drift on admission, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential admitted goal rounds. Positive rounds advance only on admitted `user/message` events. Mutation timestamps clamp against the preceding goal update when wall time moves backward. Reentrant insertion observers see each accepted mutation exactly once, incremental replay retains its cursor at the first corrupt event, and `goal/changed` fires after injection succeeds with listener failures contained.
|
||||
|
||||
Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. A continuation driver also calls `disarm()` before unload or after durability uncertainty. Session resume, fork, and driver replacement therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation.
|
||||
|
||||
The separately published `./invariant` companion maintains an independent fold of each attached session. It rejects malformed goal source changes, model-visible content drift, discontinuous revisions, illegal lifecycle transitions, timestamp regressions, and non-sequential admitted rounds before the candidate event enters the durable log.
|
||||
The separately published `./invariant` companion maintains an independent fold of each attached session. It rejects malformed goal source changes, duplicate-id drift between insertion and admission, model-visible content drift, discontinuous revisions, illegal lifecycle transitions, timestamp regressions, and non-sequential admitted rounds before the candidate event enters the durable log.
|
||||
|
||||
## Extension points
|
||||
|
||||
@@ -39,15 +39,15 @@ Policy plugins call the service verbs and react to the scoped `goal/changed` eve
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each mutation is one raw user-role context block. A snapshot is rendered as `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. The descriptive XML delimiter follows this repository's existing `<workspace_context>` convention and [Anthropic's published XML-tag prompting guidance](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags); it is public model-experience prior art, not a claim about any provider's proprietary training corpus.
|
||||
Each mutation queues one raw user-role context block. If admitted, a snapshot is rendered as `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`; a clear renders the tombstone id/revision and `clearedAt`. The mutation remains durable if the queued context is discarded before admission, and there is no hidden state summary outside the session log. The descriptive XML delimiter follows this repository's existing `<workspace_context>` convention and [Anthropic's published XML-tag prompting guidance](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags); it is public model-experience prior art, not a claim about any provider's proprietary training corpus.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Every retained mutation adds one full snapshot to derived history until compaction shadows it. Full snapshots make each record independently inspectable but repeat the objective and lifecycle fields.
|
||||
An admitted mutation adds one full snapshot to derived history until compaction shadows it; an insertion discarded before admission costs no model tokens. Full snapshots make each admitted record independently inspectable but repeat the objective and lifecycle fields.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only within an epoch: each mutation follows the reusable request prefix and preceding history. Compaction may replace the derived-history suffix and move the reusable boundary.
|
||||
Append-only within an epoch after admission: each visible mutation follows the reusable request prefix and preceding history. Compaction may replace the derived-history suffix and move the reusable boundary.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
|
||||
最多只有一个当前目标。创建操作会生成 revision 为 1、phase 为 active 的目标并启用续行。未完成的目标必须编辑、转换或清除;已完成目标可以由拥有全局未使用过的 id 的目标替换。编辑会保留 phase、blocker reason 与 activation。暂停、完成、阻塞和清除都会停用续行。阻塞会记录策略自有的 lower-kebab-case 代码和规范化的自由文本说明;提供方限制、配置预算、执行错误与请求人工输入都使用这一种持久 phase,不会扩增生命周期状态。只有配置的 Round 上限仍有剩余容量时,resume 才接受已停止 phase 或 phase 为 active 但已停用续行的目标;它会清除原 blocker reason。phase 为 active 且已启用续行的目标会拒绝冗余操作。
|
||||
|
||||
每次变更都会通过 `agent.inject()` 把完整的版本化快照排队;clear 使用带 revision 的 tombstone。后续返回 enter 的 pre-step 会把它记录为模型可见的 `user/message`,其内容与带类型的 `{ kind: 'goal', change }` 来源必须完全一致。回放会拒绝形状错误、来源/内容漂移、不连续 revision、非法生命周期转换、每目标时间戳非单调,以及不连续的 Goal Round。挂钟时间倒退时,变更时间戳会限制在不早于上一次目标更新的值。
|
||||
每次变更都会通过 `agent.inject()` 传递完整的版本化快照;clear 使用带 revision 的 tombstone。注入将消息记录到持久 `agent/inbox/spliced` 插入项时,变更即已提交,即使该上下文仍在队列中且从未抵达模型也是如此。移除或丢弃已排队的消息不会回滚变更。如果同一消息随后获准成为模型可见的 `user/message`,回放会验证其 id、内容和带类型的 `{ kind: 'goal', change }` 来源与插入项一致,而不会再次应用变更。
|
||||
|
||||
注入可以立即追加,也可能在活跃工具批次 FIFO 中等待。服务会在内存中叠加已接受的待处理变更,并在每个完全一致的载荷进入日志时逐一完成对账,因此连续的模型工具变更可以看到自身最新 revision,而不会把尚未记录的缓存当作持久状态。可重入追加观察者会且只会看到每项已接受变更一次;增量回放会把游标保留在第一个损坏事件处。追加或入队成功后才触发 `goal/changed`;监听器失败会被隔离处理。
|
||||
严格回放只从 inbox 插入项派生变更,并拒绝形状错误、以不同变更复用消息 id、准入时的来源/内容漂移、不连续 revision、非法生命周期转换、每目标时间戳非单调,以及不连续的已准入 Goal Round。只有获准的 `user/message` 事件会推进正数 Round。挂钟时间倒退时,变更时间戳会限制在不早于上一次目标更新的值。可重入插入观察者会且只会看到每项已接受变更一次;增量回放会把游标保留在第一个损坏事件处;`goal/changed` 会在注入成功后触发,监听器失败会被隔离处理。
|
||||
|
||||
续行启用状态绝不持久化。新缓存与每次触发 `agent/session-start` 时都会停用续行,即使回放找到了持久 phase 为 active 的目标。续行驱动器在卸载前或持久性不确定后也会调用 `disarm()`。因此,会话恢复、fork 与驱动器替换会保留目标、phase、revision 和已准入 Round 数量,却不会启动工作;之后必须通过显式 resume 变更重新启用续行。
|
||||
|
||||
单独发布的 `./invariant` 配套模块会为每个已挂接会话维护独立折叠。它会在候选事件进入持久日志前拒绝格式错误的 goal 来源变更、模型可见内容漂移、不连续 revision、非法生命周期转换、时间戳回退,以及不连续的已准入 round。
|
||||
单独发布的 `./invariant` 配套模块会为每个已挂接会话维护独立折叠。它会在候选事件进入持久日志前拒绝格式错误的 goal 来源变更、相同 id 在插入与准入之间的变更漂移、模型可见内容漂移、不连续 revision、非法生命周期转换、时间戳回退,以及不连续的已准入 Round。
|
||||
|
||||
## 扩展点
|
||||
|
||||
@@ -39,15 +39,15 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
每项变更都是一个原始用户角色上下文块。快照渲染为 `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`;clear 会渲染 tombstone id/revision 与 `clearedAt`。日志外不存在隐藏状态摘要。这种描述性 XML 分隔符遵循仓库已有的 `<workspace_context>` 约定和 [Anthropic 发布的 XML 标签提示词指南](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags);它是公开的模型体验先例,并非关于任何提供方专有训练语料的声明。
|
||||
每项变更都会将一个原始用户角色上下文块排队。获准后,快照渲染为 `<goal_state>{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}</goal_state>`;clear 会渲染 tombstone id/revision 与 `clearedAt`。如果排队的上下文在准入前被丢弃,变更仍然持久;会话日志外不存在隐藏状态摘要。这种描述性 XML 分隔符遵循仓库已有的 `<workspace_context>` 约定和 [Anthropic 发布的 XML 标签提示词指南](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags);它是公开的模型体验先例,并非关于任何提供方专有训练语料的声明。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每项保留的变更都会向派生历史增加一份完整快照,直到压缩(compaction)将其遮蔽。完整快照让每条记录都能独立检查,但会重复目标和生命周期字段。
|
||||
获准的变更会向派生历史增加一份完整快照,直到压缩(compaction)将其遮蔽;准入前被丢弃的插入项不消耗模型 token。完整快照让每条获准记录都能独立检查,但会重复目标和生命周期字段。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
在一个 epoch 内仅追加:每项变更都位于可复用请求前缀和既有历史之后。压缩可能替换派生历史后缀,并移动可复用边界。
|
||||
准入后在一个 epoch 内仅追加:每项可见变更都位于可复用请求前缀和既有历史之后。压缩可能替换派生历史后缀,并移动可复用边界。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export type GoalOperation =
|
||||
| 'block'
|
||||
| 'clear'
|
||||
|
||||
/** Full-snapshot goal mutation retained in a model-visible context event. */
|
||||
/** Full-snapshot goal mutation committed by an injected inbox message. */
|
||||
export interface GoalSnapshotChangeMeta {
|
||||
readonly kind: 'goal/change'
|
||||
readonly version: 1
|
||||
@@ -101,7 +101,7 @@ export interface EditGoalRequest {
|
||||
readonly maxGoalRounds?: number
|
||||
}
|
||||
|
||||
/** Live notification after one goal mutation has been accepted for logging. */
|
||||
/** Live notification after one goal mutation commits through inbox insertion. */
|
||||
export interface GoalChanged {
|
||||
readonly operation: GoalOperation
|
||||
readonly ref: GoalRef
|
||||
@@ -124,9 +124,9 @@ export type GoalErrorCode =
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Goal mutation accepted by one live agent. The matching context event is
|
||||
* already appended or queued in that agent's active tool-batch FIFO.
|
||||
* Listener failures are contained.
|
||||
* Goal mutation accepted by one live agent. The matching message has
|
||||
* already committed through a durable inbox insertion; later admission or
|
||||
* discard does not change that fact. Listener failures are contained.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @param agent - agent whose session owns the goal.
|
||||
* @param change - fresh current projection or clear tombstone.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { renderGoalChange } from './render.ts'
|
||||
import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts'
|
||||
import type { GoalBlockReason, GoalPhase, GoalRef, GoalSnapshot } from './types.ts'
|
||||
@@ -14,8 +15,6 @@ import type {
|
||||
GoalSnapshotChangeMeta,
|
||||
} from './domain.ts'
|
||||
|
||||
type UserMessageEvent = Extract<SessionEvent, { type: 'user/message' }>
|
||||
|
||||
const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Set([
|
||||
'create',
|
||||
'edit',
|
||||
@@ -34,6 +33,7 @@ export interface GoalFoldState {
|
||||
updatedAt: number | undefined
|
||||
lastRef: GoalRef | undefined
|
||||
seenGoalIds: Set<GoalSnapshot['id']>
|
||||
insertedChangeMessages: Map<UserMessage['id'], GoalChangeMeta>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,6 +48,7 @@ export function emptyGoalFoldState(): GoalFoldState {
|
||||
updatedAt: undefined,
|
||||
lastRef: undefined,
|
||||
seenGoalIds: new Set(),
|
||||
insertedChangeMessages: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,55 +308,75 @@ export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): v
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode and verify one model-visible goal state change without folding it. A
|
||||
* goal state change is a round-zero goal-sourced `user/message` carrying the
|
||||
* complete change in its source; any other user message returns `undefined`.
|
||||
* A mismatched attribution, source change, or rendered body
|
||||
* fails replay loudly.
|
||||
* @param event - user message whose source and rendered content must agree.
|
||||
* Decode and verify one goal state message without folding it. A goal state
|
||||
* message has a round-zero goal source carrying the complete change; any other
|
||||
* message returns `undefined`. Attribution and rendered-body drift fail loudly.
|
||||
* @param message - inserted or admitted message to decode.
|
||||
* @param location - event location included in replay failures.
|
||||
* @returns validated change, or `undefined` when the message is not a goal state change.
|
||||
*/
|
||||
export function decodeGoalEvent(event: UserMessageEvent): GoalChangeMeta | undefined {
|
||||
const source = goalSource(event.data.source)
|
||||
function decodeGoalMessage(message: UserMessage, location: string): GoalChangeMeta | undefined {
|
||||
const source = goalSource(message.source)
|
||||
if (source === undefined) {
|
||||
const [block] = event.data.content
|
||||
const [block] = message.content
|
||||
if (block?.type === 'text' && block.text.startsWith('<goal_state>')) {
|
||||
throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`)
|
||||
throw new Error(`goal change at ${location} has mismatched source attribution`)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (source.round !== 0) return undefined
|
||||
const change = decodeGoalChange(source.change)
|
||||
if (change === undefined) throw new Error(`goal change at session event ${event.seq} lacks source change data`)
|
||||
if (change === undefined) throw new Error(`goal change at ${location} lacks source change data`)
|
||||
const ref = goalChangeRef(change)
|
||||
if (source.goalId !== ref.id || source.revision !== ref.revision) {
|
||||
throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`)
|
||||
throw new Error(`goal change at ${location} has mismatched source attribution`)
|
||||
}
|
||||
if (JSON.stringify(event.data.content) !== JSON.stringify(renderGoalChange(change))) {
|
||||
throw new Error(`goal change at session event ${event.seq} has mismatched model-visible content`)
|
||||
if (JSON.stringify(message.content) !== JSON.stringify(renderGoalChange(change))) {
|
||||
throw new Error(`goal change at ${location} has mismatched model-visible content`)
|
||||
}
|
||||
return change
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one session event and return its goal change, when present.
|
||||
* Apply one session event to the strict durable goal fold.
|
||||
* @param state - mutable fold accumulator.
|
||||
* @param event - next event in sequence order.
|
||||
* @returns decoded change for pending-overlay reconciliation.
|
||||
*/
|
||||
export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalChangeMeta | undefined {
|
||||
if (event.type === 'user/message') {
|
||||
// A goal state change carries a complete source change (round zero).
|
||||
const change = decodeGoalEvent(event)
|
||||
if (change !== undefined) {
|
||||
export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): void {
|
||||
if (event.type === 'agent/inbox/spliced') {
|
||||
for (const message of event.data.inserted) {
|
||||
const location = `session event ${event.seq}`
|
||||
const change = decodeGoalMessage(message, location)
|
||||
if (change === undefined) continue
|
||||
const inserted = state.insertedChangeMessages.get(message.id)
|
||||
if (inserted !== undefined) {
|
||||
if (JSON.stringify(inserted) !== JSON.stringify(change)) {
|
||||
throw new Error(`goal change at ${location} reuses a message id with different change data`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
applyGoalChange(state, change)
|
||||
return change
|
||||
state.insertedChangeMessages.set(message.id, change)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'user/message') {
|
||||
const inserted = state.insertedChangeMessages.get(event.data.id)
|
||||
const change = decodeGoalMessage(event.data, `session event ${event.seq}`)
|
||||
if (inserted !== undefined && change !== undefined) {
|
||||
if (JSON.stringify(inserted) !== JSON.stringify(change)) {
|
||||
throw new Error(`goal change at session event ${event.seq} differs from its inbox insertion`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (change !== undefined) {
|
||||
throw new Error(`goal change at session event ${event.seq} was not committed by an inbox insertion`)
|
||||
}
|
||||
const source = goalSource(event.data.source)
|
||||
if (source === undefined) return undefined
|
||||
if (source === undefined) return
|
||||
// A goal-sourced message without a change must be a positive-round
|
||||
// admitted continuation prompt; round zero owes a durable source change.
|
||||
/* v8 ignore next 3 -- decodeGoalEvent returns the change or fails loud for every
|
||||
/* v8 ignore next 3 -- decodeGoalMessage returns the change or fails loud for every
|
||||
round-zero goal source, so only positive rounds reach here; the guard keeps
|
||||
replay fail-loud against a decoder change */
|
||||
if (source.round === 0) {
|
||||
@@ -369,7 +390,6 @@ export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalC
|
||||
}
|
||||
state.roundsStarted = source.round
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,13 +12,11 @@ import type { ZodType } from 'zod'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
// Type-only: resolves ctx.sessionProjections for the optional unit child.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
import {
|
||||
applyGoalChange,
|
||||
applyGoalEvent,
|
||||
decodeGoalEvent,
|
||||
emptyGoalFoldState,
|
||||
goalChangeRef,
|
||||
} from './fold.ts'
|
||||
@@ -82,6 +80,12 @@ const goalProjectionSchema: ZodType<GoalProjection | null> = zod.union([
|
||||
zod.null(),
|
||||
]) as ZodType<GoalProjection | null>
|
||||
|
||||
/** Plain-JSON projection accumulator retaining duplicate-change identity. */
|
||||
type GoalProjectionState = readonly [
|
||||
value: GoalProjection | null,
|
||||
insertedChangeMessageIds: readonly string[],
|
||||
]
|
||||
|
||||
/**
|
||||
* Light last-wins fold of the `goal` projection unit. Unlike the strict
|
||||
* replay fold (fold.ts: transition validation, fail-loud on malformed
|
||||
@@ -95,23 +99,31 @@ const goalProjectionSchema: ZodType<GoalProjection | null> = zod.union([
|
||||
* @param event - the next committed session event.
|
||||
* @returns the next projection (same reference when the event is not a goal change).
|
||||
*/
|
||||
export function applyGoalProjection(state: GoalProjection | null, event: SessionEvent): GoalProjection | null {
|
||||
if (event.type !== 'user/message') return state
|
||||
const source = event.data.source
|
||||
if (source.kind !== 'goal' || source.round !== 0) return state
|
||||
const change = source.change
|
||||
// Session-log data is a durable boundary: the static type promises the kind,
|
||||
// but a foreign or corrupted change record must degrade to same-reference,
|
||||
// never feed the zod parse in the registry drive.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable-boundary guard
|
||||
if (change === undefined || change.kind !== 'goal/change') return state
|
||||
if (change.operation === 'clear') return null
|
||||
return {
|
||||
goal: change.goal,
|
||||
roundsStarted: change.roundsStarted,
|
||||
createdAt: change.createdAt,
|
||||
updatedAt: change.updatedAt,
|
||||
export function applyGoalProjection(state: GoalProjectionState, event: SessionEvent): GoalProjectionState {
|
||||
if (event.type !== 'agent/inbox/spliced') return state
|
||||
let projection = state[0]
|
||||
let insertedChangeMessageIds: string[] | undefined
|
||||
const seen = new Set(state[1])
|
||||
for (const message of event.data.inserted) {
|
||||
const source = message.source
|
||||
const change = source.kind === 'goal' && source.round === 0 ? source.change : undefined
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- durable-boundary guard
|
||||
if (seen.has(message.id) || change === undefined || change.kind !== 'goal/change') continue
|
||||
seen.add(message.id)
|
||||
insertedChangeMessageIds ??= [...state[1]]
|
||||
insertedChangeMessageIds.push(message.id)
|
||||
projection = change.operation === 'clear'
|
||||
? null
|
||||
: {
|
||||
goal: change.goal,
|
||||
roundsStarted: change.roundsStarted,
|
||||
createdAt: change.createdAt,
|
||||
updatedAt: change.updatedAt,
|
||||
}
|
||||
}
|
||||
return insertedChangeMessageIds === undefined
|
||||
? state
|
||||
: [projection, insertedChangeMessageIds]
|
||||
}
|
||||
|
||||
/** Deployment defaults for goal creation. */
|
||||
@@ -126,19 +138,12 @@ export interface ResolvedConfig {
|
||||
defaultMaxGoalRounds: number
|
||||
}
|
||||
|
||||
/** One accepted mutation waiting to enter or be observed in the session log. */
|
||||
interface PendingGoalChange {
|
||||
readonly change: GoalChangeMeta
|
||||
readonly activation: GoalActivation
|
||||
applied: boolean
|
||||
}
|
||||
|
||||
/** Process-local cache plus mutations waiting in the active tool-batch FIFO. */
|
||||
/** Process-local cache plus activation intent crossing the synchronous injection boundary. */
|
||||
interface GoalCache {
|
||||
readonly state: GoalFoldState
|
||||
activation: GoalActivation
|
||||
observedSeq: number
|
||||
readonly pending: PendingGoalChange[]
|
||||
readonly pendingActivations: Map<UserMessage['id'], GoalActivation>
|
||||
}
|
||||
|
||||
/** Validated create input with every deployment default materialized. */
|
||||
@@ -188,11 +193,6 @@ function resolveBlockReason(reason: unknown): GoalBlockReason {
|
||||
return { code, message: message.trim() }
|
||||
}
|
||||
|
||||
/** Compare the complete canonical payloads used for deferred reconciliation. */
|
||||
function sameChange(left: GoalChangeMeta, right: GoalChangeMeta): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
}
|
||||
|
||||
/** Goal service (`ctx.goals`) backed exclusively by the owning session log. */
|
||||
export class GoalService extends Service {
|
||||
static inject = ['agents']
|
||||
@@ -216,13 +216,13 @@ export class GoalService extends Service {
|
||||
// (see applyGoalProjection). The unit child activates only when a
|
||||
// projection registry is composed (headless assemblies stay unaffected).
|
||||
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register<'goal', GoalProjection | null>({
|
||||
projectionCtx.sessionProjections.register<'goal', GoalProjectionState>({
|
||||
key: 'goal',
|
||||
schema: goalProjectionSchema,
|
||||
init: () => null,
|
||||
init: () => [null, []],
|
||||
apply: applyGoalProjection,
|
||||
view: state => state,
|
||||
stateVersion: 1,
|
||||
view: state => state[0],
|
||||
stateVersion: 3,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -436,34 +436,24 @@ export class GoalService extends Service {
|
||||
state,
|
||||
activation: 'disarmed',
|
||||
observedSeq: session.seq,
|
||||
pending: [],
|
||||
pendingActivations: new Map(),
|
||||
}
|
||||
this.caches.set(session, cache)
|
||||
return cache
|
||||
}
|
||||
|
||||
/** Incrementally observe durable events without losing deferred mutations. */
|
||||
/** Incrementally observe durable events and reconcile local activation intent. */
|
||||
private sync(session: Session, cache: GoalCache): void {
|
||||
for (const event of session.events.slice(cache.observedSeq)) {
|
||||
// A goal state change is a round-zero goal-sourced user message; a
|
||||
// positive round is a continuation prompt handled by applyGoalEvent.
|
||||
if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round === 0) {
|
||||
const change = decodeGoalEvent(event)
|
||||
if (change !== undefined) {
|
||||
const pending = cache.pending[0]
|
||||
if (pending !== undefined && sameChange(pending.change, change)) {
|
||||
if (!pending.applied) {
|
||||
applyGoalChange(cache.state, change)
|
||||
cache.activation = pending.activation
|
||||
pending.applied = true
|
||||
}
|
||||
cache.pending.shift()
|
||||
cache.observedSeq += 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
const newGoalMessages = event.type === 'agent/inbox/spliced'
|
||||
? event.data.inserted.filter(message => message.source.kind === 'goal'
|
||||
&& message.source.round === 0 && !cache.state.insertedChangeMessages.has(message.id))
|
||||
: []
|
||||
applyGoalEvent(cache.state, event)
|
||||
for (const message of newGoalMessages) {
|
||||
cache.activation = cache.pendingActivations.get(message.id) ?? 'disarmed'
|
||||
cache.pendingActivations.delete(message.id)
|
||||
}
|
||||
cache.observedSeq += 1
|
||||
}
|
||||
}
|
||||
@@ -555,7 +545,7 @@ export class GoalService extends Service {
|
||||
}
|
||||
this.commit(agent, cache, change, activation)
|
||||
const view = this.view(cache)
|
||||
/* v8 ignore next -- applyGoalChange installs the snapshot immediately before this read */
|
||||
/* v8 ignore next -- the durable inbox insertion installs the snapshot before this read */
|
||||
if (view === undefined) throw new Error('snapshot commit cleared the goal unexpectedly')
|
||||
return view
|
||||
}
|
||||
@@ -563,26 +553,21 @@ export class GoalService extends Service {
|
||||
/** Accept one mutation into the agent injection queue, cache, and live event stream. */
|
||||
private commit(agent: Agent, cache: GoalCache, change: GoalChangeMeta, activation: GoalActivation): void {
|
||||
const ref = goalChangeRef(change)
|
||||
const pending: PendingGoalChange = { change, activation, applied: false }
|
||||
cache.pending.push(pending)
|
||||
const message = createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change },
|
||||
})
|
||||
cache.pendingActivations.set(message.id, activation)
|
||||
try {
|
||||
agent.inject(createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change },
|
||||
}))
|
||||
agent.inject(message)
|
||||
} catch (error: unknown) {
|
||||
const index = cache.pending.indexOf(pending)
|
||||
/* v8 ignore next -- a committed goal append cannot reject after its contained observers run */
|
||||
if (index < 0) throw new Error('goal injection failed after its pending mutation was reconciled', { cause: error })
|
||||
cache.pending.splice(index, 1)
|
||||
cache.pendingActivations.delete(message.id)
|
||||
throw error
|
||||
}
|
||||
if (!pending.applied) {
|
||||
applyGoalChange(cache.state, change)
|
||||
cache.activation = activation
|
||||
pending.applied = true
|
||||
}
|
||||
this.sync(agent.session, cache)
|
||||
if (cache.pendingActivations.delete(message.id)) {
|
||||
throw new Error('goal injection returned without a durable inbox insertion')
|
||||
}
|
||||
const goal = this.view(cache)
|
||||
const notification: GoalChanged = {
|
||||
operation: change.operation,
|
||||
|
||||
@@ -22,6 +22,7 @@ function cloneState(state: GoalFoldState): GoalFoldState {
|
||||
updatedAt: state.updatedAt,
|
||||
lastRef: state.lastRef,
|
||||
seenGoalIds: new Set(state.seenGoalIds),
|
||||
insertedChangeMessages: new Map(state.insertedChangeMessages),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, freezeMessage, HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import GoalService, {
|
||||
GoalError,
|
||||
@@ -13,15 +13,9 @@ import GoalService, {
|
||||
} from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
|
||||
|
||||
type DeferredInjection = UserMessage
|
||||
|
||||
interface StubAgent {
|
||||
agent: Agent
|
||||
session: Session
|
||||
deferred: DeferredInjection[]
|
||||
setDeferred(value: boolean): void
|
||||
setStatus(value: AgentStatus): void
|
||||
drain(): void
|
||||
}
|
||||
|
||||
/** Number the next balanced test-fixture turn. */
|
||||
@@ -31,46 +25,34 @@ function nextTurn(session: Session): number {
|
||||
|
||||
/** Mirror the public Agent.inject contract for domain tests. */
|
||||
function appendInjection(session: Session, input: UserMessage): void {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
new Inbox(session, { inserted: () => {}, discarded: () => {} }).append('next-step', input)
|
||||
}
|
||||
|
||||
/** Build a registry-compatible agent around one concrete session. */
|
||||
function stubAgentForSession(session: Session): StubAgent {
|
||||
const id = session.id
|
||||
const deferred: DeferredInjection[] = []
|
||||
let shouldDefer = false
|
||||
let status: AgentStatus = 'idle'
|
||||
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
|
||||
const agent: Agent = {
|
||||
id,
|
||||
options: {},
|
||||
session,
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
|
||||
inbox,
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
status: 'idle',
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input) {
|
||||
if (shouldDefer) deferred.push(input)
|
||||
else appendInjection(session, input)
|
||||
},
|
||||
inject(input) { inbox.append('next-step', input) },
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
return {
|
||||
agent,
|
||||
session,
|
||||
deferred,
|
||||
setDeferred(value) { shouldDefer = value },
|
||||
setStatus(value) { status = value },
|
||||
drain() {
|
||||
shouldDefer = false
|
||||
for (const injection of deferred.splice(0)) appendInjection(session, injection)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a registry-compatible agent with controllable context deferral. */
|
||||
/** Build a registry-compatible agent around a fresh session. */
|
||||
function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[]): StubAgent {
|
||||
return stubAgentForSession(new Session(SessionId(rawId), seed))
|
||||
}
|
||||
@@ -117,16 +99,18 @@ describe('GoalService creation and replay', () => {
|
||||
})
|
||||
expect(goal.id).toMatch(/^goal-/)
|
||||
expect(seen).toEqual(['create'])
|
||||
expect(session.events.map(event => event.type)).toEqual(['user/message'])
|
||||
expect(session.events.map(event => event.type)).toEqual(['agent/inbox/spliced'])
|
||||
const context = session.events[0]
|
||||
expect(context?.type).toBe('user/message')
|
||||
if (context?.type !== 'user/message') throw new Error('expected goal context')
|
||||
expect(context.data.source).toMatchObject({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 })
|
||||
const change = context.data.source.kind === 'goal' ? decodeGoalChange(context.data.source.change) : undefined
|
||||
expect(context?.type).toBe('agent/inbox/spliced')
|
||||
if (context?.type !== 'agent/inbox/spliced') throw new Error('expected queued goal context')
|
||||
const message = context.data.inserted[0]
|
||||
if (message === undefined) throw new Error('expected inserted goal context')
|
||||
expect(message.source).toMatchObject({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 })
|
||||
const change = message.source.kind === 'goal' ? decodeGoalChange(message.source.change) : undefined
|
||||
if (change === undefined) throw new Error('expected decoded goal change')
|
||||
expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } })
|
||||
expect(context.data.content).toEqual(renderGoalChange(change))
|
||||
expect(session.deriveMessages()).toEqual([context.data])
|
||||
expect(message.content).toEqual(renderGoalChange(change))
|
||||
expect(session.deriveMessages()).toEqual([])
|
||||
expect(foldGoal(session.events)).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 })
|
||||
vi.useRealTimers()
|
||||
})
|
||||
@@ -390,9 +374,11 @@ describe('GoalService mutations', () => {
|
||||
vi.setSystemTime(80)
|
||||
ctx.goals.clear(agent, goal)
|
||||
const clear = session.events
|
||||
.filter(event => event.type === 'user/message' && event.data.source.kind === 'goal')
|
||||
.map(event => event.type === 'user/message' && event.data.source.kind === 'goal'
|
||||
? decodeGoalChange(event.data.source.change)
|
||||
.filter(event => event.type === 'agent/inbox/spliced')
|
||||
.flatMap(event => event.type === 'agent/inbox/spliced' ? event.data.inserted : [])
|
||||
.filter(message => message.source.kind === 'goal')
|
||||
.map(message => message.source.kind === 'goal'
|
||||
? decodeGoalChange(message.source.change)
|
||||
: undefined)
|
||||
.at(-1)
|
||||
expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 })
|
||||
@@ -411,23 +397,15 @@ describe('GoalService mutations', () => {
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer'))
|
||||
})
|
||||
|
||||
it('preserves multiple pending revisions until deferred injections enter the log', async () => {
|
||||
const test = await harness()
|
||||
const { ctx, agent, session, deferred } = test
|
||||
test.setDeferred(true)
|
||||
it('commits consecutive revisions through synchronous inbox insertions', async () => {
|
||||
const { ctx, agent, session } = await harness()
|
||||
let goal = ctx.goals.create(agent, { objective: 'deferred', maxGoalRounds: 5 })
|
||||
goal = ctx.goals.edit(agent, goal, { objective: 'deferred edit' })
|
||||
goal = ctx.goals.pause(agent, goal)
|
||||
expect(goal).toMatchObject({ revision: 3, phase: 'paused', activation: 'disarmed' })
|
||||
expect(deferred).toHaveLength(3)
|
||||
expect(session.events).toHaveLength(0)
|
||||
|
||||
appendInjection(session, createUserMessage({
|
||||
content: [{ type: 'text', text: 'unrelated' }], source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' })
|
||||
test.drain()
|
||||
expect(deferred).toHaveLength(0)
|
||||
expect(session.events.map(event => event.type)).toEqual([
|
||||
'agent/inbox/spliced', 'agent/inbox/spliced', 'agent/inbox/spliced',
|
||||
])
|
||||
expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' })
|
||||
expect(foldGoal(session.events)).toMatchObject({ goal: { revision: 3, phase: 'paused' } })
|
||||
})
|
||||
@@ -441,7 +419,8 @@ describe('GoalService mutations', () => {
|
||||
ctx.agents.register(stub.agent)
|
||||
let observed: ReturnType<GoalService['get']>
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === stub.session && event.type === 'user/message' && event.data.source.kind === 'goal') observed = ctx.goals.get(stub.agent)
|
||||
if (session === stub.session && event.type === 'agent/inbox/spliced'
|
||||
&& event.data.inserted.some(message => message.source.kind === 'goal')) observed = ctx.goals.get(stub.agent)
|
||||
})
|
||||
|
||||
const created = ctx.goals.create(stub.agent, { objective: 'publish once' })
|
||||
@@ -472,15 +451,19 @@ describe('GoalService mutations', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects deferred goal mutations that enter the log out of FIFO order', async () => {
|
||||
const test = await harness()
|
||||
test.setDeferred(true)
|
||||
const created = test.ctx.goals.create(test.agent, { objective: 'ordered' })
|
||||
test.ctx.goals.edit(test.agent, created, { objective: 'ordered edit' })
|
||||
const second = test.deferred[1]
|
||||
if (second === undefined) throw new Error('expected a second deferred goal mutation')
|
||||
appendInjection(test.session, second)
|
||||
expect(() => test.ctx.goals.get(test.agent)).toThrow('advance the current goal')
|
||||
it('rejects an inject implementation that returns before durable insertion', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(GoalService)
|
||||
const stub = stubAgent('goal-missing-insertion')
|
||||
const inject = stub.agent.inject.bind(stub.agent)
|
||||
stub.agent.inject = () => {}
|
||||
ctx.agents.register(stub.agent)
|
||||
|
||||
expect(() => ctx.goals.create(stub.agent, { objective: 'missing' }))
|
||||
.toThrow('without a durable inbox insertion')
|
||||
stub.agent.inject = inject
|
||||
expect(ctx.goals.create(stub.agent, { objective: 'committed' })).toMatchObject({ revision: 1 })
|
||||
})
|
||||
|
||||
it('observes a valid goal snapshot appended after an empty cache was established', async () => {
|
||||
@@ -502,12 +485,9 @@ describe('GoalService mutations', () => {
|
||||
updatedAt: 12,
|
||||
}
|
||||
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
appendInjection(session, createUserMessage({
|
||||
content: renderGoalChange(change), source,
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}))
|
||||
|
||||
expect(ctx.goals.get(agent)).toMatchObject({
|
||||
id: change.goal.id,
|
||||
@@ -583,12 +563,14 @@ describe('goal replay validation', () => {
|
||||
round: 0,
|
||||
change,
|
||||
}
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
const message = createUserMessage({
|
||||
content: overrides.content ?? renderGoalChange(change),
|
||||
source,
|
||||
}), { surfaceOp: 'append' })
|
||||
})
|
||||
appendInjection(session, message)
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', message, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
@@ -622,6 +604,74 @@ describe('goal replay validation', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('commits queued changes before admission and verifies the admitted copy without applying it twice', () => {
|
||||
const change = snapshotChange()
|
||||
const message = createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change },
|
||||
})
|
||||
const session = new Session(SessionId('queued-change'))
|
||||
appendInjection(session, message)
|
||||
expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } })
|
||||
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
|
||||
expect(inbox.remove('next-step', message.id)).toBe(true)
|
||||
inbox.append('next-step', message)
|
||||
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', message, { surfaceOp: 'append' })
|
||||
expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } })
|
||||
})
|
||||
|
||||
it('rejects an admitted change without its inbox insertion', () => {
|
||||
const change = snapshotChange()
|
||||
const message = createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change },
|
||||
})
|
||||
const session = new Session(SessionId('orphan-admitted-change'))
|
||||
session.append('user/message', message, { surfaceOp: 'append' })
|
||||
expect(() => foldGoal(session.events)).toThrow('was not committed by an inbox insertion')
|
||||
})
|
||||
|
||||
it('allows an ordinary admission rewrite but rejects changed goal data under an inserted message id', () => {
|
||||
const change = snapshotChange()
|
||||
const message = createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change },
|
||||
})
|
||||
const drift = new Session(SessionId('admitted-change-drift'))
|
||||
appendInjection(drift, message)
|
||||
drift.append('user/message', freezeMessage({
|
||||
...message,
|
||||
content: [{ type: 'text', text: 'rewritten as ordinary context' }],
|
||||
source: { kind: 'plugin', plugin: 'changed-after-claim' },
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(foldGoal(drift.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } })
|
||||
|
||||
const edit = mutation(change, 'edit', 'active')
|
||||
const changedAdmission = new Session(SessionId('changed-admitted-goal'))
|
||||
appendInjection(changedAdmission, message)
|
||||
changedAdmission.append('user/message', freezeMessage({
|
||||
...message,
|
||||
content: renderGoalChange(edit),
|
||||
source: { kind: 'goal', goalId: edit.goal.id, revision: 2, round: 0, change: edit },
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(() => foldGoal(changedAdmission.events)).toThrow('differs from its inbox insertion')
|
||||
|
||||
const reused = new Session(SessionId('reused-change-message-id'))
|
||||
appendInjection(reused, message)
|
||||
reused.append('agent/inbox/spliced', {
|
||||
target: 'next-step',
|
||||
start: 1,
|
||||
inserted: [freezeMessage({
|
||||
...message,
|
||||
content: renderGoalChange(edit),
|
||||
source: { kind: 'goal', goalId: edit.goal.id, revision: 2, round: 0, change: edit },
|
||||
})],
|
||||
})
|
||||
expect(() => foldGoal(reused.events)).toThrow('reuses a message id with different change data')
|
||||
})
|
||||
|
||||
function foldPair(first: GoalSnapshotChangeMeta, second: GoalChangeMeta): ReturnType<typeof foldGoal> {
|
||||
const session = new Session(SessionId(`validation-pair-${Math.random()}`))
|
||||
appendChange(session, first)
|
||||
@@ -848,13 +898,7 @@ describe('goal replay validation', () => {
|
||||
cleared: { id: change.goal.id, revision: 2 },
|
||||
clearedAt: 20,
|
||||
}
|
||||
const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0, change: clear } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(clear), source,
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
appendChange(session, clear)
|
||||
expect(foldGoal(session.events)).toEqual({
|
||||
roundsStarted: 0,
|
||||
lastRef: { id: change.goal.id, revision: 2 },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import {
|
||||
@@ -46,11 +46,15 @@ describe('goal stream invariants', () => {
|
||||
it('accepts canonical goal snapshots and sequential admitted rounds', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('goal-invariant-valid'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
const message = createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
}), { surfaceOp: 'append' })
|
||||
})
|
||||
session.append('agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, inserted: [message],
|
||||
})
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', message, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
expect(() => {
|
||||
@@ -64,22 +68,22 @@ describe('goal stream invariants', () => {
|
||||
it('rejects model-visible drift before committing it and keeps the fold reusable', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('goal-invariant-invalid'))
|
||||
const message = createUserMessage({ content: renderGoalChange(change), source: changeSource })
|
||||
session.append('agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, inserted: [message],
|
||||
})
|
||||
session.append('turn/start', { turn: 1 })
|
||||
expect(() => {
|
||||
session.append('user/message', createUserMessage({
|
||||
session.append('user/message', freezeMessage({ ...message,
|
||||
content: [{ type: 'text', text: 'counterfeit' }],
|
||||
source: changeSource,
|
||||
}), { surfaceOp: 'append' })
|
||||
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-goal',
|
||||
}))
|
||||
expect(session.seq).toBe(1)
|
||||
expect(session.seq).toBe(2)
|
||||
expect(() => {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', message, { surfaceOp: 'append' })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -87,11 +91,12 @@ describe('goal stream invariants', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('goal-invariant-late-load'))
|
||||
const message = createUserMessage({ content: renderGoalChange(change), source: changeSource })
|
||||
session.append('agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, inserted: [message],
|
||||
})
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', message, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import GoalService, { applyGoalProjection } from '@deepseek-ai/dsh-goal'
|
||||
import GoalService, { applyGoalProjection, foldGoal } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef } from '@deepseek-ai/dsh-goal'
|
||||
|
||||
interface Bench {
|
||||
@@ -31,18 +31,19 @@ interface Bench {
|
||||
/** Register a minimal registry-compatible live agent over a store session. */
|
||||
function liveAgent(ctx: Context, session: Session): Agent {
|
||||
const status: AgentStatus = 'idle'
|
||||
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
|
||||
const agent: Agent = {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {} }),
|
||||
inbox,
|
||||
ctx,
|
||||
get status() { return status },
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input: UserMessage) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
inbox.append('next-step', input)
|
||||
},
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
@@ -123,37 +124,107 @@ describe('goal projection unit', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not revive a cleared goal when its create message is reinserted', async () => {
|
||||
const bench = await harness(true)
|
||||
const created = bench.ctx.goals.create(bench.agent, { objective: 'stay cleared' })
|
||||
const createMessage = bench.agent.inbox.nextStep.find(message => message.source.kind === 'goal'
|
||||
&& message.source.change?.operation === 'create')
|
||||
if (createMessage === undefined) throw new Error('missing create message')
|
||||
bench.ctx.goals.clear(bench.agent, created)
|
||||
|
||||
bench.agent.inbox.claim('next-step')
|
||||
bench.agent.inbox.prepend('next-step', createMessage)
|
||||
|
||||
expect(bench.tailValues().goal).toBeNull()
|
||||
expect(foldGoal(bench.session.events).goal).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not regress a goal revision when its create message is reinserted', async () => {
|
||||
const bench = await harness(true)
|
||||
const created = bench.ctx.goals.create(bench.agent, { objective: 'first revision' })
|
||||
const createMessage = bench.agent.inbox.nextStep.find(message => message.source.kind === 'goal'
|
||||
&& message.source.change?.operation === 'create')
|
||||
if (createMessage === undefined) throw new Error('missing create message')
|
||||
const edited = bench.ctx.goals.edit(bench.agent, created, { objective: 'second revision' })
|
||||
|
||||
bench.agent.inbox.claim('next-step')
|
||||
bench.agent.inbox.prepend('next-step', createMessage)
|
||||
|
||||
expect(bench.tailValues().goal).toMatchObject({
|
||||
goal: { revision: edited.revision, objective: 'second revision' },
|
||||
})
|
||||
expect(foldGoal(bench.session.events).goal).toMatchObject({
|
||||
revision: edited.revision,
|
||||
objective: 'second revision',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores non-goal and malformed goal-shaped events fail-soft (same reference)', () => {
|
||||
// The package invariant rejects a violating stream loudly wherever it is
|
||||
// installed — the unit itself must never throw on the projection drive
|
||||
// (a throwing apply would tear down every registered unit's drive), so
|
||||
// its transition is exercised directly as the pure function it is.
|
||||
const user = { type: 'user/message', seq: 0, time: 1, data: createUserMessage({
|
||||
const plainUser = createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
source: { kind: 'user' },
|
||||
}) } as never
|
||||
expect(applyGoalProjection(null, user)).toBeNull()
|
||||
})
|
||||
const user = { type: 'user/message', seq: 0, time: 1, data: plainUser } as never
|
||||
const state = { goal: { id: 'g1', revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 4 }, roundsStarted: 0, createdAt: 1, updatedAt: 1 } as never
|
||||
const empty = [null, []] as const
|
||||
expect(applyGoalProjection(empty, user)).toBe(empty)
|
||||
const queuedUser = {
|
||||
type: 'agent/inbox/spliced', seq: 1, time: 2,
|
||||
data: { target: 'next-step', start: 0, inserted: [plainUser] },
|
||||
} as never
|
||||
const current = [state, []] as const
|
||||
expect(applyGoalProjection(current, queuedUser)).toBe(current)
|
||||
|
||||
const malformed = { type: 'user/message', seq: 1, time: 2, data: createUserMessage({
|
||||
const malformedMessage = createUserMessage({
|
||||
content: [{ type: 'text', text: 'broken' }],
|
||||
source: { kind: 'goal', goalId: 'g-broken', revision: 1, round: 0 } as never,
|
||||
}) } as never
|
||||
const state = { goal: { id: 'g1', revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 4 }, roundsStarted: 0, createdAt: 1, updatedAt: 1 } as never
|
||||
})
|
||||
const malformed = { type: 'user/message', seq: 1, time: 2, data: malformedMessage } as never
|
||||
// Same-reference return: the registry's Object.is gate sees no change.
|
||||
expect(applyGoalProjection(state, malformed)).toBe(state)
|
||||
expect(applyGoalProjection(null, malformed)).toBeNull()
|
||||
expect(applyGoalProjection(current, malformed)).toBe(current)
|
||||
expect(applyGoalProjection(empty, malformed)).toBe(empty)
|
||||
const queuedMalformed = {
|
||||
type: 'agent/inbox/spliced', seq: 2, time: 3,
|
||||
data: { target: 'next-step', start: 0, inserted: [malformedMessage] },
|
||||
} as never
|
||||
expect(applyGoalProjection(current, queuedMalformed)).toBe(current)
|
||||
|
||||
const queuedRound = {
|
||||
type: 'agent/inbox/spliced', seq: 3, time: 4,
|
||||
data: { target: 'next-step', start: 0, inserted: [createUserMessage({
|
||||
content: [{ type: 'text', text: 'later round' }],
|
||||
source: { kind: 'goal', goalId: 'g1', revision: 1, round: 1 } as never,
|
||||
})] },
|
||||
} as never
|
||||
expect(applyGoalProjection(current, queuedRound)).toBe(current)
|
||||
|
||||
const validGoalUser = { type: 'user/message', seq: 2, time: 3, data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'legacy direct change' }],
|
||||
source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0, change: { kind: 'goal/change' } } as never,
|
||||
}) } as never
|
||||
expect(applyGoalProjection(empty, validGoalUser)).toBe(empty)
|
||||
|
||||
// A non-message event (the registry drives EVERY committed event through
|
||||
// apply): early same-reference return.
|
||||
const turnStart = { type: 'turn/start', seq: 3, time: 4, data: { turn: 1 } } as never
|
||||
expect(applyGoalProjection(state, turnStart)).toBe(state)
|
||||
expect(applyGoalProjection(current, turnStart)).toBe(current)
|
||||
|
||||
// A round-zero goal source whose change carries a foreign kind: same posture.
|
||||
const foreignKind = { type: 'user/message', seq: 2, time: 3, data: createUserMessage({
|
||||
const foreignMessage = createUserMessage({
|
||||
content: [{ type: 'text', text: 'foreign' }],
|
||||
source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0, change: { kind: 'not-a-goal-change' } } as never,
|
||||
}) } as never
|
||||
expect(applyGoalProjection(state, foreignKind)).toBe(state)
|
||||
})
|
||||
const foreignKind = { type: 'user/message', seq: 2, time: 3, data: foreignMessage } as never
|
||||
expect(applyGoalProjection(current, foreignKind)).toBe(current)
|
||||
const queuedForeignKind = {
|
||||
type: 'agent/inbox/spliced', seq: 4, time: 5,
|
||||
data: { target: 'next-step', start: 0, inserted: [foreignMessage] },
|
||||
} as never
|
||||
expect(applyGoalProjection(current, queuedForeignKind)).toBe(current)
|
||||
})
|
||||
|
||||
it('has no goal key when the goal service is not composed', async () => {
|
||||
|
||||
@@ -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/goal/tool-goal/README.md
|
||||
README.md: aaed61dd517aeb2f94efa22c34c64d1068155d46
|
||||
README.zh.md: 5365b64ef65fb3d3f00e19357327479ebd8285a8
|
||||
README.md: a4742e4117ca89f4395a1c59264f6ea6c3ab8b96
|
||||
README.zh.md: 48e89332db7dfbe746d1ba4e57077eb787a10a66
|
||||
|
||||
@@ -61,15 +61,15 @@ Prefix-stable while the plugin scope, configured threshold, and guidance text ar
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `<goal_state>` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority.
|
||||
The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. A mutation queues the goal domain's raw `<goal_state>` snapshot after the tool batch; a later pre-step may admit it, while discarding the queued context does not roll back the durable mutation. `activation` in a result is a live observation and never becomes replay authority.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction.
|
||||
Fixed schema cost plus one compact result per call. An admitted mutation context retains the domain snapshot until compaction; one discarded before admission adds no model tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Schemas are prefix-stable while their definitions and visibility are unchanged. Calls, results, and resulting goal snapshots append after the reusable request prefix without invalidating earlier entries.
|
||||
Schemas are prefix-stable while their definitions and visibility are unchanged. Calls, results, and admitted goal snapshots append after the reusable request prefix without invalidating earlier entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -61,15 +61,15 @@ Use goal tools for one long-running completion objective in the current session.
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `<goal_state>` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。
|
||||
生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更会在工具批次结束后将 goal 领域的原始 `<goal_state>` 快照排队;后续 pre-step 可以准入它,而丢弃已排队的上下文不会回滚持久变更。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
固定 schema 成本,加上每次调用的一条紧凑结果。变更还会保留领域快照,直到压缩(compaction)。
|
||||
固定 schema 成本,加上每次调用的一条紧凑结果。获准的变更上下文会保留领域快照,直到压缩(compaction);准入前被丢弃的上下文不增加模型 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
schema 的定义与可见性不变时,前缀保持稳定。调用、结果和生成的 goal 快照会追加到可复用请求前缀之后,不会使更早条目失效。
|
||||
schema 的定义与可见性不变时,前缀保持稳定。调用、结果和已准入的 goal 快照会追加到可复用请求前缀之后,不会使更早条目失效。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ interface StubAgent {
|
||||
setStatus(status: AgentStatus): void
|
||||
}
|
||||
|
||||
/** Build one registry-compatible live agent whose injections append in place. */
|
||||
/** Build one registry-compatible live agent whose injections enter the durable inbox. */
|
||||
function stubAgent(rawId: string, supplied?: Session): StubAgent {
|
||||
const session = supplied ?? new Session(SessionId(rawId))
|
||||
let status: AgentStatus = 'running'
|
||||
@@ -36,7 +36,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
this.inbox.append('next-step', input)
|
||||
},
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
@@ -49,11 +49,17 @@ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): numb
|
||||
const turn = stub.session.events
|
||||
.filter(event => event.type === 'turn/start')
|
||||
.reduce((max, event) => Math.max(max, event.data.turn), 0) + 1
|
||||
stub.session.append('turn/start', { turn })
|
||||
stub.session.append('user/message', createUserMessage({
|
||||
const message = createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source,
|
||||
}), { surfaceOp: 'append' })
|
||||
})
|
||||
stub.agent.inbox.append('next-turn', message)
|
||||
const claimed = stub.agent.inbox.claim('next-turn')
|
||||
if (claimed.length === 0) throw new Error('expected queued turn input')
|
||||
stub.session.append('turn/start', { turn })
|
||||
for (const admitted of claimed) {
|
||||
stub.session.append('user/message', admitted, { surfaceOp: 'append' })
|
||||
}
|
||||
return turn
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user