diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index 158540a66f..2082ebf627 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: 74484f46b59c3395d7807ae6819026ad8793d033 -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 0bc9b65115a18411b83bec4e791c2ddff41e76e1 +2026-08-23-python-sdk-dsh-profile-runtime.md: 19c870b3b0e10b25480bacc85b9db29b01d2577d +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 404798ff4ae1fcafbaa8403c7187297adf374a19 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index 74484f46b5..19c870b3b0 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -24,6 +24,8 @@ Every Python launch requires either explicit `dsh_home` or a non-empty `DSH_HOME Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile sdk ...` manages external dependencies and bundle order, `$DSH_HOME/profiles/sdk/cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A different profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback; a profile that remains alive without serving JSON-RPC fails the independently bounded initialization handshake with a diagnostic naming that profile. +The checked-in minimal overlay retains the shared SDK profile while configuring the server with a root-agent tool allow list. Later base-bundle tools remain unavailable unless the overlay names them. Its deployment persona is a complete system prompt, so unrelated guidance sections cannot describe hidden tools; dynamic runtime context, workspace instructions, compaction, and the conflicting one-shot Bash row are disabled separately. + The runtime wheel installs a `dsh` console command. Ordinary profile and SDK execution remains Node-free; external package management requires a caller-installed `pnpm`. ### Executable packaging diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index 0bc9b65115..404798ff4a 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -24,6 +24,8 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml 持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile sdk ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles/sdk/cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。另一个 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退;保持运行却不提供 JSON-RPC 服务的 profile 会在独立有界的初始化握手中失败,诊断会指明该 profile。 +检入的极简 overlay 会保留共享 SDK profile,并为 server 配置根 agent 工具 allow 列表。基础 bundle 后续新增的工具只有在 overlay 指名时才可用。其部署 persona 是完整系统提示词,因此无关引导段不会描述已隐藏的工具;动态运行时上下文、workspace 指令、compaction 与存在名称冲突的单次 Bash 配置项会分别停用。 + 运行时 wheel 安装 `dsh` 控制台命令。普通 profile 与 SDK 运行仍不需要 Node;外部包管理要求调用方自行安装 `pnpm`。 ### 可执行程序打包 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9dc39c909c..4998dcd505 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: acd32b7e76180f13956ec8a54ca16d0ef99bf203 -config-catalog.zh.md: eacca1575191cff6dde93cf80e5ac576e47f485c +config-catalog.md: cb156837fa0768dfa74d9b351cc3e362449ca248 +config-catalog.zh.md: 151a72190332a400b643111495d36e2d7f205d93 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index acd32b7e76..cb156837fa 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -166,9 +166,10 @@ Source: [`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/ag * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`, - * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener, - * dynamic-context policy, deployment persona, and explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`), + * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin + * (the fixed opener, dynamic-context policy, deployment persona completeness, + * and explicit model-facing tool order), the `tools` object to the tool + * registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the @@ -196,6 +197,8 @@ export interface Config { includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] + /** Whether the deployment persona is the complete system prompt. */ + personaComplete?: SystemPromptConfig['personaComplete'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ @@ -247,7 +250,7 @@ export interface GoalConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`JobsConfig`](#deepseek-aidsh-jobs-local) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillFileSystem`](../packages/skill/skill-filesystem/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/shell/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`toolJobs`](../packages/jobs/tool-jobs/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/context/agent-instructions/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:92`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:93`](../packages/examples/agent-spine-demo/src/index.ts) @@ -1689,6 +1692,13 @@ Requires: `agents` export interface JsonRpcConfig { /** Report max-token turn/subagent termination as a successful SDK result. */ maxTokensAsSuccess?: boolean + /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */ + toolFilter?: { + /** Global tool names that remain visible. */ + allow?: string[] + /** Global tool names removed from visibility. */ + deny?: string[] + } /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -2414,6 +2424,8 @@ export interface Config { * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string + /** Treat the deployment persona as the complete system prompt (default false). */ + personaComplete?: boolean /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. * Invalid fields fail at load and unknown names fail at assembly; known names diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index eacca15751..151a721903 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -168,9 +168,10 @@ export type PresetTrust = 'system' | 'user' * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`, - * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener, - * dynamic-context policy, deployment persona, and explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`), + * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin + * (the fixed opener, dynamic-context policy, deployment persona completeness, + * and explicit model-facing tool order), the `tools` object to the tool + * registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the @@ -198,6 +199,8 @@ export interface Config { includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] + /** Whether the deployment persona is the complete system prompt. */ + personaComplete?: SystemPromptConfig['personaComplete'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ @@ -249,7 +252,7 @@ export interface GoalConfig { 依赖:[`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`JobsConfig`](#deepseek-aidsh-jobs-local) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillFileSystem`](../packages/skill/skill-filesystem/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/shell/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`toolJobs`](../packages/jobs/tool-jobs/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/context/agent-instructions/src/index.ts) -来源:[`packages/examples/agent-spine-demo/src/index.ts:92`](../packages/examples/agent-spine-demo/src/index.ts) +来源:[`packages/examples/agent-spine-demo/src/index.ts:93`](../packages/examples/agent-spine-demo/src/index.ts) @@ -1691,6 +1694,13 @@ export interface Config { export interface JsonRpcConfig { /** Report max-token turn/subagent termination as a successful SDK result. */ maxTokensAsSuccess?: boolean + /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */ + toolFilter?: { + /** Global tool names that remain visible. */ + allow?: string[] + /** Global tool names removed from visibility. */ + deny?: string[] + } /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -1702,7 +1712,7 @@ export interface JsonRpcConfig { 依赖:`Readable`(`node:stream`)· `Writable`(`node:stream`) -来源:[`packages/sdk/server/src/index.ts:29`](../packages/sdk/server/src/index.ts) +来源:[`packages/sdk/server/src/index.ts:25`](../packages/sdk/server/src/index.ts) @@ -2416,6 +2426,8 @@ export interface Config { * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string + /** Treat the deployment persona as the complete system prompt (default false). */ + personaComplete?: boolean /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. * Invalid fields fail at load and unknown names fail at assembly; known names diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index bbb6cff7ec..69e9720f11 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.i18n.yaml @@ -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 docs/user/guide/python-sdk.md -python-sdk.md: 4fbc6b343714b232b90a7954b35bf6863716281b -python-sdk.zh.md: 51637ed986d298d46633891077bf2a9d27c35bdd +python-sdk.md: 98d7f8160dee0874338c1d914796dcfb72400a48 +python-sdk.zh.md: 8a0417b24be859b8d327a8650ce1db23adb4dba2 diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 4fbc6b3437..98d7f8160d 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -101,7 +101,7 @@ Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or anothe | Context compaction | Disabled | | Session persistence | Zstandard JSONL under `/sessions` | -The overlay removes runtime-context prompt messages and most default tools while retaining the SDK application's protocol and persistence. Persistent Bash and the editor can modify any path visible to the runtime, so use a disposable checkout or container. The PTY implementation makes this example POSIX-only. +The overlay allowlists persistent Bash and the editor for every SDK-created root agent, so later base-profile tools cannot appear implicitly. It suppresses unrelated prompt sections and runtime-context messages, disables local instruction discovery and compaction, and retains the SDK application's protocol, persistence, policy, settings, credentials, and providers. Persistent Bash and the editor can modify any path visible to the runtime, so use a disposable checkout or container. The PTY implementation makes this example POSIX-only. Use a fresh home when profiles, plugins, credentials, settings, and sessions must be isolated. Use a fresh session id for independent work; reuse a harness, home, and id only to continue the same durable conversation and session-owned resources. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index 51637ed986..8a0417b24b 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -101,7 +101,7 @@ dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle | 上下文压缩 | 禁用 | | 会话持久化 | `/sessions` 下的 Zstandard JSONL | -该 overlay 会移除运行时上下文提示消息与大多数默认工具,但保留 SDK 应用的协议与持久化。持久 Bash 与 editor 可以修改运行时可见的任何路径,因此应使用一次性 checkout 或容器。由于采用 PTY 实现,本示例只支持 POSIX。 +该 overlay 会为每个由 SDK 创建的根 agent allowlist 持久 Bash 与 editor,因此基础 profile 以后新增的工具不会隐式出现。它会抑制无关提示词段与运行时上下文消息,停用本地指令发现与 compaction,并保留 SDK 应用的协议、持久化、策略、settings、credentials 与 provider。持久 Bash 与 editor 可以修改运行时可见的任何路径,因此应使用一次性 checkout 或容器。由于采用 PTY 实现,本示例只支持 POSIX。 需要隔离 profile、插件、凭据、设置与会话时,应使用新的 home。独立工作应使用新的 session id;只有继续同一段持久对话和会话资源时,才同时复用 harness、home 与 id。 diff --git a/examples/python-sdk-agent/README.i18n.yaml b/examples/python-sdk-agent/README.i18n.yaml index 1f9b897250..f0f1ef1980 100644 --- a/examples/python-sdk-agent/README.i18n.yaml +++ b/examples/python-sdk-agent/README.i18n.yaml @@ -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 examples/python-sdk-agent/README.md -README.md: e46765b0419b0b614163fa507cadef0e5c5a53b9 -README.zh.md: 13c297bdbf01b612369ded5909ec8b7f71bab26c +README.md: b1718bfa3fb6ea4cb241bb4211b58a21bacc17a2 +README.zh.md: dbc4053093f66a60a18ab02bb727611a279fe03c diff --git a/examples/python-sdk-agent/README.md b/examples/python-sdk-agent/README.md index e46765b041..b1718bfa3f 100644 --- a/examples/python-sdk-agent/README.md +++ b/examples/python-sdk-agent/README.md @@ -19,12 +19,12 @@ python examples/python-sdk-agent/minimal.py \ Set `DEEPSEEK_BASE_URL` for a compatible proxy, `DSH_MODEL` for the default model, or `DSH_SYSTEM_PROMPT` for the deployment persona. `--model` and `--profile` override their script defaults. The selected home stores the generated profile and Zstandard session logs under `sessions/`; the script never reads `~/.dsh` implicitly. -[`minimal.patch.yml`](minimal.patch.yml) is an ordered overlay on the shipped SDK profile. It preserves the SDK application bundle but narrows model-visible behavior to: +[`minimal.patch.yml`](minimal.patch.yml) is an ordered overlay on the shipped SDK profile. Its root-agent tool allowlist exposes exactly: - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -The patch omits Harness identity and runtime-context messages, local instruction discovery, skills, compaction, plan/goal/task/web/subagent/workflow tools, and the profile's one-shot Bash. It inserts the local PTY and persistent Bash providers and sets the editor output limit to 16,000 characters. +The allowlist excludes every other current or later global tool without requiring a disable entry for each base row. A complete deployment persona suppresses unrelated tool-guidance sections; runtime-context messages, local instruction discovery, compaction, and the conflicting one-shot Bash row are disabled separately. The overlay inserts the local PTY and persistent Bash providers and sets the editor output limit to 16,000 characters. Other SDK-profile services remain mounted, including persistence, policy, settings, credentials, and providers. This variant is intentionally POSIX-only. Its persistent PTY and editor can modify any path available to the runtime process, so use a disposable checkout or container. diff --git a/examples/python-sdk-agent/README.zh.md b/examples/python-sdk-agent/README.zh.md index 13c297bdbf..dbc4053093 100644 --- a/examples/python-sdk-agent/README.zh.md +++ b/examples/python-sdk-agent/README.zh.md @@ -19,12 +19,12 @@ python examples/python-sdk-agent/minimal.py \ 兼容代理使用 `DEEPSEEK_BASE_URL`,默认模型使用 `DSH_MODEL`,deployment persona 使用 `DSH_SYSTEM_PROMPT`。`--model` 与 `--profile` 会覆盖脚本默认值。所选 home 保存生成的 profile,并在 `sessions/` 下保存 Zstandard 会话日志;脚本绝不会隐式读取 `~/.dsh`。 -[`minimal.patch.yml`](minimal.patch.yml) 是随附 SDK profile 上的有序 overlay。它保留 SDK 应用 bundle,但将模型可见行为收窄为: +[`minimal.patch.yml`](minimal.patch.yml) 是随附 SDK profile 上的有序 overlay。其根 agent 工具 allow 列表只暴露: - agent 所有的持久 `bash` - 支持 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -该 patch 会省略 Harness 身份与运行时上下文消息、本地指令发现、skill、compaction,以及 plan/goal/task/web/subagent/workflow 工具和 profile 的单次 Bash。它插入本地 PTY 与持久 Bash provider,并把 editor 输出上限设为 16,000 字符。 +Allow 列表会排除当前及以后出现的其他所有全局工具,无需为每个基础配置项添加 disable。完整部署 persona 会抑制无关工具的引导段;运行时上下文消息、本地指令发现、compaction 与存在名称冲突的单次 Bash 配置项会分别停用。该 overlay 会插入本地 PTY 与持久 Bash provider,并将 editor 输出上限设为 16,000 字符。其他 SDK profile 服务仍保持挂载,包括持久化、策略、settings、credentials 与 provider。 此变体刻意只支持 POSIX。其持久 PTY 与 editor 可以修改运行时进程可访问的任何路径,因此只应在一次性 checkout 或容器中使用。 diff --git a/examples/python-sdk-agent/minimal.patch.yml b/examples/python-sdk-agent/minimal.patch.yml index be3f292cc0..2c3ac0f5bc 100644 --- a/examples/python-sdk-agent/minimal.patch.yml +++ b/examples/python-sdk-agent/minimal.patch.yml @@ -1,49 +1,28 @@ -# Minimal Python SDK overlay for `dsh --profile sdk`: keep only persistent -# Bash and the string-replacement editor, with no runtime-context prompt or -# compaction. The profile still owns JSON-RPC serving and persistence. +# Minimal Python SDK overlay for `dsh --profile sdk`. The SDK server applies +# the model-facing allowlist to every root session, so later base-bundle tools +# cannot appear implicitly. The profile still owns JSON-RPC and persistence. - id: system-prompt config: includeHarnessIdentity: false includeRuntimeContext: false persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' + personaComplete: true +- id: sdk-jsonrpc-server + config: + maxTokensAsSuccess: !!js "process.env.DSH_MAX_TOKENS_AS_SUCCESS === undefined ? true : JSON.parse(process.env.DSH_MAX_TOKENS_AS_SUCCESS)" + toolFilter: + allow: + - bash + - str_replace_editor + +# Remove non-tool model inputs and turn rewriting, plus the one-shot Bash row +# that would conflict with the persistent Bash tool inserted below. - id: agent-instructions disabled: true -- id: skill-filesystem - disabled: true -- id: tool-skill - disabled: true - id: tool-bash disabled: true -- id: tool-jobs - disabled: true -- id: tool-fs - disabled: true -- id: tool-fs-search - disabled: true -- id: tool-subagent-control - disabled: true -- id: tool-subagent-list-agents - disabled: true -- id: tool-subagent - disabled: true -- id: tool-subagent-fork - disabled: true -- id: tool-subagent-report - disabled: true -- id: tool-workflow - disabled: true -- id: tool-todo - disabled: true -- id: tool-goal - disabled: true -- id: tool-ralph - disabled: true -- id: tool-web - disabled: true -- id: plan-mode - disabled: true - id: compaction-basic disabled: true - id: command-compact diff --git a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts index c0aabb48b8..4b2519c7f8 100644 --- a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts +++ b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest' const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) const patchPath = fileURLToPath(new URL('./keyless.patch.yml', import.meta.url)) +const minimalPatchPath = fileURLToPath(new URL('../minimal.patch.yml', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) const decompress = promisify(zstdDecompress) @@ -180,6 +181,97 @@ describe('Python SDK dsh profile keyless smoke', () => { } }, 40_000) + it('keeps the minimal overlay on an explicit model-facing allowlist', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-')) + const modelRequests: Record[] = [] + const modelServer = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + modelRequests.push(JSON.parse(body) as Record) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n') + response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n') + response.write('data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n') + response.end('data: [DONE]\n\n') + }) + }) + await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) + const address = modelServer.address() + if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port') + const child = execa(process.execPath, [ + '--import', + 'tsx/esm', + binScript, + '--profile', + 'sdk', + '--patch', + minimalPatchPath, + ], { + cwd: repoRoot, + env: { + DSH_HOME: join(root, '.dsh'), + DSH_PERMISSION_MODE: 'danger-full-access', + DSH_SYSTEM_PROMPT: 'Minimal allowlist prompt.', + DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: 'keyless-smoke-no-call', + DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + }, + timeout: 35_000, + killSignal: 'SIGKILL', + reject: false, + }) + const lines: string[] = [] + let stdoutBuffer = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { + stdoutBuffer += chunk.toString('utf8') + const parts = stdoutBuffer.split('\n') + stdoutBuffer = parts.pop() ?? '' + lines.push(...parts) + }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) + + try { + child.stdin.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro' }, + })}\n`) + await waitForLine(lines, value => value.id === 1, () => stderr) + child.stdin.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'session/prompt', + params: { sessionId: 'minimal', contentBlocks: [{ type: 'text', text: 'inspect tools' }] }, + })}\n`) + await waitForLine(lines, (value) => { + const params = value.params as Record | undefined + const event = params?.event as Record | undefined + return params?.sessionId === 'minimal' && event?.type === 'turn/end' + }, () => stderr) + + const request = modelRequests[0] as { + messages?: Array<{ role?: string; content?: unknown }> + tools?: Array<{ function?: { name?: string } }> + } + expect(request.messages?.[0]).toMatchObject({ role: 'system', content: 'Minimal allowlist prompt.' }) + expect(request.tools?.map(tool => tool.function?.name).sort()).toEqual(['bash', 'str_replace_editor']) + + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) + await waitForLine(lines, value => value.id === 3, () => stderr) + const exit = await child + expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0) + } finally { + child.kill('SIGKILL') + await child + await new Promise(resolve => modelServer.close(() => { resolve() })) + await rm(root, { recursive: true, force: true }) + } + }, 40_000) + it('rejects an invalid max-token success env value', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-invalid-')) try { diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index dd68b6a139..c34ff9547a 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md -README.md: d750a507e628e7609af542227e4528d4d4934ce8 -README.zh.md: ec5b32d742b96c8044a3707c35f15e30ba642b4f +README.md: a52aa3e4c2782993fed5a525cc827aba4e3eaeb0 +README.zh.md: cf0ba43aa2f2f47ea61ef13c74fbf182fbd9f2ee diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index d750a507e6..a52aa3e4c2 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -11,6 +11,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem | `includeHarnessIdentity` | `true` | Include the fixed `You are an AI agent powered by DeepSeek Harness.` order-−100 opener. Set false only when a compatibility deployment owns the complete system prompt. | | `includeRuntimeContext` | `true` | Include ordered dynamic contexts in assembly. When false, context providers are not evaluated and contexts added by `system-prompt/assemble` listeners are discarded after the waterfall; other services and their enforcement remain active. | | `persona` | `''` | The global deployment-persona default: the ONE config-authored prompt fragment, rendered as the order-0 `deployment:persona` section unless an agent-scoped contribution shadows it. A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. | +| `personaComplete` | `false` | Treat `persona` as the complete system prompt after assembly. Other sections remain registered but are omitted from model requests; tool schemas and variables remain available. | | `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `''` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md). | ## Service: `SystemPrompt` (ctx key: `systemPrompt`) diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index ec5b32d742..cf0ba43aa2 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -11,6 +11,7 @@ | `includeHarnessIdentity` | `true` | 是否包含顺序为 −100 的固定开场白 `You are an AI agent powered by DeepSeek Harness.`。仅当兼容性部署拥有完整系统提示词时设为 false。 | | `includeRuntimeContext` | `true` | 是否在组装中包含有序动态上下文。设为 false 时不会求值上下文提供方,并会在 waterfall 后丢弃 `system-prompt/assemble` 监听器添加的上下文;其他服务及其强制机制仍然生效。 | | `persona` | `''` | 全局部署 persona 默认值:唯一由配置提供的提示词片段,渲染为顺序为 0 的 `deployment:persona` 段,除非 agent 作用域的贡献将其遮蔽。它是模板,完整的 `{{…}}` 组会严格按已注册变量解释(随附循环注册 `{{model}}`/`{{cwd}}`),目前没有表达字面量花括号的转义语法。为空 ⇒ 渲染时删除该段。 | +| `personaComplete` | `false` | 在组装后将 `persona` 作为完整系统提示词。其他段仍保持注册,但不会进入模型请求;工具 schema 与变量仍然可用。 | | `toolOrder` | 无 | 显式指定面向模型的工具顺序。该列表由 `ToolSchema.name` 组成,并且必须恰好包含一个 `''` 其余项标记(`TOOL_ORDER_REST`):已列工具按列表位置排列,未列工具则按名称字典序插入该标记所在的位置。缺席 ⇒ 直接按名称字典序排列。该顺序会在 `system-prompt/assemble` waterfall(瀑布式事件)之前应用于已收集的工具。与段的 `order` 排序一样,它会规范化注册表贡献的内容;注册顺序只是插件加载时序的产物。修改列表的 waterfall 监听器对其输出的确定性负责。配置错误会明确失败:列表没有恰好一个其余项或存在重复项,会在加载时抛出;已列名称没有对应已注册工具,会使每次 `assemble()` 被拒绝;工具提供方返回保留的其余项名称也会被拒绝。在随附循环下,轮次会在任何模型请求前失败。为何采用中心列表而非每插件权重,见[显式面向模型工具顺序](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md)。 | ## 服务:`SystemPrompt`(ctx 键:`systemPrompt`) diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index ffc052e0b9..ec36b32432 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -193,6 +193,8 @@ export interface Config { * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string + /** Treat the deployment persona as the complete system prompt (default false). */ + personaComplete?: boolean /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. * Invalid fields fail at load and unknown names fail at assembly; known names @@ -340,6 +342,7 @@ export class SystemPrompt extends Service { includeHarnessIdentity: z.boolean().default(true), includeRuntimeContext: z.boolean().default(true), persona: z.string().default(''), + personaComplete: z.boolean().default(false), // Preserve omission because an explicit empty order lacks the rest marker. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), }) @@ -366,6 +369,7 @@ export class SystemPrompt extends Service { order: PERSONA_ORDER, // The fallback narrows the optional input type; the schema already defaults it. text: config.persona ?? '', + complete: config.personaComplete ?? false, }) if (!(config.includeRuntimeContext ?? true)) this.suppressRuntimeContext() } diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index cf196892a7..c4018103d3 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -49,6 +49,21 @@ describe('SystemPrompt', () => { expect(renderPrompt(assembly)).toBe('You are a helpful software engineer assistant.') }) + it('can make the deployment persona the complete system prompt', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, { + persona: 'You are a focused SDK agent.', + personaComplete: true, + }) + ctx.systemPrompt.section({ name: 'tool:future', order: 100, text: 'Future tool guidance.' }) + + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections).toEqual([ + { name: 'deployment:persona', text: 'You are a focused SDK agent.' }, + ]) + expect(renderPrompt(assembly)).toBe('You are a focused SDK agent.') + }) + it('can suppress runtime context without evaluating providers or accepting waterfall additions', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt, { includeRuntimeContext: false }) diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index 1144fb7047..dedce4969d 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/README.i18n.yaml @@ -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/examples/agent-spine-demo/README.md -README.md: 509e4e6d6b5885fb0c685e4ee34fe8078944415b -README.zh.md: 6b429d139a40daa516735b1192af8924e8c9f7cd +README.md: 28e1496a8c941f6524ec4b4dfbfad52d17df5d71 +README.zh.md: c2838bb4cf78d6ac863bac2eba4d2e4df335fa55 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 509e4e6d6b..28e1496a8c 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -55,11 +55,11 @@ This applies the [Service Definition / Service Provider / Consumer separation](. ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, personaComplete?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. `includeRuntimeContext: false` is forwarded to `dsh-system-prompt` and suppresses all dynamic context snapshots for fresh sessions without disabling their policy services. Prompt, tool, title, skill, agent-instructions, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `jobs.maxConcurrentJobsPerOwner` configures the local provider independently of the model-facing `toolJobs` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. +The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. `includeRuntimeContext: false` suppresses all dynamic context snapshots for fresh sessions without disabling their policy services; `personaComplete: true` makes the deployment persona the sole system-prompt section. Prompt, tool, title, skill, agent-instructions, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `jobs.maxConcurrentJobsPerOwner` configures the local provider independently of the model-facing `toolJobs` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../runtime-diagnostics/invariants/README.md) for regex and lifecycle rules. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 6b429d139a..c2838bb4cf 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -55,11 +55,11 @@ ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, personaComplete?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。`includeRuntimeContext: false` 会转发给 `dsh-system-prompt`,为新建会话抑制所有动态上下文快照,但不禁用其策略服务。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值;`jobs.maxConcurrentJobsPerOwner` 配置本地 Service Provider,并与面向模型的 `toolJobs` 控制工具相互独立。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 +组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。`includeRuntimeContext: false` 会为新建会话抑制所有动态上下文快照,但不禁用其策略服务;`personaComplete: true` 会让部署 persona 成为唯一系统提示词段。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值;`jobs.maxConcurrentJobsPerOwner` 配置本地 Service Provider,并与面向模型的 `toolJobs` 控制工具相互独立。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../runtime-diagnostics/invariants/README.zh.md)。 diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 87098c38da..a60ebd9e43 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -70,9 +70,10 @@ export interface GoalConfig { * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`, - * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener, - * dynamic-context policy, deployment persona, and explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`), + * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin + * (the fixed opener, dynamic-context policy, deployment persona completeness, + * and explicit model-facing tool order), the `tools` object to the tool + * registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the @@ -100,6 +101,8 @@ export interface Config { includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] + /** Whether the deployment persona is the complete system prompt. */ + personaComplete?: SystemPromptConfig['personaComplete'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ @@ -185,6 +188,7 @@ export function pickSpineConfig(config: Omit): Omit): Omit { includeHarnessIdentity: false, includeRuntimeContext: false, persona: 'You are a helpful software engineer assistant.', + personaComplete: true, workspaceContext: false, skills: { enabled: false }, toolBash: false, @@ -723,6 +724,7 @@ describe('dsh-agent-spine-demo bundle', () => { expect(ctx.tools.schemas()).toEqual([]) ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'hidden policy' }) expect((await ctx.systemPrompt.assemble()).contexts).toEqual([]) + ctx.systemPrompt.section({ name: 'hidden', order: 100, text: 'hidden guidance' }) expect(renderPrompt(await ctx.systemPrompt.assemble())) .toBe('You are a helpful software engineer assistant.') @@ -736,6 +738,7 @@ describe('dsh-agent-spine-demo bundle', () => { includeHarnessIdentity: false, includeRuntimeContext: false, persona: 'You are merged.', + personaComplete: true, toolOrder: ['zulu'], tools: { mode: 'native' as const }, dshHome: '/tmp/dsh-home', @@ -754,6 +757,7 @@ describe('dsh-agent-spine-demo bundle', () => { includeHarnessIdentity: appConfig.includeHarnessIdentity, includeRuntimeContext: appConfig.includeRuntimeContext, persona: appConfig.persona, + personaComplete: appConfig.personaComplete, toolOrder: appConfig.toolOrder, tools: appConfig.tools, dshHome: appConfig.dshHome, diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index 5301b4b19e..3ffdb5d1e4 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/server/README.i18n.yaml @@ -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/sdk/server/README.md -README.md: 547836ddab99d5c5a5c39c013c38b4bb25ba7e7d -README.zh.md: 97a7e9897fbf338a79c2cb33a8bab809c1cd3899 +README.md: 2fac60b9313c66a8eb1653adb02405f2f5fe4b08 +README.zh.md: ed2c18f0a2ee96fdacdf0d998314d349f0b0264b diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index 547836ddab..2fac60b931 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -10,7 +10,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc ## Config -`maxTokensAsSuccess` defaults to `false` and affects only the deployment-mapped status on `subagent.finished`; root-session prompts have no prompt-level status. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport hooks; production uses process stdio and `process.exit`. +`maxTokensAsSuccess` defaults to `false` and affects only the deployment-mapped status on `subagent.finished`; root-session prompts have no prompt-level status. Optional `toolFilter.allow` and `toolFilter.deny` restrict each SDK-created root agent through `ctx.tools.restrict()`. An allow list excludes later global tool registrations that it does not name, so a fixed SDK deployment cannot silently gain model-facing tools when its base bundle expands. Unknown names and an empty filter fail when the first session is created. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport hooks; production uses process stdio and `process.exit`. ## stdout is the protocol @@ -30,7 +30,7 @@ The plugin answers `shutdown`, flushes the response, disposes the root context s #### What the model sees -For each accepted `session/prompt`, the conversation model receives the caller-supplied `contentBlocks` verbatim as one user message in that SDK session. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. +For each accepted `session/prompt`, the conversation model receives the caller-supplied `contentBlocks` verbatim as one user message in that SDK session. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. A configured `toolFilter` projects that composition's global tool registry before the request is assembled and executed. #### Token effect diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index 97a7e9897f..ed2c18f0a2 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -10,7 +10,7 @@ ## 配置 -`maxTokensAsSuccess` 默认为 `false`,且只影响 `subagent.finished` 上由部署映射的状态;根会话提示词没有提示词级状态。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输钩子;生产环境使用进程 stdio 和 `process.exit`。 +`maxTokensAsSuccess` 默认为 `false`,且只影响 `subagent.finished` 上由部署映射的状态;根会话提示词没有提示词级状态。可选的 `toolFilter.allow` 与 `toolFilter.deny` 通过 `ctx.tools.restrict()` 限制每个由 SDK 创建的根 agent。Allow 列表会排除之后出现但未指名的全局工具,因此固定的 SDK 部署不会在基础 bundle 扩展时静默获得面向模型的新工具。未知名称与空筛选器会在创建首个会话时明确失败。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输钩子;生产环境使用进程 stdio 和 `process.exit`。 ## stdout 即协议 @@ -30,7 +30,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 #### 模型看到的内容 -对于每个已接受的 `session/prompt`,对话模型会将调用方提供的 `contentBlocks` 原样作为该 SDK 会话中的一条用户消息接收。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。 +对于每个已接受的 `session/prompt`,对话模型会将调用方提供的 `contentBlocks` 原样作为该 SDK 会话中的一条用户消息接收。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。配置的 `toolFilter` 会在请求组装与执行前投影该组合的全局工具注册表。 #### Token 影响 diff --git a/packages/sdk/server/src/index.ts b/packages/sdk/server/src/index.ts index 963b4fb3bd..17e9d3a89f 100644 --- a/packages/sdk/server/src/index.ts +++ b/packages/sdk/server/src/index.ts @@ -25,6 +25,13 @@ export const inject = ['agents'] export interface JsonRpcConfig { /** Report max-token turn/subagent termination as a successful SDK result. */ maxTokensAsSuccess?: boolean + /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */ + toolFilter?: { + /** Global tool names that remain visible. */ + allow?: string[] + /** Global tool names removed from visibility. */ + deny?: string[] + } /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -35,6 +42,11 @@ export interface JsonRpcConfig { export const Config: Schema = Schema.object({ maxTokensAsSuccess: Schema.boolean().default(false), + // Preserve omission; Schemastery's materialized empty object is not a valid restriction. + toolFilter: Schema.object({ + allow: Schema.array(Schema.string()).default(undefined as unknown as string[]), + deny: Schema.array(Schema.string()).default(undefined as unknown as string[]), + }).default(undefined as unknown as { allow: string[]; deny: string[] }), }) /** @@ -59,6 +71,7 @@ export function apply(ctx: Context, config: JsonRpcConfig): void { const transport = new JsonRpcLineTransport(input, output) const server = new HarnessSdkJsonRpcServer(ctx, transport, { maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess, + ...resolvedConfig.toolFilter === undefined ? {} : { toolFilter: resolvedConfig.toolFilter }, }) // Share one exit task so racing shutdown requests cannot dispose the root or diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index ccc3b6192d..a2ec97d9f4 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -13,6 +13,7 @@ import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import { SessionId } from '@deepseek-ai/dsh-session' import type SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' +import type { ToolRestriction } from '@deepseek-ai/dsh-tools' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { InitializeParams, @@ -38,6 +39,8 @@ function subagentParentOf(carrier: Scoped): Agent { export interface HarnessSdkJsonRpcServerOptions { /** Report max-token termination as an accepted result instead of an infrastructure error. */ maxTokensAsSuccess?: boolean + /** Restrict each SDK-created root agent to an explicit subset of global tools. */ + toolFilter?: ToolRestriction } function successStatus(reason: string, options: HarnessSdkJsonRpcServerOptions): 'ok' | 'error' { @@ -220,6 +223,7 @@ export class HarnessSdkJsonRpcServer { // rows in the host plane, so this agent reads them from the global layer. A // deployment that configures a roster has to join one here first // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). + const toolFilter = this.options.toolFilter const handle = await this.ctx.agents.create({ sessionId: SessionId(sessionId), meta: { cwd: this.cwd }, @@ -228,6 +232,9 @@ export class HarnessSdkJsonRpcServer { model: this.model, ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }, }, + ...toolFilter === undefined + ? {} + : { setup: (agentCtx: Context) => { agentCtx.tools.restrict(toolFilter) } }, }) const rec: SessionRecord = { handle } this.sessions.set(sessionId, rec) diff --git a/packages/sdk/server/tests/plugin-apply.spec.ts b/packages/sdk/server/tests/plugin-apply.spec.ts index 28c89c19fe..41954007eb 100644 --- a/packages/sdk/server/tests/plugin-apply.spec.ts +++ b/packages/sdk/server/tests/plugin-apply.spec.ts @@ -11,6 +11,7 @@ import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import { defineTool } from '@deepseek-ai/dsh-tools' import * as jsonrpc from '../src/index.ts' /** @@ -71,6 +72,7 @@ async function mountPlugin( writeDelayMs?: number failFlush?: boolean beforeServer?: (ctx: Context) => Promise | void + toolFilter?: jsonrpc.JsonRpcConfig['toolFilter'] } = {}, ): Promise { const ctx = new Context() @@ -116,7 +118,12 @@ async function mountPlugin( const exit = (code: number): void => { events.push({ kind: 'exit', code }) } ctx.effect(() => () => { events.push({ kind: 'root-disposed' }) }, 'jsonrpc test root-disposal witness') - const fiber = await ctx.plugin(jsonrpc, { input, output, exit }) + const fiber = await ctx.plugin(jsonrpc, { + input, + output, + exit, + ...options.toolFilter === undefined ? {} : { toolFilter: options.toolFilter }, + }) const frames = (): Record[] => events.flatMap(event => event.kind === 'frame' ? [event.frame] : []) @@ -282,6 +289,51 @@ describe('dsh-sdk-jsonrpc-server plugin apply', () => { } }) + it('applies the configured root-agent tool filter through the Loader plugin', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-tool-filter-')) + const llmServer = await mockCompletionServer() + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url) + const harness = await mountPlugin(storageDir, { + toolFilter: { allow: ['kept'] }, + beforeServer: (ctx) => { + for (const name of ['kept', 'excluded']) { + ctx.tools.register(defineTool({ + name, + description: name, + parameters: {}, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + execute: async () => name, + })) + } + }, + }) + try { + harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'filtered-model' } }) + await harness.waitForFrame(frame => frame.id === 1, 'initialize response') + harness.send({ + jsonrpc: '2.0', + id: 2, + method: 'session/prompt', + params: { sessionId: 'filtered', contentBlocks: [{ type: 'text', text: 'inspect tools' }] }, + }) + await harness.waitForFrame( + frame => frame.method === 'session.status' + && (frame.params as { status?: string } | undefined)?.status === 'idle', + 'filtered session idle status', + ) + + const request = llmServer.requests[0] as { tools?: Array<{ function?: { name?: string } }> } + expect(request.tools?.map(entry => entry.function?.name)).toEqual(['kept']) + } finally { + await harness.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + it('answers shutdown before exiting 0 exactly once, even against a racing second shutdown', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-shutdown-')) const harness = await mountPlugin(storageDir, { writeDelayMs: 10 }) diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index 495f1d90f4..d9527e10d5 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -14,6 +14,7 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentRuntime, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol' +import { defineTool } from '@deepseek-ai/dsh-tools' import { HarnessSdkJsonRpcServer } from '../src/index.ts' class FakeTransport implements JsonRpcTransportPeer { @@ -171,6 +172,46 @@ describe('HarnessSdkJsonRpcServer', () => { } }) + it('allowlists each root session against current and later global tools', { timeout: 15_000 }, async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-tool-filter-')) + const llmServer = await mockCompletionServer() + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url) + const ctx = await makeHarness(storageDir) + const tool = (name: string) => defineTool({ + name, + description: name, + parameters: {}, + output: { + schema: { type: 'string' as const }, + render: (_args, value) => [{ type: 'text' as const, text: value }], + }, + execute: async () => name, + }) + ctx.tools.register(tool('kept')) + ctx.tools.register(tool('excluded')) + const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport(), { + toolFilter: { allow: ['kept'] }, + }) + try { + await server.initialize({ cwd: storageDir, provider: 'deepseek-official', model: 'filtered-model' }) + await server.prompt({ sessionId: 'first', contentBlocks: [{ type: 'text', text: 'first' }] }) + await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) }) + ctx.tools.register(tool('future')) + await server.prompt({ sessionId: 'second', contentBlocks: [{ type: 'text', text: 'second' }] }) + await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(2) }) + + expect(llmServer.requests.map((request) => { + const tools = (request as { tools?: Array<{ function?: { name?: string } }> }).tools ?? [] + return tools.map(entry => entry.function?.name) + })).toEqual([['kept'], ['kept']]) + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + it('queues overlapping prompts for one session without blocking other sessions', async () => { const mainFollowup = vi.fn() const mainAgent = ({ diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 06653c5741..3be8a9bcfb 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -985,23 +985,7 @@ def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) - sessions = dsh_home / "sessions" disabled = [ "agent-instructions", - "skill-filesystem", - "tool-skill", "tool-bash", - "tool-jobs", - "tool-fs", - "tool-fs-search", - "tool-subagent-control", - "tool-subagent-list-agents", - "tool-subagent", - "tool-subagent-fork", - "tool-subagent-report", - "tool-workflow", - "tool-todo", - "tool-goal", - "tool-ralph", - "tool-web", - "plan-mode", "compaction-basic", "command-compact", "tool-result-pruner", @@ -1013,6 +997,14 @@ def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) - "includeHarnessIdentity": False, "includeRuntimeContext": False, "persona": "You are a helpful software engineer assistant.", + "personaComplete": True, + }, + }, + { + "id": "sdk-jsonrpc-server", + "config": { + "maxTokensAsSuccess": True, + "toolFilter": {"allow": ["bash", "str_replace_editor"]}, }, }, *({"id": row_id, "disabled": True} for row_id in disabled),