Merge remote-tracking branch 'origin/master' into stack/agent-profiles-1-seam

This commit is contained in:
Yichen Jiang
2026-08-08 22:43:13 +08:00
910 changed files with 17011 additions and 6270 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md
README.md: 567fb905797513ea148a087a59f96c2116231006
README.zh.md: 2eb8ebc4a7bcba0fcf74dbe149af3e64202b3cb5
README.md: c256b89288e3e384c1dd3e64629a06d7cfef31f6
README.zh.md: 88d1c4ad0ced2f5a6440a1b64e34e738a842f938

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so loader-failure behavior has one owner instead of drifting between published artifacts.
Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so loader-failure behavior has one owner instead of drifting between published artifacts.
| Export | Role |
|---|---|

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
供 app bin([`dsh`](../../../apps/cli/README.md)、[`dsh-cli-demo`](../../examples/cli-demo/README.md)、[`dsh-acp-demo`](../../examples/acp-demo/README.md))共用的启动粘合层:每个 bin 都是在这些 helper 上构建的精简自执行组合,并以自身诊断前缀参数化。这样,Loader 故障行为只由一处负责,不会在已发布产物之间逐渐分化。
供 app bin([`dsh`](../../../apps/cli/README.md) 与 [`dsh-acp-demo`](../../examples/acp-demo/README.md))共用的启动粘合层:每个 bin 都是在这些 helper 上构建的精简自执行组合,并以自身诊断前缀参数化。这样,Loader 故障行为只由一处负责,不会在已发布产物之间逐渐分化。
| 导出 | 职责 |
|---|---|

View File

