Merge branch 'stack/agent-profiles-1-seam' into stack/agent-profiles-3-wire

This commit is contained in:
Yichen Jiang
2026-08-08 22:47:15 +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/host/apiproxy/README.md
README.md: 7ac7bdc6db2e2abbc60d1a8813e229c21ed39fe7
README.zh.md: d6ece5caed752cf0cc59cc97017549ec2b1e66cb
README.md: a3c9f214690144ec0f39a8690e4fd346f5e315e2
README.zh.md: aeaf1b29e5a71674c9feedb30b67f9ce11c47340

View File

@@ -46,13 +46,13 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
## Carrier layer (`/client` + root)
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless.
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh run` headless.
## Model Experience

View File

@@ -46,13 +46,13 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html``.htm``.xhtml``.svg`macOS 和桌面 Linux 会优先使用能够确定的默认浏览器无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户可调用的 skill;该领域没有直接加载 skill 的 RPC`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt``dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token并以注入的 `<skill_content>` 上下文作答因此每一种前端web、TUI、ACP、手动键入的文本共享同一条确定性路径没有专设的调用协议`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
`settings.*``credentials.*``llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable``credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected``llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate``credentials.set`且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据都折叠为 `model-discovery-failed`其消息是适配器自己的文本details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}``settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission``ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
## 载体层(`/client` + 根路径)
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient``toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient``toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh run` headless 模式使用。
## 模型体验

View File

@@ -18,6 +18,7 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
import { SubagentError } from '@deepseek-ai/dsh-subagent'
import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent'
import { isUserInvocable } from '@deepseek-ai/dsh-skill'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import {
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
@@ -1409,6 +1410,34 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return llm === undefined || llm.listProviders().some(entry => entry.id === provider)
}
/**
* Resolve the addressed agent for a turn-starting method and refuse when no
* adapter serves its current route: a route nothing serves cannot start a
* turn, and letting it try spends the whole pre-step path to fail inside
* the adapter with a message about registration. Refusing here names the
* model the session is pointed at while the draft is still in the composer.
* This is `session.prompt`'s enforcement boundary: a client that disables
* its input is an affordance, and the method stays callable regardless.
*/
async function turnAgentFor<T>(
request: RpcRequest<unknown>, sessionId: SessionId,
): Promise<{ agent: Agent } | { refused: RpcResponse<T> }> {
const found = await agentFor(sessionId)
if ('error' in found) return { refused: err(request, found.error) }
const agent = found.agent
const target = targetFor(agent).current
if (!routeServed(target.provider)) {
return {
refused: err(request, {
code: 'model-unavailable',
message: `no adapter serves provider "${target.provider}"; select a model for this session`,
details: { provider: target.provider, model: target.model },
}),
}
}
return { agent }
}
/** Missing-service report shared by the settings domain (skills-domain stance). */
function settingsAbsent(): RpcError {
return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} }
@@ -1986,23 +2015,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async prompt(request) {
const { sessionId, mode, content } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const agent = found.agent
// A route no adapter serves cannot start a turn, and letting it try
// spends the whole pre-step path to fail inside the adapter with a
// message about registration. Refusing here names the model the
// session is pointed at while the draft is still in the composer.
// This is the enforcement boundary: a client that disables its input
// is an affordance, and this method stays callable regardless.
const target = targetFor(agent).current
if (!routeServed(target.provider)) {
return err(request, {
code: 'model-unavailable',
message: `no adapter serves provider "${target.provider}"; select a model for this session`,
details: { provider: target.provider, model: target.model },
})
}
const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId)
if ('refused' in resolved) return resolved.refused
const agent = resolved.agent
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
try {
@@ -2202,6 +2217,31 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return subagentPromptError(request, error, signal)
}
},
// Deliberately no catalog, history, persistence, or parent Agent lookup:
// the core primitive alone authorizes the durable address against the
// live Activation, which is what keeps a live child interruptible while
// its parent Agent is offline. Absent targets are accepted no-ops there.
interrupt(request) {
const { parentSessionId, childSessionId } = request.payload
try {
ctx.subagents.interrupt(childSessionId, { kind: 'user', parentSessionId })
} catch (error: unknown) {
if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') {
return Promise.resolve(err(request, {
code: 'subagent-unauthorized',
message: 'subagent does not belong to this parent',
details: { childSessionId },
}))
}
return Promise.resolve(err(request, {
code: 'internal',
message: 'subagent interrupt failed',
details: {},
}))
}
return Promise.resolve(ok(request, { accepted: true as const }))
},
},
workspace: {
@@ -2571,13 +2611,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} })
}
try {
const skills = (await skillRegistry.list({ cwd }))
.filter(skill => skill.invocation.modelInvocable && skill.invocation.userInvocable)
const skills = (await skillRegistry.list({ cwd })).filter(isUserInvocable)
return ok(request, {
skills: skills.map(skill => ({
name: skill.name,
description: skill.description,
...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
modelInvocable: skill.invocation.modelInvocable,
})),
})
} catch (error: unknown) {

View File

@@ -42,7 +42,8 @@ export type {
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type {
SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi,
SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry,
SubagentPromptReceipt, SubagentsApi,
} from './subagents.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'

View File

@@ -36,6 +36,7 @@ export interface RpcMethodMap {
'subagent.list': SubagentsApi['list']
'subagent.history': SubagentsApi['history']
'subagent.prompt': SubagentsApi['prompt']
'subagent.interrupt': SubagentsApi['interrupt']
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']
'host.listDirectory': HostApi['listDirectory']

View File

@@ -14,6 +14,7 @@ export const skillEntrySchema = z.object({
name: z.string().min(1),
description: z.string(),
whenToUse: z.string().optional(),
modelInvocable: z.boolean(),
}) satisfies z.ZodType<Wire<SkillEntry>>
/** skill.list request payload. */

View File

@@ -10,16 +10,24 @@ import type { RpcRequest, RpcResponse } from './rpc.ts'
/** Skill catalog row (wire projection of the host SkillSummary; provider/source vocabulary stays host-side). */
export interface SkillEntry {
/** Kebab-case identifier referenced as `<skill>name</skill>` in prompts. */
/** Kebab-case identifier the user references as `/name` in the composer. */
readonly name: string
/** Short routing description. */
readonly description: string
/** Optional extra routing guidance. */
readonly whenToUse?: string
/** False marks a user-only skill (`disable-model-invocation`): invocable here, absent from the model catalog. */
readonly modelInvocable: boolean
}
/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */
/**
* Skill-domain unary methods (the map key skill.* of RpcMethodMap). Listing
* is the domain's only RPC: invocation itself is a plain `session.prompt`
* whose leading `/name` token the host recognizes at the pre-step boundary
* (`dsh-tool-skill` injects the rendered body there), so every client shares
* one deterministic path with no dedicated invocation wire.
*/
export interface SkillsApi {
/** Lists skills usable by the browser's user-selected model-reference path. */
/** Lists the user-invocable skill catalog for the session's project. */
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>>
}

View File

@@ -69,6 +69,18 @@ export const subagentPromptRequestSchema = z.object({
content: z.array(contentBlockSchema),
}) as unknown as z.ZodType<RequestPayload<'subagent.prompt'>>
/** subagent.interrupt request payload. */
export const subagentInterruptRequestSchema = z.object({
parentSessionId: sessionIdSchema,
childSessionId: sessionIdSchema,
mode: z.literal('continuable'),
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.interrupt'>>>
/** subagent.interrupt response value. */
export const subagentInterruptValueSchema = z.object({
accepted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.interrupt'>>>
const messageIdSchema = z.string() as unknown as z.ZodType<MessageId>
/** subagent.prompt response value. */

View File

@@ -40,6 +40,11 @@ export interface SubagentPromptReceipt {
messageId: MessageId
}
/** Uniform acknowledgement that one interrupt request was admitted. */
export interface SubagentInterruptReceipt {
accepted: true
}
/** Durable parent/child address that selects subagent transport in the client. */
export type SubagentAddress =
& {
@@ -94,4 +99,17 @@ export interface SubagentsApi {
>,
signal: AbortSignal,
): Promise<RpcResponse<SubagentPromptReceipt>>
/**
* Interrupts a live continuable child's current turn under the address's
* durable direct-parent authority, without requiring a live parent Agent,
* consulting the catalog, or resuming anything. Fire-and-return: `accepted`
* acknowledges the admitted cancel signal, not target quiescence, so the
* child may remain visibly running briefly. Unclaimed queued follow-ups are
* kept and parked; an absent, idle, or already-completed target is likewise
* `accepted`.
*/
interrupt(
request: RpcRequest<Extract<SubagentAddress, { mode: 'continuable' }>>,
): Promise<RpcResponse<SubagentInterruptReceipt>>
}

View File

@@ -58,6 +58,7 @@ import {
import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
import {
subagentHistoryValueSchema,
subagentInterruptValueSchema,
subagentListValueSchema,
subagentPromptValueSchema,
} from '../api/subagents.schema.ts'
@@ -96,6 +97,7 @@ export interface IApiClient {
list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.list'>>>
history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.history'>>>
prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.prompt'>>>
interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.interrupt'>>>
}
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
@@ -171,6 +173,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'subagent.list': subagentListValueSchema,
'subagent.history': subagentHistoryValueSchema,
'subagent.prompt': subagentPromptValueSchema,
'subagent.interrupt': subagentInterruptValueSchema,
'host.describe': hostDescribeValueSchema,
'host.pickDirectory': hostPickDirectoryValueSchema,
'host.listDirectory': hostListDirectoryValueSchema,
@@ -407,6 +410,7 @@ export abstract class AbstractApiClient implements IApiClient {
list: (payload, signal) => this.callUnary('subagent.list', payload, signal),
history: (payload, signal) => this.callUnary('subagent.history', payload, signal),
prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal),
interrupt: (payload, signal) => this.callUnary('subagent.interrupt', payload, signal),
}
readonly host: IApiClient['host'] = {

View File

@@ -60,6 +60,7 @@ import {
import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
import {
subagentHistoryRequestSchema,
subagentInterruptRequestSchema,
subagentListRequestSchema,
subagentPromptRequestSchema,
} from '../api/subagents.schema.ts'
@@ -95,6 +96,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) },
'subagent.history': { schema: subagentHistoryRequestSchema, invoke: (api, r, signal) => api.subagents.history(r, signal) },
'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) },
'subagent.interrupt': { schema: subagentInterruptRequestSchema, invoke: (api, r) => api.subagents.interrupt(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },

View File

@@ -228,7 +228,10 @@ describe('skill.list', () => {
// touch (or resume through) the Agent registry.
const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } })
const value = expectOk(await api.skills.list(request({ sessionId: session.id })))
expect(value.skills).toEqual([{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }])
expect(value.skills).toEqual([
{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true },
{ name: 'user-only', description: 'User-only', modelInvocable: false },
])
expect(seenCwds).toEqual(['/proj'])
expect(ctx.agents.get(session.id)).toBeUndefined()
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -10,6 +10,7 @@ import { createApiProxy } from '../src/api-proxy.ts'
async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
return {
ctx,
@@ -17,8 +18,11 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
}
}
function agent(id: string): Agent {
return { id } as unknown as Agent
function agent(ctx: Context): Agent {
const session = ctx.sessions.create()
const value = { id: session.id, session, status: 'idle', ctx } as Agent
ctx.agents.register(value)
return value
}
function openMux(api: ApiProxy, abort: AbortController): {
@@ -71,7 +75,7 @@ describe('question response validation', () => {
const abort = new AbortController()
const mux = openMux(api, abort)
const asked = ctx.userInteraction.ask({
agent: agent('session-multi'),
agent: agent(ctx),
questions: [{
id: 'targets',
question: 'Choose targets and add another',
@@ -95,7 +99,7 @@ describe('question response validation', () => {
const abort = new AbortController()
const mux = openMux(api, abort)
const asked = ctx.userInteraction.ask({
agent: agent('session-single'),
agent: agent(ctx),
questions: [{
id: 'target',
question: 'Choose one target',

View File

@@ -19,6 +19,7 @@ function bench(options: {
childStatus?: 'idle' | 'running'
entries?: object[]
followupError?: Error
interruptError?: Error
listError?: Error
/** Persistence forgets the child entirely (the vanished-mid-read race). */
storedChild?: false
@@ -53,6 +54,12 @@ function bench(options: {
) => options.followupError === undefined
? Promise.resolve('message-1')
: Promise.reject(options.followupError))
const interrupt = vi.fn((
_targetSessionId: SessionId,
_authority: { kind: 'user'; parentSessionId: SessionId },
) => {
if (options.interruptError !== undefined) throw options.interruptError
})
const childHeader = {
version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT,
} satisfies SessionHeader
@@ -72,7 +79,7 @@ function bench(options: {
})
const ctx = new Context()
ctx.provide('agents', { get: getAgent })
ctx.provide('subagents', { listChildren, followup })
ctx.provide('subagents', { listChildren, followup, interrupt })
ctx.provide('sessions', {
get: (id: SessionId) => options.liveChild === true && id === CHILD
? { id: CHILD, header: childHeader, events: childEvents }
@@ -90,7 +97,7 @@ function bench(options: {
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp',
})
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent }
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent }
}
describe('subagent gateway', () => {
@@ -309,4 +316,48 @@ describe('subagent gateway', () => {
error: { code: 'internal', message: 'subagent prompt failed' },
})
})
it('interrupts through the core primitive alone while the parent Agent is offline', async () => {
const { api, interrupt, getAgent, listChildren, inspect } = bench({ parentLive: false })
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.rpcId).toBe('subagent-rpc')
expect(response.result).toEqual({ ok: true, value: { accepted: true } })
expect(interrupt).toHaveBeenCalledExactlyOnceWith(CHILD, { kind: 'user', parentSessionId: PARENT })
// No parent-registry, catalog, or history dependency: this is what keeps a
// live child interruptible after its parent Agent went offline.
expect(getAgent).not.toHaveBeenCalled()
expect(listChildren).not.toHaveBeenCalled()
expect(inspect).not.toHaveBeenCalled()
})
it('maps interrupt authorization rejection without touching other services', async () => {
const { api, listChildren } = bench({
interruptError: new SubagentError('secret lineage', 'UNAUTHORIZED'),
})
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.result).toEqual({
ok: false,
error: {
code: 'subagent-unauthorized',
message: 'subagent does not belong to this parent',
details: { childSessionId: CHILD },
},
})
expect(listChildren).not.toHaveBeenCalled()
})
it('hides unexpected interrupt failures behind the internal code', async () => {
const { api } = bench({ interruptError: new Error('secret activation state') })
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.result).toEqual({
ok: false,
error: { code: 'internal', message: 'subagent interrupt failed', details: {} },
})
})
})

View File

@@ -63,6 +63,7 @@ function scriptedApi(overrides: {
list: r => ok(r, { entries: [], parentAvailable: false }),
history: r => ok(r, { events: [], hasMore: false }),
prompt: r => ok(r, { messageId: 'message-1' as never }),
interrupt: r => ok(r, { accepted: true as const }),
...overrides.subagents,
},
host: {
@@ -248,6 +249,32 @@ describe('unary round trip', () => {
}
})
it('round-trips subagent.interrupt and rejects a one-shot or incomplete address', async () => {
const interrupt = vi.fn((r: RpcRequest<unknown>) => ok(r, { accepted: true as const }))
const api = scriptedApi({ subagents: { interrupt } })
const c = client(api)
const accepted = await c.subagents.interrupt({
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable',
})
expect(accepted.result).toEqual({ ok: true, value: { accepted: true } })
expect(interrupt).toHaveBeenCalledTimes(1)
// The wire schema owns the mode fence: a one-shot address never reaches the impl.
const oneShot = await c.subagents.interrupt({
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'one-shot',
} as never)
expect(oneShot.result.ok).toBe(false)
if (!oneShot.result.ok) expect(oneShot.result.error.code).toBe('bad-request')
const incomplete = await c.subagents.interrupt({
parentSessionId: sid('parent'), mode: 'continuable',
} as never)
expect(incomplete.result.ok).toBe(false)
if (!incomplete.result.ok) expect(incomplete.result.error.code).toBe('bad-request')
expect(interrupt).toHaveBeenCalledTimes(1)
})
it('rejects a method/path mismatch as bad-request', async () => {
const handler = toFetchHandler(scriptedApi())
const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} }

View File

@@ -128,6 +128,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
result: { ok: true, value: { messageId: 'message-1' as never } },
}
},
async interrupt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
},
host: {
async describe(request) {
@@ -196,7 +199,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
},
skills: {
async list(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } }
return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } }
},
},
goals: {
@@ -381,7 +384,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' })
expect(miss.result).toEqual({ ok: true, value: { matched: false } })
const skills = await c.skills.list({ sessionId: 's' as never })
expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } })
expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } })
})
it('lets command.execute finish after the 30-second default unary deadline', async () => {
@@ -433,6 +436,11 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
mode: 'continuable',
content: [],
})).result).toEqual({ ok: true, value: { messageId: 'message-1' } })
expect((await c.subagents.interrupt({
parentSessionId: 'parent' as never,
childSessionId: 'child' as never,
mode: 'continuable',
})).result).toEqual({ ok: true, value: { accepted: true } })
})
it('keeps caller and connection aborts on command.execute', async () => {

View File

@@ -395,12 +395,15 @@ describe('skills domain schemas', () => {
expect(() => skillListRequestSchema.parse({})).toThrow()
expect(skillListValueSchema.parse({ skills: [] }).skills).toEqual([])
const value = skillListValueSchema.parse({ skills: [
{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' },
{ name: 'bare', description: 'No guidance' },
{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true },
{ name: 'bare', description: 'No guidance', modelInvocable: false },
] })
expect(value.skills[0]?.whenToUse).toBe('when committing')
expect(value.skills[1]?.whenToUse).toBeUndefined()
expect(() => skillEntrySchema.parse({ name: '', description: 'd' })).toThrow()
expect(value.skills[1]?.modelInvocable).toBe(false)
expect(() => skillEntrySchema.parse({ name: '', description: 'd', modelInvocable: true })).toThrow()
// modelInvocable is required wire data: an entry without it fails.
expect(() => skillEntrySchema.parse({ name: 'n', description: 'd' })).toThrow()
})
})