feat(system-prompt): cache dynamic policy context

This commit is contained in:
NI0317
2026-07-30 22:09:15 +08:00
parent 936a487bb0
commit 8b4cbe4293
45 changed files with 722 additions and 374 deletions

View File

@@ -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/ui/user-approval/README.md
README.md: 38bcfbfe81c3ff5f16d1835259bd4c35a06dcb64
README.zh.md: 7f2678d8572b191ec88a326374420dde7deed3dc
README.md: 7b87a75d1c7c43874c484bc11f8deed45cb523ce
README.zh.md: c15871073231b6e97f37fc0338f4824025ba86ca

View File

@@ -8,38 +8,37 @@ Each request must belong to an open agent turn. The service appends a paired `ap
Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP automation bridge supplies one-shot machine decisions for sessions it owns.
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise.
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch. Both policies contribute their complete current meaning to the cache-safe runtime-context snapshot.
The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP automation bridge answers calls for its own agents through the client's machine policy. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
## Model Experience
### System prompt and policy notice
### Current approval policy context
#### What the model sees
Under `ask`, every agent request carries the ask-policy prompt section below. Under `never`, it carries the never-policy prompt section below. A policy switch injects exactly `The approval policy changed from "<old>" to "<new>" (changed by the user).` or `The approval policy changed from "<old>" to "<new>" (changed by the operator/config).` before the next step.
The first request and each effective policy change append a full runtime-context snapshot after retained history. Under `ask`, the approval contribution states that configured answerers may be consulted and absence fails closed. Under `never`, it states the deterministic rejection and non-escalation consequence. Unchanged requests retain the earlier snapshot without adding another message.
##### Ask-policy prompt section
##### Ask-policy contribution
```markdown
<!-- dsh-user-approval-policy:ask -->
Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.
```
##### Never-policy prompt section
##### Never-policy contribution
```markdown
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
<!-- dsh-user-approval-policy:never -->
```
#### Token effect
Small fixed per-request cost, larger under `never`; a change notice is conditional and retained in history.
One concise context message on the first request and on an effective change; unchanged requests add no duplicate policy tokens.
#### KV Cache effect
Prefix-stable while the approval policy is unchanged. An `ask`/`never` switch changes the system-prompt section and invalidates reuse from its first changed token; the accompanying notice is append-only.
Append-only after retained history. An `ask`/`never` switch preserves the stable system and conversation prefix instead of rewriting the first wire message.
### Tool outcome

View File

@@ -8,38 +8,37 @@
应答者是 `approval/request` waterfall瀑布式事件监听器。要回答其负责的 agent 请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求每项部署应当组合一个最终应答者因为同级监听器的顺序不是策略优先级机制。ACPAgent Client Protocol自动化桥接层为其负责的会话提供一次性机器决定。
`ApprovalPolicy``'ask'``'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置
`ApprovalPolicy``'ask'``'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求。两种策略都会将各自完整的当前含义贡献给缓存安全的运行时上下文快照
工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
## 模型体验
### 系统提示词与策略通知
### 当前审批策略上下文
#### 模型看到的内容
`ask` 下,每个 agent 请求都会携带下方的 ask 策略提示词段。在 `never` 下,请求会携带下方的 never 策略提示词段。策略切换会在下一步骤前精确注入 `The approval policy changed from "<old>" to "<new>" (changed by the user).``The approval policy changed from "<old>" to "<new>" (changed by the operator/config).`
首次请求和有效策略每次变化时,都会在保留的历史后追加一份完整运行时上下文快照。在 `ask` 下,批准贡献会说明可咨询已配置的应答者,缺少应答者时以拒绝方式关闭。在 `never` 下,它会说明确定性的拒绝与非升权后果。未变化的请求会保留先前快照,不增加另一条消息
##### Ask 策略提示词段
##### Ask 策略贡献
```markdown
<!-- dsh-user-approval-policy:ask -->
Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.
```
##### Never 策略提示词段
##### Never 策略贡献
```markdown
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
<!-- dsh-user-approval-policy:never -->
```
#### Token 影响
每个请求有少量固定成本,`never` 下的成本更高;变更通知按条件出现,并保留在历史中
首次请求和策略实际变化时增加一条简洁的上下文消息;未变化的请求不增加重复的策略 token
#### KV Cache 影响
审批策略不变时,前缀保持稳定`ask``never` 切换会改变系统提示词段,并从首个变化的 token 开始使复用失效;随附通知只会追加
在保留的历史之后仅追加`ask``never` 切换会保留稳定的系统与对话前缀,而不会改写第一条 wire 消息
### 工具结果

View File

@@ -8,7 +8,7 @@ import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm'
import type { CallId } from '@deepseek-ai/dsh-llm'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
@@ -59,7 +59,7 @@ declare module '@deepseek-ai/dsh-session' {
/**
* The session's approval policy was switched — log-only, durable,
* replayable, never in the model transcript (the model learns the policy
* from the prompt section and the narrator's notices). The LAST such
* from the cache-safe runtime-context snapshot). The LAST such
* event is the session's override ({@link effectiveApprovalPolicy}).
* `source: 'delegation'` marks an override seeded into a child; an absent
* source is a runtime switch.
@@ -90,41 +90,17 @@ const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cance
* (exactly today's behavior).
* - `'never'` — never prompt anyone: every ask resolves `'rejected'`
* deterministically. The strict headless stance (CI, unattended runs) and
* the only policy value stated in the system prompt — unlike `'ask'`, its
* outcome is knowable without asking, so stating it cannot overclaim.
* the policy whose outcome is knowable without asking.
*/
export type ApprovalPolicy = 'ask' | 'never'
/** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */
export const APPROVAL_POLICIES: readonly ApprovalPolicy[] = ['ask', 'never']
/**
* The prompt sentence stating a `'never'` policy — visibility for the one
* deterministic policy (see {@link ApprovalPolicy}). Narrator persistence
* does NOT parse this prose: deployments can quote it in a persona or another
* section, so the section also emits a source-owned marker.
*/
/** Model-facing statement for the deterministic `'never'` policy. */
const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
/** Source-owned prompt markers used to reconstruct the policy in a logged header. */
const POLICY_MARKERS = {
ask: '<!-- dsh-user-approval-policy:ask -->',
never: '<!-- dsh-user-approval-policy:never -->',
} as const satisfies Record<ApprovalPolicy, string>
/**
* Read the policy fact emitted by this service from a logged system prompt.
* The section is ordered after deployment persona text, and the last marker
* wins so a persona quoting an earlier marker cannot shadow the service's own
* contribution. Ordinary policy prose is deliberately ignored.
*/
function toldApprovalPolicy(system: string | undefined): ApprovalPolicy | undefined {
if (system === undefined) return undefined
const ask = system.lastIndexOf(POLICY_MARKERS.ask)
const never = system.lastIndexOf(POLICY_MARKERS.never)
if (ask < 0 && never < 0) return undefined
return never > ask ? 'never' : 'ask'
}
/** Model-facing statement for an interactive policy that may still fail closed. */
const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
/**
* The session's approval-policy override: the last `approval/policy` event in
@@ -212,7 +188,7 @@ export interface Config {
/**
* Approval service that applies session policy before answerers and logs every
* ask/outcome pair to the requesting session. It exposes deterministic policy
* changes to the model through prompt and pre-step notices.
* changes to the model through the cache-safe runtime-context snapshot.
*/
export class ApprovalService extends Service {
static Config: z<Config> = z.object({
@@ -224,9 +200,10 @@ export class ApprovalService extends Service {
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session)
// State only deterministic policy; a marker records the otherwise silent state.
// The complete current value travels after retained history, so switching
// policy does not rewrite the stable system-prompt cache prefix.
ctx.inject(['systemPrompt'], (scope: Context) => {
scope.systemPrompt.section({
scope.systemPrompt.context({
name: 'approval:policy',
order: 115,
text: (context) => {
@@ -234,54 +211,10 @@ export class ApprovalService extends Service {
// A bare assemble() (tests, diagnostics) has no session to state.
if (agent === undefined) return ''
const policy = effective(agent)
return policy === 'never' ? `${NEVER_SENTENCE}\n${POLICY_MARKERS.never}` : POLICY_MARKERS.ask
return policy === 'never' ? NEVER_SENTENCE : ASK_SENTENCE
},
})
})
// Visibility layer 2: the boundary narrator. agent/step runs before the
// request history is derived, so the notice is
// seen by THIS step's request: idle-time flip-flops coalesce at the
// turn's first step (net-zero → nothing), and a mid-turn switch is
// narrated no later than the next step. What each session was last told
// is in-memory with a log-derived fallback (the folded header's system
// text), so restarts lose nothing. Attribution is positional: an
// override event after the log's last `request/header` was a runtime
// switch by the user; otherwise the configured default moved under the
// session (operator/config).
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
ctx.on('agent/step', (agent) => {
const session = agent.session
const events = session.events
let overrideIndex = -1
let overrideSource: 'delegation' | undefined
let headerIndex = -1
for (let index = events.length - 1; index >= 0 && (overrideIndex < 0 || headerIndex < 0); index -= 1) {
const event = events[index] as (typeof events)[number]
if (overrideIndex < 0 && event.type === 'approval/policy') {
overrideIndex = index
overrideSource = event.data.source
} else if (headerIndex < 0 && event.type === 'request/header') {
headerIndex = index
}
}
// Same fold effectivePolicy performs — override is scanned here anyway
// for POSITIONAL attribution; the default lives once, in the method.
const current = this.effectivePolicy(session)
const header = session.requestHeader()
const told = narrated.get(session) ?? toldApprovalPolicy(header?.system)
narrated.set(session, current)
// Cold start (nothing ever told) narrates nothing — the section about
// to go out states the truth, and there is no delta to explain.
if (told === undefined || told === current) return
const cause = overrideSource === 'delegation'
? 'inherited from the delegating session'
: overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
agent.inject(createUserMessage({
content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
source: { kind: 'plugin', plugin: 'user-approval' },
}))
})
}
/**

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
@@ -351,33 +351,17 @@ describe('ApprovalService.request', () => {
describe('approval policy (the approval/policy fold)', () => {
const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
const ASK_MARKER = '<!-- dsh-user-approval-policy:ask -->'
const NEVER_MARKER = '<!-- dsh-user-approval-policy:never -->'
const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
/**
* An agent stand-in over a REAL Session — gate, section, and narrator fold
* real events; the opened turn satisfies request()'s enclosure precondition.
*/
function sessionAgent(id: string): { agent: Agent; session: Session; injected: string[] } {
/** Agent stand-in over a real Session; the opened turn satisfies request()'s enclosure precondition. */
function sessionAgent(id: string): { agent: Agent; session: Session } {
const session = new Session(SessionId(id))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const injected: string[] = []
const agent = {
id,
session,
inject: (input: { content: Array<{ type: string; text: string }> }) => {
injected.push(input.content[0]?.text ?? '')
},
} as unknown as Agent
return { agent, session, injected }
}
const preStep = (ctx: Context, agent: Agent): Promise<void> =>
agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
/** Append a `request/header` snapshot whose system text is exactly `system`. */
function appendHeader(session: Session, system: string): void {
session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' }, system }, reason: 'initial' })
return { agent, session }
}
it('folds to the last event, or undefined without one', () => {
@@ -464,131 +448,46 @@ describe('approval policy (the approval/policy fold)', () => {
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
})
it('states never (and only never) in prose while recording either policy with a source-owned marker', async () => {
it('contributes the complete current ask or never policy as cache-safe context', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ApprovalService)
const askAgent = sessionAgent('sess-sect-ask').agent
const { agent: neverAgent, session } = sessionAgent('sess-sect-never')
setApprovalPolicy(session, 'never')
const sectionFor = async (context: object) =>
(await ctx.systemPrompt.assemble(context)).sections.find(s => s.name === 'approval:policy')?.text
expect(await sectionFor({ agent: askAgent })).toBe(ASK_MARKER)
expect(await sectionFor({ agent: neverAgent })).toBe(`${NEVER_SENTENCE}\n${NEVER_MARKER}`)
const contextFor = async (context: object) =>
(await ctx.systemPrompt.assemble(context)).contexts.find(entry => entry.name === 'approval:policy')?.text
expect(await contextFor({ agent: askAgent })).toBe(ASK_SENTENCE)
expect(await contextFor({ agent: neverAgent })).toBe(NEVER_SENTENCE)
// A bare assemble (no agent) has no session to state.
expect(await sectionFor({})).toBe('')
expect(await contextFor({})).toBe('')
})
it('narrates nothing cold, once per coalesced switch (user wording), and idempotently', async () => {
it('reflects the latest durable switch and stays byte-stable while unchanged', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-1')
await preStep(ctx, agent)
expect(injected).toEqual([])
const { agent, session } = sessionAgent('sess-context-switch')
const contextFor = async () =>
(await ctx.systemPrompt.assemble({ agent })).contexts.find(entry => entry.name === 'approval:policy')?.text
expect(await contextFor()).toBe(ASK_SENTENCE)
expect(await contextFor()).toBe(ASK_SENTENCE)
setApprovalPolicy(session, 'never')
setApprovalPolicy(session, 'ask')
setApprovalPolicy(session, 'never')
await preStep(ctx, agent)
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).'])
await preStep(ctx, agent)
expect(injected).toHaveLength(1)
setApprovalPolicy(session, 'ask')
setApprovalPolicy(session, 'never')
await preStep(ctx, agent)
expect(injected).toHaveLength(1)
expect(await contextFor()).toBe(NEVER_SENTENCE)
expect(await contextFor()).toBe(NEVER_SENTENCE)
})
it('reads what the model was told back from the folded header text after a restart', async () => {
// A session whose last request carried the never sentence resumes under
// an ask default: the narrator attributes the change to the operator.
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-2')
appendHeader(session, `persona\n\n${NEVER_SENTENCE}\n${NEVER_MARKER}`)
await preStep(ctx, agent)
expect(injected).toEqual(['The approval policy changed from "never" to "ask" (changed by the operator/config).'])
})
it('attributes a constructor-seeded policy event to delegation', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-inherited')
appendHeader(session, ASK_MARKER)
session.append('approval/policy', { policy: 'never', source: 'delegation' })
await preStep(ctx, agent)
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).'])
})
it('narrates a config default drift from the logged ask marker', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
const { agent, session, injected } = sessionAgent('sess-narr-3')
appendHeader(session, `persona only\n${ASK_MARKER}`)
await preStep(ctx, agent)
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the operator/config).'])
})
it('a pinned override survives a default change silently', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
const { agent, session, injected } = sessionAgent('sess-narr-4')
appendHeader(session, `persona only\n${ASK_MARKER}`)
setApprovalPolicy(session, 'ask')
appendHeader(session, `persona only\n${ASK_MARKER}`)
await preStep(ctx, agent)
expect(injected).toEqual([])
})
it('does not infer never from deployment prose that quotes the never sentence', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-spoof-prose')
appendHeader(session, `persona quotes this warning: ${NEVER_SENTENCE}\n${ASK_MARKER}`)
await preStep(ctx, agent)
expect(injected).toEqual([])
})
it('treats a legacy header with no source-owned marker as untold', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
const { agent, session, injected } = sessionAgent('sess-narr-unmarked-header')
appendHeader(session, 'legacy persona-only header')
await preStep(ctx, agent)
expect(injected).toEqual([])
})
it('uses the service marker after an earlier persona marker', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-spoof-marker')
appendHeader(session, `persona quotes ${NEVER_MARKER}\n${ASK_MARKER}`)
await preStep(ctx, agent)
expect(injected).toEqual([])
})
it('disposes the service prompt section and pre-step narrator together (HMR safety)', async () => {
it('disposes the service context contribution with its fiber (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
const fiber = await ctx.plugin(ApprovalService)
const live = sessionAgent('sess-hmr-service-live')
const afterDispose = sessionAgent('sess-hmr-service-disposed')
const sectionFor = async () =>
(await ctx.systemPrompt.assemble({ agent: live.agent })).sections.find(section => section.name === 'approval:policy')
expect(await sectionFor()).toBeDefined()
appendHeader(live.session, `persona\n${ASK_MARKER}`)
setApprovalPolicy(live.session, 'never')
await preStep(ctx, live.agent)
expect(live.injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).'])
appendHeader(afterDispose.session, `persona\n${ASK_MARKER}`)
setApprovalPolicy(afterDispose.session, 'never')
const contextFor = async () =>
(await ctx.systemPrompt.assemble({ agent: live.agent })).contexts.find(context => context.name === 'approval:policy')
expect(await contextFor()).toBeDefined()
await fiber.dispose()
expect(await sectionFor()).toBeUndefined()
await preStep(ctx, afterDispose.agent)
expect(afterDispose.injected).toEqual([])
expect(await contextFor()).toBeUndefined()
})
})