refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/interaction/README.md
README.md: 4640bf015723d03257eae3a360201c8cbe122e63
README.zh.md: 3a88c52cf1f97ade88c687b30909c90a4f5d482a
README.md: a7842e40aa708ee9ec159dd51b8a94a6b2b18539
README.zh.md: ec329365331efd2769078078d1da4a8e1b3b57e0

View File

@@ -8,10 +8,10 @@ The services and plugins through which a human collaborates with a running agent
|---|---|---|
| [`commands/`](commands/README.md) | Registers and dispatches human commands for interactive adapters. | `ctx.commands` |
| [`user-approval/`](user-approval/README.md) | Coordinates one-shot approval decisions. | `ctx.approval` |
| [`permission/`](permission/README.md) | Presents and persists user-facing permission presets. | `ctx.permission` |
| [`user-interaction/`](user-interaction/README.md) | Defines the provider-neutral human question/answer seam. | `ctx.userInteraction` |
| [`permission/`](permission-presets/README.md) | Presents and persists user-facing permission presets. | `ctx.permissionPresets` |
| [`user-questions/`](user-questions/README.md) | Defines the provider-neutral human question/answer seam. | `ctx.userQuestions` |
| [`tool-ask-user/`](tool-ask-user/README.md) | Exposes human questions to the model. | (registers on `ctx.tools`) |
These packages integrate through existing agent and session contracts rather than changing the loop. Interactive applications provide the concrete command, approval, and question adapters; automation uses [`acp/`](../acp/README.md), and runnable demo bundles live under [`examples/`](../examples/README.md). The product [`dsh`](../../apps/cli/README.md) CLI composes these packages directly.
The subsystem references: [approval.md](../../docs/subsystems/approval.md), [permission.md](../../docs/subsystems/permission.md), [user-interaction.md](../../docs/subsystems/user-interaction.md), and [commands.md](../../docs/subsystems/commands.md). The automation-only ACP transport is [`acp/`](../acp/README.md), the SDK's JSON-RPC server half is [`sdk/server`](../sdk/README.md), and the shared bin boot glue is [`boot/`](../boot/README.md).
The subsystem references: [approval.md](../../docs/subsystems/approval.md), [permission-presets.md](../../docs/subsystems/permission-presets.md), [user-questions.md](../../docs/subsystems/user-questions.md), and [commands.md](../../docs/subsystems/commands.md). The automation-only ACP transport is [`acp/`](../acp/README.md), the SDK's JSON-RPC server half is [`sdk/server`](../sdk/README.md), and the shared bin boot glue is [`boot/`](../boot/README.md).

View File