@@ -1,5 +1,5 @@
/**
* Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
* Shared boot glue for the app bins (`dsh`, `dsh-acp-demo`): load the gitignored
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
* optional user patch layers from the Harness home (`~/.dsh`), expose its path resolver to
* config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles.

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/commands/README.md
README.md: 3105ae1a866e03f3c8f621bfe588df15ee38957e
README.zh.md: 704a2daefb65fde12ca85d1c9051ad762c5ccc70
README.md: 1709bdcdce4e43d98cfea5ff3972ab95bfd3c33b
README.zh.md: 569f2aa8293793b26d63ee16e3ea7600e04a8397

View File

@@ -8,11 +8,11 @@ Plugin-owned human-command registry consumed by interactive UI adapters. The [pl
`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, optional `recordInput` policy, and abortable handler. `recordInput` defaults to true; a command whose authoritative domain event owns the payload sets it to false so `command/run` omits `args` instead of duplicating the input. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured name, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown.
`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured name, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a successful result may also name an earlier non-command authoritative domain event through `sourceEventSeq`; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown.
`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
Handlers return `success` or `error` plus optional UI text. Results are rendered directly by the adapter and never enter model history. The registry never submits `rawInput` to the agent implicitly; a command producer may explicitly schedule model-visible work through the receiving `Agent`, in which case that producer owns the resulting message contract. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it.
Handlers return `success` or `error` plus optional UI text. A successful handler may also return `sourceEventSeq` when an earlier domain event owns a richer presentation; the lifecycle invariant requires that reference to be a prior non-command event in the same session. Results are rendered directly by the adapter and never enter model history. The registry never submits `rawInput` to the agent implicitly; a command producer may explicitly schedule model-visible work through the receiving `Agent`, in which case that producer owns the resulting message contract. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it.
## Composition

View File

@@ -8,11 +8,11 @@
`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示、可选的 `recordInput` 策略,以及可中止的处理器。`recordInput` 默认为 true;若载荷由命令的权威领域事件持有,该命令会将 `recordInput` 设为 false,让 `command/run` 省略 `args`,避免重复记录输入。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent(智能体)的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop(智能体循环)依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。
`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成的 `commandId`、解析器的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。
`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成的 `commandId`、解析器的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;成功结果还可通过 `sourceEventSeq` 指向更早的一条非命令权威领域事件;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。
`parseCommand()` 识别位于第 0 字节的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方负责各命令专用的语法,只能执行该语法允许的规范化。
处理器返回 `success` 或 `error`,并可附带 UI 文本。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent;命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方负责由此产生的消息契约。注册表会同时等待处理器完成和所提供的中止信号,以先发生者为准,但不响应中止的处理器可能在调用方停止等待后继续产生自身的外部副作用。
处理器返回 `success` 或 `error`,并可附带 UI 文本。若更丰富的呈现由一条更早的领域事件持有,成功的处理器还可返回 `sourceEventSeq`;生命周期不变量要求该引用指向同一会话中更早的一条非命令事件。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent;命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方负责由此产生的消息契约。注册表会同时等待处理器完成和所提供的中止信号,以先发生者为准,但不响应中止的处理器可能在调用方停止等待后继续产生自身的外部副作用。
## 组合

View File

@@ -47,7 +47,12 @@ export interface CommandInvocation {
/** Expected command outcome rendered directly by the dispatching UI. */
export type CommandResult =
| { readonly kind: 'success'; readonly text?: string }
| {
readonly kind: 'success'
readonly text?: string
/** Earlier authoritative domain event that owns a richer presentation. */
readonly sourceEventSeq?: number
}
| { readonly kind: 'error'; readonly text: string }
/**
@@ -140,9 +145,15 @@ declare module '@deepseek-ai/dsh-session' {
/**
* The paired command settled. `kind`/`text` carry the handler's verbatim
* outcome (a thrown/aborted handler settles as `kind: 'error'` with the
* rendered failure); presentation stays client-computed at render time.
* rendered failure). A successful command may identify the earlier
* authoritative domain event for a richer client-computed presentation.
*/
'command/done': { commandId: CommandId; kind: 'success' | 'error'; text?: string }
'command/done': {
commandId: CommandId
kind: 'success' | 'error'
text?: string
sourceEventSeq?: number
}
}
}
@@ -262,12 +273,20 @@ function normalizeResult(command: string, value: unknown): CommandResult {
if (typeof value !== 'object' || value === null || !('kind' in value)) {
throw new TypeError(`command "${command}" handler must return a CommandResult`)
}
const result = value as { kind?: unknown; text?: unknown }
const result = value as { kind?: unknown; text?: unknown; sourceEventSeq?: unknown }
if (result.kind === 'success') {
if (result.text !== undefined && typeof result.text !== 'string') {
throw new TypeError(`command "${command}" success text must be a string when supplied`)
}
return Object.freeze(result.text === undefined ? { kind: 'success' } : { kind: 'success', text: result.text })
if (result.sourceEventSeq !== undefined
&& (!Number.isSafeInteger(result.sourceEventSeq) || (result.sourceEventSeq as number) < 0)) {
throw new TypeError(`command "${command}" success sourceEventSeq must be a non-negative safe integer when supplied`)
}
return Object.freeze({
kind: 'success',
...result.text === undefined ? {} : { text: result.text },
...result.sourceEventSeq === undefined ? {} : { sourceEventSeq: result.sourceEventSeq as number },
})
}
if (result.kind === 'error') {
if (typeof result.text !== 'string' || result.text.trim().length === 0) {
@@ -389,6 +408,9 @@ export class CommandService extends Service {
this.appendLifecycle(agent.session, 'command/done', {
commandId, kind: result.kind,
...result.text === undefined ? {} : { text: result.text },
...result.kind === 'success' && result.sourceEventSeq !== undefined
? { sourceEventSeq: result.sourceEventSeq }
: {},
})
return Object.freeze({ commandId, result })
}

View File

@@ -34,6 +34,16 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
if (runIds.get(session)?.has(event.data.commandId) !== true) {
fail(`command/done ${JSON.stringify(event.data.commandId)} pairs no prior command/run in this log`)
}
const source = event.data.sourceEventSeq
const sourceEvent = source === undefined ? undefined : session.events[source]
if (source !== undefined
&& (event.data.kind !== 'success'
|| !Number.isSafeInteger(source) || source < 0 || source >= event.seq
|| sourceEvent?.seq !== source
|| sourceEvent.type === 'command/run'
|| sourceEvent.type === 'command/done')) {
fail(`command/done ${JSON.stringify(event.data.commandId)} has invalid sourceEventSeq ${String(source)}`)
}
}
for (const session of ctx.sessions.list()) {
for (const event of session.events) validateEvent(session, event)

View File

@@ -320,6 +320,25 @@ describe('CommandService', () => {
])
})
it('preserves an earlier authoritative domain-event reference on successful settlement', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
const source = agent.session.append('turn/start', { turn: 1 })
ctx.commands.register({
name: 'linked',
description: 'Link outcome',
handler: () => ({ kind: 'success', text: 'linked', sourceEventSeq: source.seq }),
})
const execution = await ctx.commands.execute(agent, '/linked', new AbortController().signal)
expect(execution?.result).toEqual({ kind: 'success', text: 'linked', sourceEventSeq: source.seq })
expect(lifecycleOf(agent)).toMatchObject([
{ type: 'command/run', data: { name: 'linked' } },
{ type: 'command/done', data: { kind: 'success', text: 'linked', sourceEventSeq: source.seq } },
])
})
it('omits raw input from command/run when an authoritative domain event owns it', async () => {
const ctx = await mount()
const { agent } = await mintAgentScope(ctx, 'a')
@@ -427,6 +446,9 @@ describe('CommandService', () => {
[null, /CommandResult/],
[{}, /CommandResult/],
[{ kind: 'success', text: 1 }, /success text/],
[{ kind: 'success', sourceEventSeq: -1 }, /sourceEventSeq/],
[{ kind: 'success', sourceEventSeq: 1.5 }, /sourceEventSeq/],
[{ kind: 'success', sourceEventSeq: '1' }, /sourceEventSeq/],
[{ kind: 'error', text: '' }, /error text/],
[{ kind: 'error', text: 1 }, /error text/],
[{ kind: 'future', text: 'x' }, /unknown result kind/],

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import * as CommandInvariant from '@deepseek-ai/dsh-commands/invariant'
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import { CommandId } from '@deepseek-ai/dsh-commands'
async function mount(installCompanion = true): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('commands-invariant'))
await ctx.plugin(InvariantService, { enabled: true })
if (installCompanion) await ctx.plugin(CommandInvariant)
return { ctx, session }
}
function appendRun(session: Session, id: string): void {
session.append('command/run', {
commandId: CommandId(id),
name: 'linked',
args: '',
source: { kind: 'user' },
})
}
describe('command lifecycle invariants', () => {
it('accepts a success outcome linked to an earlier non-command domain event', async () => {
const { session } = await mount()
const source = session.append('turn/start', { turn: 1 })
appendRun(session, 'cmd-valid')
expect(() => {
session.append('command/done', {
commandId: CommandId('cmd-valid'),
kind: 'success',
sourceEventSeq: source.seq,
})
}).not.toThrow()
})
it.each([-1, 1.5, 1])('rejects invalid or non-prior sourceEventSeq %s', async (sourceEventSeq) => {
const { session } = await mount()
appendRun(session, 'cmd-invalid')
expect(() => {
session.append('command/done', {
commandId: CommandId('cmd-invalid'),
kind: 'success',
sourceEventSeq,
})
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-commands',
}))
})
it('rejects an error settlement carrying a success-only source reference', async () => {
const { session } = await mount()
const source = session.append('turn/start', { turn: 1 })
appendRun(session, 'cmd-error-source')
expect(() => {
session.append('command/done', {
commandId: CommandId('cmd-error-source'),
kind: 'error',
text: 'failed',
sourceEventSeq: source.seq,
})
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-commands',
}))
})
it('attributes an invalid durable prefix during late companion loading', async () => {
const { ctx, session } = await mount(false)
appendRun(session, 'cmd-late')
session.append('command/done', {
commandId: CommandId('cmd-late'),
kind: 'success',
sourceEventSeq: 0,
})
await expect(ctx.plugin(CommandInvariant)).rejects.toMatchObject({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-commands',
})
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tool-ask-user/README.md
README.md: 64da4d75d01a0df0ae51b1557ed1c796317b906f
README.zh.md: fdfa1ff2470258e2864f505fbadfcd2fc8be8101
README.md: 7af356263ea6582a081e7c6de22fd317ca8b96df
README.zh.md: 3f7b814b83c8c6957a4b2574ee69e87d45f65ae6

View File

@@ -54,4 +54,5 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **A pending question blocks the tool call until the human answers** — the tool declares no `timeout-policy` budget; cancellation rides the turn's `exec.signal` only.
- **Runtime-owned subagents cannot ask the user** — `ask_user_question` rejects a live child owned by another agent with `DELEGATED_CALLER`; the child must include the unresolved question or decision in its final result. Durable lineage does not decide this boundary, so a lineage-bearing session resumed as a runtime root may ask normally.
- **Native answers render as JSON text** — the canonical value remains structured, but the model-facing result uses compact JSON rather than a richer content-block vocabulary.

View File

@@ -54,4 +54,5 @@
## 已知限制与暂缓事项
- **待处理问题会阻塞工具调用,直至用户作答**:该工具未声明 `timeout-policy` 预算;取消仅沿用当前轮次的 `exec.signal`。
- **运行时中归属于其他 agent 的 subagent 不能向用户提问**:`ask_user_question` 会以 `DELEGATED_CALLER` 拒绝由另一个 agent 所有的存活子级;该子级必须在最终结果中包含尚未解决的问题或决策。持久化会话谱系不能决定这一边界,因此带有谱系的会话恢复为运行时根后可以正常提问。
- **Native 回答渲染为 JSON 文本**:规范值仍为结构化数据,但模型侧结果使用紧凑 JSON,而非更丰富的内容块词汇。

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
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'
@@ -27,6 +27,7 @@ interface OptionSchemaShape {
async function setup() {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
@@ -34,6 +35,14 @@ async function setup() {
return ctx
}
function stubAgent(id: string, delegationDepth = 0): Agent {
const agentId = id as Agent['id']
return {
id: agentId,
session: { id: agentId, header: { delegationDepth } },
} as unknown as Agent
}
describe('ask_user_question tool', () => {
it('registers a model-facing tool schema', async () => {
const ctx = await setup()
@@ -207,7 +216,7 @@ describe('ask_user_question tool', () => {
expect(seen[0]?.signal).toBe(controller.signal)
})
it('passes optional header and agent through to the user-interaction request', async () => {
it('passes optional header and a resumed runtime root through to the user-interaction request', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
@@ -216,7 +225,8 @@ describe('ask_user_question tool', () => {
return { answers: [{ id: 'continue', selected: ['ok'] }] }
},
})
const agent = { id: 'main' } as unknown as Agent
const agent = stubAgent('resumed-root', 1)
ctx.agents.enter(agent, undefined)
const result = await ctx.tools.execute({
signal: testToolSignal,
@@ -246,6 +256,39 @@ describe('ask_user_question tool', () => {
})
})
it('rejects a live runtime-owned agent with a structured DELEGATED_CALLER error', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
async ask(request) {
seen.push(request)
return { answers: [{ id: 'continue', selected: ['ok'] }] }
},
})
const root = stubAgent('root', 0)
const child = stubAgent('child', 0)
ctx.agents.enter(root, undefined)
ctx.agents.enter(child, root)
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('ask-delegated'),
name: 'ask_user_question',
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
agent: child,
})
expect(result).toMatchObject({
isError: true,
error: { info: { name: 'UserInteractionError', 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",
}],
})
expect(seen).toHaveLength(0)
})
it('returns a structured error for empty question batches', async () => {
const ctx = await setup()

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/user-interaction/README.md
README.md: 7d3c4e87c018e42794d319b540fd0a94abc9b43c
README.zh.md: 0e5d15a673c124abab4b13e869623df1a5c63acd
README.md: cba015e782623b3a5adf018303823577a0b96774
README.zh.md: a5f944850c5ac4e05da59e8478167eef91796610

View File

@@ -13,15 +13,17 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
### Key Types
- `AskUserQuestionRequest` — `{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label.
- `AskUserQuestionRequest` — `{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label. When present, `agent` must be the registry's exact live runtime root.
- `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`, and `ASK_ABORTED`.
- `UserInteractionError` — `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.
When a request carries an agent, `ask()` authenticates its exact identity through the live `AgentRegistry` and admits only a runtime root. Durable lineage is not authority: a session with historical delegation depth may ask after it is resumed as a new runtime root, while a live child owned by another agent is rejected even if its durable depth is zero. Agentless programmatic requests retain the existing provider path.
### Presentation intent
`intent` declares that a question IS a decision of a known shape, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent shapes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read one answer shape either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of.
@@ -32,7 +34,7 @@ This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-
## 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: 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-interaction provider is registered`, or `Error: <message>`. Waiting for the human adds no tokens.
#### KV Cache effect

View File

@@ -13,15 +13,17 @@
### 关键类型
- `AskUserQuestionRequest`:`{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`;`detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。
- `AskUserQuestionRequest`:`{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`;`detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。如提供 `agent`,它必须与注册表中的存活运行时根 agent(智能体)是同一对象。
- `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` 等代码。
- `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER`、`ASK_ABORTED`、`CALLER_NOT_LIVE` 和 `DELEGATED_CALLER` 等代码。
对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
请求包含 agent 时,`ask()` 会通过当前 `AgentRegistry` 验证该 agent 与注册表中的存活实例是同一对象,并且只允许运行时根调用。持久化会话谱系不构成权限依据:带有历史委托深度的会话恢复为新的运行时根后可以提问;由另一个 agent 所有的存活子级即使持久化深度为零也会被拒绝。不含 agent 的程序化请求继续沿用现有提供方路径。
### 呈现意图
`intent` 声明某个问题本身就是一种已知形态的决策,因此认识该标签的 UI 可以照此呈现——`plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只塑造呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上——而 `detail` 正是它自称在审阅的东西。
@@ -32,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: 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-interaction provider is registered` 或 `Error: <message>`。等待人类回答不会增加 token。
#### KV Cache 影响

View File

@@ -28,7 +28,7 @@ export type {
export interface AskUserQuestionRequest {
/** Questions to display. */
questions: AskUserQuestionItem[]
/** Calling agent, when the request came from an agent tool call. */
/** Exact live calling agent, when the request came from an agent tool call. */
agent?: Agent
/** Abort signal for the owning tool/step. */
signal?: AbortSignal
@@ -77,8 +77,17 @@ export class UserInteractionService extends Service {
/**
* Ask the active UI provider and wait for the user's answer.
*
* When a caller supplies an agent, human interaction is valid only for the
* exact live runtime root. Runtime ownership, not durable session lineage,
* decides this boundary: an owned child has no human answerer and would
* block forever, while a lineage-bearing session resumed as a new runtime
* root may ask normally.
*
* @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
* 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) {
@@ -87,6 +96,21 @@ export class UserInteractionService extends Service {
if (request.questions.length === 0) {
throw new UserInteractionError('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(
'human interaction requires the exact live calling agent when an agent is supplied',
'CALLER_NOT_LIVE')
}
if (!agents.roots().includes(agent)) {
throw new UserInteractionError(
'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')
}
}
// A presentation intent asserts two things the types cannot: that the
// named approve label is one of this question's own options, and that a
// plan-review carries the plan it is a review of. A UI honouring the

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import UserInteractionService, {
UserInteractionError,
type AskUserQuestionRequest,
@@ -17,6 +18,14 @@ function provider(answer = 'approved'): UserInteractionProvider & { seen: AskUse
}
}
function stubAgent(id: string, delegationDepth = 0): Agent {
const agentId = id as Agent['id']
return {
id: agentId,
session: { id: agentId, header: { delegationDepth } },
} as unknown as Agent
}
describe('UserInteractionService', () => {
it('delegates ask requests to the registered provider', async () => {
const ctx = new Context()
@@ -84,6 +93,74 @@ describe('UserInteractionService', () => {
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)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.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({
questions: [{ id: 'confirm', question: 'Proceed?' }],
agent: child,
})).rejects.toMatchObject({
name: 'UserInteractionError',
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",
})
expect(p.ask).not.toHaveBeenCalled()
})
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)
const p = provider('yes')
ctx.userInteraction.registerProvider(p)
const agent = stubAgent('resumed-root', 1)
ctx.agents.enter(agent, undefined)
const result = await ctx.userInteraction.ask({
questions: [{ id: 'confirm', question: 'Proceed?' }],
agent,
})
expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] })
})
it('rejects a supplied agent when no live registry can attest it', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
await expect(ctx.userInteraction.ask({
questions: [{ id: 'confirm', question: 'Proceed?' }],
agent: stubAgent('unattested'),
})).rejects.toMatchObject({ name: 'UserInteractionError', 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)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
const live = stubAgent('same-id')
ctx.agents.enter(live, undefined)
await expect(ctx.userInteraction.ask({
questions: [{ id: 'confirm', question: 'Proceed?' }],
agent: stubAgent('same-id'),
})).rejects.toMatchObject({ name: 'UserInteractionError', 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)