feat(system-prompt): cache dynamic policy context
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/bash/tool-bash/README.md
|
||||
README.md: deb6b899c81cb8c335b4c1cffdde4797e0a8be92
|
||||
README.zh.md: c2514308fb9f234e6d191a6b1a821ac3d195378b
|
||||
README.md: 7af756ef5198b0b459085f28f9f5920b905e7ddb
|
||||
README.zh.md: cc2e824cdc6cf6da743537cef70997020285b74e
|
||||
|
||||
@@ -69,9 +69,11 @@ Commands run with the executor's full authority unless a sandboxing executor ([`
|
||||
|
||||
Escalating bash calls resolve `ctx.approval` before execution. `allowed-once` applies the requested mode only to that call; rejection, cancellation, unavailability, or missing approval context executes nothing and returns a distinct error. On a real denial, the model may retry the same command once in the same turn with the narrowest sufficient mode and justification; the approval prompt itself is the consent step. Escalation is never speculative, and a disabled or rejected approval is final. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns the rationale.
|
||||
|
||||
When the executor confines and the schema exposes escalation, this plugin registers the `bash` family as escalatable with `ctx.sandboxPolicy`. The policy owner can therefore add anti-refusal context only when the actual bash tool offers the denial-and-approved-retry path; the contribution disposes with the plugin.
|
||||
|
||||
## Per-session mode switching
|
||||
|
||||
For sandboxing executors, each call resolves mode as one-shot escalation, then session override, then executor default. Non-sandboxing and agent-less calls carry no session override. Neither the prompt nor a switch notice announces the standing mode; denial results report the effective mode when the boundary matters. See the [`dsh-bash` fold](../bash/README.md) and [sandbox switching contract](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
For sandboxing executors, each call resolves mode as one-shot escalation, then session override, then executor default. Non-sandboxing and agent-less calls carry no session override. The policy owner contributes current standing mode and composition-conditioned anti-refusal context; denial results still own the operation-specific effective mode and retry guidance. See the [`dsh-bash` fold](../bash/README.md) and [sandbox switching contract](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -79,7 +81,7 @@ For sandboxing executors, each call resolves mode as one-shot escalation, then s
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Every request in this plugin's registration scope contains the bash guidance below. A sandboxing executor adds no mode statement or switch notice. Scoped tool restrictions can hide the schemas without removing this independently registered section.
|
||||
Every request in this plugin's registration scope contains the bash guidance below. A sandboxing executor contributes capability facts through the policy owner's cache-safe runtime context rather than changing this section. Scoped tool restrictions can hide the schemas without removing this independently registered section.
|
||||
|
||||
##### Bash guidance
|
||||
|
||||
|
||||
@@ -69,9 +69,11 @@ overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecReques
|
||||
|
||||
需要升权的 bash 调用会在执行前解析 `ctx.approval`。`allowed-once` 只对该次调用应用请求模式;审批被拒、取消、不可用或缺少审批上下文时,命令完全不会执行,并返回不同的错误。发生真实拒绝后,模型可以在同一轮次中使用满足需要的最窄模式和理由重试同一命令一次;审批提示本身就是征求同意的步骤。升权绝不能预先推测,禁用或拒绝审批即为最终结果。其理由由 [沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) 持有。
|
||||
|
||||
执行器施加沙箱限制且 schema 公开升权能力时,此插件会将 `bash` 家族注册到 `ctx.sandboxPolicy`,标记为可升权。策略归属方因而只会在实际 bash 工具具备「拒绝后经批准重试」路径时添加反预防性拒绝上下文;该贡献随插件 dispose。
|
||||
|
||||
## 逐会话模式切换
|
||||
|
||||
对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。提示词和切换通知均不公布当前常驻模式;拒绝结果会在边界相关时报告有效模式。参见 [`dsh-bash` 整合](../bash/README.md)和[沙箱切换契约](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
|
||||
对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。策略归属方通过缓存安全的运行时上下文贡献当前常驻模式与按组合条件化的反预防性拒绝上下文;拒绝结果仍负责操作特定的有效模式与重试引导。参见 [`dsh-bash` 整合](../bash/README.md)和[沙箱切换契约](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -79,7 +81,7 @@ overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecReques
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
此插件注册作用域内的每个请求都包含下方 bash 指引。启用沙箱的执行器不会添加模式声明或切换通知。作用域工具限制可以隐藏 schema,但不会移除这个独立注册的段落。
|
||||
此插件注册作用域内的每个请求都包含下方 bash 指引。启用沙箱的执行器会通过策略归属方的缓存安全运行时上下文贡献能力事实,而不改变此段落。作用域工具限制可以隐藏 schema,但不会移除这个独立注册的段落。
|
||||
|
||||
##### Bash 指引
|
||||
|
||||
|
||||
@@ -376,6 +376,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
if (defaultMode !== undefined && sandboxPolicy === undefined) {
|
||||
throw new Error('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing')
|
||||
}
|
||||
if (escalationModes.length > 0) sandboxPolicy?.registerEscalatableFamily('bash')
|
||||
|
||||
/** Resolve the complete standing policy for this call when a confining executor is mounted. */
|
||||
const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined =>
|
||||
|
||||
@@ -514,7 +514,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
methods: [
|
||||
{
|
||||
signature: 'registerEnforcedFamily(family: \'filesystem\' | \'bash\' | \'terminal\'): () => void',
|
||||
jsDoc: '/**\n * Register one runtime contribution that enforces the shared file policy for\n * a model-facing operation family. Equal families remain independently\n * disposable; registration and removal invalidate assembled prompt caches\n * when a system-prompt service is active.\n * @param family - operation family whose file effects this contribution enforces.\n * @returns the exact Cordis effect disposer for this contribution.\n */',
|
||||
jsDoc: '/**\n * Register one runtime contribution that enforces the shared file policy for\n * a model-facing operation family. Equal families remain independently\n * disposable; registration and removal invalidate request-input assemblies\n * when a system-prompt service is active.\n * @param family - operation family whose file effects this contribution enforces.\n * @returns the exact Cordis effect disposer for this contribution.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'registerEscalatableFamily(family: \'filesystem\' | \'bash\' | \'terminal\'): () => void',
|
||||
jsDoc: '/**\n * Register one model-facing family whose tool schema and execution path offer\n * an approved wider retry after a real denial. Equal contributions remain\n * independently disposable; a family is narrated as escalatable only while\n * it is also enforced.\n * @param family - operation family whose tools expose escalation.\n * @returns the exact Cordis effect disposer for this contribution.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy',
|
||||
@@ -860,6 +864,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'section(section: PromptSection): () => void',
|
||||
jsDoc: '/**\n * Register an ordered prompt section in the calling context\'s scope. A scoped\n * section shadows a global section with the same name; duplicates within one\n * layer and non-finite orders throw. Registration and disposal emit\n * `system-prompt/change`.\n * @param section - the section to register.\n * @returns the exact Cordis effect disposer.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'context(context: PromptContext): () => void',
|
||||
jsDoc: '/**\n * Register ordered cache-safe dynamic context in the calling context\'s scope.\n * A scoped context shadows a global context with the same name; duplicates\n * within one layer and non-finite orders throw. Registration and disposal\n * emit `system-prompt/change`.\n * @param context - the context contribution to register.\n * @returns the exact Cordis effect disposer.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => void',
|
||||
jsDoc: '/**\n * Register a tool-schema provider in the calling context\'s scope. Global and\n * matching scoped providers both contribute; returning the reserved\n * {@link TOOL_ORDER_REST} name makes assembly fail.\n * @param provider - evaluated for each assembly with its context.\n * @returns the exact Cordis effect disposer.\n */',
|
||||
@@ -1358,8 +1366,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
name: 'system-prompt/assemble',
|
||||
mode: 'waterfall',
|
||||
signature: '\'system-prompt/assemble\'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>',
|
||||
jsDoc: '/**\n * Expert waterfall over the assembled sections, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * A supplied signal controls only this explicit assembly request and must not\n * be retained to control later turns.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */',
|
||||
summary: 'Expert waterfall over the assembled sections, tools, and variables.',
|
||||
jsDoc: '/**\n * Expert waterfall over the assembled sections, contexts, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * A supplied signal controls only this explicit assembly request and must not\n * be retained to control later turns.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */',
|
||||
summary: 'Expert waterfall over the assembled sections, contexts, tools, and variables.',
|
||||
},
|
||||
{
|
||||
name: 'system-prompt/change',
|
||||
@@ -1523,6 +1531,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'AssembleContext',
|
||||
declaration: 'export interface AssembleContext {\n scope?: ScopeKey;\n signal?: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AssembledContext',
|
||||
declaration: 'export interface AssembledContext {\n name: string;\n text: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AssembledSection',
|
||||
declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n}',
|
||||
@@ -2009,7 +2021,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'PromptAssembly',
|
||||
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',
|
||||
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n contexts: AssembledContext[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PromptContext',
|
||||
declaration: 'export interface PromptContext {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}',
|
||||
},
|
||||
{
|
||||
name: 'PromptSection',
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
LlmError,
|
||||
assertNever,
|
||||
createAssistantMessage,
|
||||
createUserMessage,
|
||||
deepFreeze,
|
||||
errorChain,
|
||||
freezeMessage,
|
||||
@@ -45,7 +46,7 @@ import {
|
||||
import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import type { AssistantMessage, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import { executeToolCalls } from './tool-calls.ts'
|
||||
|
||||
@@ -54,6 +55,34 @@ type StepOutcome =
|
||||
| { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean }
|
||||
| { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined }
|
||||
|
||||
const RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt'
|
||||
const CLEARED_RUNTIME_CONTEXT = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
|
||||
|
||||
/** Latest retained cache-safe runtime-context snapshot, excluding compacted-away history. */
|
||||
function retainedRuntimeContext(session: Session): string | undefined {
|
||||
for (const message of [...session.deriveMessages()].reverse()) {
|
||||
if (message.role !== 'user'
|
||||
|| message.source.kind !== 'plugin'
|
||||
|| message.source.plugin !== RUNTIME_CONTEXT_SOURCE) continue
|
||||
const [block] = message.content
|
||||
if (message.content.length === 1 && block?.type === 'text') return block.text
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Append a full current snapshot only when it changed or compaction removed it. */
|
||||
function materializeRuntimeContext(session: Session, current: string): void {
|
||||
const previous = retainedRuntimeContext(session)
|
||||
if (previous === undefined && current.length === 0) return
|
||||
const snapshot = current.length === 0 ? CLEARED_RUNTIME_CONTEXT : current
|
||||
if (previous === snapshot) return
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: snapshot }],
|
||||
source: { kind: 'plugin', plugin: RUNTIME_CONTEXT_SOURCE },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/**
|
||||
* The concrete {@link Agent}: each `run()` owns one turn and repeats model
|
||||
* steps while tools or steering require another request.
|
||||
@@ -511,10 +540,13 @@ export class ReactLoopAgent implements Agent {
|
||||
// this request together.
|
||||
this.drainOutbox(turn)
|
||||
|
||||
// Assemble the system prompt fresh each step (it may depend on log state).
|
||||
// Assemble request-owned prompt inputs fresh each step. Dynamic context is
|
||||
// committed at the tail before deriving history, preserving the stable
|
||||
// system/history cache prefix while keeping every model-visible byte logged.
|
||||
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
|
||||
signal.throwIfAborted()
|
||||
const system = renderPrompt(assembly)
|
||||
materializeRuntimeContext(session, renderContextSnapshot(assembly))
|
||||
|
||||
// Snapshot the exact log prefix: the reconstruction boundary. Appends
|
||||
// after this synchronous snapshot join the next request.
|
||||
|
||||
@@ -254,7 +254,7 @@ describe('agent loop', () => {
|
||||
// NO system field at all (not an empty string).
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} }))
|
||||
ctx.on('system-prompt/assemble', async () => ({ sections: [], contexts: [], tools: [], variables: {} }))
|
||||
const agent = ctx.agentLoop.create(SessionId('a-no-system'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'hi')
|
||||
@@ -264,6 +264,122 @@ describe('agent loop', () => {
|
||||
expect('system' in adapter.requests[0]!).toBe(false)
|
||||
})
|
||||
|
||||
it('materializes changed runtime context at the history tail without rewriting the system header', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('one'),
|
||||
textResponse('two'),
|
||||
textResponse('three'),
|
||||
textResponse('four'),
|
||||
textResponse('five'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
let mode = 'read-only'
|
||||
const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: () => `Mode: ${mode}.` })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-runtime-context'), { provider: 'mock', model: 'mock' })
|
||||
const contextEvents = () => agent.session.events.flatMap(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt'
|
||||
? [event]
|
||||
: [])
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(contextEvents()).toHaveLength(1)
|
||||
expect(contextEvents()[0]?.data.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nMode: read-only.',
|
||||
}])
|
||||
|
||||
send(agent, 'unchanged')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(contextEvents()).toHaveLength(1)
|
||||
|
||||
mode = 'danger-full-access'
|
||||
send(agent, 'changed')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(contextEvents()).toHaveLength(2)
|
||||
const changedBlock = contextEvents()[1]?.data.content[0]
|
||||
expect(changedBlock?.type).toBe('text')
|
||||
if (changedBlock?.type !== 'text') throw new Error('changed runtime context is not text')
|
||||
expect(changedBlock.text).toContain('danger-full-access')
|
||||
|
||||
dispose()
|
||||
send(agent, 'cleared')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(contextEvents()).toHaveLength(3)
|
||||
expect(contextEvents()[2]?.data.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.',
|
||||
}])
|
||||
|
||||
send(agent, 'still clear')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(contextEvents()).toHaveLength(3)
|
||||
expect(adapter.requests.map(request => request.system)).toEqual(Array(5).fill(adapter.requests[0]?.system))
|
||||
expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('re-emits unchanged runtime context when a surface replacement removed the retained snapshot', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-compacted'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
const contextEvent = agent.session.events.find(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')
|
||||
if (contextEvent?.type !== 'user/message') throw new Error('first turn did not materialize runtime context')
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'compacted summary' }],
|
||||
source: { kind: 'plugin', plugin: 'test-compaction' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: contextEvent.seq, end: contextEvent.seq },
|
||||
sourceEventSeqs: [contextEvent.seq],
|
||||
})
|
||||
|
||||
send(agent, 'after compaction')
|
||||
await waitForIdle(ctx, agent)
|
||||
const runtimeContexts = agent.session.events.flatMap(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt'
|
||||
? [event]
|
||||
: [])
|
||||
expect(runtimeContexts).toHaveLength(2)
|
||||
expect(adapter.requests[1]?.messages.some(message =>
|
||||
message.source.kind === 'plugin'
|
||||
&& message.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(true)
|
||||
})
|
||||
|
||||
it('replaces a malformed retained runtime-context message with the current complete snapshot', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-malformed'), { provider: 'mock', model: 'mock' })
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'broken' }, { type: 'text', text: 'snapshot' }],
|
||||
source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
send(agent, 'repair context')
|
||||
await waitForIdle(ctx, agent)
|
||||
const runtimeContexts = agent.session.events.flatMap(event =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt'
|
||||
? [event]
|
||||
: [])
|
||||
expect(runtimeContexts).toHaveLength(2)
|
||||
expect(runtimeContexts[1]?.data.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nMode: read-only.',
|
||||
}])
|
||||
})
|
||||
|
||||
it('records raw chunks for replay as assistant/chunk session events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('abc')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md
|
||||
README.md: 23bc0e8177ad2a778df9522e254bfd5e03a9871f
|
||||
README.zh.md: 1fd4febc1c15acda19e7abfca94079b9585c1972
|
||||
README.md: d4e0f69323b7326fc7575834bf48a5aeeec0777e
|
||||
README.zh.md: 47290335d725083fc46ef4f2ee09b09263276788
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
System prompt assembly registry. Plugins contribute ordered sections, tool schemas, and named variables. The loop assembles once per step and renders the result as the complete model prompt. This plugin owns the static harness identity and global deployment persona; an agent-scoped persona shadows the global default.
|
||||
Model-input assembly registry. Plugins contribute ordered stable system sections, cache-safe dynamic context, tool schemas, and named variables. The loop assembles once per step, renders stable sections as the system prompt, and appends a durable full dynamic-context snapshot only when its text changes or compaction removed the retained snapshot. This plugin owns the static harness identity and global deployment persona; an agent-scoped persona shadows the global default.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -17,6 +17,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem
|
||||
### Public API
|
||||
|
||||
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.context(context: PromptContext): () => void` Contribute cache-safe dynamic model context. Contexts are ordered independently from system sections; scoped contributions shadow same-named globals. The agent loop materializes the complete current set as one sourced user-role snapshot after retained history, only when changed or missing. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
|
||||
@@ -29,14 +30,17 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem
|
||||
|
||||
- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent, signal)`). Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame.
|
||||
- `PromptSection` — `{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`.
|
||||
- `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
|
||||
- `PromptContext` — `{ name, order, text }`. Contexts carry changing current facts that must not rewrite the cached system/history prefix; they use the same per-assembly provider and strict-variable contracts as sections.
|
||||
- `PromptAssembly` — `{ sections: AssembledSection[], contexts: AssembledContext[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section and context texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
|
||||
- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned.
|
||||
- `renderContextSnapshot(assembly)` — applies the same strict interpolation to contexts, drops empty entries, and emits one full snapshot with an explicit supersession statement. An empty active set returns `''`; the loop emits one clearing snapshot when previously visible context disappears.
|
||||
|
||||
Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `AssembleContext` via declaration merging.
|
||||
|
||||
### Extension points
|
||||
|
||||
- Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`.
|
||||
- Context providers: policy and other changing-state owners contribute complete current facts without mutating the stable system prompt.
|
||||
- Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …).
|
||||
- Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically.
|
||||
- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller.
|
||||
@@ -65,6 +69,20 @@ Identity is a fixed per-request cost when enabled. Persona and plugin text are r
|
||||
|
||||
Prefix-stable while identity, persona, variables, section text, and order render identically. Any change may invalidate reuse from the first changed system-prompt token.
|
||||
|
||||
### Dynamic runtime context
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Active contexts are joined in deterministic order after strict interpolation and logged as one sourced user-role message immediately before the request that first needs that snapshot. The message begins `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.` A changed snapshot is appended after retained history; an unchanged retained snapshot adds nothing. If compaction removes it, the current full snapshot is emitted again. Removing the last context emits one explicit clearing snapshot.
|
||||
|
||||
#### Token effect
|
||||
|
||||
One concise message on the first request, on an effective context change, after compaction removed the retained snapshot, or when the active set becomes empty. Unchanged steps add no duplicate tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only after retained history. A context change preserves the previously cached system and conversation prefix instead of rewriting the first wire message.
|
||||
|
||||
### Tool schemas
|
||||
|
||||
#### What the model sees
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
系统提示词组装注册表。插件贡献有序段、工具 schema 和具名变量。循环在每个步骤组装一次,并将结果渲染为完整的模型提示词。此插件拥有静态 harness 身份和全局部署 persona;agent(智能体)作用域的 persona 会遮蔽全局默认值。
|
||||
模型输入组装注册表。插件贡献有序且稳定的系统段、缓存安全的动态上下文、工具 schema 和具名变量。循环在每个步骤组装一次,将稳定段渲染为系统提示词,并且仅在文本变化或压缩(compaction)移除了保留的快照时,追加一份持久的完整动态上下文快照。此插件拥有静态 harness 身份和全局部署 persona;agent(智能体)作用域的 persona 会遮蔽全局默认值。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
### 公开 API
|
||||
|
||||
- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose(资源释放)。
|
||||
- `ctx.systemPrompt.context(context: PromptContext): () => void`:贡献缓存安全的动态模型上下文。上下文与系统段分别排序;带作用域的贡献会遮蔽同名全局项。仅在完整当前集合变化或缺失时,agent loop(智能体循环)会在保留的历史后将其具体化为一份带来源的 user 角色快照。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose。
|
||||
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void`:贡献工具 schema;每次组装时使用该次组装的上下文求值。`ToolProviderResult` = `{ schemas, knownNames? }`:`schemas` 是限制后的可见集合;`knownNames` 是限制前由 `toolOrder` 使用的全集。提供方不得返回名为 `TOOL_ORDER_REST` 的 schema。带作用域提供方只在其作用域的组装中查询。随调用 fiber 一并 dispose。
|
||||
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void`:贡献提示词变量,在段文本中以 `{{name}}` 引用。带作用域变量会为该 agent 遮蔽同名全局变量。同层重复或无法引用的名称会抛出;`undefined` 表示「本次组装没有值」。随调用 fiber 一并 dispose。
|
||||
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 seam 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,并返回其权威结果。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。当已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。
|
||||
@@ -29,15 +30,18 @@
|
||||
|
||||
- `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent`(类型化 DX 字段;绝不能在没有 `scope` 时设置,应使用 `assembleContextFor(agent, signal)`)。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。
|
||||
- `PromptSection`:`{ name, order, text }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。
|
||||
- `PromptAssembly`:`{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`。段文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。
|
||||
- `PromptContext`:`{ name, order, text }`。上下文承载不断变化的当前事实,这些事实不能改写已缓存的系统/历史前缀;上下文与段使用相同的逐组装提供方契约和严格变量契约。
|
||||
- `PromptAssembly`:`{ sections: AssembledSection[], contexts: AssembledContext[], tools: ToolSchema[], variables: Record<string, string | undefined> }`。段与上下文文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。
|
||||
- `renderPrompt(assembly)`:插值每个段中的 `{{variable}}` 引用,删除空段,并用空行连接。严格规则:未知引用(使用 `Object.hasOwn` 查找,因此 `{{constructor}}` 等原型名称未知)、已注册但无值的引用、格式错误的完整 `{{…}}` 组,或一个起始 `{{` 没有打开完整组、但后面仍有 `}}`(`{{{model}}}`),都会抛出;明确失败胜过交付格式错误的提示词。孤立的 `{{` 如果后面任何位置都没有 `}}`,会按字面量通过;替换值绝不再次扫描。
|
||||
- `renderContextSnapshot(assembly)`:对上下文执行同样严格的插值,删除空条目,并发出一份带显式取代声明的完整快照。活动集合为空时返回 `''`;先前可见的上下文消失时,循环会发出一份清除快照。
|
||||
|
||||
可通过合并扩展:插件可以借助声明合并,为 `PromptAssembly` 和 `AssembleContext` 声明额外字段。
|
||||
|
||||
### 扩展点
|
||||
|
||||
- 段提供方:工具包(package)拥有跨调用引导(`tool:bash`、`tool:read` 等);此插件拥有 `harness:identity` 与 `deployment:persona`。
|
||||
- 变量提供方:agent loop(智能体循环)注册 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)。
|
||||
- 上下文提供方:策略及其他变化状态的归属方贡献完整的当前事实,而不改变稳定的系统提示词。
|
||||
- 变量提供方:agent loop 注册 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)。
|
||||
- 工具 schema 提供方:`ToolRegistry` 自动将自身注册为工具提供方。
|
||||
- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果。
|
||||
|
||||
@@ -65,6 +69,20 @@ You are an AI agent powered by the DeepSeek Harness SDK.
|
||||
|
||||
只要身份、persona、变量、段文本与顺序的渲染完全相同,前缀就保持稳定。任何变更都可能从第一个变化的系统提示词 token 起使复用失效。
|
||||
|
||||
### 动态运行时上下文
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
活动上下文经过严格插值后按确定顺序连接,并在首次需要该快照的请求之前立即记录为一条带来源的 user 角色消息。消息以 `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.` 开头。变化后的快照会追加到保留的历史之后;保留的快照未变时不会增加内容。如果压缩移除了它,当前完整快照会再次发出。移除最后一项上下文时会发出一份显式清除快照。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
首次请求、上下文实际变化、压缩移除保留的快照或活动集合变空时,会增加一条简洁消息。未变化的步骤不会增加重复 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
在保留的历史之后仅追加。上下文变化会保留先前缓存的系统与对话前缀,而不会改写第一条 wire 消息。
|
||||
|
||||
### 工具 schema
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Registry for ordered prompt sections, tool schemas, and prompt variables.
|
||||
* Registry for ordered system sections, cache-safe context, tool schemas, and prompt variables.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-system-prompt
|
||||
*/
|
||||
@@ -17,7 +17,7 @@ declare module 'cordis' {
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Expert waterfall over the assembled sections, tools, and variables.
|
||||
* Expert waterfall over the assembled sections, contexts, tools, and variables.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners
|
||||
* receive only that scope's assemblies. The returned value is authoritative.
|
||||
* A supplied signal controls only this explicit assembly request and must not
|
||||
@@ -65,6 +65,20 @@ export interface PromptSection {
|
||||
readonly text: string | ((context: AssembleContext) => string)
|
||||
}
|
||||
|
||||
/**
|
||||
* One dynamic model-context contribution. Unlike a {@link PromptSection}, its
|
||||
* rendered text is materialized as a durable user-role snapshot at the request
|
||||
* tail, so changing runtime state preserves the stable system/history prefix.
|
||||
*/
|
||||
export interface PromptContext {
|
||||
/** Unique name — a duplicate registration throws (see {@link SystemPrompt.context}). */
|
||||
readonly name: string
|
||||
/** Contexts are joined in ascending order, independently of system-section order. */
|
||||
readonly order: number
|
||||
/** Static text or a provider evaluated for each assembly. Empty text contributes nothing. */
|
||||
readonly text: string | ((context: AssembleContext) => string)
|
||||
}
|
||||
|
||||
/** One section of an assembly: {@link PromptSection} with its text resolved. */
|
||||
export interface AssembledSection {
|
||||
/** The contributing section's unique name. */
|
||||
@@ -73,6 +87,14 @@ export interface AssembledSection {
|
||||
text: string
|
||||
}
|
||||
|
||||
/** One dynamic context contribution with its text resolved. */
|
||||
export interface AssembledContext {
|
||||
/** The contributing context's unique name. */
|
||||
name: string
|
||||
/** The resolved (but not yet interpolated) context text. */
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Tool schemas visible in one assembly and their pre-restriction name set. */
|
||||
export interface ToolProviderResult {
|
||||
/** The schemas this provider contributes to THIS assembly. */
|
||||
@@ -82,11 +104,13 @@ export interface ToolProviderResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge-extensible assembled prompt. Sections remain uninterpolated until
|
||||
* {@link renderPrompt}; tools are already in canonical model-facing order.
|
||||
* Merge-extensible assembled model input. Sections and contexts remain
|
||||
* uninterpolated until their renderers; tools are already in canonical
|
||||
* model-facing order.
|
||||
*/
|
||||
export interface PromptAssembly {
|
||||
sections: AssembledSection[]
|
||||
contexts: AssembledContext[]
|
||||
tools: ToolSchema[]
|
||||
variables: Record<string, string | undefined>
|
||||
}
|
||||
@@ -175,6 +199,23 @@ export function renderPrompt(assembly: PromptAssembly): string {
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the complete current dynamic context snapshot. The agent loop appends
|
||||
* a new durable snapshot only when this text changes or is no longer retained
|
||||
* after compaction; the explicit supersession clause makes older snapshots in
|
||||
* history harmless.
|
||||
* @param assembly - the assembly whose contexts and variables to render.
|
||||
* @returns the current full snapshot, or `''` when no context is active.
|
||||
*/
|
||||
export function renderContextSnapshot(assembly: PromptAssembly): string {
|
||||
const body = assembly.contexts
|
||||
.map(context => interpolate(context, assembly.variables))
|
||||
.filter(text => text.length > 0)
|
||||
.join('\n\n')
|
||||
if (body.length === 0) return ''
|
||||
return `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n${body}`
|
||||
}
|
||||
|
||||
/** Interpolate one section's `{{variable}}` references (see {@link renderPrompt}). */
|
||||
function interpolate(section: AssembledSection, variables: Record<string, string | undefined>): string {
|
||||
const text = section.text
|
||||
@@ -220,6 +261,7 @@ type VariableProvider = (context: AssembleContext) => string | undefined
|
||||
/** All prompt registrations owned by one global or scoped layer. */
|
||||
class PromptLayer implements ScopeLayer {
|
||||
readonly sections: NamedEntries<PromptSection>
|
||||
readonly contexts: NamedEntries<PromptContext>
|
||||
readonly toolProviders = new AnonymousEntries<ToolProvider>()
|
||||
readonly variables: NamedEntries<VariableProvider>
|
||||
|
||||
@@ -231,6 +273,9 @@ class PromptLayer implements ScopeLayer {
|
||||
this.sections = new NamedEntries(name => new Error(scope === undefined
|
||||
? `prompt section "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt section "${name}" is already registered in this scope`))
|
||||
this.contexts = new NamedEntries(name => new Error(scope === undefined
|
||||
? `prompt context "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt context "${name}" is already registered in this scope`))
|
||||
this.variables = new NamedEntries(name => new Error(scope === undefined
|
||||
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt variable "${name}" is already registered in this scope`))
|
||||
@@ -239,6 +284,7 @@ class PromptLayer implements ScopeLayer {
|
||||
/** @returns whether this layer owns no prompt registrations. */
|
||||
isEmpty(): boolean {
|
||||
return this.sections.isEmpty()
|
||||
&& this.contexts.isEmpty()
|
||||
&& this.toolProviders.isEmpty()
|
||||
&& this.variables.isEmpty()
|
||||
}
|
||||
@@ -297,6 +343,25 @@ export class SystemPrompt extends Service {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register ordered cache-safe dynamic context in the calling context's scope.
|
||||
* A scoped context shadows a global context with the same name; duplicates
|
||||
* within one layer and non-finite orders throw. Registration and disposal
|
||||
* emit `system-prompt/change`.
|
||||
* @param context - the context contribution to register.
|
||||
* @returns the exact Cordis effect disposer.
|
||||
*/
|
||||
context(context: PromptContext): () => void {
|
||||
if (!Number.isFinite(context.order)) {
|
||||
throw new TypeError(`prompt context "${context.name}" order must be a finite number`)
|
||||
}
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.contexts.insert(context.name, context),
|
||||
{ label: 'systemPrompt.context()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a tool-schema provider in the calling context's scope. Global and
|
||||
* matching scoped providers both contribute; returning the reserved
|
||||
@@ -352,6 +417,7 @@ export class SystemPrompt extends Service {
|
||||
}
|
||||
// Scoped sections shadow globals before the stable order sort.
|
||||
const sectionByName = this.layers.merge(scope, layer => layer.sections)
|
||||
const contextByName = this.layers.merge(scope, layer => layer.contexts)
|
||||
// Validate order against pre-restriction names while collecting visible schemas.
|
||||
const providers = [
|
||||
...this.layers.global.toolProviders.values(),
|
||||
@@ -377,6 +443,12 @@ export class SystemPrompt extends Service {
|
||||
name: section.name,
|
||||
text: typeof section.text === 'function' ? section.text(context) : section.text,
|
||||
})),
|
||||
contexts: [...contextByName.values()]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(entry => ({
|
||||
name: entry.name,
|
||||
text: typeof entry.text === 'function' ? entry.text(context) : entry.text,
|
||||
})),
|
||||
tools: orderTools(collected, this.toolOrder, knownNames),
|
||||
variables,
|
||||
}
|
||||
|
||||
@@ -22,6 +22,14 @@ function validateAssembly(assembly: PromptAssembly, fail: InvariantFailure): voi
|
||||
if (typeof section.text !== 'string') fail(`assembled section ${JSON.stringify(section.name)} text must be a string`)
|
||||
}
|
||||
|
||||
const contextNames = new Set<string>()
|
||||
for (const context of assembly.contexts) {
|
||||
if (context.name.length === 0) fail('assembled context names must be non-empty')
|
||||
if (contextNames.has(context.name)) fail(`assembled context name ${JSON.stringify(context.name)} is duplicated`)
|
||||
contextNames.add(context.name)
|
||||
if (typeof context.text !== 'string') fail(`assembled context ${JSON.stringify(context.name)} text must be a string`)
|
||||
}
|
||||
|
||||
for (const tool of assembly.tools) {
|
||||
if (tool.name.length === 0) fail('assembled tool names must be non-empty')
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ async function setup(): Promise<Context> {
|
||||
|
||||
const valid = (): PromptAssembly => ({
|
||||
sections: [{ name: 'identity', text: 'prompt' }],
|
||||
contexts: [{ name: 'policy', text: 'current policy' }],
|
||||
tools: [{ name: 'echo', description: 'Echo', parameters: {} }],
|
||||
variables: { cwd: '/repo', optional: undefined },
|
||||
})
|
||||
@@ -34,6 +35,9 @@ describe('system-prompt invariants', () => {
|
||||
[{ ...valid(), sections: [{ name: '', text: 'x' }] }, /section names must be non-empty/],
|
||||
[{ ...valid(), sections: [{ name: 'x', text: 'a' }, { name: 'x', text: 'b' }] }, /section name "x" is duplicated/],
|
||||
[{ ...valid(), sections: [{ name: 'x', text: 1 as never }] }, /section "x" text must be a string/],
|
||||
[{ ...valid(), contexts: [{ name: '', text: 'x' }] }, /context names must be non-empty/],
|
||||
[{ ...valid(), contexts: [{ name: 'x', text: 'a' }, { name: 'x', text: 'b' }] }, /context name "x" is duplicated/],
|
||||
[{ ...valid(), contexts: [{ name: 'x', text: 1 as never }] }, /context "x" text must be a string/],
|
||||
[{ ...valid(), tools: [{ name: '', description: 'x', parameters: {} }] }, /tool names must be non-empty/],
|
||||
[{ ...valid(), variables: { Bad: 'x' } }, /variable name "Bad" is invalid/],
|
||||
[{ ...valid(), variables: { value: 1 as never } }, /variable "value" must be a string or undefined/],
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import SystemPrompt, { TOOL_ORDER_REST, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import SystemPrompt, { TOOL_ORDER_REST, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Config, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
async function mount(config: Config = {}): Promise<Context> {
|
||||
@@ -125,6 +125,25 @@ describe('scoped variables', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoped cache-safe context', () => {
|
||||
it('shadows a global context for one scope and cleans up with that scope', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child-context')
|
||||
ctx.systemPrompt.context({ name: 'policy', order: 1, text: 'global policy' })
|
||||
scope.ctx.systemPrompt.context({ name: 'policy', order: 1, text: 'scoped policy' })
|
||||
expect(() => scope.ctx.systemPrompt.context({ name: 'policy', order: 2, text: 'duplicate' }))
|
||||
.toThrow('prompt context "policy" is already registered in this scope')
|
||||
|
||||
expect(renderContextSnapshot(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })))
|
||||
.toContain('scoped policy')
|
||||
expect(renderContextSnapshot(await ctx.systemPrompt.assemble())).toContain('global policy')
|
||||
|
||||
await scope.dispose()
|
||||
expect(renderContextSnapshot(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })))
|
||||
.toContain('global policy')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoped tool providers and toolOrder × restriction', () => {
|
||||
it('scoped providers are consulted only for their scope', async () => {
|
||||
const ctx = await mount()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt, { AssembleContext, PromptAssembly, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import SystemPrompt, { AssembleContext, PromptAssembly, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
/**
|
||||
* Every assembly carries the plugin's own built-ins — `harness:identity`
|
||||
@@ -64,14 +64,21 @@ describe('SystemPrompt', () => {
|
||||
|
||||
ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => 'cwd: /tmp' })
|
||||
ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' })
|
||||
ctx.systemPrompt.context({ name: 'later', order: 20, text: () => 'context 2' })
|
||||
ctx.systemPrompt.context({ name: 'earlier', order: 10, text: 'context 1' })
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'echo', description: 'echo back', parameters: {} }] }))
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'rules', 'cwd'])
|
||||
expect(assembly.sections.map(s => s.text)).toEqual([IDENTITY, 'You are DeepSeek Harness SDK.', 'Be precise.', 'cwd: /tmp'])
|
||||
expect(assembly.contexts).toEqual([
|
||||
{ name: 'earlier', text: 'context 1' },
|
||||
{ name: 'later', text: 'context 2' },
|
||||
])
|
||||
expect(assembly.tools).toEqual([{ name: 'echo', description: 'echo back', parameters: {} }])
|
||||
expect(assembly.variables).toEqual({})
|
||||
expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.\n\nBe precise.\n\ncwd: /tmp`)
|
||||
expect(renderContextSnapshot(assembly)).toBe('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\ncontext 1\n\ncontext 2')
|
||||
})
|
||||
|
||||
it('resolves section text providers against the assemble context, at each assemble call', async () => {
|
||||
@@ -96,16 +103,19 @@ describe('SystemPrompt', () => {
|
||||
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.systemPrompt.section({ name: 'scoped', order: 0, text: 'scoped section' })
|
||||
inner.systemPrompt.context({ name: 'scoped-context', order: 0, text: 'scoped context' })
|
||||
inner.systemPrompt.tools(() => ({ schemas: [{ name: 'scoped-tool', description: '', parameters: {} }] }))
|
||||
inner.systemPrompt.variable('scoped_var', () => 'v')
|
||||
}, { inject: ['systemPrompt'] }))
|
||||
|
||||
const before = await ctx.systemPrompt.assemble()
|
||||
expect(contributed(before)).toHaveLength(1)
|
||||
expect(before.contexts).toHaveLength(1)
|
||||
expect(before.variables).toEqual({ scoped_var: 'v' })
|
||||
await fiber.dispose()
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(contributed(assembly)).toHaveLength(0)
|
||||
expect(assembly.contexts).toHaveLength(0)
|
||||
// The built-ins belong to the service fiber, so they survive the plugin's disposal.
|
||||
expect(assembly.sections.map(s => s.name)).toEqual(BUILT_IN)
|
||||
expect(assembly.tools).toHaveLength(0)
|
||||
@@ -131,6 +141,17 @@ describe('SystemPrompt', () => {
|
||||
expect(contributed(await ctx.systemPrompt.assemble())).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects duplicate and non-finite context registrations without leaking', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.context({ name: 'policy', order: 1, text: 'first' })
|
||||
expect(() => ctx.systemPrompt.context({ name: 'policy', order: 2, text: 'second' }))
|
||||
.toThrow('prompt context "policy" is already registered')
|
||||
expect(() => ctx.systemPrompt.context({ name: 'bad', order: Number.NaN, text: 'x' }))
|
||||
.toThrow('prompt context "bad" order must be a finite number')
|
||||
expect((await ctx.systemPrompt.assemble()).contexts).toEqual([{ name: 'policy', text: 'first' }])
|
||||
})
|
||||
|
||||
it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -236,7 +257,7 @@ describe('SystemPrompt', () => {
|
||||
ctx.systemPrompt.section({ name: 'real', order: 0, text: 'real' })
|
||||
|
||||
ctx.on('system-prompt/assemble', async () => {
|
||||
return { sections: [], tools: [], variables: {} } satisfies PromptAssembly
|
||||
return { sections: [], contexts: [], tools: [], variables: {} } satisfies PromptAssembly
|
||||
})
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
@@ -252,6 +273,7 @@ describe('SystemPrompt', () => {
|
||||
const first = await ctx.systemPrompt.assemble()
|
||||
first.sections[0]!.name = 'mutated'
|
||||
first.sections[0]!.text = 'mutated'
|
||||
first.contexts.push({ name: 'mutated', text: 'mutated' })
|
||||
first.tools[0]!.description = 'mutated'
|
||||
const firstParameters = first.tools[0]!.parameters as { properties: Record<string, unknown> }
|
||||
firstParameters.properties['leak'] = { type: 'string' }
|
||||
@@ -259,6 +281,7 @@ describe('SystemPrompt', () => {
|
||||
const second = await ctx.systemPrompt.assemble()
|
||||
expect(second.sections.map(section => section.name)).toEqual(['harness:identity', 'deployment:persona', 'base'])
|
||||
expect(second.sections[0]!.text).toBe(IDENTITY)
|
||||
expect(second.contexts).toEqual([])
|
||||
expect(second.tools).toEqual([{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }])
|
||||
})
|
||||
|
||||
@@ -268,12 +291,24 @@ describe('SystemPrompt', () => {
|
||||
{ name: 'empty', text: '' },
|
||||
{ name: 'real', text: 'content' },
|
||||
],
|
||||
contexts: [],
|
||||
tools: [],
|
||||
variables: {},
|
||||
})
|
||||
expect(result).toBe('content')
|
||||
})
|
||||
|
||||
it('filters empty context, interpolates variables, and returns empty without active context', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.context({ name: 'empty', order: 0, text: '' })
|
||||
expect(renderContextSnapshot(await ctx.systemPrompt.assemble())).toBe('')
|
||||
ctx.systemPrompt.variable('mode', () => 'read-only')
|
||||
ctx.systemPrompt.context({ name: 'policy', order: 1, text: 'Mode: {{mode}}.' })
|
||||
expect(renderContextSnapshot(await ctx.systemPrompt.assemble()))
|
||||
.toBe('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nMode: read-only.')
|
||||
})
|
||||
|
||||
it('emits system-prompt/change when a tool provider is registered and disposed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -290,6 +325,17 @@ describe('SystemPrompt', () => {
|
||||
expect(changeCount).toBe(2)
|
||||
})
|
||||
|
||||
it('emits system-prompt/change when a context is registered and disposed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
let changeCount = 0
|
||||
ctx.on('system-prompt/change', () => void changeCount++)
|
||||
const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'current' })
|
||||
expect(changeCount).toBe(1)
|
||||
dispose()
|
||||
expect(changeCount).toBe(2)
|
||||
})
|
||||
|
||||
it('cleans up tool providers on fiber dispose', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -404,13 +450,14 @@ describe('SystemPrompt', () => {
|
||||
})
|
||||
|
||||
it('names "(none)" when no variables are registered at all', () => {
|
||||
expect(() => renderPrompt({ sections: [{ name: 's', text: '{{x}}' }], tools: [], variables: {} }))
|
||||
expect(() => renderPrompt({ sections: [{ name: 's', text: '{{x}}' }], contexts: [], tools: [], variables: {} }))
|
||||
.toThrow('unknown prompt variable "{{x}}" in section "s"; registered variables: (none)')
|
||||
})
|
||||
|
||||
it('throws when a referenced variable has no value for this assembly', () => {
|
||||
expect(() => renderPrompt({
|
||||
sections: [{ name: 'persona', text: 'in {{cwd}}' }],
|
||||
contexts: [],
|
||||
tools: [],
|
||||
variables: { cwd: undefined },
|
||||
})).toThrow('prompt variable "{{cwd}}" has no value for this assembly (section "persona")')
|
||||
@@ -419,6 +466,7 @@ describe('SystemPrompt', () => {
|
||||
it('throws on a malformed complete reference, e.g. inner spaces', () => {
|
||||
expect(() => renderPrompt({
|
||||
sections: [{ name: 's', text: 'on {{ model }}' }],
|
||||
contexts: [],
|
||||
tools: [],
|
||||
variables: { model: 'm' },
|
||||
})).toThrow('malformed prompt variable reference "{{ model }}" in section "s"')
|
||||
@@ -427,6 +475,7 @@ describe('SystemPrompt', () => {
|
||||
it('leaves a lone {{ verbatim only when NO }} follows anywhere after it', () => {
|
||||
const text = renderPrompt({
|
||||
sections: [{ name: 's', text: 'shell ${X:-{{fallback} stays' }],
|
||||
contexts: [],
|
||||
tools: [],
|
||||
variables: {},
|
||||
})
|
||||
@@ -439,6 +488,7 @@ describe('SystemPrompt', () => {
|
||||
])('throws on a mangled reference with a }} still following ($label)', ({ text }) => {
|
||||
expect(() => renderPrompt({
|
||||
sections: [{ name: 's', text }],
|
||||
contexts: [],
|
||||
tools: [],
|
||||
variables: { model: 'm' },
|
||||
})).toThrow('malformed prompt variable reference at')
|
||||
@@ -449,6 +499,7 @@ describe('SystemPrompt', () => {
|
||||
// source into the prompt; Object.hasOwn must reject it instead.
|
||||
expect(() => renderPrompt({
|
||||
sections: [{ name: 's', text: 'on {{constructor}}' }],
|
||||
contexts: [],
|
||||
tools: [],
|
||||
variables: { model: 'm' },
|
||||
})).toThrow('unknown prompt variable "{{constructor}}"')
|
||||
@@ -465,6 +516,7 @@ describe('SystemPrompt', () => {
|
||||
it('never re-scans substituted values (a value containing {{sneaky}} stays literal)', () => {
|
||||
const text = renderPrompt({
|
||||
sections: [{ name: 's', text: 'v = {{model}}!' }],
|
||||
contexts: [],
|
||||
tools: [],
|
||||
variables: { model: 'literal {{sneaky}} inside' },
|
||||
})
|
||||
|
||||
@@ -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/fs/tool-fs/README.md
|
||||
README.md: 4ff9b043525e8e7a0b59e3d91410951d88bb9a69
|
||||
README.zh.md: ce93e10072d74ce268273aa472bfbb3f34f46259
|
||||
README.md: eae4fca7fcb7416a139e17a2ec356b4445ba4fc3
|
||||
README.zh.md: 8005fc63851ea2373ecd225358694d02cbe9a6ec
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it.
|
||||
The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly**. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. Under a confining provider, the shared sandbox-policy service is required for per-session execution and the tool registers that filesystem mutations expose escalation.
|
||||
|
||||
```ts ignore-check
|
||||
// Default deployment: a ctx.fs provider, the policy plugin, then the tools.
|
||||
@@ -46,6 +46,8 @@ The tools do **not** inject a policy service or inspect any cache. Each tool res
|
||||
|
||||
The tool passes `exec` (the tool-execution context) as the opaque `actor` on every dispatch. The default thunks return `undefined` (the unconstrained bare provider). When `@deepseek-ai/dsh-fs-policy` is loaded it occupies the single decision slot — returning `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED` — and records on `fs/observed`. Backend errors (`FsError`) and a thrown `FS_NOT_OBSERVED` flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached.
|
||||
|
||||
When `ctx.fs.sandboxMode` reports confinement, write/edit advertise `sandbox_permissions` and `justification`, resolve approved retries through `ctx.approval`, and register `filesystem` as escalatable with `ctx.sandboxPolicy`. The policy owner therefore adds anti-refusal context only while the real mutation tools offer that path.
|
||||
|
||||
## `fs/observed` is fire-and-forget
|
||||
|
||||
`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**面向模型的文件系统工具**(`read`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方 seam([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑:注入 `fs`(以及 `tools`/`systemPrompt`),**不**注入策略服务。新鲜度/观察策略由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。
|
||||
**面向模型的文件系统工具**(`read`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方 seam([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑。新鲜度/观察策略由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。使用施加沙箱限制的提供方时,逐会话执行需要共享沙箱策略服务,工具还会登记文件系统变更具备升权路径。
|
||||
|
||||
```ts ignore-check
|
||||
// Default deployment: a ctx.fs provider, the policy plugin, then the tools.
|
||||
@@ -46,6 +46,8 @@ await ctx.plugin(ToolFs) // this package — re
|
||||
|
||||
工具在每次分派中把 `exec`(工具执行上下文)作为不透明 `actor` 传入。默认 thunk 返回 `undefined`(不受约束的裸提供方)。加载 `@deepseek-ai/dsh-fs-policy` 后,它会占用单个决策槽:返回 `createIfAbsent`/`replaceIfVersion`/`{ version }` 或抛出 `FS_NOT_OBSERVED`,并在 `fs/observed` 时记录。后端错误(`FsError`)和抛出的 `FS_NOT_OBSERVED` 会流经 `ToolRegistry.execute()`,变成 `isError` 工具结果,并附带 `{ name, code }`。
|
||||
|
||||
当 `ctx.fs.sandboxMode` 表明提供方施加沙箱限制时,write/edit 会公开 `sandbox_permissions` 与 `justification`,通过 `ctx.approval` 解析经批准的重试,并将 `filesystem` 家族注册到 `ctx.sandboxPolicy`,标记为可升权。因此,策略归属方只会在实际变更工具提供该路径时添加反预防性拒绝上下文。
|
||||
|
||||
## `fs/observed` 发后即忘
|
||||
|
||||
`fs/observed` 在读取/写入/编辑已经成功之后,通过普通 `ctx.emit` 发出。监听器的契约是同步且只有副作用的记录器(`@deepseek-ai/dsh-fs-policy` 使用 `WeakMap.set`);工具不保护这次发出,因此监听器抛出会作为工具的 `isError` 结果出现。异步或可能失败的观察不属于该事件。
|
||||
|
||||
@@ -67,6 +67,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// per-call policy resolution, and denial-marker mapping, all keyed off whether
|
||||
// the mounted ctx.fs confines (ctx.fs.sandboxMode).
|
||||
const sandbox = new FsSandboxSurface(ctx)
|
||||
if (sandbox.escalationModes.length > 0) ctx.get('sandboxPolicy')?.registerEscalatableFamily('filesystem')
|
||||
applyWriteTool(ctx, sandbox)
|
||||
applyEditTool(ctx, sandbox)
|
||||
}
|
||||
|
||||
@@ -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/sandbox/sandbox-policy/README.md
|
||||
README.md: 45349f7b0bbb6e035dd2aa6f4695735124dd9f2d
|
||||
README.zh.md: 9393d2a22aa3df310287ccb9c5e486880453a838
|
||||
README.md: 0f5c9b7c21acddd789b21edf6c5a7e1316fe4053
|
||||
README.zh.md: 10a4af24d82e8472803d96af1d9b7046731a7833
|
||||
|
||||
@@ -18,16 +18,17 @@ Filesystem tools, one-shot bash commands, and terminal sessions may enforce the
|
||||
- `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution.
|
||||
- `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`.
|
||||
- `ctx.sandboxPolicy.registerEnforcedFamily(family)` — independently registers `filesystem`, `bash`, or `terminal` and returns the exact effect disposer. Equal families remain separate contributions; the section uses canonical family order and removes a family only after its final contribution leaves.
|
||||
- `sandbox:policy` — a request-time system-prompt section derived from `resolve({ session })` and the active family contributions. It is empty without an enforcing family and states only the mode, the affected model-facing operations, and the canonical session workspace under `workspace-write`.
|
||||
- `ctx.sandboxPolicy.registerEscalatableFamily(family)` — independently registers a family whose actual tool schema and execution path offer an approved wider retry. Anti-refusal guidance names only families that are both enforced and escalatable; contributions dispose independently.
|
||||
- `sandbox:policy` — a request-time cache-safe context contribution derived from `resolve({ session })` and the active family contributions. It is empty without an enforcing family and states only the mode, the affected model-facing operations, and the canonical session workspace under `workspace-write`.
|
||||
- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`.
|
||||
- `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band.
|
||||
- `SANDBOX_MODES` — every mode, for option advertisement and runtime validation.
|
||||
|
||||
The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and core execution-enclosure rules. The rendered section is logged inside `request/header`, so the exact effective policy remains reconstructable without another event or an in-memory “last told” mirror.
|
||||
The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and core execution-enclosure rules. The agent loop logs the assembled full runtime-context snapshot as a sourced `user/message`, so exact policy input remains reconstructable without an in-memory “last told” mirror.
|
||||
|
||||
## The per-session store
|
||||
|
||||
A runtime switch is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? fold(events) ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event stays log-only; the next request assembles the current section from the fold before any tool call.
|
||||
A runtime switch is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? fold(events) ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event stays log-only; before the next request, the owner contributes the current fact to the full runtime-context snapshot.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -35,12 +36,12 @@ A runtime switch is one log-only `sandbox/mode` event on the session it applies
|
||||
|
||||
#### What the model sees
|
||||
|
||||
One `sandbox:policy` system section on each agent request when at least one enforcing family is registered. The examples below show all three families; absent families are omitted. Tool plugins retain operation and escalation guidance, approval policy remains `dsh-user-approval`'s section, and plan guidance remains `dsh-plan-mode`'s section.
|
||||
One `sandbox:policy` contribution in the current runtime-context snapshot when at least one enforcing family is registered. The examples below show all three families; absent families are omitted. Tool plugins retain operation and escalation guidance, approval policy contributes separately to the same snapshot, and plan guidance remains `dsh-plan-mode`'s system section.
|
||||
|
||||
##### Read-only
|
||||
|
||||
```markdown
|
||||
Current DSH file policy: read-only. The write and edit tools, one-shot bash commands, and terminal sessions cannot modify files under this policy.
|
||||
Current DSH file policy: read-only. The write and edit tools, one-shot bash commands, and terminal sessions cannot modify files in the standing mode. For the write and edit tools and one-shot bash commands, do not refuse a required modification from this standing mode alone: attempt it normally and follow the tool's denial and escalation guidance.
|
||||
```
|
||||
|
||||
##### Workspace-write
|
||||
@@ -57,14 +58,14 @@ Current DSH file policy: danger-full-access. The DSH file sandbox does not restr
|
||||
|
||||
#### Token effect
|
||||
|
||||
One concise system section per request. `workspace-write` carries only the canonical session workspace path; platform-specific temporary paths are summarized without adding host-dependent bytes.
|
||||
One concise durable context message on the first request and each effective policy change; unchanged requests add nothing. `workspace-write` carries only the canonical session workspace path; platform-specific temporary paths are summarized without adding host-dependent bytes.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The request prefix is byte-stable while the session mode and immutable workspace root stay unchanged. A mode switch changes the section on the next request; the resulting `request/header` records the new prefix.
|
||||
The stable system prompt remains byte-identical across mode changes. A changed full context snapshot is appended after retained history, preserving the prior cached prefix; subsequent unchanged requests reuse that retained snapshot.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One primary workspace root per session** — policy resolves `SessionHeader.cwd`; extra writable roots are not part of `SandboxExecutionPolicy`.
|
||||
- **File-effect modes only** — `SandboxMode` governs file effects; network and process policy are outside its vocabulary, so no knob here restricts them.
|
||||
- **Temporary areas are deliberately summarized** — enforcing backends grant different platform temporary areas, which are selected after policy resolution and therefore cannot be enumerated truthfully in the standing section.
|
||||
- **Temporary areas are deliberately summarized** — enforcing backends grant different platform temporary areas, which are selected after policy resolution and therefore cannot be enumerated truthfully in the current context.
|
||||
|
||||
@@ -18,16 +18,17 @@
|
||||
- `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。
|
||||
- `ctx.sandboxPolicy.defaultMode`/`ctx.sandboxPolicy.workspaceRoot`:`resolve()` 使用的部署默认值与回退根目录。
|
||||
- `ctx.sandboxPolicy.registerEnforcedFamily(family)`:独立注册 `filesystem`、`bash` 或 `terminal`,并返回对应的精确 effect disposer。相同家族仍是彼此独立的贡献;该段落使用规范的家族顺序,并且只有最后一项贡献离开后才移除对应家族。
|
||||
- `sandbox:policy`:由 `resolve({ session })` 和当前家族贡献派生的请求时系统提示词段落。没有强制执行家族时为空,只说明模式、受影响的面向模型操作,以及 `workspace-write` 下规范化的会话工作区。
|
||||
- `ctx.sandboxPolicy.registerEscalatableFamily(family)`:独立注册实际工具 schema 与执行路径可提供经批准的更宽松模式重试的家族。反预防性拒绝引导只会列出既受强制执行又可升权的家族;各项贡献独立释放。
|
||||
- `sandbox:policy`:由 `resolve({ session })` 和当前家族贡献派生的请求时缓存安全上下文贡献。没有强制执行家族时为空,只说明模式、受影响的面向模型操作,以及 `workspace-write` 下规范化的会话工作区。
|
||||
- `effectiveSandboxMode(events)`:会话 `sandbox/mode` 事件的纯 fold(最后一次切换胜出,没有则为 `undefined`),在 `resolve()` 内使用。
|
||||
- `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。
|
||||
- `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。
|
||||
|
||||
可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件,只要其值不在该封闭词汇中;Session 与其配套组件负责相关存储与核心执行封闭规则。渲染后的段落记录在 `request/header` 中,因此无需另一条事件或内存中的「上次告知」镜像,也能重建确切的有效策略。
|
||||
可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件,只要其值不在该封闭词汇中;Session 与其配套组件负责相关存储与核心执行封闭规则。agent loop(智能体循环)会将组装后的完整运行时上下文快照记录为一条带来源的 `user/message`,因此无需内存中的「上次告知」镜像,也能重建确切的策略输入。
|
||||
|
||||
## 逐会话存储
|
||||
|
||||
运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? fold(events) ?? deployment default`,因此覆盖会通过回放跨重启保留,两个会话也绝不会看到彼此状态。工作区标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件仍只进入日志;下一次请求会在任何工具调用发生前,根据 fold 组装当前段落。
|
||||
运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? fold(events) ?? deployment default`,因此覆盖会通过回放跨重启保留,两个会话也绝不会看到彼此状态。工作区标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件仍只进入日志;在下一次请求前,归属方会将当前事实贡献给完整运行时上下文快照。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -35,12 +36,12 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
只要至少注册了一个强制执行家族,每次 agent 请求就会有一个 `sandbox:policy` 系统段落。以下示例展示全部三个家族;缺失的家族会被省略。工具插件继续负责操作与升级引导,批准策略仍由 `dsh-user-approval` 的段落管理,计划引导仍由 `dsh-plan-mode` 的段落管理。
|
||||
只要至少注册了一个强制执行家族,当前运行时上下文快照中就会有一项 `sandbox:policy` 贡献。以下示例展示全部三个家族;缺失的家族会被省略。工具插件继续负责操作与升级引导,批准策略单独贡献给同一份快照,计划引导仍由 `dsh-plan-mode` 的系统段落管理。
|
||||
|
||||
##### 只读
|
||||
|
||||
```markdown
|
||||
Current DSH file policy: read-only. The write and edit tools, one-shot bash commands, and terminal sessions cannot modify files under this policy.
|
||||
Current DSH file policy: read-only. The write and edit tools, one-shot bash commands, and terminal sessions cannot modify files in the standing mode. For the write and edit tools and one-shot bash commands, do not refuse a required modification from this standing mode alone: attempt it normally and follow the tool's denial and escalation guidance.
|
||||
```
|
||||
|
||||
##### 工作区写入
|
||||
@@ -57,14 +58,14 @@ Current DSH file policy: danger-full-access. The DSH file sandbox does not restr
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个请求增加一个简洁的系统段落。`workspace-write` 只携带规范化的会话工作区路径;平台特定的临时路径会以摘要表述,不会加入依赖主机的字节。
|
||||
首次请求和有效策略每次变化时增加一条简洁的持久上下文消息;未变化的请求不增加内容。`workspace-write` 只携带规范化的会话工作区路径;平台特定的临时路径会以摘要表述,不会加入依赖主机的字节。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要会话模式与不可变工作区根目录不变,请求前缀就在字节层面保持稳定。模式切换会在下一次请求中改变该段落;生成的 `request/header` 会记录新的前缀。
|
||||
模式切换时,稳定的系统提示词仍逐字节相同。变化后的完整上下文快照会追加到保留的历史之后,从而保留此前已缓存的前缀;后续未变化的请求会复用该保留快照。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **每个会话只有一个主要工作区根目录**:策略解析 `SessionHeader.cwd`;额外可写根目录不属于 `SandboxExecutionPolicy`。
|
||||
- **仅限文件操作模式**:`SandboxMode` 管控文件操作;网络和进程策略不在其词汇中,因此这里没有限制它们的旋钮。
|
||||
- **有意概述临时区域**:强制执行后端会授予不同的平台临时区域,这些区域在策略解析后才会选定,因此无法在常驻段落中如实枚举。
|
||||
- **有意概述临时区域**:强制执行后端会授予不同的平台临时区域,这些区域在策略解析后才会选定,因此无法在当前上下文中如实枚举。
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
* deployment's sandbox fallbacks plus per-session resolution: the file-effect
|
||||
* {@link SandboxMode}, the `workspace-write` root, and the override kit (the
|
||||
* `sandbox/mode` event, its fold, and its write path, from `./session-mode.ts`).
|
||||
* Before each agent request, the owner also renders the resolved policy as the
|
||||
* `sandbox:policy` system section; request headers therefore reconstruct the
|
||||
* same mode and roots the enforcing consumers resolve.
|
||||
* Before each agent request, the owner also contributes the resolved policy to
|
||||
* the cache-safe runtime-context snapshot. The agent loop logs that snapshot as
|
||||
* model history, so replay reconstructs the same mode and root the enforcing
|
||||
* consumers resolve without rewriting the stable system prompt.
|
||||
*
|
||||
* Enforcing filesystem, one-shot bash, and terminal backends read the SAME
|
||||
* resolved policy here and register their independently disposable model-facing
|
||||
* families. The request section therefore describes only operations this
|
||||
* runtime actually fences, while each backend retains its own enforcement
|
||||
* dialect. The service reads session state once at each operation boundary;
|
||||
* executors and providers remain session-free.
|
||||
* families. Tool owners separately register families whose schemas expose an
|
||||
* approved wider retry. The context therefore describes only operations this
|
||||
* runtime actually fences and adds anti-refusal guidance only where escalation
|
||||
* exists, while each backend retains its own enforcement dialect. The service
|
||||
* reads session state once at each operation boundary; executors and providers
|
||||
* remain session-free.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sandbox-policy
|
||||
*/
|
||||
@@ -54,12 +57,19 @@ function familyList(families: readonly FilePolicyFamily[], conjunction: 'and' |
|
||||
}
|
||||
|
||||
/** Render only policy facts shared by every backend enforcing each registered family. */
|
||||
function renderPolicyContext(policy: SandboxExecutionPolicy, families: readonly FilePolicyFamily[]): string {
|
||||
function renderPolicyContext(
|
||||
policy: SandboxExecutionPolicy,
|
||||
families: readonly FilePolicyFamily[],
|
||||
escalatableFamilies: readonly FilePolicyFamily[],
|
||||
): string {
|
||||
if (families.length === 0) return ''
|
||||
switch (policy.mode) {
|
||||
case 'read-only': {
|
||||
const subjects = familyList(families, 'and')
|
||||
return `Current DSH file policy: read-only. ${subjects[0]?.toUpperCase()}${subjects.slice(1)} cannot modify files under this policy.`
|
||||
const standing = `Current DSH file policy: read-only. ${subjects[0]?.toUpperCase()}${subjects.slice(1)} cannot modify files in the standing mode.`
|
||||
if (escalatableFamilies.length === 0) return standing
|
||||
const escalatable = familyList(escalatableFamilies, 'and')
|
||||
return `${standing} For ${escalatable}, do not refuse a required modification from this standing mode alone: attempt it normally and follow the tool's denial and escalation guidance.`
|
||||
}
|
||||
case 'workspace-write': {
|
||||
const subjects = familyList(families, 'and')
|
||||
@@ -128,6 +138,8 @@ export class SandboxPolicyService extends Service {
|
||||
readonly workspaceRoot: string
|
||||
/** Independently disposable enforcement-family contributions. */
|
||||
private readonly enforcedFamilies = new Map<FilePolicyFamily, Set<symbol>>()
|
||||
/** Independently disposable tool families that expose an approved wider retry. */
|
||||
private readonly escalatableFamilies = new Map<FilePolicyFamily, Set<symbol>>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'sandboxPolicy')
|
||||
@@ -138,12 +150,14 @@ export class SandboxPolicyService extends Service {
|
||||
this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd())
|
||||
|
||||
ctx.inject(['systemPrompt'], (scope: Context) => {
|
||||
scope.systemPrompt.section({
|
||||
scope.systemPrompt.context({
|
||||
name: 'sandbox:policy',
|
||||
order: 110,
|
||||
text: (context) => {
|
||||
const session = context.agent?.session
|
||||
return session === undefined ? '' : renderPolicyContext(this.resolve({ session }), this.activeFamilies())
|
||||
return session === undefined
|
||||
? ''
|
||||
: renderPolicyContext(this.resolve({ session }), this.activeFamilies(), this.activeEscalatableFamilies())
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -152,26 +166,47 @@ export class SandboxPolicyService extends Service {
|
||||
/**
|
||||
* Register one runtime contribution that enforces the shared file policy for
|
||||
* a model-facing operation family. Equal families remain independently
|
||||
* disposable; registration and removal invalidate assembled prompt caches
|
||||
* disposable; registration and removal invalidate request-input assemblies
|
||||
* when a system-prompt service is active.
|
||||
* @param family - operation family whose file effects this contribution enforces.
|
||||
* @returns the exact Cordis effect disposer for this contribution.
|
||||
*/
|
||||
registerEnforcedFamily(family: 'filesystem' | 'bash' | 'terminal'): () => void {
|
||||
return this.registerFamily(this.enforcedFamilies, family, 'sandboxPolicy.registerEnforcedFamily()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one model-facing family whose tool schema and execution path offer
|
||||
* an approved wider retry after a real denial. Equal contributions remain
|
||||
* independently disposable; a family is narrated as escalatable only while
|
||||
* it is also enforced.
|
||||
* @param family - operation family whose tools expose escalation.
|
||||
* @returns the exact Cordis effect disposer for this contribution.
|
||||
*/
|
||||
registerEscalatableFamily(family: 'filesystem' | 'bash' | 'terminal'): () => void {
|
||||
return this.registerFamily(this.escalatableFamilies, family, 'sandboxPolicy.registerEscalatableFamily()')
|
||||
}
|
||||
|
||||
/** Register one independently disposable family contribution in an owned map. */
|
||||
private registerFamily(
|
||||
registry: Map<FilePolicyFamily, Set<symbol>>,
|
||||
family: FilePolicyFamily,
|
||||
label: string,
|
||||
): () => void {
|
||||
const token = Symbol(family)
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const contributions = this.enforcedFamilies.get(family) ?? new Set<symbol>()
|
||||
const contributions = registry.get(family) ?? new Set<symbol>()
|
||||
contributions.add(token)
|
||||
this.enforcedFamilies.set(family, contributions)
|
||||
registry.set(family, contributions)
|
||||
this.emitPromptChange()
|
||||
return () => {
|
||||
contributions.delete(token)
|
||||
if (contributions.size === 0 && this.enforcedFamilies.get(family) === contributions) {
|
||||
this.enforcedFamilies.delete(family)
|
||||
if (contributions.size === 0 && registry.get(family) === contributions) {
|
||||
registry.delete(family)
|
||||
}
|
||||
this.emitPromptChange()
|
||||
}
|
||||
}, 'sandboxPolicy.registerEnforcedFamily()')
|
||||
}, label)
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
@@ -206,6 +241,11 @@ export class SandboxPolicyService extends Service {
|
||||
return FILE_POLICY_FAMILIES.filter(family => (this.enforcedFamilies.get(family)?.size ?? 0) > 0)
|
||||
}
|
||||
|
||||
/** Escalatable families that are also currently enforced, in canonical order. */
|
||||
private activeEscalatableFamilies(): FilePolicyFamily[] {
|
||||
return this.activeFamilies().filter(family => (this.escalatableFamilies.get(family)?.size ?? 0) > 0)
|
||||
}
|
||||
|
||||
/** Notify prompt consumers only after their registry exists. */
|
||||
private emitPromptChange(): void {
|
||||
if (this.ctx.get('systemPrompt') !== undefined) this.ctx.emit('system-prompt/change')
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import SystemPrompt, { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}) {
|
||||
const ctx = new Context()
|
||||
@@ -34,9 +34,9 @@ function agentFor(activeSession: Session): Agent {
|
||||
return { session: activeSession } as unknown as Agent
|
||||
}
|
||||
|
||||
async function policySection(ctx: Context, activeSession: Session): Promise<string | undefined> {
|
||||
async function policyContext(ctx: Context, activeSession: Session): Promise<string | undefined> {
|
||||
return (await ctx.systemPrompt.assemble({ agent: agentFor(activeSession) }))
|
||||
.sections.find(section => section.name === 'sandbox:policy')?.text
|
||||
.contexts.find(context => context.name === 'sandbox:policy')?.text
|
||||
}
|
||||
|
||||
describe('SandboxPolicyService', () => {
|
||||
@@ -44,6 +44,8 @@ describe('SandboxPolicyService', () => {
|
||||
const ctx = await mounted()
|
||||
expect(ctx.sandboxPolicy.defaultMode).toBe('read-only')
|
||||
expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve(process.cwd()))
|
||||
const dispose = ctx.sandboxPolicy.registerEscalatableFamily('bash')
|
||||
expect(() =>{ dispose() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('carries a configured mode and resolves the workspace root absolute', async () => {
|
||||
@@ -131,10 +133,10 @@ describe('SandboxPolicyService', () => {
|
||||
const fiber = await ctx.plugin(SandboxPolicyService, {})
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('filesystem')
|
||||
expect(ctx.sandboxPolicy).toBeDefined()
|
||||
expect(await policySection(ctx, session('sess-hmr'))).toContain('read-only')
|
||||
expect(await policyContext(ctx, session('sess-hmr'))).toContain('read-only')
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('sandboxPolicy')).toBeUndefined()
|
||||
expect((await ctx.systemPrompt.assemble()).sections.find(section => section.name === 'sandbox:policy')).toBeUndefined()
|
||||
expect((await ctx.systemPrompt.assemble()).contexts.find(context => context.name === 'sandbox:policy')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -148,21 +150,21 @@ describe('sandbox:policy request context', () => {
|
||||
|
||||
it('omits policy prose when no enforcing family is registered', async () => {
|
||||
const ctx = await promptMounted()
|
||||
expect(await policySection(ctx, session('sess-no-family'))).toBe('')
|
||||
expect(await policyContext(ctx, session('sess-no-family'))).toBe('')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[['filesystem'], 'Current DSH file policy: read-only. The write and edit tools cannot modify files under this policy.'],
|
||||
[['bash'], 'Current DSH file policy: read-only. One-shot bash commands cannot modify files under this policy.'],
|
||||
[['terminal'], 'Current DSH file policy: read-only. Terminal sessions cannot modify files under this policy.'],
|
||||
[['filesystem', 'bash'], 'Current DSH file policy: read-only. The write and edit tools and one-shot bash commands cannot modify files under this policy.'],
|
||||
[['filesystem', 'terminal'], 'Current DSH file policy: read-only. The write and edit tools and terminal sessions cannot modify files under this policy.'],
|
||||
[['bash', 'terminal'], 'Current DSH file policy: read-only. One-shot bash commands and terminal sessions cannot modify files under this policy.'],
|
||||
[['filesystem', 'bash', 'terminal'], 'Current DSH file policy: read-only. The write and edit tools, one-shot bash commands, and terminal sessions cannot modify files under this policy.'],
|
||||
[['filesystem'], 'Current DSH file policy: read-only. The write and edit tools cannot modify files in the standing mode.'],
|
||||
[['bash'], 'Current DSH file policy: read-only. One-shot bash commands cannot modify files in the standing mode.'],
|
||||
[['terminal'], 'Current DSH file policy: read-only. Terminal sessions cannot modify files in the standing mode.'],
|
||||
[['filesystem', 'bash'], 'Current DSH file policy: read-only. The write and edit tools and one-shot bash commands cannot modify files in the standing mode.'],
|
||||
[['filesystem', 'terminal'], 'Current DSH file policy: read-only. The write and edit tools and terminal sessions cannot modify files in the standing mode.'],
|
||||
[['bash', 'terminal'], 'Current DSH file policy: read-only. One-shot bash commands and terminal sessions cannot modify files in the standing mode.'],
|
||||
[['filesystem', 'bash', 'terminal'], 'Current DSH file policy: read-only. The write and edit tools, one-shot bash commands, and terminal sessions cannot modify files in the standing mode.'],
|
||||
] as const)('states read-only consequences for %j', async (families, expected) => {
|
||||
const ctx = await promptMounted()
|
||||
for (const family of [...families].reverse()) ctx.sandboxPolicy.registerEnforcedFamily(family)
|
||||
expect(await policySection(ctx, session(`sess-read-only-${families.join('-')}`))).toBe(expected)
|
||||
expect(await policyContext(ctx, session(`sess-read-only-${families.join('-')}`))).toBe(expected)
|
||||
})
|
||||
|
||||
it('states the portable workspace guarantee without enumerating host temp paths', async () => {
|
||||
@@ -171,14 +173,33 @@ describe('sandbox:policy request context', () => {
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('bash')
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('terminal')
|
||||
const active = session('sess-workspace-write', '/projects/../projects/current')
|
||||
expect(await policySection(ctx, active)).toBe('Current DSH file policy: workspace-write. The write and edit tools, one-shot bash commands, and terminal sessions may modify files under the session workspace: "/projects/current". Some platform temporary areas may also be writable.')
|
||||
expect(await policyContext(ctx, active)).toBe('Current DSH file policy: workspace-write. The write and edit tools, one-shot bash commands, and terminal sessions may modify files under the session workspace: "/projects/current". Some platform temporary areas may also be writable.')
|
||||
})
|
||||
|
||||
it('adds anti-refusal guidance only for enforced families with a real escalation path', async () => {
|
||||
const ctx = await promptMounted()
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('filesystem')
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('bash')
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('terminal')
|
||||
ctx.sandboxPolicy.registerEscalatableFamily('filesystem')
|
||||
const disposeBash = ctx.sandboxPolicy.registerEscalatableFamily('bash')
|
||||
ctx.sandboxPolicy.registerEscalatableFamily('terminal')
|
||||
const isolated = await promptMounted()
|
||||
isolated.sandboxPolicy.registerEnforcedFamily('filesystem')
|
||||
isolated.sandboxPolicy.registerEscalatableFamily('terminal')
|
||||
expect(await policyContext(isolated, session('sess-unenforced-escalation'))).not.toContain('do not refuse')
|
||||
|
||||
const active = session('sess-escalatable-families')
|
||||
expect(await policyContext(ctx, active)).toContain('For the write and edit tools, one-shot bash commands, and terminal sessions, do not refuse')
|
||||
disposeBash()
|
||||
expect(await policyContext(ctx, active)).toContain('For the write and edit tools and terminal sessions, do not refuse')
|
||||
})
|
||||
|
||||
it('states the exact families bypassed by danger-full-access', async () => {
|
||||
const ctx = await promptMounted({ mode: 'danger-full-access' })
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('filesystem')
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('terminal')
|
||||
expect(await policySection(ctx, session('sess-danger', '/projects/current'))).toBe('Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict the write and edit tools or terminal sessions.')
|
||||
expect(await policyContext(ctx, session('sess-danger', '/projects/current'))).toBe('Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict the write and edit tools or terminal sessions.')
|
||||
})
|
||||
|
||||
it('renders family contributions independently across mount and repeated disposal', async () => {
|
||||
@@ -187,7 +208,7 @@ describe('sandbox:policy request context', () => {
|
||||
const filesystemFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.sandboxPolicy.registerEnforcedFamily('filesystem')
|
||||
}, { inject: ['sandboxPolicy'] }))
|
||||
expect(await policySection(ctx, active)).toContain('The write and edit tools cannot modify files')
|
||||
expect(await policyContext(ctx, active)).toContain('The write and edit tools cannot modify files')
|
||||
|
||||
let disposeBashFirst!: () => void
|
||||
const bashFirstFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
@@ -196,15 +217,15 @@ describe('sandbox:policy request context', () => {
|
||||
const bashSecondFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.sandboxPolicy.registerEnforcedFamily('bash')
|
||||
}, { inject: ['sandboxPolicy'] }))
|
||||
expect(await policySection(ctx, active)).toContain('The write and edit tools and one-shot bash commands')
|
||||
expect(await policyContext(ctx, active)).toContain('The write and edit tools and one-shot bash commands')
|
||||
disposeBashFirst()
|
||||
disposeBashFirst()
|
||||
expect(await policySection(ctx, active)).toContain('The write and edit tools and one-shot bash commands')
|
||||
expect(await policyContext(ctx, active)).toContain('The write and edit tools and one-shot bash commands')
|
||||
await bashSecondFiber.dispose()
|
||||
expect(await policySection(ctx, active)).toContain('The write and edit tools cannot modify files')
|
||||
expect(await policyContext(ctx, active)).toContain('The write and edit tools cannot modify files')
|
||||
await bashFirstFiber.dispose()
|
||||
await filesystemFiber.dispose()
|
||||
expect(await policySection(ctx, active)).toBe('')
|
||||
expect(await policyContext(ctx, active)).toBe('')
|
||||
})
|
||||
|
||||
it('keeps the complete rendered prompt byte-stable across TMPDIR changes', async () => {
|
||||
@@ -214,11 +235,14 @@ describe('sandbox:policy request context', () => {
|
||||
const previous = process.env.TMPDIR
|
||||
try {
|
||||
process.env.TMPDIR = '/tmp/first-host-temp'
|
||||
const first = renderPrompt(await ctx.systemPrompt.assemble({ agent: agentFor(active) }))
|
||||
const firstAssembly = await ctx.systemPrompt.assemble({ agent: agentFor(active) })
|
||||
const firstPrompt = renderPrompt(firstAssembly)
|
||||
const firstContext = renderContextSnapshot(firstAssembly)
|
||||
process.env.TMPDIR = '/tmp/second-host-temp'
|
||||
const second = renderPrompt(await ctx.systemPrompt.assemble({ agent: agentFor(active) }))
|
||||
expect(second).toBe(first)
|
||||
expect(second).not.toContain('host-temp')
|
||||
const secondAssembly = await ctx.systemPrompt.assemble({ agent: agentFor(active) })
|
||||
expect(renderPrompt(secondAssembly)).toBe(firstPrompt)
|
||||
expect(renderContextSnapshot(secondAssembly)).toBe(firstContext)
|
||||
expect(firstContext).not.toContain('host-temp')
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.TMPDIR
|
||||
else process.env.TMPDIR = previous
|
||||
@@ -229,16 +253,16 @@ describe('sandbox:policy request context', () => {
|
||||
const ctx = await promptMounted()
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('filesystem')
|
||||
const active = session('sess-switch', '/projects/current')
|
||||
const first = await policySection(ctx, active)
|
||||
expect(await policySection(ctx, active)).toBe(first)
|
||||
const first = await policyContext(ctx, active)
|
||||
expect(await policyContext(ctx, active)).toBe(first)
|
||||
|
||||
setSandboxMode(active, 'danger-full-access')
|
||||
const danger = await policySection(ctx, active)
|
||||
const danger = await policyContext(ctx, active)
|
||||
expect(danger).toContain('does not restrict the write and edit tools')
|
||||
expect(await policySection(ctx, active)).toBe(danger)
|
||||
expect(await policyContext(ctx, active)).toBe(danger)
|
||||
|
||||
setSandboxMode(active, 'workspace-write')
|
||||
expect(await policySection(ctx, active)).toContain(JSON.stringify(resolve('/projects/current')))
|
||||
expect(await policyContext(ctx, active)).toContain(JSON.stringify(resolve('/projects/current')))
|
||||
})
|
||||
|
||||
it('reconstructs resumed policy from the session log and omits diagnostics without an agent', async () => {
|
||||
@@ -248,8 +272,8 @@ describe('sandbox:policy request context', () => {
|
||||
const ctx = await promptMounted({ mode: 'read-only' })
|
||||
ctx.sandboxPolicy.registerEnforcedFamily('filesystem')
|
||||
|
||||
expect(await policySection(ctx, resumed)).toContain('workspace-write')
|
||||
expect((await ctx.systemPrompt.assemble()).sections.find(section => section.name === 'sandbox:policy')?.text).toBe('')
|
||||
expect(await policyContext(ctx, resumed)).toContain('workspace-write')
|
||||
expect((await ctx.systemPrompt.assemble()).contexts.find(context => context.name === 'sandbox:policy')?.text).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -8,38 +8,37 @@
|
||||
|
||||
应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答其负责的 agent 请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个最终应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent 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 Note(agent 决策记录)](../../../.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 消息。
|
||||
|
||||
### 工具结果
|
||||
|
||||
|
||||
@@ -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' },
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user