@@ -8,10 +8,10 @@
|---|---|---|
| [`commands/`](commands/README.md) | 为交互式适配器注册并分派用户命令。 | `ctx.commands` |
| [`user-approval/`](user-approval/README.md) | 协调一次性审批决策。 | `ctx.approval` |
| [`permission/`](permission/README.md) | 呈现并持久化面向用户的权限预设。 | `ctx.permission` |
| [`user-interaction/`](user-interaction/README.md) | 定义与提供方无关的用户问答 seam。 | `ctx.userInteraction` |
| [`permission/`](permission-presets/README.md) | 呈现并持久化面向用户的权限预设。 | `ctx.permissionPresets` |
| [`user-questions/`](user-questions/README.md) | 定义与提供方无关的用户问答 seam。 | `ctx.userQuestions` |
| [`tool-ask-user/`](tool-ask-user/README.md) | 向模型提供用户问题。 | (注册到 `ctx.tools` |
这些包通过现有的 agent 和会话约定集成,而不改变循环。交互式应用提供具体的命令、审批和提问适配器;自动化使用 [`acp/`](../acp/README.md),可运行的演示组合包位于 [`examples/`](../examples/README.md)。产品 [`dsh`](../../apps/cli/README.md) CLI命令行界面直接组合这些包。
子系统参考:[approval.md](../../docs/subsystems/approval.md)、[permission.md](../../docs/subsystems/permission.md)、[user-interaction.md](../../docs/subsystems/user-interaction.md)与 [commands.md](../../docs/subsystems/commands.md)。仅自动化的 ACP 传输是 [`acp/`](../acp/README.md)SDK 的 JSON-RPC 服务器端是 [`sdk/server`](../sdk/README.md),共享 bin 启动胶水是 [`boot/`](../boot/README.md)。
子系统参考:[approval.md](../../docs/subsystems/approval.md)、[permission-presets.md](../../docs/subsystems/permission-presets.md)、[user-questions.md](../../docs/subsystems/user-questions.md)与 [commands.md](../../docs/subsystems/commands.md)。仅自动化的 ACP 传输是 [`acp/`](../acp/README.md)SDK 的 JSON-RPC 服务器端是 [`sdk/server`](../sdk/README.md),共享 bin 启动胶水是 [`boot/`](../boot/README.md)。

View File

@@ -58,7 +58,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
@@ -70,7 +70,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -8,7 +8,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta'
import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
import { CommandId } from './brand.ts'
import type {
CommandDescriptor,
@@ -89,7 +89,7 @@ class CommandLayer implements ScopeLayer {
declare module '@deepseek-ai/cordis' {
interface Context {
commands: CommandService
commands: CommandRuntime
}
}
@@ -222,7 +222,7 @@ function normalizeResult(command: string, value: unknown): CommandResult {
* registered through a command-injected child of an agent context shadow
* globals for that agent.
*/
export class CommandService extends GatewayService {
export class CommandRuntime extends TypertRemoteService {
private readonly layers = new ScopedLayers(
scope => new CommandLayer(scope),
() => { this.notifyChange() },
@@ -384,4 +384,4 @@ export class CommandService extends GatewayService {
}
}
export default CommandService
export default CommandRuntime

View File

@@ -4,7 +4,7 @@ import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import CommandService, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands'
import CommandRuntime, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands'
function command(name: string, text = `ran:${name}`): CommandDefinition {
return {
@@ -17,7 +17,7 @@ function command(name: string, text = `ran:${name}`): CommandDefinition {
async function mount(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(CommandService)
await ctx.plugin(CommandRuntime)
return ctx
}
@@ -52,7 +52,7 @@ describe('parseCommand()', () => {
})
})
describe('CommandService', () => {
describe('CommandRuntime', () => {
it('lists immutable global descriptors with input metadata', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import * as CommandInvariant from '@deepseek-ai/dsh-commands/invariant'
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
import InvariantRegistry, { InvariantError } from '@deepseek-ai/dsh-invariants'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import { CommandId } from '@deepseek-ai/dsh-commands'
@@ -9,7 +9,7 @@ async function mount(installCompanion = true): Promise<{ ctx: Context; session:
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('commands-invariant'))
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(InvariantRegistry, { enabled: true })
if (installCompanion) await ctx.plugin(CommandInvariant)
return { ctx, session }
}

View File

@@ -27,10 +27,10 @@
"path": "../../util/brand"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../typert/type-meta"
"path": "../../typert/protocol"
}
]
}

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/interaction/user-interaction/README.md
README.md: a1fe8e63011b0726e67f8b873b0b67f4af2e890a
README.zh.md: 1951721d6471804f7b6ec169cd261d76bb54f62e
# pnpm run verify-translation-pairing --write packages/interaction/permission-presets/README.md
README.md: 2b671f9e6e835c529453dc7d4ca7bc2eff01f2b6
README.zh.md: 686138c1f2b0b3770771d0e6d6a785ed17cc8621

View File

@@ -0,0 +1,28 @@
# @deepseek-ai/dsh-permission-presets
English | [中文](README.zh.md)
User-facing permission presets through `ctx.permissionPresets` ([`PermissionPresetService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs.
`set(session, name)` records a changed selection in a log-only `permissionPresets/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
The service owns the `permissionPresets` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permissionPresets/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent.
The service requires a confining `ctx.shell` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permissionPresets` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed.
## Model Experience
Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permissionPresets/preset` itself is log-only.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet.
- **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service.
- **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin.
- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permissionPresets` section in `settings.yaml` is updated or reset.

View File

@@ -0,0 +1,28 @@
# @deepseek-ai/dsh-permission-presets
[English](README.md) | 中文
通过 `ctx.permissionPresets`[`PermissionPresetService`](src/index.ts))提供面向用户的权限预设。每个配置名称都会将 `sandbox/mode``approval/policy` 组成一组;默认项为 `workspace-write``workspace-write` + `ask`)和 `danger-full-access``danger-full-access` + `never`。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。
`set(session, name)` 会先在仅写日志的 `permissionPresets/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件并在多个预设共享同一组取值时保留用户意图净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
该服务拥有 `permissionPresets` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permissionPresets/preset``sandbox/mode``approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed包括由 `session/end-seed` 标记的显式空 seed都会保留其有效权限只补齐缺失的持久事实而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话因此 HMR热模块替换会固定插件缺席期间创建的所有会话。
该服务要求存在具有约束能力的 `ctx.shell` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key单元以组合默认值为基础折叠三个全量值可调参数事件并生成选择器视图其中包含表内选项和仅作当前值的 `custom`)与 `/permissionPresets` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。
## 模型体验
间接地,通过 `dsh-user-approval``dsh-tool-bash`:二者会渲染由此服务的可调参数事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permissionPresets/preset` 本身只写入日志。
#### KV Cache 影响
不会直接使缓存失效;具名消费方拥有所有请求前缀变更。
## 已知限制与暂缓事项
- **只组合两个机制级可调参数**预设选择沙箱模式和审批策略agent智能体profile 选择尚未纳入 `PresetSpec`
- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个名为 custom 的预设。
- **预设表是进程级配置**:配置在插件生命周期内固定;更改可用预设必须重新加载插件。
- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permissionPresets` 分节。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-permission",
"description": "User-facing permission presets (ctx.permission) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events",
"name": "@deepseek-ai/dsh-permission-presets",
"description": "User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
@@ -8,7 +8,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/interaction/permission"
"directory": "packages/interaction/permission-presets"
},
"type": "module",
"main": "lib/index.js",
@@ -41,7 +41,7 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
@@ -57,7 +57,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",

View File

@@ -4,7 +4,7 @@
* (repo discipline), so `./client` projects the same single-source content
* `./types` serves to host consumers zero duplication.
*
* @module @deepseek-ai/dsh-permission/client
* @module @deepseek-ai/dsh-permission-presets/client
*/
export type * from './types.ts'

View File

@@ -7,7 +7,7 @@
* `permissions` session projection; the write side ships as the
* `/permission` command both optional children over the same service.
*
* @module dsh-permission
* @module dsh-permission-presets
*/
import { Context, Service } from '@deepseek-ai/cordis'
@@ -16,9 +16,9 @@ import { z as zod } from 'zod'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
// Side-effect type import: declaration-merges `ctx.bash` (the capability fact
// Side-effect type import: declaration-merges `ctx.shell` (the capability fact
// `sandboxMode` this service reads), without a value dependency on the seam.
import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-shell'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
@@ -35,7 +35,7 @@ export type * from './types.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
permission: PermissionService
permissionPresets: PermissionPresetService
}
}
@@ -136,7 +136,7 @@ export interface PermissionSettings {
defaultPreset: string
}
/** The {@link PermissionService} config: preset table and composition default. */
/** The {@link PermissionPresetService} config: preset table and composition default. */
export interface Config {
/**
* The preset table: name knob bundle. Defaults to `workspace-write`
@@ -153,10 +153,10 @@ export interface Config {
/**
* Owns the deployment's permission presets and their write path. Requires a
* confining `ctx.bash` executor and `ctx.approval`; unmatched knob values are
* confining `ctx.shell` executor and `ctx.approval`; unmatched knob values are
* reported as {@link CUSTOM_PRESET}, not an error.
*/
export class PermissionService extends Service {
export class PermissionPresetService extends Service {
// Inline schema call: the config catalog walks `static Config` statically.
static Config: z<Config> = z.object({
presets: z.dict(z.object({
@@ -177,19 +177,19 @@ export class PermissionService extends Service {
defaultPreset: z.string(),
})
static inject = ['bash', 'approval', 'sessions']
static inject = ['shell', 'approval', 'sessions']
private readonly presets: Record<string, PresetSpec>
private defaultSettings: () => PermissionSettings
constructor(ctx: Context, config: Config) {
super(ctx, 'permission')
super(ctx, 'permissionPresets')
// The schema defaulted the table — the cast records that runtime fact.
this.presets = config.presets as Record<string, PresetSpec>
if (CUSTOM_PRESET in this.presets) {
throw new Error(`permission: "${CUSTOM_PRESET}" is reserved for the derived not-a-preset state and cannot name a table entry`)
}
if (ctx.bash.sandboxMode === undefined) {
if (ctx.shell.sandboxMode === undefined) {
throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration')
}
const inferredDefault = this.derive(EMPTY_KNOBS)
@@ -307,7 +307,7 @@ export class PermissionService extends Service {
/** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */
private derive(state: KnobState): string {
const sandbox = state.sandbox ?? this.ctx.bash.sandboxMode
const sandbox = state.sandbox ?? this.ctx.shell.sandboxMode
const approval = state.approval ?? this.ctx.approval.config.policy ?? 'ask'
const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval
if (state.preset !== null) {
@@ -383,7 +383,7 @@ export class PermissionService extends Service {
session.append('permission/preset', { preset: name })
}
const events = session.events
if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode)) {
if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.shell.sandboxMode)) {
setSandboxMode(session, spec.sandbox)
}
if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) {
@@ -422,7 +422,7 @@ export class PermissionService extends Service {
session.append('permission/preset', { preset: effective })
}
if (sandbox === undefined) {
setSandboxMode(session, this.ctx.bash.sandboxMode as SandboxMode)
setSandboxMode(session, this.ctx.shell.sandboxMode as SandboxMode)
}
if (approval === undefined) {
setApprovalPolicy(session, this.ctx.approval.config.policy ?? 'ask')
@@ -430,4 +430,4 @@ export class PermissionService extends Service {
}
}
export default PermissionService
export default PermissionPresetService

View File

@@ -1,19 +1,19 @@
/** Package-owned permission-preset event invariants. @module @deepseek-ai/dsh-permission/invariant */
/** Package-owned permission-preset event invariants. @module @deepseek-ai/dsh-permission-presets/invariant */
import type { Context } from '@deepseek-ai/cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-permission'
const PACKAGE_NAME = '@deepseek-ai/dsh-permission-presets'
/** Cordis companion plugin name. */
export const name = 'permission-invariant'
export const name = 'permission-presets-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Validate the package-owned event fields and ignore unrelated events. */
function validateEvent(ctx: Context, event: SessionEvent, fail: InvariantFailure): void {
if (event.type === 'permission/preset' && !ctx.permission.names.includes(event.data.preset)) {
if (event.type === 'permission/preset' && !ctx.permissionPresets.names.includes(event.data.preset)) {
fail(`permission/preset names unknown preset ${JSON.stringify(event.data.preset)}`)
}
}
@@ -28,7 +28,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
const event = (args as [Session, SessionEvent])[1]
validateEvent(ctx, event, fail)
}, { global: true })
}, { inject: ['permission', 'sessions'] })
}, { inject: ['permissionPresets', 'sessions'] })
/**
* Register the permission invariant companion.

View File

@@ -6,7 +6,7 @@
* browser half-entry's re-export) for client aggregates with zero content
* duplication.
*
* @module @deepseek-ai/dsh-permission/types
* @module @deepseek-ai/dsh-permission-presets/types
*/
/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */

View File

@@ -1,14 +1,14 @@
import { describe, expect, it } from 'vitest'
import { Context, Service } from '@deepseek-ai/cordis'
import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
import * as PermissionInvariant from '@deepseek-ai/dsh-permission/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as PermissionInvariant from '@deepseek-ai/dsh-permission-presets/invariant'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
class PermissionProbe extends Service {
readonly names = ['safe', 'trusted']
constructor(ctx: Context) {
super(ctx, 'permission')
super(ctx, 'permissionPresets')
}
}
@@ -16,7 +16,7 @@ async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(PermissionProbe)
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(InvariantRegistry, { enabled: true })
await ctx.plugin(PermissionInvariant)
return ctx
}
@@ -46,7 +46,7 @@ describe('permission invariants', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(PermissionProbe)
ctx.sessions.create().append('permission/preset', { preset: 'missing' })
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(InvariantRegistry, { enabled: true })
await expect(ctx.plugin(PermissionInvariant).then(() => undefined)).rejects.toThrow(/unknown preset "missing"/)
})

View File

@@ -3,15 +3,15 @@ import { Context } from '@deepseek-ai/cordis'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import PermissionService, {
import PermissionPresetService, {
CUSTOM_PRESET, effectivePermissionPreset, PERMISSION_SETTINGS_NAMESPACE,
} from '@deepseek-ai/dsh-permission'
import type { Config } from '@deepseek-ai/dsh-permission'
import { Settings } from '@deepseek-ai/dsh-settings'
} from '@deepseek-ai/dsh-permission-presets'
import type { Config } from '@deepseek-ai/dsh-permission-presets'
import { SettingsProvider } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
/** Writable memory provider for the permission/settings lifecycle specs. */
class MemorySettings extends Settings {
class MemorySettings extends SettingsProvider {
readonly doc: Record<string, unknown> = {}
readonly writable = true
@@ -32,14 +32,14 @@ async function mounted(options: {
} = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.provide('bash', {
ctx.provide('shell', {
sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
run() { throw new Error('permission tests do not execute bash') },
start() { throw new Error('permission tests do not execute bash') },
})
ctx.provide('approval', { config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' } })
await ctx.plugin(PermissionService, options.config ?? {})
await ctx.plugin(PermissionPresetService, options.config ?? {})
return ctx
}
@@ -51,7 +51,7 @@ async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefi
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(MemorySettings)
ctx.provide('bash', {
ctx.provide('shell', {
sandboxMode: 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
run() { throw new Error('permission tests do not execute bash') },
@@ -60,7 +60,7 @@ async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefi
ctx.provide('approval', {
config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' },
})
await ctx.plugin(PermissionService, {})
await ctx.plugin(PermissionPresetService, {})
return ctx
}
@@ -77,30 +77,30 @@ describe('effectivePermissionPreset', () => {
})
})
describe('PermissionService', () => {
describe('PermissionPresetService', () => {
it('advertises the preset table in declaration order and resolves bundles', async () => {
const ctx = await mounted()
expect(ctx.permission.names).toEqual(['workspace-write', 'danger-full-access'])
expect(ctx.permission.resolve('danger-full-access')).toMatchObject({ sandbox: 'danger-full-access', approval: 'never' })
expect(() => ctx.permission.resolve('plan')).toThrow(/unknown preset "plan"/)
expect(ctx.permissionPresets.names).toEqual(['workspace-write', 'danger-full-access'])
expect(ctx.permissionPresets.resolve('danger-full-access')).toMatchObject({ sandbox: 'danger-full-access', approval: 'never' })
expect(() => ctx.permissionPresets.resolve('plan')).toThrow(/unknown preset "plan"/)
})
it('current() derives from the effective knobs: composition defaults hit workspace-write, a switch hits its preset', async () => {
const ctx = await mounted()
const session = freshSession('sess-current')
expect(ctx.permission.current(session.events)).toBe('workspace-write')
ctx.permission.set(session, 'danger-full-access')
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
expect(ctx.permissionPresets.current(session.events)).toBe('workspace-write')
ctx.permissionPresets.set(session, 'danger-full-access')
expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access')
})
it('a knob state matching no table entry derives custom — a state, not an error', async () => {
const ctx = await mounted()
const session = freshSession('sess-custom')
session.append('sandbox/mode', { mode: 'read-only' })
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
ctx.permission.set(session, 'danger-full-access')
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/)
expect(ctx.permissionPresets.current(session.events)).toBe(CUSTOM_PRESET)
ctx.permissionPresets.set(session, 'danger-full-access')
expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access')
expect(() => ctx.permissionPresets.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/)
})
it('composition defaults outside the table still derive custom when an explicit new-session default is configured', async () => {
@@ -109,7 +109,7 @@ describe('PermissionService', () => {
config: { defaultPreset: 'workspace-write' },
})
const session = freshSession('sess-defaults-custom')
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
expect(ctx.permissionPresets.current(session.events)).toBe(CUSTOM_PRESET)
})
it('the fold breaks bundle ties; a stale fold no longer matching falls back to table order', async () => {
@@ -119,17 +119,17 @@ describe('PermissionService', () => {
'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' },
} } })
const session = freshSession('sess-tie')
ctx.permission.set(session, 'agentish')
expect(ctx.permission.current(session.events)).toBe('agentish')
ctx.permissionPresets.set(session, 'agentish')
expect(ctx.permissionPresets.current(session.events)).toBe('agentish')
session.append('approval/policy', { policy: 'never' })
session.append('sandbox/mode', { mode: 'danger-full-access' })
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access')
})
it('set() writes through: one preset event plus both knob events', async () => {
const ctx = await mounted()
const session = freshSession('sess-set')
ctx.permission.set(session, 'danger-full-access')
ctx.permissionPresets.set(session, 'danger-full-access')
expect(session.events.map(e => [e.type, e.data])).toEqual([
['permission/preset', { preset: 'danger-full-access' }],
['sandbox/mode', { mode: 'danger-full-access' }],
@@ -140,18 +140,18 @@ describe('PermissionService', () => {
it('set() to the current preset is a no-op when the knobs already match (clicks are not switches)', async () => {
const ctx = await mounted()
const session = freshSession('sess-noop')
ctx.permission.set(session, 'workspace-write')
ctx.permissionPresets.set(session, 'workspace-write')
expect(session.events).toHaveLength(0)
})
it('re-asserting a preset from a drifted (custom) state re-records the choice and repairs the knob', async () => {
const ctx = await mounted()
const session = freshSession('sess-drift')
ctx.permission.set(session, 'danger-full-access')
ctx.permissionPresets.set(session, 'danger-full-access')
// Re-selecting from a drifted state records the choice and repairs only
// the changed knob.
session.append('sandbox/mode', { mode: 'read-only' })
ctx.permission.set(session, 'danger-full-access')
ctx.permissionPresets.set(session, 'danger-full-access')
const tail = session.events.slice(4)
expect(tail.map(e => [e.type, e.data])).toEqual([
['permission/preset', { preset: 'danger-full-access' }],
@@ -166,11 +166,11 @@ describe('PermissionService', () => {
it('optionOf() presents shipped labels/descriptions, falls back to the raw key, and fixes custom', async () => {
const ctx = await mounted()
expect(ctx.permission.optionOf('danger-full-access')).toEqual({ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' })
expect(ctx.permission.optionOf('custom')).toEqual({ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' })
expect(ctx.permissionPresets.optionOf('danger-full-access')).toEqual({ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' })
expect(ctx.permissionPresets.optionOf('custom')).toEqual({ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' })
const bare = await mounted({ config: { presets: { plain: { sandbox: 'workspace-write', approval: 'ask' } } } })
expect(bare.permission.optionOf('plain')).toEqual({ value: 'plain', name: 'plain' })
expect(() => ctx.permission.optionOf('plan')).toThrow(/unknown preset/)
expect(bare.permissionPresets.optionOf('plain')).toEqual({ value: 'plain', name: 'plain' })
expect(() => ctx.permissionPresets.optionOf('plan')).toThrow(/unknown preset/)
})
it('rejects a table entry named custom (reserved for the derived state)', async () => {
@@ -186,9 +186,9 @@ describe('PermissionService', () => {
it('reads a schema-less approval stand-in as the ask default', async () => {
const ctx = await mounted({ approvalDefault: undefined })
const session = freshSession('sess-standin')
ctx.permission.set(session, 'workspace-write')
ctx.permissionPresets.set(session, 'workspace-write')
expect(session.events).toHaveLength(0)
expect(ctx.permission.current(session.events)).toBe('workspace-write')
expect(ctx.permissionPresets.current(session.events)).toBe('workspace-write')
})
})
@@ -205,10 +205,10 @@ describe('new-session default', () => {
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'danger-full-access',
})
expect(ctx.permission.defaultPreset).toBe('danger-full-access')
expect(ctx.permissionPresets.defaultPreset).toBe('danger-full-access')
const second = ctx.sessions.create(SessionId('second'))
expect(ctx.permission.current(first.events)).toBe('workspace-write')
expect(ctx.permission.current(second.events)).toBe('danger-full-access')
expect(ctx.permissionPresets.current(first.events)).toBe('workspace-write')
expect(ctx.permissionPresets.current(second.events)).toBe('danger-full-access')
expect(second.events.map(event => event.type)).toEqual([
'permission/preset', 'sandbox/mode', 'approval/policy',
])
@@ -223,7 +223,7 @@ describe('new-session default', () => {
legacy.append('turn/start', { turn: 1 })
legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events })
expect(ctx.permission.current(resumed.events)).toBe('workspace-write')
expect(ctx.permissionPresets.current(resumed.events)).toBe('workspace-write')
expect(resumed.events.slice(-3).map(event => event.type)).toEqual([
'permission/preset', 'sandbox/mode', 'approval/policy',
])
@@ -235,7 +235,7 @@ describe('new-session default', () => {
defaultPreset: 'danger-full-access',
})
const resumed = ctx.sessions.create(SessionId('empty-resumed'), { seed: [] })
expect(ctx.permission.current(resumed.events)).toBe('workspace-write')
expect(ctx.permissionPresets.current(resumed.events)).toBe('workspace-write')
expect(resumed.events.map(event => event.type)).toEqual([
'session/end-seed', 'permission/preset', 'sandbox/mode', 'approval/policy',
])
@@ -244,7 +244,7 @@ describe('new-session default', () => {
it('pins sessions that already exist when the service remounts', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.provide('bash', {
ctx.provide('shell', {
sandboxMode: 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
run() { throw new Error('permission tests do not execute bash') },
@@ -254,11 +254,11 @@ describe('new-session default', () => {
const existing = ctx.sessions.create(SessionId('existing-before-permission'))
expect(existing.events).toEqual([])
await ctx.plugin(PermissionService, {})
await ctx.plugin(PermissionPresetService, {})
expect(existing.events.map(event => event.type)).toEqual([
'permission/preset', 'sandbox/mode', 'approval/policy',
])
expect(ctx.permission.current(existing.events)).toBe('workspace-write')
expect(ctx.permissionPresets.current(existing.events)).toBe('workspace-write')
})
it('fills only missing legacy facts and preserves an unmatched seeded combination', async () => {
@@ -276,7 +276,7 @@ describe('new-session default', () => {
custom.append('sandbox/mode', { mode: 'read-only' })
custom.append('approval/policy', { policy: 'never' })
const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.events })
expect(ctx.permission.current(unmatched.events)).toBe(CUSTOM_PRESET)
expect(ctx.permissionPresets.current(unmatched.events)).toBe(CUSTOM_PRESET)
expect(unmatched.events.at(-1)?.type).toBe('session/end-seed')
})
@@ -296,6 +296,6 @@ describe('new-session default', () => {
await expect(ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
defaultPreset: 'missing',
})).rejects.toThrow()
expect(ctx.permission.defaultPreset).toBe('workspace-write')
expect(ctx.permissionPresets.defaultPreset).toBe('workspace-write')
})
})

View File

@@ -16,24 +16,24 @@ import type { Session } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import CommandService from '@deepseek-ai/dsh-commands'
import PermissionService from '@deepseek-ai/dsh-permission'
import type { Config } from '@deepseek-ai/dsh-permission'
import CommandRuntime from '@deepseek-ai/dsh-commands'
import PermissionPresetService from '@deepseek-ai/dsh-permission-presets'
import type { Config } from '@deepseek-ai/dsh-permission-presets'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
async function harness(options: { withPermission?: boolean; config?: Config } = {}): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(CommandService)
ctx.provide('bash', {
await ctx.plugin(CommandRuntime)
ctx.provide('shell', {
sandboxMode: 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
run() { throw new Error('permission tests do not execute bash') },
start() { throw new Error('permission tests do not execute bash') },
})
await ctx.plugin(ApprovalService)
if (options.withPermission !== false) await ctx.plugin(PermissionService, options.config ?? {})
if (options.withPermission !== false) await ctx.plugin(PermissionPresetService, options.config ?? {})
return { ctx, session: ctx.sessions.create(SessionId('perm-projected')) }
}
@@ -59,7 +59,7 @@ describe('permissions projection unit', () => {
ctx.sessionProjections.onChanged((_session, key, value, seq) => {
changes.push({ key, value, seq })
})
ctx.permission.set(session, 'danger-full-access')
ctx.permissionPresets.set(session, 'danger-full-access')
// set() appends preset + sandbox/mode + approval/policy: three knob transitions.
expect(changes).toHaveLength(3)
expect(changes.at(-1)).toMatchObject({ key: 'permissions', value: { currentValue: 'danger-full-access' } })
@@ -79,7 +79,7 @@ describe('permissions projection unit', () => {
it('has no permissions key without the service, and drops it on unload (HMR safety)', async () => {
const { ctx, session } = await harness({ withPermission: false })
expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false)
const fiber = await ctx.plugin(PermissionService, {})
const fiber = await ctx.plugin(PermissionPresetService, {})
expect(ctx.sessionProjections.snapshot(session).values.permissions).toMatchObject({ currentValue: 'workspace-write' })
await fiber.dispose()
expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false)
@@ -92,7 +92,7 @@ describe('/permission command', () => {
const { agent, inject } = await agentFor(ctx, session)
const execution = await ctx.commands.execute(agent, '/permission danger-full-access', new AbortController().signal)
expect(execution?.result).toEqual({ kind: 'success', text: 'preset danger-full-access' })
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access')
expect(inject.mock.calls[0]?.[0]).toMatchObject({
content: [{
type: 'text',

View File

@@ -27,13 +27,13 @@
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../bash/bash"
"path": "../../shell/shell"
},
{
"path": "../user-approval"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../session/session-projection"

View File

@@ -1,28 +0,0 @@
# @deepseek-ai/dsh-permission
English | [中文](README.zh.md)
User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs.
`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
The service owns the `permission` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permission/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent.
The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed.
## Model Experience
Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet.
- **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service.
- **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin.
- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permission` section in `settings.yaml` is updated or reset.

View File

@@ -1,28 +0,0 @@
# @deepseek-ai/dsh-permission
[English](README.md) | 中文
通过 `ctx.permission`[`PermissionService`](src/index.ts))提供面向用户的权限预设。每个配置名称都会将 `sandbox/mode``approval/policy` 组成一组;默认项为 `workspace-write``workspace-write` + `ask`)和 `danger-full-access``danger-full-access` + `never`。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。
`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件并在多个预设共享同一组取值时保留用户意图净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
该服务拥有 `permission` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permission/preset``sandbox/mode``approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed包括由 `session/end-seed` 标记的显式空 seed都会保留其有效权限只补齐缺失的持久事实而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话因此 HMR热模块替换会固定插件缺席期间创建的所有会话。
该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key单元以组合默认值为基础折叠三个全量值可调参数事件并生成选择器视图其中包含表内选项和仅作当前值的 `custom`)与 `/permission` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。
## 模型体验
间接地,通过 `dsh-user-approval``dsh-tool-bash`:二者会渲染由此服务的可调参数事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permission/preset` 本身只写入日志。
#### KV Cache 影响
不会直接使缓存失效;具名消费方拥有所有请求前缀变更。
## 已知限制与暂缓事项
- **只组合两个机制级可调参数**预设选择沙箱模式和审批策略agent智能体profile 选择尚未纳入 `PresetSpec`
- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个名为 custom 的预设。
- **预设表是进程级配置**:配置在插件生命周期内固定;更改可用预设必须重新加载插件。
- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permission` 分节。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/interaction/tool-ask-user/README.md
README.md: bb0af4286d6d0626596202d57e55fd818b92f44b
README.zh.md: c1424bb3cc9c7805f1b5112c382d6f75d7a3380d
README.md: bf2ba369fca36c462c6155ed2354ab3d0e930dd0
README.zh.md: 671bea0132093b890de4ce27095fa60dac396276

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the model ask the human a concise question when it needs confirmation, a choice, or missing information before continuing.
Model-facing `ask_user_question` tool over `ctx.userQuestions`. It lets the model ask the human a concise question when it needs confirmation, a choice, or missing information before continuing.
## Tool
@@ -15,11 +15,11 @@ Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the mo
- `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label.
- `multi_select` — whether that question may return more than one selected option.
The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` carries a free-form answer, supplementing `selected` for a multi-select question and overriding it for a single-select question. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`.
The tool calls `ctx.userQuestions.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` carries a free-form answer, supplementing `selected` for a multi-select question and overriding it for a single-select question. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`.
## Role
This is the Consumer package for the user-interaction seam. It does not render UI and does not know how input is collected; it only translates model arguments into `AskUserQuestionRequest` and returns the human answer to the agent loop.
This is the Consumer package for the user-questions seam. It does not render UI and does not know how input is collected; it only translates model arguments into `AskUserQuestionRequest` and returns the human answer to the agent loop.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现。当模型需要确认、选择结果或缺失的信息才能继续时,它可以借此向用户提出简明问题。
模型侧 `ask_user_question` 工具,基于 `ctx.userQuestions` 实现。当模型需要确认、选择结果或缺失的信息才能继续时,它可以借此向用户提出简明问题。
## 工具
@@ -15,7 +15,7 @@
- `options`:可选选项,包含 `label``description`。如需推荐某个选项,请将其置于首位,并在该标签末尾追加 `(Recommended)`
- `multi_select`:该问题是否可以返回多个选中的选项。
工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }``selected` 包含选项标签;`custom` 携带自由填写的回答,对于多选题会补充 `selected`对于单选题则会覆盖它。Native 渲染器会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`
工具调用 `ctx.userQuestions.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }``selected` 包含选项标签;`custom` 携带自由填写的回答,对于多选题会补充 `selected`对于单选题则会覆盖它。Native 渲染器会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`
## 职责

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-tool-ask-user",
"description": "Model-facing ask_user_question tool over the ctx.userInteraction seam",
"description": "Model-facing ask_user_question tool over the ctx.userQuestions seam",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
@@ -35,7 +35,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-user-questions": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
@@ -44,7 +44,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-user-questions": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -1,5 +1,5 @@
/**
* Model-facing Consumer of the `ctx.userInteraction` capability seam.
* Model-facing Consumer of the `ctx.userQuestions` capability seam.
* The tool pauses until a UI provider returns a human answer, then feeds that
* answer back into the agent loop as an ordinary tool result.
*
@@ -8,10 +8,10 @@
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import '@deepseek-ai/dsh-user-interaction'
import '@deepseek-ai/dsh-user-questions'
export const name = 'tool-ask-user'
export const inject = ['tools', 'userInteraction']
export const inject = ['tools', 'userQuestions']
const description = 'Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. '
+ 'Send one or more questions, each with a stable id that will be echoed in the answer.'
@@ -78,7 +78,7 @@ export function apply(ctx: Context): void {
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
},
async execute(args, exec) {
const result = await ctx.userInteraction.ask({
const result = await ctx.userQuestions.ask({
questions: args.questions.map(question => ({
id: question.id,
question: question.question,

View File

@@ -3,8 +3,8 @@ import { Context } from '@deepseek-ai/cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import UserQuestionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-questions'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
const testToolSignal = new AbortController().signal
@@ -29,8 +29,8 @@ async function setup() {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(ToolRuntime)
await ctx.plugin(UserQuestionService)
await ctx.plugin(toolAskUser)
return ctx
}
@@ -75,10 +75,10 @@ describe('ask_user_question tool', () => {
expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('preview')
})
it('asks the registered user-interaction provider and projects structured answers to text', async () => {
it('asks the registered user-questions provider and projects structured answers to text', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
ctx.userQuestions.registerProvider({
async ask(request) {
seen.push(request)
return { answers: [{ id: 'pkg', selected: ['pnpm'] }] }
@@ -114,7 +114,7 @@ describe('ask_user_question tool', () => {
it('passes recommended option labels through without adding schema fields', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
ctx.userQuestions.registerProvider({
async ask(request) {
seen.push(request)
return { answers: [{ id: 'pkg', selected: ['pnpm (Recommended)'] }] }
@@ -145,7 +145,7 @@ describe('ask_user_question tool', () => {
it('projects custom answers and multi-select choices', async () => {
const ctx = await setup()
ctx.userInteraction.registerProvider({
ctx.userQuestions.registerProvider({
async ask() {
return {
answers: [
@@ -195,10 +195,10 @@ describe('ask_user_question tool', () => {
}])
})
it('passes the tool abort signal to the user-interaction request', async () => {
it('passes the tool abort signal to the user-questions request', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
ctx.userQuestions.registerProvider({
async ask(request) {
seen.push(request)
return { answers: [{ id: 'continue', selected: ['ok'] }] }
@@ -216,10 +216,10 @@ describe('ask_user_question tool', () => {
expect(seen[0]?.signal).toBe(controller.signal)
})
it('passes optional header and a resumed runtime root through to the user-interaction request', async () => {
it('passes optional header and a resumed runtime root through to the user-questions request', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
ctx.userQuestions.registerProvider({
async ask(request) {
seen.push(request)
return { answers: [{ id: 'continue', selected: ['ok'] }] }
@@ -240,7 +240,7 @@ describe('ask_user_question tool', () => {
expect(seen[0]).toMatchObject({ questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }], agent })
})
it('returns structured user-interaction errors through tool execution', async () => {
it('returns structured user-questions errors through tool execution', async () => {
const ctx = await setup()
const result = await ctx.tools.execute({
@@ -252,14 +252,14 @@ describe('ask_user_question tool', () => {
expect(result).toMatchObject({
isError: true,
error: { info: { name: 'UserInteractionError', code: 'NO_PROVIDER' } },
error: { info: { name: 'UserQuestionError', code: 'NO_PROVIDER' } },
})
})
it('rejects a live runtime-owned agent with a structured DELEGATED_CALLER error', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
ctx.userQuestions.registerProvider({
async ask(request) {
seen.push(request)
return { answers: [{ id: 'continue', selected: ['ok'] }] }
@@ -280,7 +280,7 @@ describe('ask_user_question tool', () => {
expect(result).toMatchObject({
isError: true,
error: { info: { name: 'UserInteractionError', code: 'DELEGATED_CALLER' } },
error: { info: { name: 'UserQuestionError', code: 'DELEGATED_CALLER' } },
content: [{
type: 'text',
text: "Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result",
@@ -301,15 +301,15 @@ describe('ask_user_question tool', () => {
expect(result).toMatchObject({
isError: true,
error: { info: { name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' } },
error: { info: { name: 'UserQuestionError', code: 'EMPTY_QUESTIONS' } },
})
})
it('unregisters the tool when its plugin fiber is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(ToolRuntime)
await ctx.plugin(UserQuestionService)
const fiber = await ctx.plugin(toolAskUser)
expect(ctx.tools.get('ask_user_question')).toBeDefined()

View File

@@ -30,10 +30,10 @@
"path": "../../core/tools"
},
{
"path": "../user-interaction"
"path": "../user-questions"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -3,12 +3,12 @@ import { Context } from '@deepseek-ai/cordis'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
import * as ApprovalInvariant from '@deepseek-ai/dsh-user-approval/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
await ctx.plugin(InvariantRegistry)
await ctx.plugin(ApprovalInvariant)
return ctx
}
@@ -35,7 +35,7 @@ describe('approval invariants', () => {
session.append('turn/start', { turn: 1 })
const id = ApprovalRequestId('ask-resume')
session.append('approval/asked', { id, toolName: 'bash' })
await ctx.plugin(InvariantService)
await ctx.plugin(InvariantRegistry)
await ctx.plugin(ApprovalInvariant)
expect(() => session.append('approval/decided', { id, outcome: 'cancelled' })).not.toThrow()
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -81,7 +81,7 @@ describe('approval invariants', () => {
session.append('approval/asked', {
id: ApprovalRequestId('ask-replay'), toolName: 'bash',
})
await ctx.plugin(InvariantService)
await ctx.plugin(InvariantRegistry)
await expect(ctx.plugin(ApprovalInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
})

View File

@@ -36,7 +36,7 @@
"path": "../../core/system-prompt"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/interaction/permission/README.md
README.md: 4f7f560bb81eaad3b6b95b2742432fa252682d5a
README.zh.md: d45f89e243ce2d8f6bb08943fb7e776ced106b5a
# pnpm run verify-translation-pairing --write packages/interaction/user-questions/README.md
README.md: 4a8f0d5bd409872c7849adf0a35b836fb884fb25
README.zh.md: dcba617f042be84f516e14dbfa6db125dd0aca5b

View File

@@ -1,15 +1,15 @@
# @deepseek-ai/dsh-user-interaction
# @deepseek-ai/dsh-user-questions
English | [中文](README.zh.md)
User-interaction Service Definition. It owns `ctx.userInteraction`, the service a model-facing tool or permission plugin uses when it needs to pause work and ask the human for a decision.
User-interaction Service Definition. It owns `ctx.userQuestions`, the service a model-facing tool or permission plugin uses when it needs to pause work and ask the human for a decision.
## Service: `UserInteractionService` (ctx key: `userInteraction`)
## Service: `UserQuestionService` (ctx key: `userQuestions`)
### Public API
- `ctx.userInteraction.registerProvider(provider): () => void` Register the UI-side provider. Only one provider may be active in a context; disposal unregisters it.
- `ctx.userInteraction.ask(request): Promise<AskUserQuestionAnswer>` Ask the active provider and wait for the answer.
- `ctx.userQuestions.registerProvider(provider): () => void` Register the UI-side provider. Only one provider may be active in a context; disposal unregisters it.
- `ctx.userQuestions.ask(request): Promise<AskUserQuestionAnswer>` Ask the active provider and wait for the answer.
### Key Types
@@ -17,8 +17,8 @@ User-interaction Service Definition. It owns `ctx.userInteraction`, the service
- `AskUserQuestionOption``{ label, description? }`.
- `AskUserQuestionIntent``{ kind: 'plan-review', approve }`; the tagged presentation intent below.
- `AskUserQuestionAnswer``{ answers: [{ id, selected, custom? }] }`.
- `UserInteractionProvider` — UI implementation with `ask(request)`.
- `UserInteractionError``HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, `ASK_ABORTED`, `CALLER_NOT_LIVE`, and `DELEGATED_CALLER`.
- `UserQuestionProvider` — UI implementation with `ask(request)`.
- `UserQuestionError``HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, `ASK_ABORTED`, `CALLER_NOT_LIVE`, and `DELEGATED_CALLER`.
For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
@@ -34,7 +34,7 @@ This is the Service Definition package. Consumers such as `@deepseek-ai/dsh-tool
## Model Experience
Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: human interaction requires the exact live calling agent when an agent is supplied`, `Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result`, `Error: no user-interaction provider is registered`, or `Error: <message>`. Waiting for the human adds no tokens.
Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: human interaction requires the exact live calling agent when an agent is supplied`, `Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result`, `Error: no user-questions provider is registered`, or `Error: <message>`. Waiting for the human adds no tokens.
#### KV Cache effect

View File

@@ -1,15 +1,15 @@
# @deepseek-ai/dsh-user-interaction
# @deepseek-ai/dsh-user-questions
[English](README.md) | 中文
用户交互 Service Definition。它定义 `ctx.userInteraction`,供面向模型的工具或权限插件在需要暂停工作并询问人类决定时使用。
用户交互 Service Definition。它定义 `ctx.userQuestions`,供面向模型的工具或权限插件在需要暂停工作并询问人类决定时使用。
## 服务:`UserInteractionService`ctx 键:`userInteraction`
## 服务:`UserQuestionService`ctx 键:`userQuestions`
### 公开 API
- `ctx.userInteraction.registerProvider(provider): () => void` 注册 UI 侧提供方。同一上下文中只能有一个活跃提供方dispose资源释放会将其注销。
- `ctx.userInteraction.ask(request): Promise<AskUserQuestionAnswer>` 向活跃提供方提问并等待回答。
- `ctx.userQuestions.registerProvider(provider): () => void` 注册 UI 侧提供方。同一上下文中只能有一个活跃提供方dispose资源释放会将其注销。
- `ctx.userQuestions.ask(request): Promise<AskUserQuestionAnswer>` 向活跃提供方提问并等待回答。
### 关键类型
@@ -17,8 +17,8 @@
- `AskUserQuestionOption``{ label, description? }`
- `AskUserQuestionIntent``{ kind: 'plan-review', approve }`;即下文的带标签呈现意图。
- `AskUserQuestionAnswer``{ answers: [{ id, selected, custom? }] }`
- `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。
- `UserInteractionError``HarnessError` 的子类,包含 `EMPTY_QUESTIONS``BAD_INTENT``NO_PROVIDER``DUPLICATE_PROVIDER``ASK_ABORTED``CALLER_NOT_LIVE``DELEGATED_CALLER` 等代码。
- `UserQuestionProvider`:包含 `ask(request)` 的 UI 实现。
- `UserQuestionError``HarnessError` 的子类,包含 `EMPTY_QUESTIONS``BAD_INTENT``NO_PROVIDER``DUPLICATE_PROVIDER``ASK_ABORTED``CALLER_NOT_LIVE``DELEGATED_CALLER` 等代码。
对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
@@ -34,7 +34,7 @@
## 模型体验
间接地,通过 `dsh-tool-ask-user`:它会将成功的提供方回答保留为紧凑 JSON或返回以下失败之一`Error: ask_user_question was aborted before the user answered``Error: ask_user_question requires at least one question``Error: human interaction requires the exact live calling agent when an agent is supplied``Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result``Error: no user-interaction provider is registered``Error: <message>`。等待人类回答不会增加 token。
间接地,通过 `dsh-tool-ask-user`:它会将成功的提供方回答保留为紧凑 JSON或返回以下失败之一`Error: ask_user_question was aborted before the user answered``Error: ask_user_question requires at least one question``Error: human interaction requires the exact live calling agent when an agent is supplied``Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result``Error: no user-questions provider is registered``Error: <message>`。等待人类回答不会增加 token。
#### KV Cache 影响

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-user-interaction",
"description": "Abstract user-interaction seam (ctx.userInteraction) for asking the human during agent runs",
"name": "@deepseek-ai/dsh-user-questions",
"description": "Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
@@ -8,7 +8,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/interaction/user-interaction"
"directory": "packages/interaction/user-questions"
},
"type": "module",
"main": "lib/index.js",

View File

@@ -1,10 +1,10 @@
/**
* Service Definition for the user-interaction capability seam (`ctx.userInteraction`): a UI-backed service for
* Service Definition for the user-questions capability seam (`ctx.userQuestions`): a UI-backed service for
* pausing an agent tool call until the human answers a question. The model-
* facing tool lives in `@deepseek-ai/dsh-tool-ask-user`; UI packages provide
* the single active provider.
*
* @module @deepseek-ai/dsh-user-interaction
* @module @deepseek-ai/dsh-user-questions
*/
import { Context, Service } from '@deepseek-ai/cordis'
@@ -13,7 +13,7 @@ import { HarnessError } from '@deepseek-ai/dsh-llm'
declare module '@deepseek-ai/cordis' {
interface Context {
userInteraction: UserInteractionService
userQuestions: UserQuestionService
}
}
@@ -35,24 +35,24 @@ export interface AskUserQuestionRequest {
}
/** UI-side provider for user questions. */
export interface UserInteractionProvider {
export interface UserQuestionProvider {
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
}
/** Stable error taxonomy for user-interaction failures. */
export class UserInteractionError extends HarnessError {
/** Stable error taxonomy for user-questions failures. */
export class UserQuestionError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options)
this.name = 'UserInteractionError'
this.name = 'UserQuestionError'
}
}
/** `ctx.userInteraction`: one active UI provider plus an `ask()` API. */
export class UserInteractionService extends Service {
private provider: UserInteractionProvider | undefined
/** `ctx.userQuestions`: one active UI provider plus an `ask()` API. */
export class UserQuestionService extends Service {
private provider: UserQuestionProvider | undefined
constructor(ctx: Context) {
super(ctx, 'userInteraction')
super(ctx, 'userQuestions')
}
/**
@@ -61,10 +61,10 @@ export class UserInteractionService extends Service {
* @param provider UI-side implementation that collects answers.
* @returns Disposer that unregisters this provider.
*/
registerProvider(provider: UserInteractionProvider): () => void {
const dispose = this.ctx.effect(function* (this: UserInteractionService) {
registerProvider(provider: UserQuestionProvider): () => void {
const dispose = this.ctx.effect(function* (this: UserQuestionService) {
if (this.provider !== undefined) {
throw new UserInteractionError('a user-interaction provider is already registered', 'DUPLICATE_PROVIDER')
throw new UserQuestionError('a user-questions provider is already registered', 'DUPLICATE_PROVIDER')
}
this.provider = provider
yield () => {
@@ -85,27 +85,27 @@ export class UserInteractionService extends Service {
*
* @param request Questions, owner agent, and abort signal.
* @returns The answer chosen or typed by the human.
* @throws {UserInteractionError} code `CALLER_NOT_LIVE` when a supplied
* @throws {UserQuestionError} code `CALLER_NOT_LIVE` when a supplied
* agent is not the registry's exact live instance, or `DELEGATED_CALLER`
* when that live agent is owned by another agent.
*/
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
if (request.signal?.aborted) {
throw new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')
throw new UserQuestionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')
}
if (request.questions.length === 0) {
throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
throw new UserQuestionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
}
const agent = request.agent
if (agent !== undefined) {
const agents = this.ctx.get('agents')
if (agents === undefined || agents.get(agent.id) !== agent) {
throw new UserInteractionError(
throw new UserQuestionError(
'human interaction requires the exact live calling agent when an agent is supplied',
'CALLER_NOT_LIVE')
}
if (!agents.roots().includes(agent)) {
throw new UserInteractionError(
throw new UserQuestionError(
'human interaction is unavailable while the calling agent is owned by another live agent; '
+ "include the unresolved question or decision in the child agent's final result",
'DELEGATED_CALLER')
@@ -122,22 +122,22 @@ export class UserInteractionService extends Service {
const intent = question.intent
if (intent === undefined) continue
if (!(question.options ?? []).some(option => option.label === intent.approve)) {
throw new UserInteractionError(
throw new UserQuestionError(
`question ${question.id} declares intent ${intent.kind} whose approve label `
+ `${JSON.stringify(intent.approve)} names none of its options`,
'BAD_INTENT')
}
if (question.detail === undefined) {
throw new UserInteractionError(
throw new UserQuestionError(
`question ${question.id} declares intent ${intent.kind} without the detail it reviews`,
'BAD_INTENT')
}
}
if (this.provider === undefined) {
throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER')
throw new UserQuestionError('no user-questions provider is registered', 'NO_PROVIDER')
}
return this.provider.ask(request)
}
}
export default UserInteractionService
export default UserQuestionService

View File

@@ -1,16 +1,16 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-user-interaction`.
* @module @deepseek-ai/dsh-user-interaction/invariant
* Package-owned invariant companion for `@deepseek-ai/dsh-user-questions`.
* @module @deepseek-ai/dsh-user-questions/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-user-interaction'
const PACKAGE_NAME = '@deepseek-ai/dsh-user-questions'
/** Cordis companion plugin name. */
export const name = 'user-interaction-invariant'
export const name = 'user-questions-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']

View File

@@ -2,7 +2,7 @@
* Wire-safe question and answer types, free of cordis/service imports so browser
* type chains (apiproxy api client) can consume them without loading this
* package's Context augmentation.
* @module @deepseek-ai/dsh-user-interaction/types
* @module @deepseek-ai/dsh-user-questions/types
*/
/** One selectable answer offered to the user. */
@@ -31,7 +31,7 @@ export type AskUserQuestionIntent = {
approve: string
}
/** One question in a user-interaction request. */
/** One question in a user-questions request. */
export interface AskUserQuestionItem {
/** Stable caller-provided question id, echoed in the answer. */
id: string

View File

@@ -1,13 +1,13 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import UserInteractionService, {
UserInteractionError,
import UserQuestionService, {
UserQuestionError,
type AskUserQuestionRequest,
type UserInteractionProvider,
} from '@deepseek-ai/dsh-user-interaction'
type UserQuestionProvider,
} from '@deepseek-ai/dsh-user-questions'
function provider(answer = 'approved'): UserInteractionProvider & { seen: AskUserQuestionRequest[] } {
function provider(answer = 'approved'): UserQuestionProvider & { seen: AskUserQuestionRequest[] } {
const seen: AskUserQuestionRequest[] = []
return {
seen,
@@ -26,14 +26,14 @@ function stubAgent(id: string, delegationDepth = 0): Agent {
} as unknown as Agent
}
describe('UserInteractionService', () => {
describe('UserQuestionService', () => {
it('delegates ask requests to the registered provider', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const p = provider('yes')
ctx.userInteraction.registerProvider(p)
ctx.userQuestions.registerProvider(p)
const result = await ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] })
const result = await ctx.userQuestions.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] })
expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] })
expect(p.seen).toEqual([{ questions: [{ id: 'confirm', question: 'Proceed?' }] }])
@@ -41,74 +41,74 @@ describe('UserInteractionService', () => {
it('rejects ask requests when no provider is registered', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_PROVIDER' })
await expect(ctx.userQuestions.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
.rejects.toMatchObject({ name: 'UserQuestionError', code: 'NO_PROVIDER' })
})
it('registers providers with HMR-safe disposal', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const p = provider()
const dispose = ctx.userInteraction.registerProvider(p)
const dispose = ctx.userQuestions.registerProvider(p)
dispose()
dispose()
await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
await expect(ctx.userQuestions.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
})
it('rejects duplicate providers instead of replacing the active UI', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
ctx.userInteraction.registerProvider(provider('first'))
await ctx.plugin(UserQuestionService)
ctx.userQuestions.registerProvider(provider('first'))
expect(() => ctx.userInteraction.registerProvider(provider('second')))
.toThrow(UserInteractionError)
expect(() => ctx.userQuestions.registerProvider(provider('second')))
.toThrow(UserQuestionError)
})
it('fails before reaching the provider when the signal is already aborted', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const p = { ask: vi.fn(async () => ({ answers: [{ id: 'confirm', selected: ['too late'] }] })) }
ctx.userInteraction.registerProvider(p)
ctx.userQuestions.registerProvider(p)
const controller = new AbortController()
controller.abort()
await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }], signal: controller.signal }))
await expect(ctx.userQuestions.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }], signal: controller.signal }))
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects empty question batches before reaching the provider', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
ctx.userQuestions.registerProvider(p)
await expect(ctx.userInteraction.ask({ questions: [] }))
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' })
await expect(ctx.userQuestions.ask({ questions: [] }))
.rejects.toMatchObject({ name: 'UserQuestionError', code: 'EMPTY_QUESTIONS' })
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects a live runtime-owned agent before reaching the provider', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
ctx.userQuestions.registerProvider(p)
const root = stubAgent('root', 0)
const child = stubAgent('child', 0)
ctx.agents.enter(root, undefined)
ctx.agents.enter(child, root)
await expect(ctx.userInteraction.ask({
await expect(ctx.userQuestions.ask({
questions: [{ id: 'confirm', question: 'Proceed?' }],
agent: child,
})).rejects.toMatchObject({
name: 'UserInteractionError',
name: 'UserQuestionError',
code: 'DELEGATED_CALLER',
message: "human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result",
})
@@ -118,13 +118,13 @@ describe('UserInteractionService', () => {
it('reaches the provider for a lineage-bearing session resumed as a runtime root', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const p = provider('yes')
ctx.userInteraction.registerProvider(p)
ctx.userQuestions.registerProvider(p)
const agent = stubAgent('resumed-root', 1)
ctx.agents.enter(agent, undefined)
const result = await ctx.userInteraction.ask({
const result = await ctx.userQuestions.ask({
questions: [{ id: 'confirm', question: 'Proceed?' }],
agent,
})
@@ -134,79 +134,79 @@ describe('UserInteractionService', () => {
it('rejects a supplied agent when no live registry can attest it', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
ctx.userQuestions.registerProvider(p)
await expect(ctx.userInteraction.ask({
await expect(ctx.userQuestions.ask({
questions: [{ id: 'confirm', question: 'Proceed?' }],
agent: stubAgent('unattested'),
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'CALLER_NOT_LIVE' })
})).rejects.toMatchObject({ name: 'UserQuestionError', code: 'CALLER_NOT_LIVE' })
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects a stale agent object that reuses a live id', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
ctx.userQuestions.registerProvider(p)
const live = stubAgent('same-id')
ctx.agents.enter(live, undefined)
await expect(ctx.userInteraction.ask({
await expect(ctx.userQuestions.ask({
questions: [{ id: 'confirm', question: 'Proceed?' }],
agent: stubAgent('same-id'),
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'CALLER_NOT_LIVE' })
})).rejects.toMatchObject({ name: 'UserQuestionError', code: 'CALLER_NOT_LIVE' })
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects an intent whose approve label names none of its own options', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
ctx.userQuestions.registerProvider(p)
const question = { id: 'plan-review', question: 'Approve?', detail: '# Plan' }
// A wrong label among offered options, and no options offered at all.
for (const options of [[{ label: 'Approve' }], undefined]) {
await expect(ctx.userInteraction.ask({
await expect(ctx.userQuestions.ask({
questions: [{
...question,
...(options === undefined ? {} : { options }),
intent: { kind: 'plan-review', approve: 'Ship it' },
}],
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' })
})).rejects.toMatchObject({ name: 'UserQuestionError', code: 'BAD_INTENT' })
}
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects a plan-review intent on a question carrying no plan to review', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
ctx.userQuestions.registerProvider(p)
// Detail IS the plan for this intent, so a UI honouring it would ask the
// user to approve something they cannot see.
await expect(ctx.userInteraction.ask({
await expect(ctx.userQuestions.ask({
questions: [{
id: 'plan-review', question: 'Approve?',
options: [{ label: 'Approve' }, { label: 'Keep planning' }],
intent: { kind: 'plan-review', approve: 'Approve' },
}],
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' })
})).rejects.toMatchObject({ name: 'UserQuestionError', code: 'BAD_INTENT' })
expect(p.ask).not.toHaveBeenCalled()
})
it('passes an intent through once its approve label names an offered option', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const p = provider('Approve')
ctx.userInteraction.registerProvider(p)
ctx.userQuestions.registerProvider(p)
const intent = { kind: 'plan-review', approve: 'Approve' } as const
const result = await ctx.userInteraction.ask({
const result = await ctx.userQuestions.ask({
questions: [
{ id: 'plain', question: 'Proceed?' },
{

View File

@@ -21,7 +21,7 @@
"path": "../../llm/llm"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}