feat(session): centralize working directories and directory context

This commit is contained in:
Tianyi Cui committed 2026-10-06 01:27:54 +08:00
1 parent 3d9cd9fa95
commit 79bd3d8da7
497 files changed
+5670 -1187

No files matched your search

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-13-session-working-directory.md
2026-09-13-session-working-directory.md: f98c1a8c082aeb66e765582d32030ea7043928e8
2026-09-13-session-working-directory.zh.md: 181dd0cdb6b1272d668a04349fd47abc96e1ab71
@@ -0,0 +1,35 @@
# Agent Note: Session working directory
Status: implemented
English | [中文](2026-09-13-session-working-directory.zh.md)
## Problem
A fixed Session creation directory cannot represent a task that changes checkout during its conversation. Independent tools resolving paths from different sources can read and write different projects, while changing the process directory also affects unrelated Sessions. Putting mutable directory information in the system prompt changes the reusable prompt prefix.
## Decision
`dsh-working-directory` owns one logged effective directory per Session. Its projection initializes from the immutable original project and folds `working-directory/change`; consumers read that owner instead of deriving execution paths from the header or sandbox root. Directory changes validate through the filesystem provider, serialize within the Session, and publish only after the new directory is available.
The built-in `working_directory({ cd? })` tool reads or changes the directory. Required user-context snapshots carry the current value; each committed change attempts to queue a notice through the ordinary Agent inbox. Cancellation or disposal may discard unadmitted notices while the directory event survives for subsequent requests. The original project is the recovery destination when the selected directory disappears. Missing recovery destinations produce an error. Write permissions remain independent.
Subagents capture the parent's current directory or an explicit override and commit their selected directory after inherited history. Existing processes, terminals, and persistent shells keep their process-local directory. Project instructions, skills, and completion follow the effective directory without changing Session identity, grouping, or runtime composition.
File links and command directory labels use result metadata captured when each operation runs, so historical rows remain correct after later directory changes.
## Alternatives considered
**Rewrite the Session header.** The header is immutable identity metadata used by storage, grouping, and permission roots. Rewriting it would conflate current execution state with the original project.
**Let worktree tools own directory state.** Ordinary directory changes, filesystem tools, and subagents need the same value. A Git-specific owner would duplicate selection and recovery.
**Change the process directory or synchronize every shell.** Concurrent Sessions and existing subprocesses have independent lifetimes. A Session change affects new directory-based operations, while a shell's `cd` remains local.
**Keep directory text in the system prompt.** Mutable operational context belongs after retained history, using the same user-context mechanism as permission state.
## Consequences
The runtime exposes one directory owner and preserves the original project. Consumers migrate together, including filesystem, shell, instructions, skills, subagents, and both SDKs. Directory existence can change outside the harness, so validation and logged recovery occur at use. Required runtime context remains visible even when optional context is suppressed.
Verification covers independent Sessions, relative changes, replay, cancellation, recovery, tool disposal, user-context rendering, provider inheritance, and real directory-based operations.
@@ -0,0 +1,35 @@
# Agent Note: Session working directory
Status: implemented
[English](2026-09-13-session-working-directory.md) | 中文
## Problem
固定的 Session 创建目录无法表示在对话中切换检出目录的任务。各工具从不同来源解析路径时,可能读写不同项目;改变进程目录还会影响其他 Session。把可变目录信息放入系统提示词会改变可复用的提示词前缀。
## Decision
`dsh-working-directory` 拥有每个 Session 的一个日志化有效目录。投影从不可变的原始项目初始化,并折叠 `working-directory/change`;消费者读取该服务,而不是从 header 或沙箱根目录派生执行路径。目录变更通过文件系统 provider 验证,在 Session 内串行执行,并只在新目录可用后发布。
内置 `working_directory({ cd? })` 工具读取或改变目录。必需的用户上下文快照携带当前值;每次已提交变更都会尝试通过普通 Agent 收件箱排入通知。取消或销毁可能丢弃尚未进入请求的通知,但目录事件仍然保留,供后续请求使用。所选目录消失时恢复到原始项目。恢复目录也消失时返回错误。写权限保持独立。
子代理捕获父代理当前目录或显式覆盖值,并在继承历史之后提交选定目录。已有进程、终端和持久 shell 保留进程局部目录。项目指令、技能和补全跟随有效目录,不改变 Session 身份、归属或运行时组合。
文件链接与命令目录标签使用各次操作执行时记录的结果元数据,因此后续目录变更不会使历史记录指向错误的位置。
## Alternatives considered
**改写 Session header。** header 是存储、归属与权限根目录使用的不可变身份元数据。改写它会混淆当前执行状态与原始项目。
**让 worktree 工具拥有目录状态。** 普通目录变更、文件系统工具和子代理需要同一个值。Git 专用 owner 会重复实现选择与恢复。
**改变进程目录或同步所有 shell。** 并发 Session 与已有子进程具有独立生命周期。Session 变更影响新的目录相关操作,shell 内的 `cd` 则保持局部。
**把目录文本留在系统提示词中。** 可变操作上下文应位于保留历史之后,复用权限状态的用户上下文机制。
## Consequences
运行时提供一个目录 owner,并保留原始项目。消费者一起迁移,包括文件系统、shell、指令、技能、子代理和两种 SDK。目录可能在 harness 之外消失,因此在使用时执行验证和日志化恢复。即使可选上下文被禁用,必需运行时上下文也仍然可见。
验证覆盖 Session 相互独立、相对路径变更、回放、取消、恢复、工具卸载、用户上下文渲染、provider 继承和真实目录操作。
+6
View File
@@ -64,6 +64,10 @@ flowchart LR
cfg --> plugin_dsh_base_attachment_local
plugin_dsh_base_session_query_sqlite["session-query-sqlite<br/>@deepseek-ai/dsh-session-query-sqlite"]
cfg --> plugin_dsh_base_session_query_sqlite
plugin_dsh_base_working_directory["working-directory<br/>@deepseek-ai/dsh-working-directory"]
cfg --> plugin_dsh_base_working_directory
plugin_dsh_base_tool_working_directory["tool-working-directory<br/>@deepseek-ai/dsh-tool-working-directory"]
cfg --> plugin_dsh_base_tool_working_directory
plugin_dsh_base_session_projection["session-projection<br/>@deepseek-ai/dsh-session-projection"]
cfg --> plugin_dsh_base_session_projection
plugin_dsh_base_storage["storage<br/>@deepseek-ai/dsh-storage"]
@@ -228,6 +232,8 @@ flowchart LR
| `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` |
| `attachment-local` | `@deepseek-ai/dsh-attachment-local` |
| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
| `working-directory` | `@deepseek-ai/dsh-working-directory` |
| `tool-working-directory` | `@deepseek-ai/dsh-tool-working-directory` |
| `session-projection` | `@deepseek-ai/dsh-session-projection` |
| `storage` | `@deepseek-ai/dsh-storage` |
| `storage-json` | `@deepseek-ai/dsh-storage-json` |
+8 -2
View File
@@ -81,8 +81,13 @@ describe('minimal agent preset', () => {
if (requestHeader === undefined) throw new Error('the minimal agent issued no model request')
const systemPrompt = systemPromptText(agentHandle.agent.session)
if (systemPrompt === undefined) throw new Error('the minimal agent issued no system prompt')
expect(agentHandle.agent.session.snapshotEvents().some(event => event.type === 'user/message'
&& event.data.source.kind === 'runtime-context')).toBe(false)
const runtimeContexts = agentHandle.agent.session.snapshotEvents().flatMap(event => event.type === 'user/message'
&& event.data.source.kind === 'runtime-context'
? [event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')]
: [])
expect(runtimeContexts).toEqual([
`Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: ${JSON.stringify(agentHandle.agent.session.header.cwd)}.`,
])
expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'fs')).toBeUndefined()
expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'compaction')).toBeUndefined()
@@ -123,6 +128,7 @@ describe('minimal agent preset', () => {
"prompt": "You are a helpful software engineer assistant.",
"tools": [
"bash",
"working_directory",
],
}
`)
+1 -1
View File
@@ -36,7 +36,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => {
replayOverride = join(replayRoot, 'replay.override.json')
const script = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
// Recorded absolute paths must follow each isolated Session's working directory.
const cwdToken = '{{fromRequest:Your working directory is ([^\\n]+)\\.}}'
const cwdToken = '{{fromRequest:Current working directory: "([^"]+)"}}'
await writeFile(replayOverride, JSON.stringify(script).replaceAll('{{cwd}}', JSON.stringify(cwdToken).slice(1, -1)))
}
scaffold = await launchWebScaffold({
+6 -6
View File
@@ -105,12 +105,12 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
await page.getByText(/^PRESENT_DONE\.?$/).waitFor({ timeout: 30_000 })
await assertFinalWorkspaceSnapshot(DIR, cwd)
expect(events.filter(event => event.type === 'deliverables/presented').flatMap(event => event.data.files.map(file => file.path)))
.toEqual(['report.txt', '说明.txt'])
.toEqual([join(cwd, 'report.txt'), join(cwd, '说明.txt')])
for (const event of events) {
if (event.type === 'deliverables/presented') {
expect(event.data.files).toEqual([
{ path: 'report.txt', description: 'delivered report' },
{ path: '说明.txt', description: 'delivered note' },
{ path: join(cwd, 'report.txt'), description: 'delivered report' },
{ path: join(cwd, '说明.txt'), description: 'delivered note' },
])
}
}
@@ -147,7 +147,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
const beforePreview = (await opened()).length
const column = page.locator('[data-rightbar-col]')
for (const [name, content] of [['report.txt', 'EDITED_REPORT'], ['说明.txt', 'EDITED_NOTE']] as const) {
const mention = page.locator('code').getByRole('button', { name: `Open ${name} in sidebar`, exact: true })
const mention = page.locator('code').getByRole('button', { name: `Open ${join(cwd, name)} in sidebar`, exact: true })
await mention.click()
const preview = column.locator('[data-document-preview]')
await expect.poll(() => preview.getAttribute('data-textpreview-url'))
@@ -187,8 +187,8 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
.filter(event => event.type === 'deliverables/presented')
expect(declarations).toHaveLength(1)
expect(declarations[0]!.data.files).toEqual([
{ path: 'report.txt', description: 'delivered report' },
{ path: '说明.txt', description: 'delivered note' },
{ path: join(cwd, 'report.txt'), description: 'delivered report' },
{ path: join(cwd, '说明.txt'), description: 'delivered note' },
])
expect(exported).not.toContain('EDITED_REPORT')
if (MODE !== 'record') {
+8 -2
View File
@@ -213,7 +213,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
}
}, 200_000)
it('ends the system prompt with the source checkout, Web surface, and session cwd', async () => {
it('keeps source checkout and Web context in the system prompt and cwd in user context', async () => {
if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id')
const agent = scaffold.ctx.agents.get(settledSessionId)
if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`)
@@ -224,7 +224,13 @@ describe('web e2e: fresh round trip through the real assembly', () => {
'You are an AI agent powered by DeepSeek Harness.',
'You are a coding agent powered by the deepseek-v4-flash model.',
])
const suffix = paragraphs.slice(-3).join('\n\n')
const cwd = agent.session.header.cwd
if (cwd === undefined) throw new Error('the Web session has no working directory')
expect(system).not.toContain(cwd)
const userText = agent.session.deriveMessages().filter(message => message.role === 'user')
.flatMap(message => message.content.flatMap(block => block.type === 'text' ? [block.text] : []))
expect(userText.some(text => text.includes(`Current working directory: ${JSON.stringify(cwd)}.`))).toBe(true)
const suffix = paragraphs.slice(-2).join('\n\n')
.split(REPO_ROOT).join('{{sourceRoot}}')
.split(join(scaffold.workspaceCwd, 'workspace')).join('{{cwd}}')
.split(scaffold.baseUrl).join('{{webUrl}}')
+2
View File
@@ -128,6 +128,8 @@ Session consumers know only the current logical format. Header-only `stat` and `
**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, host consumers read one typed state with `stateOf()`, and carriers batch cropped client views with `snapshot()`. A host reader either requires this service during activation or fails explicitly when the registry or required key is absent. Contributors may retain `ctx.inject(['sessionProjections'], ...)` registration without silently defaulting a missing host value. The agent loop registers shared `turnBoundary` state for its readers ([decision](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md)).
[Working directories](subsystems/working-directory.md) are durable Session state independent of the original project and sandbox write roots. Their owner supplies user context and the directory for new operations; existing processes retain their own directories.
## Capability seams
A **seam** is a swappable capability with three roles: a **Service Definition** declaring the interface, a **Service Provider** implementing it, and a **Consumer** using it, commonly a model-facing tool. A package may combine roles, but one role alone is not a seam; adding a capability means designing all three ([capability graph](capability-seams.md)).
+2
View File
@@ -132,6 +132,8 @@ Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list`
**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,host 消费方通过 `stateOf()` 读取单个类型化状态,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。host 读取方要么在激活时要求该服务,要么在注册表或必需 key 缺席时明确失败。贡献方可以保留 `ctx.inject(['sessionProjections'], ...)` 注册,但不能为缺失的 host 值静默提供默认值。agent loop 为读取方注册共享的 `turnBoundary` 状态([决策](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md))。
[工作目录](subsystems/working-directory.zh.md) 是独立于原始项目和沙箱可写根目录的持久化 Session 状态。目录服务提供用户上下文和新操作使用的目录;已有进程保留自己的目录。
## 能力 seam
一个 **seam** 是一项可替换能力,包含三种角色:声明接口的 **Service Definition**、实现它的 **Service Provider**,以及使用它的 **Consumer**(通常是面向模型的工具)。一个包可以合并承担多个角色,但单一角色本身不是 seam;添加一项能力意味着把三者一并设计([能力图](capability-seams.zh.md))。
+18 -3
View File
@@ -22,6 +22,15 @@ flowchart LR
svc_connection["ctx.connection<br/>Authenticated browser transport"]
pkg_api_gateway["api-gateway"]
pkg_host_frontend_static["host-frontend-static"]
pkg_working_directory["working-directory"]
svc_workingDirectory["ctx.workingDirectory<br/>Session working directory"]
pkg_tool_working_directory["tool-working-directory"]
pkg_tool_fs["tool-fs"]
pkg_tool_bash["tool-bash"]
pkg_tool_pwsh["tool-pwsh"]
pkg_subagent["subagent"]
pkg_sdk_jsonrpc_server["sdk-jsonrpc-server"]
pkg_mcp_resources["mcp-resources"]
svc_mcpResources["ctx.mcpResources<br/>Scoped MCP resource access"]
pkg_mcp_client["mcp-client"]
@@ -41,7 +50,6 @@ flowchart LR
svc_attachments["ctx.attachments<br/>Durable binary attachment storage"]
pkg_attachment_local["attachment-local"]
pkg_api_session_controller["api-session-controller"]
pkg_tool_fs["tool-fs"]
pkg_llm_pi_ai["llm-pi-ai"]
pkg_llm_deepseek["llm-deepseek"]
pkg_client_file_upload["client-file-upload"]
@@ -92,7 +100,6 @@ flowchart LR
svc_typertGateway["ctx.typertGateway<br/>Typert Host invocation gateway"]
svc_sessionPersistence["ctx.sessionPersistence<br/>Durable session persistence seam"]
pkg_session_persistence_jsonl["session-persistence-jsonl"]
pkg_tool_bash["tool-bash"]
pkg_hooks_claude_code["hooks-claude-code"]
pkg_hooks_codex["hooks-codex"]
pkg_config_editor["config-editor"]
@@ -199,7 +206,6 @@ flowchart LR
pkg_shell["shell"]
svc_shell["ctx.shell<br/>Bash executor seam"]
pkg_pwsh_local["pwsh-local"]
pkg_tool_pwsh["tool-pwsh"]
pkg_shell_env["shell-env"]
svc_shellEnv["ctx.shellEnv<br/>Managed bash environment registry"]
pkg_terminal["terminal"]
@@ -425,6 +431,7 @@ flowchart LR
pkg_webhook --> svc_webhookRuntime
pkg_workflow --> svc_workflowEngine
pkg_workflow_ptc --> svc_workflowEngine
pkg_working_directory --> svc_workingDirectory
pkg_workspace --> svc_workspaceRegistry
pkg_workspace_changes --> svc_workspaceChanges
svc_agentDefaultModel --> pkg_api_session_controller
@@ -567,6 +574,12 @@ flowchart LR
svc_webhookRuntime --> pkg_webhook_github
svc_workflowEngine --> pkg_tool_ralph
svc_workflowEngine --> pkg_tool_workflow
svc_workingDirectory --> pkg_sdk_jsonrpc_server
svc_workingDirectory --> pkg_subagent
svc_workingDirectory --> pkg_tool_bash
svc_workingDirectory --> pkg_tool_fs
svc_workingDirectory --> pkg_tool_pwsh
svc_workingDirectory --> pkg_tool_working_directory
svc_workspaceRegistry --> pkg_api_session_controller
svc_workspaceRegistry --> pkg_api_workspace_controller
svc_fs -. event gate .-> pkg_fs_observation_policy
@@ -580,6 +593,8 @@ flowchart LR
| `ctx.pluginManager` | `core` | [`plugin-manager`](../packages/boot/plugin-manager) | - | [`plugin-manager`](../packages/boot/plugin-manager), `ui-settings-plugin-inventory` | - | Shares profile package operations with the CLI and reports persisted and running state to Web and agent callers. |
| `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | The dsh launcher supplies data-only profile locations and composition inputs; reload scheduling belongs to dsh-hmr. |
| `ctx.connection` | `core` | [`client-connection`](../packages/client/connection) | - | [`api-gateway`](../packages/api/gateway), [`host-frontend-static`](../packages/host/frontend-static) | - | Owns browser authentication and shared HTTP request dispatch; API adapters register endpoints and streams. |
| `ctx.workingDirectory` | `seam` | [`working-directory`](../packages/session/working-directory) | [`working-directory`](../packages/session/working-directory) | [`tool-working-directory`](../packages/session/tool-working-directory), [`tool-fs`](../packages/fs/tool-fs), [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`subagent`](../packages/subagent/subagent), [`sdk-jsonrpc-server`](../packages/sdk/server) | - | One Session projection owns the effective execution directory. The filesystem validates changes; user context reports them while original metadata and write grants stay fixed. |
| `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. |
| `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | One provider-owned name per service instance. Providers own their tools and browser resources per live Session; the shared service has no browser operation API. |
| `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock. |
+18 -3
View File
@@ -24,6 +24,15 @@ flowchart LR
svc_connection["ctx.connection<br/>Authenticated browser transport"]
pkg_api_gateway["api-gateway"]
pkg_host_frontend_static["host-frontend-static"]
pkg_working_directory["working-directory"]
svc_workingDirectory["ctx.workingDirectory<br/>Session working directory"]
pkg_tool_working_directory["tool-working-directory"]
pkg_tool_fs["tool-fs"]
pkg_tool_bash["tool-bash"]
pkg_tool_pwsh["tool-pwsh"]
pkg_subagent["subagent"]
pkg_sdk_jsonrpc_server["sdk-jsonrpc-server"]
pkg_mcp_resources["mcp-resources"]
svc_mcpResources["ctx.mcpResources<br/>Scoped MCP resource access"]
pkg_mcp_client["mcp-client"]
@@ -43,7 +52,6 @@ flowchart LR
svc_attachments["ctx.attachments<br/>Durable binary attachment storage"]
pkg_attachment_local["attachment-local"]
pkg_api_session_controller["api-session-controller"]
pkg_tool_fs["tool-fs"]
pkg_llm_pi_ai["llm-pi-ai"]
pkg_llm_deepseek["llm-deepseek"]
pkg_client_file_upload["client-file-upload"]
@@ -94,7 +102,6 @@ flowchart LR
svc_typertGateway["ctx.typertGateway<br/>Typert Host invocation gateway"]
svc_sessionPersistence["ctx.sessionPersistence<br/>Durable session persistence seam"]
pkg_session_persistence_jsonl["session-persistence-jsonl"]
pkg_tool_bash["tool-bash"]
pkg_hooks_claude_code["hooks-claude-code"]
pkg_hooks_codex["hooks-codex"]
pkg_config_editor["config-editor"]
@@ -201,7 +208,6 @@ flowchart LR
pkg_shell["shell"]
svc_shell["ctx.shell<br/>Bash executor seam"]
pkg_pwsh_local["pwsh-local"]
pkg_tool_pwsh["tool-pwsh"]
pkg_shell_env["shell-env"]
svc_shellEnv["ctx.shellEnv<br/>Managed bash environment registry"]
pkg_terminal["terminal"]
@@ -427,6 +433,7 @@ flowchart LR
pkg_webhook --> svc_webhookRuntime
pkg_workflow --> svc_workflowEngine
pkg_workflow_ptc --> svc_workflowEngine
pkg_working_directory --> svc_workingDirectory
pkg_workspace --> svc_workspaceRegistry
pkg_workspace_changes --> svc_workspaceChanges
svc_agentDefaultModel --> pkg_api_session_controller
@@ -569,6 +576,12 @@ flowchart LR
svc_webhookRuntime --> pkg_webhook_github
svc_workflowEngine --> pkg_tool_ralph
svc_workflowEngine --> pkg_tool_workflow
svc_workingDirectory --> pkg_sdk_jsonrpc_server
svc_workingDirectory --> pkg_subagent
svc_workingDirectory --> pkg_tool_bash
svc_workingDirectory --> pkg_tool_fs
svc_workingDirectory --> pkg_tool_pwsh
svc_workingDirectory --> pkg_tool_working_directory
svc_workspaceRegistry --> pkg_api_session_controller
svc_workspaceRegistry --> pkg_api_workspace_controller
svc_fs -. event gate .-> pkg_fs_observation_policy
@@ -582,6 +595,8 @@ flowchart LR
| `ctx.pluginManager` | `core` | [`plugin-manager`](../packages/boot/plugin-manager) | - | [`plugin-manager`](../packages/boot/plugin-manager), `ui-settings-plugin-inventory` | - | 与 CLI 共享 profile 包操作,并向 Web 和 Agent 调用方分别报告持久状态与运行状态。 |
| `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | dsh launcher 提供纯数据形式的 profile 位置与组合输入;重载调度由 dsh-hmr 负责。 |
| `ctx.connection` | `core` | [`client-connection`](../packages/client/connection) | - | [`api-gateway`](../packages/api/gateway), [`host-frontend-static`](../packages/host/frontend-static) | - | 负责浏览器认证与共享 HTTP 请求分发;API 适配器注册端点和流。 |
| `ctx.workingDirectory` | `seam` | [`working-directory`](../packages/session/working-directory) | [`working-directory`](../packages/session/working-directory) | [`tool-working-directory`](../packages/session/tool-working-directory), [`tool-fs`](../packages/fs/tool-fs), [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`subagent`](../packages/subagent/subagent), [`sdk-jsonrpc-server`](../packages/sdk/server) | - | 一个 Session 投影拥有有效执行目录。文件系统验证变更,用户上下文报告目录信息,原始元数据与写入授权保持不变。 |
| `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 |
| `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | 每个服务实例注册一个提供方拥有的名称。提供方按实时 Session 拥有自己的工具与浏览器资源;共享服务不提供浏览器操作 API。 |
| `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | 每个服务实例只注册一个提供方自定的名称。各提供方也拥有自己的模型工具;服务不提供通用操作 API、运行时选择或 Session 流程锁。 |
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/persistence-changes/2026-09-13-working-directory.md
2026-09-13-working-directory.md: 0c3de3fd7eaf558faa226b7ca2a2732ce7a96993
2026-09-13-working-directory.zh.md: 81985385a370d35f5049c3c5552b8c10d2b2c821
@@ -0,0 +1,48 @@
---
description: "Records a persistence type transition and its compatibility acknowledgement."
kind: persistence-change
---
# 2026-09-13-working-directory
English | [中文](2026-09-13-working-directory.zh.md)
## Summary
Adds durable per-Session working-directory changes.
## Table of Contents
- [Declaration](#declaration)
- [Compatibility](#compatibility)
- [Verification](#verification)
- [Dev Note](#dev-note)
<a id="declaration"></a>
## Declaration
```yaml persistence-change
schemaVersion: 1
id: 2026-09-13-working-directory
baseline: false
changes:
- root: "event:working-directory/change"
previous: null
after: "124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3"
decision: same-version
```
<a id="compatibility"></a>
## Compatibility
Existing Session headers and events remain valid. The new event is required on read: older builds that do not know working-directory/change refuse logs containing it. The event vocabulary grows without changing the structural Session format.
<a id="verification"></a>
## Verification
Focused working-directory and system-prompt suites pass 113 tests with per-file 100% coverage. Keyless headless replay verifies changes, recovery, user-context notices, and a stable system prompt.
<a id="dev-note"></a>
## Dev Note
None.
@@ -0,0 +1,76 @@
{
"formatVersion": 1,
"roots": [
{
"key": "event:working-directory/change",
"kind": "event",
"event": "working-directory/change",
"surface": false,
"digest": "124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3",
"schema": {
"root": 0,
"nodes": [
{
"kind": "object",
"properties": [
{
"name": "data",
"type": 1,
"optional": false
},
{
"name": "ignorable",
"type": 3,
"optional": true
},
{
"name": "seq",
"type": 4,
"optional": false
},
{
"name": "time",
"type": 4,
"optional": false
},
{
"name": "type",
"type": 5,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "cwd",
"type": 2,
"optional": false
}
],
"indices": []
},
{
"kind": "primitive",
"type": "string"
},
{
"kind": "literal",
"value": true
},
{
"kind": "primitive",
"type": "number"
},
{
"kind": "literal",
"value": "working-directory/change"
}
]
}
}
],
"types": []
}
@@ -0,0 +1,48 @@
---
description: "记录持久化类型更改及其兼容性确认。"
kind: persistence-change
---
# 2026-09-13-working-directory
[English](2026-09-13-working-directory.md) | 中文
## 概述
增加每个 Session 的持久化工作目录变更。
## 目录
- [声明](#declaration)
- [兼容性](#compatibility)
- [验证](#verification)
- [开发备注](#dev-note)
<a id="declaration"></a>
## 声明
```yaml persistence-change
schemaVersion: 1
id: 2026-09-13-working-directory
baseline: false
changes:
- root: "event:working-directory/change"
previous: null
after: "124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3"
decision: same-version
```
<a id="compatibility"></a>
## 兼容性
已有 Session header 和事件继续有效。新事件在读取时为必需:不认识 working-directory/change 的旧版本拒绝包含它的日志。事件词汇扩展不改变 Session 的结构格式。
<a id="verification"></a>
## 验证
工作目录和系统提示词定向套件通过 113 个测试,各源码文件覆盖率达到 100%。无密钥 headless 回放验证变更、恢复、用户上下文通知和稳定的系统提示词。
<a id="dev-note"></a>
## 开发备注
无。
+1
View File
@@ -18,6 +18,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
| [deliverables.md](deliverables.md) | what a turn hands to the user: `PresentedFile` deliveries from `present` and the Host-served `WorkspaceChangesSummary` of changed files from git snapshots |
| [commands.md](commands.md) | the human-command registry service: definitions, adapter discovery, direct invocation, results, and parsing views |
| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events |
| [working-directory.md](working-directory.md) | Session execution directories, durable changes, recovery, and user context |
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, the JSONL provider, `session/flush`, crash recovery, `SessionHeader` |
| [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits |
| [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers |
+1
View File
@@ -18,6 +18,7 @@
| [deliverables.md](deliverables.zh.md) | 一轮交给用户的东西:`present` 声明的 `PresentedFile` 交付,以及由 git 快照得出、由 Host 提供的 `WorkspaceChangesSummary` 改动摘要 |
| [commands.md](commands.zh.md) | 人类命令注册表服务:定义、适配器发现、直接调用、结果与解析视图 |
| [session.md](session.zh.md) | 完整的 `SessionEventMap` 变体目录、`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 |
| [working-directory.md](working-directory.zh.md) | Session 执行目录、持久化变更、恢复与用户上下文 |
| [persistence.md](persistence.zh.md) | 持久性 seam:`SessionPersistence`、JSONL 提供方、`session/flush`、崩溃恢复、`SessionHeader` |
| [settings.md](settings.zh.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 |
| [credentials.md](credentials.zh.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、提供方来源层 |
+9 -4
View File
@@ -37,7 +37,7 @@ interface SubagentCapabilities {
## The one-shot start request
The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional Agent provider, model, reasoning-effort, and token overrides, output schema, depth, tool filter, and persona require matching capability flags. In-process backends merge `agentOptions` over the parent Agent's options, scope filters and personas to child creation, and implement the supported object-rooted schema with a forced capture tool. The DSH SDK backend merges the four Agent route fields over its instance defaults and validates them in the child runtime's initialization; ACP, Codex, and Claude Code reject `agentOptions` before starting their transports.
The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the default current directory, lineage, and delegation depth. Optional `cwd` selects the child’s initial directory; relative values resolve against the parent’s current directory. Optional Agent provider, model, reasoning-effort, and token overrides, output schema, depth, tool filter, and persona require matching capability flags. In-process backends merge `agentOptions` over the parent Agent's options, scope filters and personas to child creation, and implement the supported object-rooted schema with a forced capture tool. The DSH SDK backend merges the four Agent route fields over its instance defaults and validates them in the child runtime's initialization; ACP, Codex, and Claude Code reject `agentOptions` before starting their transports.
```ts type-equiv
/**
@@ -48,14 +48,15 @@ The tool layer builds this request from the model input and its own config; the
* {@link SubagentProvider.start}.
*/
interface SubagentStartRequest {
/** Initial child directory; relative paths resolve against the parent's current directory. Omitted inherits that directory at start. */
readonly cwd?: string
/** Optional short display label persisted with a session-backed child. */
readonly label?: string
/** Content delivered as the child's user message. */
readonly prompt: ContentBlock[]
/**
* The spawning agent. In-process providers derive workspace, lineage, and
* delegation depth from its durable session state. ACP reads only its cwd,
* and only when no deployment `cwd` override is configured.
* The spawning agent. Its effective directory supplies the default cwd;
* in-process children retain its origin, lineage, and delegation depth.
*/
readonly parent: Agent
/**
@@ -116,6 +117,8 @@ The caller-facing request does not carry catalog format details or continuation
* the durable child descriptor.
*/
interface ResolvedSubagentStartRequest extends SubagentStartRequest {
/** Absolute directory selected once before provider startup. */
readonly cwd: string
/** Detached descriptor a session-backed provider persists in the child log. */
readonly descriptor: SubagentDescriptorData
}
@@ -231,6 +234,8 @@ The provider participates only in preparing the initial creation spec, where `sp
* history.
*/
interface ContinuableCreateRequest {
/** Absolute initial directory captured before provider preparation. */
readonly cwd: string
/** The reserved durable child session id, for provider diagnostics. */
readonly sessionId: SessionId
/** The delegating parent agent whose history a seeding provider reads. */
+9 -4
View File
@@ -37,7 +37,7 @@ interface SubagentCapabilities {
## 单次启动请求
工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 Agent 提供方、模型、推理强度与 token 覆盖、output schema、depth、工具过滤器和 persona 需要对应的能力 flag 匹配。进程内后端会把 `agentOptions` 合并到父 Agent 选项之上,将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture 工具实现所支持的 object-rooted schema。DSH SDK 后端会把四个 Agent 路由字段合并到实例默认值之上,并在子运行时初始化期间校验;ACP、Codex 与 Claude Code 会在启动传输前拒绝 `agentOptions`。
工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供默认的当前目录、谱系与委派深度。可选的 `cwd` 指定子代理的初始目录;相对值基于父代理当前目录解析。可选的 Agent 提供方、模型、推理强度与 token 覆盖、output schema、depth、工具过滤器和 persona 需要对应的能力 flag 匹配。进程内后端会把 `agentOptions` 合并到父 Agent 选项之上,将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture 工具实现所支持的 object-rooted schema。DSH SDK 后端会把四个 Agent 路由字段合并到实例默认值之上,并在子运行时初始化期间校验;ACP、Codex 与 Claude Code 会在启动传输前拒绝 `agentOptions`。
```ts type-equiv
/**
@@ -48,14 +48,15 @@ interface SubagentCapabilities {
* {@link SubagentProvider.start}.
*/
interface SubagentStartRequest {
/** Initial child directory; relative paths resolve against the parent's current directory. Omitted inherits that directory at start. */
readonly cwd?: string
/** Optional short display label persisted with a session-backed child. */
readonly label?: string
/** Content delivered as the child's user message. */
readonly prompt: ContentBlock[]
/**
* The spawning agent. In-process providers derive workspace, lineage, and
* delegation depth from its durable session state. ACP reads only its cwd,
* and only when no deployment `cwd` override is configured.
* The spawning agent. Its effective directory supplies the default cwd;
* in-process children retain its origin, lineage, and delegation depth.
*/
readonly parent: Agent
/**
@@ -116,6 +117,8 @@ interface SubagentStartRequest {
* the durable child descriptor.
*/
interface ResolvedSubagentStartRequest extends SubagentStartRequest {
/** Absolute directory selected once before provider startup. */
readonly cwd: string
/** Detached descriptor a session-backed provider persists in the child log. */
readonly descriptor: SubagentDescriptorData
}
@@ -231,6 +234,8 @@ interface SubagentSettledMessageSource {
* history.
*/
interface ContinuableCreateRequest {
/** Absolute initial directory captured before provider preparation. */
readonly cwd: string
/** The reserved durable child session id, for provider diagnostics. */
readonly sessionId: SessionId
/** The delegating parent agent whose history a seeding provider reads. */
+19 -1
View File
@@ -84,6 +84,24 @@ interface PromptContext {
readonly order: number
/** Static text or a provider evaluated for each assembly. Empty text contributes nothing. */
readonly text: string | ((context: AssembleContext) => string)
/** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
readonly interpolate?: boolean
/** Keep this operational context when optional runtime context is disabled. */
readonly required?: boolean
}
```
`required: true` keeps an operational contribution when optional runtime context is suppressed. `interpolate: false` preserves literal text, including variable-like directory names. `AssembledContext` carries the resolved text and that interpolation choice into rendering.
```ts type-equiv
/** One resolved dynamic context contribution. */
interface AssembledContext {
/** The contributing context's unique name. */
name: string
/** The resolved text before variable interpolation. */
text: string
/** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
interpolate?: boolean
}
```
@@ -135,7 +153,7 @@ getContextOrder(name: PromptContextOrderName): number
context(context: PromptContext): () => void
/**
* Suppress every dynamic runtime-context contribution in the calling
* Suppress optional dynamic runtime-context contributions in the calling
* context's scope without changing the services that own or enforce those
* facts. Multiple suppressors remain independently disposable.
* @returns the exact Cordis effect disposer.
+19 -1
View File
@@ -84,6 +84,24 @@ interface PromptContext {
readonly order: number
/** Static text or a provider evaluated for each assembly. Empty text contributes nothing. */
readonly text: string | ((context: AssembleContext) => string)
/** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
readonly interpolate?: boolean
/** Keep this operational context when optional runtime context is disabled. */
readonly required?: boolean
}
```
`required: true` 在可选运行时上下文被抑制时保留操作所需的贡献。`interpolate: false` 原样保留文本,包括看起来像变量的目录名。`AssembledContext` 将已求值文本及其插值选择传给渲染过程。
```ts type-equiv
/** One resolved dynamic context contribution. */
interface AssembledContext {
/** The contributing context's unique name. */
name: string
/** The resolved text before variable interpolation. */
text: string
/** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
interpolate?: boolean
}
```
@@ -135,7 +153,7 @@ getContextOrder(name: PromptContextOrderName): number
context(context: PromptContext): () => void
/**
* Suppress every dynamic runtime-context contribution in the calling
* Suppress optional dynamic runtime-context contributions in the calling
* context's scope without changing the services that own or enforce those
* facts. Multiple suppressors remain independently disposable.
* @returns the exact Cordis effect disposer.
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/working-directory.md
working-directory.md: 4e9ae74c57ed1b4ee10355d51a08c35235009018
working-directory.zh.md: bb239ddc434132462d37bbabed7001c3ae489d28
+64
View File
@@ -0,0 +1,64 @@
# Working Directory
English | [中文](working-directory.zh.md)
A Session's active directory is owned by [`dsh-working-directory`](../../packages/session/working-directory/README.md). The immutable header identifies the original project; `working-directory/change` records the effective directory used by later directory-based operations. The generated [persistence catalog](../persistence-catalog.md) owns the event declaration.
## Directory selection
`ctx.workingDirectory.get(session)` reads the logged value. `ensure(agent)` validates it and restores the original project when the directory disappeared. `set(agent, path)` validates and records a requested change. Sessions without a header directory use the configured runtime fallback; an unavailable fallback produces an error.
Changes are local to one Session. Relative filesystem operations, newly started processes, instruction discovery, skills, and file completion use the active directory. Existing terminals and persistent shells retain their own directories. Sandbox writable roots and project membership remain anchored to the original project.
## Model context
The initial value and subsequent current values reach the model as required user-context snapshots, even when optional runtime context is disabled. Each committed change attempts to queue a notice through the ordinary Agent inbox; cancellation or disposal can discard notices before request admission. The directory event survives, and the next admitted request receives the current value. Directory context never enters the system prompt. [`working_directory`](../../packages/session/tool-working-directory/README.md) exposes one optional `cd` argument.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
## Cordis API
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxworkingdirectory--workingdirectoryservice"></a>
### `ctx.workingDirectory` — `WorkingDirectoryService`
One owner for each Session's effective directory and its model-visible changes.
```ts cordis-catalog
/**
* Read the committed directory without filesystem I/O.
* @param session - Session whose directory is requested.
* @returns its effective absolute directory.
*/
get(session: Session): string
/**
* Validate the current directory and restore the original project when it disappeared.
* @param agent - live or unpublished Agent owning the Session.
* @param signal - cancellation for filesystem inspection.
* @returns the existing directory; recovery is committed before fulfillment.
* A notice failure is warned without reverting the committed state.
* @throws when the original project is also unavailable.
*/
ensure(agent: Agent, signal?: AbortSignal): Promise<string>
/**
* Change one Session's directory without changing existing processes or permissions.
* @param agent - live or unpublished Agent owning the Session.
* @param path - absolute path or a path relative to its current directory.
* @param signal - cancellation before the durable change.
* @returns the canonical absolute directory, committed before fulfillment.
* A notice failure is warned; the next request still receives the committed directory.
* @throws when the requested path is not an existing directory.
*/
set(agent: Agent, path: string, signal?: AbortSignal): Promise<string>
```
Types: [Agent](core.md) · [Session](session.md)
Source: [`packages/session/working-directory/src/index.ts`](../../packages/session/working-directory/src/index.ts)
<!-- END GENERATED cordis-surface -->
+64
View File
@@ -0,0 +1,64 @@
# 工作目录
[English](working-directory.md) | 中文
Session 的当前目录由 [`dsh-working-directory`](../../packages/session/working-directory/README.zh.md) 拥有。不可变 header 标识原始项目;`working-directory/change` 记录后续目录相关操作使用的有效目录。生成的[持久化目录](../persistence-catalog.zh.md) 拥有事件声明。
## 目录选择
`ctx.workingDirectory.get(session)` 读取日志中的值。`ensure(agent)` 验证它,并在目录消失时恢复原始项目。`set(agent, path)` 验证并记录请求的变更。header 没有目录的 Session 使用配置的运行时回退目录;回退目录不可用时返回错误。
变更仅影响一个 Session。相对路径文件系统操作、新启动的进程、指令发现、技能和文件补全使用当前目录。已有终端和持久 shell 保留自己的目录。沙箱可写根目录与项目归属继续绑定原始项目。
## 模型上下文
初始值与后续当前值通过必需的用户上下文快照到达模型,即使可选运行时上下文被禁用也不例外。每次已提交变更都会尝试通过普通 Agent 收件箱排入通知;取消或销毁可能丢弃尚未进入请求的通知。目录事件仍然保留,下一次获准进入的请求会收到当前值。目录上下文不进入系统提示词。[`working_directory`](../../packages/session/tool-working-directory/README.zh.md) 提供一个可选的 `cd` 参数。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
## Cordis API
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxworkingdirectory--workingdirectoryservice"></a>
### `ctx.workingDirectory` — `WorkingDirectoryService`
One owner for each Session's effective directory and its model-visible changes.
```ts cordis-catalog
/**
* Read the committed directory without filesystem I/O.
* @param session - Session whose directory is requested.
* @returns its effective absolute directory.
*/
get(session: Session): string
/**
* Validate the current directory and restore the original project when it disappeared.
* @param agent - live or unpublished Agent owning the Session.
* @param signal - cancellation for filesystem inspection.
* @returns the existing directory; recovery is committed before fulfillment.
* A notice failure is warned without reverting the committed state.
* @throws when the original project is also unavailable.
*/
ensure(agent: Agent, signal?: AbortSignal): Promise<string>
/**
* Change one Session's directory without changing existing processes or permissions.
* @param agent - live or unpublished Agent owning the Session.
* @param path - absolute path or a path relative to its current directory.
* @param signal - cancellation before the durable change.
* @returns the canonical absolute directory, committed before fulfillment.
* A notice failure is warned; the next request still receives the committed directory.
* @throws when the requested path is not an existing directory.
*/
set(agent: Agent, path: string, signal?: AbortSignal): Promise<string>
```
Types: [Agent](core.zh.md) · [Session](session.zh.md)
Source: [`packages/session/working-directory/src/index.ts`](../../packages/session/working-directory/src/index.ts)
<!-- END GENERATED cordis-surface -->
+3 -1
View File
@@ -261,6 +261,8 @@
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.1.8",
"@deepseek-ai/dsh-skill-office": "workspace:*",
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*"
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*",
"@deepseek-ai/dsh-tool-working-directory": "workspace:*",
"@deepseek-ai/dsh-working-directory": "workspace:*"
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` reads one complete baseline through a live-preferred Session observation without activating an Agent. It returns null for a missing Session and serves any registered projection keys. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Initial reads and live projection frames use the same sequence ordering.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` reads one complete baseline through a live-preferred Session observation without activating an Agent. It returns null for a missing Session and serves any registered projection keys. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Initial reads and live projection frames use the same sequence ordering.
Host list generation yields between complete rows only when its configured work slice is exhausted; cheap remaining work returns without a forced yield. Request cancellation is checked per row and after each yield and rejects without partial results. Each row is synchronous; the list does not promise one cross-Session snapshot. A Session already queued as cold remains a cached row if it attaches during a yield: Client in-flight mutation replay corrects its availability, and sequenced projections supersede cached hints.
+1 -1
View File
@@ -31,7 +31,7 @@ kind: "package-reference"
Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 通过一次 live-preferred Session observation 读取完整基线,不激活 Agent。Session 不存在时返回 null,并可提供任意已注册的 projection key。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。初始读取与实时 projection 帧使用相同的序号排序规则。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 通过一次 live-preferred Session observation 读取完整基线,不激活 Agent。Session 不存在时返回 null,并可提供任意已注册的 projection key。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。初始读取与实时 projection 帧使用相同的序号排序规则。
Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件循环;剩余工作较少时直接返回,不强制让出。每行及每次让出之后检查请求取消,取消时拒绝返回部分结果。每行同步计算;列表不承诺跨 Session 的统一快照。已经进入冷态队列的 Session 若在让出期间接入,仍返回缓存行:Client 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。
+4 -2
View File
@@ -105,7 +105,8 @@
"@deepseek-ai/dsh-util-time": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/dsh-util-workspace-path": "workspace:*",
"@deepseek-ai/dsh-workspace": "workspace:*"
"@deepseek-ai/dsh-workspace": "workspace:*",
"@deepseek-ai/dsh-working-directory": "workspace:*"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-session-persistence": {
@@ -160,6 +161,7 @@
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/dsh-util-workspace-path": "workspace:*",
"@deepseek-ai/dsh-workspace": "workspace:*",
"@types/mime-types": "^3.0.1"
"@types/mime-types": "^3.0.1",
"@deepseek-ai/dsh-working-directory": "workspace:*"
}
}
@@ -4,6 +4,7 @@ import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-agent-preset-registry/types'
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
import { isUserInvocable } from '@deepseek-ai/dsh-skill'
import type {} from '@deepseek-ai/dsh-working-directory'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import type { SkillListRequest, SkillListValue } from './types.ts'
@@ -17,7 +18,7 @@ declare module '@deepseek-ai/cordis' {
/** Host service backing `ctx.remote.skills` without activating a cold Agent. */
export class SessionSkillCatalog extends TypertRemoteService {
static inject = ['agents', 'sessionQuery', 'typert']
static inject = ['agents', 'sessionQuery', 'typert', 'workingDirectory']
/** @param ctx - Host context carrying Session reads and optional skill/preset services. */
constructor(ctx: Context) {
@@ -33,17 +34,20 @@ export class SessionSkillCatalog extends TypertRemoteService {
*/
@Remote
async list(request: SkillListRequest, signal: AbortSignal): Promise<SkillListValue> {
void signal
const { sessionId } = request
let cwd: string | undefined
let cwd: string
let agentPreset: string | undefined
try {
using observation = await this.ctx.sessionQuery.observeSession(sessionId)
if (observation.projections === undefined) {
throw new Error('skill catalog requires a projected Session observation')
}
cwd = observation.header.cwd
cwd = observation.projections.values.workingDirectory
?? observation.header.cwd
?? this.ctx.workingDirectory.defaultDirectory
agentPreset = observation.projections.values.agentPreset ?? undefined
const live = this.ctx.agents.get(sessionId)
if (live !== undefined) cwd = await this.ctx.workingDirectory.ensure(live, signal)
} catch (error: unknown) {
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
@@ -55,9 +59,6 @@ export class SessionSkillCatalog extends TypertRemoteService {
{},
)
}
if (cwd === undefined) {
throw new RemoteError('gateway/internal', `session "${sessionId}" has no project cwd`, {})
}
const live = this.ctx.agents.get(sessionId)
const presets = this.ctx.get('agentPresets')
@@ -1,5 +1,6 @@
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
@@ -9,7 +10,7 @@ import { SessionSkillCatalog } from '../src/skill-catalog.ts'
function observation(
sessionId: SessionId,
options: { readonly cwd?: string; readonly agentPreset?: string } = {},
options: { readonly cwd?: string; readonly workingDirectory?: string | null; readonly agentPreset?: string } = {},
): SessionObservation {
const events = Object.freeze([])
const lease = (): SessionObservation => ({
@@ -28,6 +29,7 @@ function observation(
asOfSeq: -1,
values: {
...options.agentPreset === undefined ? {} : { agentPreset: options.agentPreset },
...options.workingDirectory === undefined ? {} : { workingDirectory: options.workingDirectory },
},
},
retain: lease,
@@ -40,18 +42,20 @@ async function context(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
provideWorkingDirectoryFixture(ctx, '/catalog/default')
return ctx
}
describe('SessionSkillCatalog', () => {
it('reads a cold Session catalog without resuming an Agent', async () => {
it('reads a moved cold Session catalog from its recorded current directory without resuming an Agent', async () => {
const ctx = await context()
const sessionId = SessionId('cold-skills')
const observed = observation(sessionId, { cwd: '/cold/project' })
const observed = observation(sessionId, { cwd: '/cold/original', workingDirectory: '/cold/project' })
const dispose = vi.spyOn(observed, Symbol.dispose)
const observeSession = vi.fn(() => Promise.resolve(observed))
ctx.provide('sessionQuery', { observeSession } as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const ensureDirectory = vi.spyOn(ctx.workingDirectory, 'ensure')
const list = vi.fn(() => Promise.resolve([
{
name: 'review',
@@ -81,16 +85,47 @@ describe('SessionSkillCatalog', () => {
expect(observeSession).toHaveBeenCalledWith(sessionId)
expect(dispose).toHaveBeenCalledOnce()
expect(resume).not.toHaveBeenCalled()
expect(ensureDirectory).not.toHaveBeenCalled()
expect(ctx.agents.list()).toEqual([])
expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope: undefined })
})
it.each([
{ cwd: undefined, workingDirectory: '/cold/current', expected: '/cold/current' },
{ cwd: '/cold/original', workingDirectory: null, expected: '/cold/original' },
{ cwd: undefined, workingDirectory: null, expected: '/catalog/default' },
{ cwd: undefined, workingDirectory: undefined, expected: '/catalog/default' },
])('resolves cold directory metadata $cwd / $workingDirectory to $expected without filesystem checks', async ({ cwd, workingDirectory, expected }) => {
const ctx = await context()
try {
const sessionId = SessionId('cold-directory-fallback')
ctx.provide('sessionQuery', {
observeSession: () => Promise.resolve(observation(sessionId, {
...cwd === undefined ? {} : { cwd },
...workingDirectory === undefined ? {} : { workingDirectory },
})),
} as never)
const list = vi.fn(() => Promise.resolve([]))
ctx.provide('skills', { list } as never)
const ensure = vi.spyOn(ctx.workingDirectory, 'ensure')
const resume = vi.spyOn(ctx.agents, 'resume')
await expect(new SessionSkillCatalog(ctx).list({ sessionId }, new AbortController().signal))
.resolves.toEqual({ skills: [] })
expect(list).toHaveBeenCalledWith({ cwd: expected, scope: undefined })
expect(ensure).not.toHaveBeenCalled()
expect(resume).not.toHaveBeenCalled()
} finally {
await ctx.fiber.dispose()
}
})
it('uses a live Agent to address a preset-owned registry', async () => {
const ctx = await context()
const sessionId = SessionId('live-skills')
const session = ctx.sessions.create(sessionId, { meta: { cwd: '/live/project' } })
const agent = { id: sessionId, session, status: 'idle', ctx } as Agent
await ctx.agents.register(agent)
const ensureDirectory = vi.spyOn(ctx.workingDirectory, 'ensure').mockResolvedValue('/live/current')
ctx.provide('sessionQuery', {
observeSession: () => Promise.resolve(observation(sessionId, { cwd: '/live/project' })),
} as never)
@@ -113,7 +148,9 @@ describe('SessionSkillCatalog', () => {
modelInvocable: false,
}],
})
expect(scopedList).toHaveBeenCalledWith({ cwd: '/live/project', scope: agent })
expect(scopedList).toHaveBeenCalledWith({ cwd: '/live/current', scope: agent })
expect(ensureDirectory).toHaveBeenCalledWith(agent, expect.any(AbortSignal))
expect(session.header.cwd).toBe('/live/project')
expect(acquireScope).not.toHaveBeenCalled()
})
@@ -192,22 +229,18 @@ describe('SessionSkillCatalog', () => {
await expect(failed).rejects.toThrow('skill registry is absent')
})
it('rejects observations without projections or a project cwd', async () => {
it('rejects observations without projections', async () => {
const ctx = await context()
const sessionId = SessionId('incomplete-skills')
const withoutProjections = { ...observation(sessionId, { cwd: '/project' }), projections: undefined }
const observeSession = vi.fn()
.mockResolvedValueOnce(withoutProjections)
.mockResolvedValueOnce(observation(sessionId))
ctx.provide('sessionQuery', { observeSession } as never)
const catalog = new SessionSkillCatalog(ctx)
const unprojected = catalog.list({ sessionId }, new AbortController().signal)
await expect(unprojected).rejects.toMatchObject({ code: 'gateway/internal' })
await expect(unprojected).rejects.toThrow('projected Session observation')
const cwdless = catalog.list({ sessionId }, new AbortController().signal)
await expect(cwdless).rejects.toMatchObject({ code: 'gateway/internal' })
await expect(cwdless).rejects.toThrow('has no project cwd')
})
it('classifies a provider listing failure', async () => {
@@ -23,34 +23,95 @@
"src/skill-catalog.ts"
],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../core/agent" },
{ "path": "../../core/agent-default-model" },
{ "path": "../../core/scope" },
{ "path": "../../core/session" },
{ "path": "../../context/file-reference" },
{ "path": "../../attachment/attachment" },
{ "path": "../../client/file-upload/tsconfig.host.json" },
{ "path": "../../client/connection/tsconfig.host.json" },
{ "path": "../../interaction/permission-presets" },
{ "path": "../../jobs/jobs" },
{ "path": "../../fs/fs" },
{ "path": "../../llm/llm" },
{ "path": "../../util/deque" },
{ "path": "../../util/native-command" },
{ "path": "../../preset/agent-preset-registry" },
{ "path": "../../session/session-persistence" },
{ "path": "../../session/session-projection" },
{ "path": "../../session/session-projection-cache" },
{ "path": "../../session/session-title" },
{ "path": "../../session-query/session-query" },
{ "path": "../../skill/skill" },
{ "path": "../../subagent/subagent" },
{ "path": "../../util/time" },
{ "path": "../../util/values" },
{ "path": "../../typert/protocol" },
{ "path": "../../typert/registry" },
{ "path": "../../workspace/workspace" }
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/agent-default-model"
},
{
"path": "../../core/scope"
},
{
"path": "../../core/session"
},
{
"path": "../../context/file-reference"
},
{
"path": "../../attachment/attachment"
},
{
"path": "../../client/file-upload/tsconfig.host.json"
},
{
"path": "../../client/connection/tsconfig.host.json"
},
{
"path": "../../interaction/permission-presets"
},
{
"path": "../../jobs/jobs"
},
{
"path": "../../fs/fs"
},
{
"path": "../../llm/llm"
},
{
"path": "../../util/deque"
},
{
"path": "../../util/native-command"
},
{
"path": "../../preset/agent-preset-registry"
},
{
"path": "../../session/session-persistence"
},
{
"path": "../../session/session-projection"
},
{
"path": "../../session/session-projection-cache"
},
{
"path": "../../session/session-title"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../skill/skill"
},
{
"path": "../../subagent/subagent"
},
{
"path": "../../util/time"
},
{
"path": "../../util/values"
},
{
"path": "../../typert/protocol"
},
{
"path": "../../typert/registry"
},
{
"path": "../../workspace/workspace"
},
{
"path": "../../session/working-directory"
}
]
}
-1
View File
@@ -2,7 +2,6 @@
- id: system-prompt
config:
personaSuffix: Your working directory is {{cwd}}.
personaPrefix: >-
You are a coding agent powered by the {{model}} model.
+1 -1
View File
@@ -49,7 +49,7 @@ Run `dsh --profile my-profile "your task"` and you get a working agent with mode
Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, public HTTP(S) fetch, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Web fetch runs without per-call approval; its provider rejects non-public destinations. Feedback stays in the Session log. [OTel session upload](../../session/session-telemetry-otel/README.md) defaults to `FEEDBACK_ONLY` for all users, including `deepseek-official`: new text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including context. Later records wait for the next explicit feedback; sending an authorized batch needs no further interaction or model call. `DISABLED` prevents OTel capture. The default-on [DeepSeek session-log contributor](../../session/session-log-deepseek/README.md) remains a separate request path.
Default file editing uses `read`, `write`, and `edit`. The `str_replace_editor` tool remains available as an explicit opt-in. To add it to a base-backed profile, put this entry in the profile, home, or invocation patch:
The built-in `working_directory` tool changes the directory for subsequent operations while existing processes and write permissions remain independent. Current directory information appears in user context. Default file editing uses `read`, `write`, and `edit`. The `str_replace_editor` tool remains available as an explicit opt-in. To add it to a base-backed profile, put this entry in the profile, home, or invocation patch:
```yaml
- insert:
+1 -1
View File
@@ -49,7 +49,7 @@ kind: "package-bundle"
开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(提供方与模型可配置,你还可以在设置中启用额外提供方)、完整工具集——文件编辑、shell 命令、web 搜索、公开 HTTP(S) 抓取、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。Web 抓取无需逐次审批,其提供方会拒绝非公开目的地址。反馈保存在会话日志中。[OTel 会话上传](../../session/session-telemetry-otel/README.zh.md)对所有用户默认使用 `FEEDBACK_ONLY`,包括 `deepseek-official`:新的文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范会话日志前缀,包含上下文。后续记录等待下一次显式反馈;发送已授权批次无需进一步交互或模型调用。`DISABLED` 阻止 OTel 捕获。默认开启的 [DeepSeek 会话日志贡献器](../../session/session-log-deepseek/README.zh.md)仍是独立的请求路径。
默认文件编辑使用 `read`、`write` 和 `edit`。`str_replace_editor` 工具仍可显式启用。要将它加入基于 base 的 profile,请在 profile、home 或逐次调用 patch 中添加以下条目:
内置 `working_directory` 工具改变后续操作使用的目录,已有进程与写权限保持独立。当前目录信息出现在用户上下文中。默认文件编辑使用 `read`、`write` 和 `edit`。`str_replace_editor` 工具仍可显式启用。要将它加入基于 base 的 profile,请在 profile、home 或逐次调用 patch 中添加以下条目:
```yaml
- insert:
+6
View File
@@ -155,6 +155,12 @@
# Shared projection registry: subagent catalog identity (mode/label) folds
# through its registered units, so the `list_agents` surface below fails
# loud without it; web layers reuse this same mount for list rows.
- id: working-directory
name: '@deepseek-ai/dsh-working-directory'
- id: tool-working-directory
name: '@deepseek-ai/dsh-tool-working-directory'
- id: session-projection
name: '@deepseek-ai/dsh-session-projection'
+3 -1
View File
@@ -126,7 +126,9 @@
"@deepseek-ai/dsh-deepseek-account-platform": "workspace:*",
"@deepseek-ai/dsh-config-editor": "workspace:*",
"@deepseek-ai/dsh-settings": "workspace:*",
"@deepseek-ai/dsh-otel": "workspace:*"
"@deepseek-ai/dsh-otel": "workspace:*",
"@deepseek-ai/dsh-working-directory": "workspace:*",
"@deepseek-ai/dsh-tool-working-directory": "workspace:*"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~"
@@ -8,7 +8,6 @@
- id: system-prompt
config:
personaSuffix: Your working directory is {{cwd}}.
personaPrefix: >-
You are a coding agent powered by the {{model}} model.
-1
View File
@@ -2,7 +2,6 @@
- id: system-prompt
config:
personaSuffix: Your working directory is {{cwd}}.
personaPrefix: >-
You are a coding agent powered by the {{model}} model.
+6 -6
View File
@@ -1,5 +1,5 @@
---
description: "Standalone SDK profile with one default shell tool for users who need a minimal cross-platform coding agent without the shared base bundle."
description: "Standalone SDK profile with a persistent shell and Session working-directory tool for a minimal cross-platform coding agent without the shared base bundle."
kind: "package-bundle"
---
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Use `dsh --profile sdk-minimal` when an SDK client needs a small, explicit coding-agent runtime. By default, the profile advertises only a platform-selected persistent shell, persists sessions as uncompressed JSONL, and selects the model from the SDK initialization request. It supplies a complete Cordis tree and deliberately excludes `dsh-base`, Web, settings, managed credentials, telemetry, compaction, filesystem tools, workspace instructions, skills, jobs, and subagents. Its danger-full-access policy lets the shell modify any path available to the process, so use it only with an isolated workspace.
Use `dsh --profile sdk-minimal` when an SDK client needs a small, explicit coding-agent runtime. By default, the profile advertises a platform-selected persistent shell and `working_directory`, persists sessions as uncompressed JSONL, and selects the model from the SDK initialization request. It supplies a complete Cordis tree and excludes `dsh-base`, Web, settings, managed credentials, telemetry, compaction, filesystem tools, workspace instructions, skills, jobs, and subagents. Its danger-full-access policy lets the shell modify any path available to the process, so use it only with an isolated workspace.
## Table of Contents
@@ -38,7 +38,7 @@ Use `dsh plugin --profile sdk-minimal` to manage persistent external dependencie
The profile mounts exactly one persistent shell stack: Bash on Linux and macOS, or PowerShell on Windows. Both stacks use a 300-second timeout and one owner-scoped terminal; the other platform's rows remain disabled.
Like every shipped profile, it mounts [MCP resources](../../mcp/mcp-resources/README.md) once. Configure only [MCP client entries](../../mcp/mcp-client/README.md) to add servers. Clients mounted by another provider also count as configured. With no server configured in the caller's scope, MCP contributes no prompt text or tools and the default remains one shell tool.
Like every shipped profile, it mounts [MCP resources](../../mcp/mcp-resources/README.md) once. Configure only [MCP client entries](../../mcp/mcp-client/README.md) to add servers. Clients mounted by another provider also count as configured. With no server configured in the caller's scope, MCP contributes no prompt text or tools and the default remains the shell and `working_directory` tools.
-----
@@ -48,7 +48,7 @@ Like every shipped profile, it mounts [MCP resources](../../mcp/mcp-resources/RE
<details>
<summary>Implementation internals — click to expand</summary>
The bundle's single insert is the complete application tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the explicit agent core, conditional MCP resource tools, local subprocess execution, a platform-selected persistent shell PTY, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not inherit another bundle, so every extra row is an explicit profile change.
The bundle's single insert is the complete application tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the explicit agent core, conditional MCP resource tools, local filesystem and subprocess services, Session working-directory state and its tool, a platform-selected persistent shell PTY, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not inherit another bundle, so every extra row is an explicit profile change.
### Source map
@@ -78,11 +78,11 @@ The bundle's single insert is the complete application tree: SDK stdio startup a
#### What the model sees
The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. With no MCP server configured, the only advertised tool is owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows; runtime context, filesystem tools, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent.
The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. With no MCP server configured, the advertised tools are `working_directory` and owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows. Required user context supplies the current directory. Optional runtime context, filesystem tools, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent.
#### Token effect
By default, one stable persona plus one tool schema. Configured MCP servers add their tools, shared resource tools, and available server instructions. Tool results and ordinary conversation history grow with the session.
By default, one stable persona, two tool schemas, and user context for the initial directory and subsequent changes. Configured MCP servers add their tools, shared resource tools, and available server instructions. Tool results and ordinary conversation history grow with the session.
#### KV Cache effect
+6 -6
View File
@@ -1,5 +1,5 @@
---
description: "供需要不含共享 base bundle 的极简跨平台 coding agent(编程智能体)的用户使用的独立 SDK profile,默认提供一个 shell 工具。"
description: "独立 SDK profile,提供持久 shell 和 Session 工作目录工具,用于不含共享 base bundle 的极简跨平台 coding agent(编程智能体)。"
kind: "package-bundle"
---
@@ -9,7 +9,7 @@ kind: "package-bundle"
## 概述
当 SDK 客户端需要小型、显式的 coding agent 运行时时,请使用 `dsh --profile sdk-minimal`。该 profile 默认只公布按平台选择的持久 shell,把会话持久化为未压缩 JSONL,并从 SDK 初始化请求选择模型。它提供完整 Cordis 配置树,并刻意排除 `dsh-base`、Web、settings、托管凭据、遥测、压缩(compaction)、文件系统工具、workspace 指令、skill(技能)、jobs 与 subagent。其 danger-full-access 策略允许 shell 修改进程可访问的任何路径,因此只能配合隔离 workspace 使用。
当 SDK 客户端需要小型、显式的 coding agent 运行时时,请使用 `dsh --profile sdk-minimal`。该 profile 默认公布按平台选择的持久 shell 和 `working_directory`,把会话持久化为未压缩 JSONL,并从 SDK 初始化请求选择模型。它提供完整 Cordis 配置树,并排除 `dsh-base`、Web、settings、托管凭据、遥测、压缩(compaction)、文件系统工具、workspace 指令、skill(技能)、jobs 与 subagent。其 danger-full-access 策略允许 shell 修改进程可访问的任何路径,因此只能配合隔离 workspace 使用。
## 目录
@@ -38,7 +38,7 @@ dsh --profile sdk-minimal
该 profile 只挂载一套持久 shell:Linux 和 macOS 使用 Bash,Windows 使用 PowerShell。两套配置都使用 300 秒超时与一个 agent 自有终端;另一平台的配置项保持禁用。
与其他随附 profile 一样,它统一挂载 [MCP 资源](../../mcp/mcp-resources/README.zh.md)一次。只需配置 [MCP 客户端条目](../../mcp/mcp-client/README.zh.md)即可添加服务器。其他提供方挂载的客户端也属于已配置状态。调用方作用域中未配置服务器时,MCP 不贡献提示词文本或工具,默认仍只有一个 shell 工具。
与其他随附 profile 一样,它统一挂载 [MCP 资源](../../mcp/mcp-resources/README.zh.md)一次。只需配置 [MCP 客户端条目](../../mcp/mcp-client/README.zh.md)即可添加服务器。其他提供方挂载的客户端也属于已配置状态。调用方作用域中未配置服务器时,MCP 不贡献提示词文本或工具,默认仍为 shell 与 `working_directory` 两个工具。
-----
@@ -48,7 +48,7 @@ dsh --profile sdk-minimal
<details>
<summary>实现细节——点击展开</summary>
该 bundle 的单个 insert 就是完整应用配置树:SDK stdio 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、显式 agent 核心、按配置启用的 MCP 资源工具、本地子进程执行、按平台选择的持久 shell PTY,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不继承其他 bundle,因此每个额外配置项都是显式 profile 变更。
该 bundle 的单个 insert 就是完整应用配置树:SDK stdio 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、显式 agent 核心、按配置启用的 MCP 资源工具、本地文件系统与子进程服务、Session 工作目录状态及其工具、按平台选择的持久 shell PTY,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不继承其他 bundle,因此每个额外配置项都是显式 profile 变更。
### 源码地图
@@ -78,11 +78,11 @@ dsh --profile sdk-minimal
#### 模型看到的内容
系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。未配置 MCP 服务器时,对外公布的唯一工具是 Linux/macOS 上 agent 所有的持久 `bash` 或 Windows 上的 `pwsh`;运行时上下文、文件系统工具、workspace 指令、skill、jobs 控制、压缩与 Harness 身份均不存在。
系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。未配置 MCP 服务器时,对外公布的工具为 `working_directory`,以及 Linux/macOS 上 agent 所有的持久 `bash` 或 Windows 上的 `pwsh`。必需的用户上下文提供当前目录。可选运行时上下文、文件系统工具、workspace 指令、skill、jobs 控制、压缩与 Harness 身份均不存在。
#### Token 影响
默认是一个稳定 persona 加一个工具 schema。已配置 MCP 服务器会添加自己的工具、共享资源工具及可用的服务器指令。工具结果与普通对话历史随会话增长。
默认是一个稳定 persona、两个工具 schema,以及初始目录与后续变更的用户上下文。已配置 MCP 服务器会添加自己的工具、共享资源工具及可用的服务器指令。工具结果与普通对话历史随会话增长。
#### KV Cache 影响
@@ -35,6 +35,15 @@
# Shared projection registry: sandbox-policy and terminal-bash fold
# sandbox-mode state through its units and require it as a hard injection.
- id: filesystem
name: '@deepseek-ai/dsh-fs-local'
- id: working-directory
name: '@deepseek-ai/dsh-working-directory'
- id: tool-working-directory
name: '@deepseek-ai/dsh-tool-working-directory'
- id: session-projection
name: '@deepseek-ai/dsh-session-projection'
+4 -1
View File
@@ -59,7 +59,10 @@
"@deepseek-ai/dsh-terminal-bash": "workspace:*",
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:*",
"@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*"
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-working-directory": "workspace:*",
"@deepseek-ai/dsh-tool-working-directory": "workspace:*",
"@deepseek-ai/dsh-fs-local": "workspace:*"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~"
-1
View File
@@ -15,7 +15,6 @@
- id: system-prompt
config:
personaSuffix: Your working directory is {{cwd}}.
personaPrefix: >-
You are a coding agent powered by the {{model}} model.
+4 -1
View File
@@ -43,7 +43,7 @@ ctx.slots.inject('tool.call.toolview', () =>
}, BusinessToolRow))
```
The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the `phase` discriminant and its stage-specific `block`, optional `cwd` and `home`, the session-authorized `loadImage` loader (for a view whose result carries durable images), and plain `openFile`/`inspect` callbacks. A PTC dispatch block retains its event's `parentCallId`; a root Session call has no such field, so descendants route through the same keyed dispatch — a registered view such as `read_image` renders its card there, and unregistered descendants keep the generic flattened form. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal Session slot runtime share but no React node or Runtime service.
The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the `phase` discriminant and its stage-specific `block`, optional `cwd` and `home`, the session-authorized `loadImage` loader (for a view whose result carries durable images), and plain `openFile`/`inspect` callbacks. A PTC dispatch block retains its event's `parentCallId`; a root Session call has no such field, so descendants route through the same keyed dispatch — a registered view such as `read_image` renders its card there, and unregistered descendants keep the generic flattened form. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open prefer the operation-time absolute result path while displayed labels keep the authored argument. Successful older root results retain their argument fallback; pending relative paths wait for a result. The registration receives the normal Session slot runtime share but no React node or Runtime service.
### Built-in views
@@ -89,6 +89,8 @@ Recorded tool details cover goal and schedule tools, Cordis inspection, workflow
Expanded status dots and labels use static semantic colors. Receipt and job-output headers keep neutral text and omit the status while expanded. An interruption receipt confirms only that interruption was requested.
Terminal prompt labels use recorded result `cwd`. Older successful root shell calls fall back to their original workspace and authored workdir; running calls show a directory only when their workdir is absolute. Terminal sends do not infer a process directory from the Session.
The terminal model uses `hasSpillNotice` from the browser-safe `@deepseek-ai/dsh-spill-policy/notice` entry, not an independent UI pattern. The [spill-policy README](../../spill/spill-policy/README.md#shared-notice-ownership) owns notice formatting and recognition. This check conservatively selects generic output; matching text cannot authenticate its source, and replay leaves recorded result bytes untouched.
### Declared optional capabilities
@@ -131,6 +133,7 @@ None; this package neither assembles nor sends a provider request.
These limits define the dispatch depth and the view ownership; they are current package constraints.
- **The Host excludes `run_code` from PTC mode program bindings** — production events produce one dispatch level; the recursive Runtime/UI contract supports nesting.
- **Nested relative locations** — PTC dispatches do not persist presentation metadata. Relative nested file links and inferred command directories stay unavailable; absolute call paths remain usable.
- **First-party Tool views are colocated here** — they can move to their owning business packages independently through the keyed slot.
- **Web tool links always open a new tab** — the collapsed `web_fetch` URL and the expanded web card links ignore the `ui-chat` link-opening setting because Tool views receive no external-link callback.
- **Tool copy reuses the `ui-conversation` locale namespace** — tool titles, row chrome, and Cordis-free primitive labels use that dictionary; presenter models retain locale keys or data rather than rendered wording.
+4 -1
View File
@@ -43,7 +43,7 @@ ctx.slots.inject('tool.call.toolview', () =>
}, BusinessToolRow))
```
owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字段及对应阶段的 `block`、可选 `cwd` 与 `home`、会话授权的 `loadImage` loader(供结果携带持久图像的视图使用),以及普通的 `openFile`/`inspect` 回调。PTC dispatch 块保留事件的 `parentCallId`;根会话调用没有该字段,因此后代调用都走同一条按 key 分发路径:已注册视图的调用(如 `read_image`)也会在嵌套处渲染对应卡片,未注册的后代调用则保持通用压平形式。路径摘要先相对会话 cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React 节点或运行时服务。
owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字段及对应阶段的 `block`、可选 `cwd` 与 `home`、会话授权的 `loadImage` loader(供结果携带持久图像的视图使用),以及普通的 `openFile`/`inspect` 回调。PTC dispatch 块保留事件的 `parentCallId`;根会话调用没有该字段,因此后代调用都走同一条按 key 分发路径:已注册视图的调用(如 `read_image`)也会在嵌套处渲染对应卡片,未注册的后代调用则保持通用压平形式。路径摘要先相对会话 cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开优先使用操作发生时记录的绝对结果路径,显示标签仍保留原始参数。成功的旧根调用结果继续回退到原参数;运行中的相对路径等待结果。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React 节点或运行时服务。
### 内置视图
@@ -89,6 +89,8 @@ Auto 拒绝优先于按工具名选择的专门视图。其通用行保留调用
展开后的状态圆点和文字使用静态语义色。操作回执和任务输出的标题保持中性色,展开时省略标题中的状态。中断回执仅确认已发出中断请求。
终端提示标签使用结果记录的 `cwd`。成功的旧根 shell 调用回退到原始工作区与调用参数中的 workdir;运行中的调用只有在 workdir 为绝对路径时才显示目录。终端发送不会根据 Session 推测进程目录。
terminal model 使用浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice` 的 `hasSpillNotice`,而非独立的 UI 匹配规则。[spill-policy README](../../spill/spill-policy/README.zh.md#shared-notice-ownership) 负责提示文本的格式化与识别。该检查保守地选择通用输出;匹配的文本无法证明其来源,回放也不改变已记录的结果字节。
### 声明的可选能力
@@ -131,6 +133,7 @@ terminal model 使用浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice
这些限制定义分派深度与视图归属;它们是当前包约束。
- **Host 不把 `run_code` 暴露为 PTC mode 程序 binding**:生产事件只产生一层分发;递归的运行时/UI 约定支持嵌套。
- **嵌套相对位置**——PTC dispatch 不持久化展示元数据。相对的嵌套文件链接与推断的命令目录保持不可用;绝对调用路径仍可使用。
- **第一方工具视图集中在本包**:它们可以通过 keyed slot 独立迁移到各自所属的业务包。
- **Web 工具链接总是打开新标签页**:折叠的 `web_fetch` URL 与展开的 web 卡片链接不遵循 `ui-chat` 的链接打开方式设置,因为工具视图没有外部链接回调。
- **工具文案复用 `ui-conversation` locale namespace**:工具标题、行 chrome 与无 Cordis 的 primitive label 使用该字典;展示转换器模型保留 locale key 或数据,而不是已渲染文案。
@@ -78,8 +78,8 @@ export interface ToolRowProps {
details?: ToolDetailsModel | null | undefined
state: ToolRowState
/**
* Filesystem path from tool args; when set with onOpenFile, the summary
* renders as a hover-underline link that opens the host default app.
* Recorded file target or a supported argument fallback. With onOpenFile,
* the summary opens that path through the owner's file-preview callback.
*/
filePath?: string | undefined
/** 1-based line the call was about; absent = open the file at its beginning. */
@@ -2,6 +2,7 @@
import type { DiffBlockProps, DiffHunk } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolCallBlock } from './tool-call-model.ts'
import { parsedToolCall } from './raw-tool-call.ts'
import { recordedAbsolutePath } from './recorded-path.ts'
/** Room for a path, one removed/added pair, and three context lines on each side. */
export const CHAT_DIFF_MAX_LINES = 9
@@ -107,5 +108,6 @@ export function diffCardModel(block: ToolCallBlock): DiffCardModel | null {
if (applied === null || applied === 'empty') {
return intended.tool === 'write' ? { card: { diffs: [intended.diff] } } : null
}
return { card: { diffs: applied } }
const path = recordedAbsolutePath(block.meta, 'path')
return { card: { diffs: applied.map(diff => diff.path === path ? { ...diff, path: intended.diff.path } : diff) } }
}
@@ -0,0 +1,14 @@
/** Operation-time filesystem locations carried by settled tool results. */
import { isAbsoluteWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
/**
* Read one absolute location from opaque persisted result metadata.
* @param meta - tool-owned result metadata from the Session log.
* @param field - the file path or command working directory.
* @returns the recorded path, or undefined for old or malformed metadata.
*/
export function recordedAbsolutePath(meta: unknown, field: 'path' | 'cwd'): string | undefined {
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return undefined
const value = (meta as Record<string, unknown>)[field]
return typeof value === 'string' && isAbsoluteWorkspacePath(value) ? value : undefined
}
@@ -1,10 +1,11 @@
/** Pure terminal-card derivation from raw Tool call and result fields. @module */
import type { TerminalBlockLabels, TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
import { isAbsoluteWorkspacePath, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
import { hasSpillNotice } from '@deepseek-ai/dsh-spill-policy/notice'
import type { ToolCallBlock } from './tool-call-model.ts'
import { parsedToolCall, singleResultText } from './raw-tool-call.ts'
import { recordedAbsolutePath } from './recorded-path.ts'
/**
* Build the TerminalBlock display copy from the conversation locale seat —
@@ -280,7 +281,7 @@ function parseExitStatus(text: string): { output: string; exitCode?: number; sig
* and malformed input use the generic path. {@link isSettledPersistentShellCall} lets that generic
* persistent result remain expandable without inventing one process status.
* @param block - running or settled Tool block.
* @param sessionCwd - session workspace root used to resolve workdir.
* @param sessionCwd - original workspace root used only for old results without recorded cwd.
* @returns locale-neutral terminal-card data, or null for the generic path.
*/
export function terminalCardModel(
@@ -295,7 +296,12 @@ export function terminalCardModel(
const copy: TerminalCardModel['copy'] = call.kind === 'shell'
? { kind: 'shell', command: call.command, description: call.description }
: { kind: 'terminal-send', text: call.text, sessionId: call.sessionId }
const cwd = resolveTerminalCwd(call.kind === 'shell' ? call.workdir : undefined, sessionCwd)
const workdir = call.kind === 'shell' ? call.workdir : undefined
const absoluteRequestCwd = workdir !== undefined && isAbsoluteWorkspacePath(workdir) ? normalizeSegments(workdir) : undefined
const cwd = 'kind' in block
? recordedAbsolutePath(block.meta, 'cwd') ?? (call.kind === 'shell' && block.parentCallId === undefined
? resolveTerminalCwd(workdir, sessionCwd) : absoluteRequestCwd)
: absoluteRequestCwd
if (!('kind' in block)) {
return {
copy,
@@ -1,13 +1,14 @@
/**
* Pure row-model derivation for tool summary rows: variant classification,
* one-line summary, expansion-time body input, and flattened result output
* from the frozen call slice. Input material comes from the call ARGUMENTS;
* output and error material from the settled result node. A supported terminal
* from the frozen call slice. Labels keep the call arguments; navigation,
* output, and errors use the settled result. A supported terminal
* call gets its expanded body from `terminalCardModel` instead.
*/
import type { ToolArgs, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots'
import { abbreviateHomePath, relativizeToCwd } from '@deepseek-ai/dsh-util-workspace-path'
import { abbreviateHomePath, isAbsoluteWorkspacePath, relativizeToCwd } from '@deepseek-ai/dsh-util-workspace-path'
import { recordedAbsolutePath } from './recorded-path.ts'
export type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client'
@@ -145,10 +146,9 @@ export interface ToolRowModel {
/** Generic rows retain the wire tool name; available arguments append their summary. */
summary: string
/**
* Filesystem path from args (`path` / `file_path`) when the row is a file
* tool; absent for URL reads and non-file tools. A `file_path` argument
* supplies it at every stage once its string is complete. The chat view
* resolves relative values against the session cwd before opening.
* Operation-time absolute result path for file rows. Old results without
* location metadata retain their argument fallback; running relative paths
* stay unavailable until their result identifies the target.
*/
filePath: string | undefined
/** Original argument JSON retained for expansion-time body formatting. */
@@ -304,10 +304,16 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin
const titleKey = toolTitleKey(toolName)
const done = 'kind' in block
const argsRaw = done ? block.call?.argsRaw ?? '' : block.phase === 'start' ? block.argsRaw : null
const primary = argumentSummary(variant, block.args, cwd, home)
const argumentPath = primary.filePath ?? (argsRaw === null ? undefined : deriveFilePath(variant, argsRaw))
const recordedPath = done ? recordedAbsolutePath(block.meta, 'path') : undefined
const canUseArgumentPath = argumentPath !== undefined
&& (isAbsoluteWorkspacePath(argumentPath) || done && !block.isError && block.parentCallId === undefined)
const filePath = argumentPath === undefined ? undefined
: recordedPath ?? (canUseArgumentPath ? argumentPath : undefined)
const state: ToolRowState = !done ? block.phase === 'preparing' ? 'preparing' : 'running'
: block.error?.code === 'interrupted' ? 'stopped'
: block.isError ? 'error' : 'ok'
const primary = argumentSummary(variant, block.args, cwd, home)
// The argument view serves every stage; the raw text is the fallback when it carries nothing useful.
const base = primary.summary !== '' || argsRaw === null ? primary.summary
: argsRaw === '' ? block.callId
@@ -323,7 +329,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin
variant,
titleKey,
summary,
filePath: primary.filePath ?? (argsRaw === null ? undefined : deriveFilePath(variant, argsRaw)),
filePath,
bodyRaw,
output,
errorSummary,
@@ -49,6 +49,11 @@ const settled = (over?: Partial<ToolResultNode>): ToolResultNode => {
}
describe('diffCardModel', () => {
it('retains the authored file label when navigation metadata identifies an absolute target', () => {
expect(diffCardModel(settled({
meta: { path: '/b/notes/demo.txt', diffs: [{ ...DIFFS[0]!, path: '/b/notes/demo.txt' }] },
}))).toEqual({ card: { diffs: DIFFS } })
})
it('derives a running card from raw edit arguments', () => {
expect(diffCardModel(running())).toEqual({
card: { diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] },
@@ -132,8 +132,8 @@ describe('terminalCardModel', () => {
call: { name: 'bash', argsRaw: shellArgs({ workdir: 'packages/ui' }) },
}))?.card.cwd).toBe('packages/ui')
expect(terminalCardModel(settled())?.card.cwd).toBeUndefined()
// The running arm resolves identically.
expect(terminalCardModel(running(), '/w/app')?.card.cwd).toBe('/w/app')
// An omitted running workdir has no recorded operation-time directory yet.
expect(terminalCardModel(running(), '/w/app')?.card.cwd).toBeUndefined()
})
it('normalizes a relative workdir so the label names the directory actually used', () => {
@@ -154,6 +154,19 @@ describe('terminalCardModel', () => {
expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '../elsewhere' }) } }))?.card.cwd).toBe('../elsewhere')
})
it('uses the recorded command directory after the Session changes again', () => {
for (const name of ['bash', 'pwsh']) {
const block = settled({ call: { name, argsRaw: shellArgs({ workdir: 'nested' }) }, meta: { cwd: '/b/nested' } })
expect(terminalCardModel(block, '/a')?.card.cwd).toBe('/b/nested')
expect(terminalCardModel(block, '/c')?.card.cwd).toBe('/b/nested')
expect(terminalCardModel(settled({ ...block, meta: { cwd: 'relative' } }), '/a')?.card.cwd).toBe('/a/nested')
expect(terminalCardModel(settled({ ...block, meta: { cwd: 'C:\\b\\nested' } }), '/a')?.card.cwd).toBe('C:\\b\\nested')
}
expect(terminalCardModel(running({ argsRaw: shellArgs({ workdir: 'nested' }) }), '/a')?.card.cwd).toBeUndefined()
expect(terminalCardModel(settled({ parentCallId: 'outer' }), '/a')?.card.cwd).toBeUndefined()
expect(terminalCardModel(running({ argsRaw: shellArgs({ workdir: '/absolute' }) }), '/a')?.card.cwd).toBe('/absolute')
})
it('keeps a UNC server and share as an unpoppable root', () => {
// Windows cannot climb above a share, so `..` from the share root stays put.
expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: shellArgs({ workdir: '..' }) } }), '\\\\server\\share')?.card.cwd).toBe('\\\\server\\share')
@@ -168,7 +181,7 @@ describe('terminalCardModel', () => {
const run = running({ name: 'terminal_send', argsRaw })
expect(terminalCardModel(run, '/w/app')).toMatchObject({
copy: { kind: 'terminal-send', text: 'make', sessionId: 'pty-3' },
card: { cwd: '/w/app', running: true },
card: { cwd: undefined, running: true },
})
const done = settled({ call: { name: 'terminal_send', argsRaw }, content: [{ type: 'text', text: 'ok' }] })
expect(localizeTerminalCardModel(terminalCardModel(done)!, enT)).toMatchObject({
@@ -148,9 +148,9 @@ describe('tool-call-model', () => {
})
it('exposes filePath for path/file_path args and skips URL-only reads', () => {
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
expect(toolRowModel('read', result({ call: { name: 'read', argsRaw: '{"path":"src/a.ts"}' } })).filePath).toBe('src/a.ts')
expect(toolRowModel('write', result({ call: { name: 'write', argsRaw: '{"file_path":"src/a.ts"}' } })).filePath).toBe('src/a.ts')
expect(toolRowModel('edit', result({ call: { name: 'edit', argsRaw: '{"file_path":"src/a.ts"}' } })).filePath).toBe('src/a.ts')
expect(toolRowModel('web_fetch', running({ name: 'web_fetch', argsRaw: '{"url":"https://example.com"}' })).filePath)
.toBeUndefined()
expect(toolRowModel('bash', running()).filePath).toBeUndefined()
@@ -166,6 +166,31 @@ describe('tool-call-model', () => {
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md')
})
it.each(['read', 'write', 'edit', 'read_image'])('opens %s at its recorded target after later directory changes without relabeling it', (name) => {
const block = result({ call: { name, argsRaw: '{"file_path":"note.txt"}' }, meta: { path: '/workspace/b/note.txt' } })
for (const cwd of ['/workspace/a', '/workspace/c']) {
const model = toolRowModel(name, block, cwd)
expect(model.filePath).toBe('/workspace/b/note.txt')
expect(model.summary).toBe('note.txt')
const openFile = vi.fn()
const view = render(<GenericToolCard callId="c1" toolName={name} block={block} cwd={cwd} openFile={openFile} loadImage={vi.fn(async () => '')} t={t} />)
fireEvent.click(view.getByText('note.txt'))
expect(openFile).toHaveBeenCalledWith('/workspace/b/note.txt')
view.unmount()
}
})
it('keeps old root results usable and waits for recorded targets before opening relative pending or nested calls', () => {
const call = { name: 'read', argsRaw: '{"file_path":"note.txt"}' }
for (const meta of [undefined, null, [], { path: 7 }, { path: 'relative.txt' }]) {
expect(toolRowModel('read', result({ call, meta }), '/original').filePath).toBe('note.txt')
}
expect(toolRowModel('read', running(call), '/original').filePath).toBeUndefined()
expect(toolRowModel('read', result({ call, parentCallId: 'outer' }), '/original').filePath).toBeUndefined()
expect(toolRowModel('read', result({ call, isError: true }), '/original').filePath).toBeUndefined()
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"file_path":"/absolute/note.txt"}' })).filePath).toBe('/absolute/note.txt')
})
it('abbreviates leftover POSIX home paths after cwd relativization', () => {
const home = '/Users/u'
const cwd = '/tmp/ws'
@@ -602,7 +627,7 @@ describe('GenericToolCard', () => {
})
it('file-path summary click reaches openFile; bash summary does not', () => {
const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
const file = props('read', result({ call: { name: 'read', argsRaw: '{"path":"src/x.ts"}' } }))
const fileView = render(<GenericToolCard {...file} />)
fireEvent.click(fileView.getByText('src/x.ts'))
expect(file.openFile).toHaveBeenCalledWith('src/x.ts')
@@ -25,6 +25,8 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## Use this package
Changing the Session's working directory reloads the applicable project instruction chain before the next request.
Mount this plugin when agents should work from the workspace's own instruction files. `dsh-base` already includes it with a 65,536-byte budget, so base-backed profiles only need to replace the row when they want another `maxBytes`; providerless trees load nothing until a filesystem provider is present.
### What the agent gets
@@ -25,6 +25,8 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
切换 Session 工作目录后,下一次请求前会重新加载适用的项目指令链。
当 agent 需要依据工作区自身的指令文件工作时,挂载此插件。`dsh-base` 已包含它并给予 65,536 字节预算,因此基于 base 的 profile 仅在需要其他 `maxBytes` 时替换该配置行;没有文件系统提供方的树加载不到任何内容,直到提供方出现。
### agent 获得的内容
@@ -34,7 +34,8 @@
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*"
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-working-directory": "workspace:*"
},
"dependencies": {
"@deepseek-ai/dsh-util-values": "workspace:*",
@@ -56,6 +57,7 @@
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-system-prompt": "workspace:*",
"@deepseek-ai/dsh-tool-fs": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*"
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-working-directory": "workspace:*"
}
}
@@ -9,6 +9,7 @@
* @module @deepseek-ai/dsh-agent-instructions
*/
import type {} from '@deepseek-ai/dsh-working-directory'
import type { Context } from '@deepseek-ai/cordis'
import { isDeepStrictEqual } from 'node:util'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
@@ -32,7 +33,7 @@ import { instructionCandidateGroup, instructionScopeKey, scopeForDisplayPath, ty
export { Config, name }
/** Services required by workspace instruction projection. */
export const inject = ['sessionProjections']
export const inject = ['sessionProjections', 'workingDirectory']
export {
discoverBaselineInstructionFiles,
loadBaselineInstructions,
@@ -119,15 +120,15 @@ export function apply(ctx: Context, config: Config): void {
}
const fileSystem = ctx.get('fs')
if (fileSystem === undefined) return undefined
if (touchedPaths.length === 0 && pending.length > 0) return pending[0]
const content: UserMessage['content'][number][] = []
const changes: AgentInstructionChange[] = []
let desiredBaseline = false
const authorityMessages = [...claimed]
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = agent.session.header.cwd ?? process.cwd()
const cwd = await ctx.workingDirectory.ensure(agent, signal)
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, signal)
const identity = workspaceBaselineIdentity(resolved, cwd, projectRoot)
if (touchedPaths.length === 0 && pending.length > 0
&& baselinePreparations.get(agent.session)?.identity === identity) return pending[0]
const visibleBaseline = visibleBaselineSource(agent, authorityMessages)
const baselinePresent = visibleBaseline !== undefined
const keepVisibleBaseline = visibleBaseline?.baselineIdentity === identity
@@ -210,6 +211,7 @@ export function apply(ctx: Context, config: Config): void {
instructionVersions,
fileSystem,
{
cwd,
authorityMessages,
scopeMessages: pending,
includeBaselineScopes: keepVisibleBaseline,
@@ -254,6 +254,7 @@ export async function reconcileInstructionContext(
versionCache: InstructionVersionCache,
fileSystem: FileSystem,
options: {
cwd: string
authorityMessages: readonly UserMessage[]
scopeMessages: readonly UserMessage[]
touchedPaths: readonly string[]
@@ -265,8 +266,7 @@ export async function reconcileInstructionContext(
): Promise<ReconciledInstructionContext | undefined> {
const session = agent.session
const effective = visibleInstructionChanges(agent, options.authorityMessages)
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = session.header.cwd ?? process.cwd()
const cwd = options.cwd
// TODO(frozen-project-root): retain the baseline root for the loop instance;
// recomputing it after marker edits reinterprets the existing relative scope keys.
const projectRoot = options.projectRoot
@@ -1,4 +1,5 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { provideWorkingDirectoryFixture, mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -7,7 +8,6 @@ import { Context } from '@deepseek-ai/cordis'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key'
import * as AgentInstructions from '@deepseek-ai/dsh-agent-instructions'
import { candidateScopeKey } from '../src/render.ts'
@@ -35,6 +35,7 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> {
await mkdir(join(workdir, '.git'), { recursive: true })
await writeFile(join(workdir, 'AGENTS.md'), `If the user asks for the workspace context handshake, reply with exactly this string and nothing else: ${PROBE}.\n`)
ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await mountAgentLoopTestDependencies(ctx, {
systemPrompt: { personaPrefix: 'Answer the user exactly and concisely.' },
})
@@ -1,4 +1,5 @@
import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'
import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { tmpdir } from 'node:os'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -69,6 +70,7 @@ const sk = (directory: string, candidateName: string): string => candidateScopeK
const testToolSignal = new AbortController().signal
const isolatedInboxCtx = new Context()
provideWorkingDirectoryFixture(isolatedInboxCtx)
await mountAgentLoopTestDependencies(isolatedInboxCtx)
const isolatedAgentLoop = await mountAgentLoopTestHarness(isolatedInboxCtx)
let nextStubSession = 1
@@ -235,6 +237,7 @@ class BlockingReadFileSystem extends RecordingFileSystem {
}
async function mountAgentInstructionsPlugin(ctx: Context, config: AgentInstructions.Config): Promise<Awaited<ReturnType<Context['plugin']>>> {
if (ctx.get('workingDirectory') === undefined) provideWorkingDirectoryFixture(ctx)
if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry)
ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
return ctx.plugin(AgentInstructions, config)
@@ -579,6 +582,7 @@ describe('workspace context instruction discovery', () => {
await write(join(outside, 'shared.md'), 'shared provider instruction body')
await symlink(join(outside, 'shared.md'), join(root, 'AGENTS.md'))
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -1229,11 +1233,13 @@ describe('workspace context request injection', () => {
it('requires an explicit maxBytes configuration', async () => {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await expect(mountAgentInstructionsPlugin(ctx, {} as AgentInstructions.Config)).rejects.toThrow(/maxBytes/)
})
it('mounts without requiring a filesystem provider', async () => {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 })
} finally {
@@ -1242,11 +1248,12 @@ describe('workspace context request injection', () => {
})
it('requires projections without making the optional filesystem a static dependency', () => {
expect(AgentInstructions.inject).toEqual(['sessionProjections'])
expect(AgentInstructions.inject).toEqual(['sessionProjections', 'workingDirectory'])
})
it('rejects a file-touch projection when the turn boundary unit is absent', async () => {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentInstructions, { maxBytes: 65536 })
const exec = stubToolExecution({
@@ -1268,6 +1275,7 @@ describe('workspace context request injection', () => {
it('does not inject baseline context when no filesystem provider is present', async () => {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 })
const agent = await stubAgent('/virtual/repo')
@@ -1287,6 +1295,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -1328,6 +1337,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -1352,6 +1362,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 65536 })
const original = await stubAgent(root)
@@ -1377,6 +1388,7 @@ describe('workspace context request injection', () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
@@ -1411,6 +1423,7 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'root '.repeat(200))
await write(join(cwd, 'AGENTS.md'), 'package rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 700 })
const original = await stubAgent(cwd)
@@ -1513,6 +1526,7 @@ describe('workspace context request injection', () => {
await mkdir(cwd, { recursive: true })
await write(join(root, 'AGENTS.md'), 'root '.repeat(200))
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 700 })
const original = await stubAgent(cwd)
@@ -1543,7 +1557,9 @@ describe('workspace context request injection', () => {
const root = await tempRepo()
const home = await tempRepo()
const originalCtx = new Context()
provideWorkingDirectoryFixture(originalCtx)
const resumedCtx = new Context()
provideWorkingDirectoryFixture(resumedCtx)
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'agents rule')
@@ -1593,8 +1609,11 @@ describe('workspace context request injection', () => {
const root = await tempRepo()
const home = await tempRepo()
const agentsCtx = new Context()
provideWorkingDirectoryFixture(agentsCtx)
const claudeCtx = new Context()
provideWorkingDirectoryFixture(claudeCtx)
const restoredCtx = new Context()
provideWorkingDirectoryFixture(restoredCtx)
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'agents rule')
@@ -1650,7 +1669,9 @@ describe('workspace context request injection', () => {
const root = await tempRepo()
const home = await tempRepo()
const originalCtx = new Context()
provideWorkingDirectoryFixture(originalCtx)
const resumedCtx = new Context()
provideWorkingDirectoryFixture(resumedCtx)
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'agents rule')
@@ -1697,6 +1718,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
const fiber = await mountAgentInstructions(ctx, { maxBytes: 65536 })
const original = await stubAgent(root)
@@ -1745,6 +1767,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'old repo rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
const fiber = await mountAgentInstructions(ctx, { maxBytes: 65536 })
const original = await stubAgent(root)
@@ -1797,7 +1820,9 @@ describe('workspace context request injection', () => {
const root = await tempRepo()
const home = await tempRepo()
const originalCtx = new Context()
provideWorkingDirectoryFixture(originalCtx)
const resumedCtx = new Context()
provideWorkingDirectoryFixture(resumedCtx)
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
@@ -1843,6 +1868,7 @@ describe('workspace context request injection', () => {
try {
await mkdir(join(root, '.git'), { recursive: true })
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -1879,6 +1905,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -1920,6 +1947,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -1957,6 +1985,7 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'repo rule')
await write(join(home, 'AGENTS.md'), 'global rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -2240,6 +2269,7 @@ describe('workspace context request injection', () => {
const resolved = resolveConfig({ maxBytes: 65536 })
const cache: InstructionVersionCache = new WeakMap()
const options = {
cwd: root,
authorityMessages: [],
scopeMessages: [],
touchedPaths: [],
@@ -2513,6 +2543,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -2542,6 +2573,7 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'repo rule')
await write(join(root, 'file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
const fiber = await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -2581,6 +2613,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
pinHarnessHome(home)
const fiber = await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 })
@@ -2618,6 +2651,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'first post-compaction request rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -2663,6 +2697,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'old root rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 65536 })
const original = await stubAgent(root)
@@ -2705,6 +2740,7 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'root '.repeat(200))
await write(join(cwd, 'AGENTS.md'), 'package rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 700 })
const agent = await stubAgent(cwd)
@@ -2727,6 +2763,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 65536 })
ctx.on('agent/pre-step', async (_payload, next) => {
@@ -2761,6 +2798,7 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'old root rule')
await write(join(root, 'file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -2792,6 +2830,7 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'root rule')
await write(join(root, 'file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -2820,6 +2859,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'shared root and global rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(root)
await mountAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -2870,6 +2910,7 @@ describe('workspace context request injection', () => {
await write(join(root, 'CLAUDE.md'), ' shared repo rule\n\n')
await write(join(root, 'file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -2896,6 +2937,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'x'.repeat(1000))
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes })
const agent = await stubAgent(root)
@@ -2929,6 +2971,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'node fs rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
@@ -2953,6 +2996,7 @@ describe('workspace context request injection', () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
@@ -2979,6 +3023,7 @@ describe('workspace context request injection', () => {
const cwd = join(root, 'pkg')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
@@ -3004,6 +3049,7 @@ describe('workspace context request injection', () => {
const root = resolve('/virtual/no-signal-repo')
const home = resolve('/virtual/no-signal-home')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
@@ -3025,6 +3071,7 @@ describe('workspace context request injection', () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
@@ -3050,6 +3097,7 @@ describe('workspace context request injection', () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
@@ -3077,6 +3125,7 @@ describe('workspace context request injection', () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(BlockingReadFileSystem)
const fs = ctx.fs as BlockingReadFileSystem
@@ -3113,6 +3162,7 @@ describe('workspace context request injection', () => {
await write(join(home, 'AGENTS.md'), 'node global rule')
await write(join(root, 'CLAUDE.md'), 'node claude rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
@@ -3142,6 +3192,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'node fs rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
@@ -3167,6 +3218,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'node fs rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
@@ -3192,6 +3244,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'node fs rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
@@ -3215,6 +3268,7 @@ describe('workspace context request injection', () => {
const home = await tempRepo()
try {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
@@ -3242,6 +3296,7 @@ describe('workspace context request injection', () => {
const home = await tempRepo()
try {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.throwOnStat.add(join(cwd, '.git'))
@@ -3272,6 +3327,7 @@ describe('workspace context request injection', () => {
await write(join(repoA, 'AGENTS.md'), 'repo A only')
await write(join(repoB, 'AGENTS.md'), 'repo B only')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 65536 })
const agentA = await stubAgent(repoA)
@@ -3301,6 +3357,7 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'root schema default rule')
await write(join(cwd, 'AGENTS.md'), 'child schema default rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 })
const agent = await stubAgent(cwd)
@@ -3323,6 +3380,7 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'base rule')
await write(join(root, 'AGENTS.local.md'), 'local rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -3345,6 +3403,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
const fiber = await mountAgentInstructions(ctx, { maxBytes: 65536 })
await fiber.dispose()
@@ -3367,6 +3426,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 0 })
const agent = await stubAgent(root)
@@ -3388,6 +3448,7 @@ describe('workspace context request injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: -1 })
const agent = await stubAgent(root)
@@ -3408,6 +3469,7 @@ describe('workspace context request injection', () => {
try {
await mkdir(join(root, '.git'), { recursive: true })
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -3552,6 +3614,7 @@ describe('dynamic nested workspace context injection', () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'pkg/AGENTS.md'), 'nested rule survives an aborted tool batch')
@@ -3648,6 +3711,7 @@ describe('dynamic nested workspace context injection', () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
@@ -3692,6 +3756,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -3736,6 +3801,7 @@ describe('dynamic nested workspace context injection', () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
@@ -3771,6 +3837,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/CLAUDE.local.md'), 'local package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, {
maxBytes: 65536,
@@ -3809,6 +3876,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.local.md'), 'nested local rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -3850,6 +3918,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.local.md'), 'nested local rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, {
maxBytes: 65536,
@@ -3883,6 +3952,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -3917,6 +3987,7 @@ describe('dynamic nested workspace context injection', () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
@@ -3957,6 +4028,7 @@ describe('dynamic nested workspace context injection', () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
@@ -4004,6 +4076,7 @@ describe('dynamic nested workspace context injection', () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
@@ -4049,6 +4122,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'old package rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -4094,6 +4168,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/CLAUDE.md'), 'sibling package rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -4134,6 +4209,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/CLAUDE.md'), 'nested rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -4163,6 +4239,7 @@ describe('dynamic nested workspace context injection', () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
@@ -4202,6 +4279,7 @@ describe('dynamic nested workspace context injection', () => {
const warmCache: InstructionVersionCache = new WeakMap()
warmCache.set(agent.session, new Map(loaded.versions))
const options = {
cwd: root,
authorityMessages,
scopeMessages: [createUserMessage({
content: [{ type: 'text', text: 'pending baseline duplicate' }],
@@ -4235,6 +4313,7 @@ describe('dynamic nested workspace context injection', () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
@@ -4260,6 +4339,7 @@ describe('dynamic nested workspace context injection', () => {
const cache: InstructionVersionCache = new WeakMap()
cache.set(agent.session, new Map(loaded.versions))
const options = {
cwd: root,
authorityMessages: [],
scopeMessages: [],
touchedPaths: [],
@@ -4282,6 +4362,7 @@ describe('dynamic nested workspace context injection', () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
@@ -4304,6 +4385,7 @@ describe('dynamic nested workspace context injection', () => {
})
const result = await reconcileInstructionContext(agent, resolved, new WeakMap(), fs, {
cwd: root,
authorityMessages: [],
scopeMessages: [],
touchedPaths: [],
@@ -4323,6 +4405,7 @@ describe('dynamic nested workspace context injection', () => {
it('loads one transition when user-global and project scopes resolve to the same file', async () => {
const root = join(await tempRepo(), 'virtual-repo')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
@@ -4337,6 +4420,7 @@ describe('dynamic nested workspace context injection', () => {
})
const result = await reconcileInstructionContext(agent, resolved, new WeakMap(), fs, {
cwd: root,
authorityMessages: [],
scopeMessages: [],
touchedPaths: [],
@@ -4363,6 +4447,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/CLAUDE.md'), 'initial divergent nested rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -4402,6 +4487,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/CLAUDE.md'), 'secondary nested rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -4442,6 +4528,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'package rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -4484,6 +4571,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'package rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -4526,6 +4614,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'first package rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -4564,6 +4653,7 @@ describe('dynamic nested workspace context injection', () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
@@ -4606,6 +4696,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -4644,6 +4735,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'old nested rule')
await write(join(root, 'pkg/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const original = await stubAgent(root)
@@ -4677,6 +4769,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -4731,6 +4824,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'AGENTS.md'), 'root rule')
await write(join(root, 'file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -4793,6 +4887,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/sub/AGENTS.md'), 'subtree rule')
await write(join(root, 'pkg/sub/file.txt'), 'subtree file')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -4833,6 +4928,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/sub/AGENTS.md'), 'subtree rule')
await write(join(root, 'pkg/sub/file.txt'), 'subtree file')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 700 })
const agent = await stubAgent(root)
@@ -4872,6 +4968,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -4925,6 +5022,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -4959,6 +5057,7 @@ describe('dynamic nested workspace context injection', () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
const nested = join(root, 'pkg/AGENTS.md')
await ctx.plugin(SystemPrompt)
@@ -5004,6 +5103,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -5068,6 +5168,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -5102,6 +5203,7 @@ describe('dynamic nested workspace context injection', () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
@@ -5156,6 +5258,7 @@ describe('dynamic nested workspace context injection', () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
@@ -5212,6 +5315,7 @@ describe('dynamic nested workspace context injection', () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
pinHarnessHome(home)
@@ -5281,6 +5385,7 @@ describe('dynamic nested workspace context injection', () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
@@ -5314,6 +5419,7 @@ describe('dynamic nested workspace context injection', () => {
it('ignores failed, aborted, agentless, and non-file final results', async () => {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 })
@@ -5363,6 +5469,7 @@ describe('dynamic nested workspace context injection', () => {
it('warns when an asynchronous file-result projection fails', { timeout: 20_000 }, async () => {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 })
@@ -5399,6 +5506,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 0 })
@@ -5423,6 +5531,7 @@ describe('dynamic nested workspace context injection', () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
@@ -5469,6 +5578,7 @@ describe('dynamic nested workspace context injection', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
@@ -5497,6 +5607,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
const fiber = await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
await fiber.dispose()
@@ -5536,6 +5647,7 @@ describe('workspace context inbox synchronization', () => {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'duplicate baseline')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -5558,6 +5670,7 @@ describe('workspace context inbox synchronization', () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
@@ -5593,6 +5706,7 @@ describe('workspace context inbox synchronization', () => {
await write(join(root, 'pkg/AGENTS.md'), 'pending version one')
await write(join(root, 'pkg/file.txt'), 'file')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(root)
@@ -5637,6 +5751,7 @@ describe('workspace context inbox synchronization', () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
@@ -5679,6 +5794,7 @@ describe('workspace context inbox synchronization', () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
@@ -5725,6 +5841,7 @@ describe('workspace context inbox synchronization', () => {
await write(join(root, 'b/AGENTS.md'), 'fresh scope B')
await write(join(root, 'b/file.txt'), 'b')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const original = await stubAgent(root)
@@ -5762,6 +5879,7 @@ describe('workspace context inbox synchronization', () => {
await write(join(root, 'pkg/AGENTS.md'), 'old claimed rule')
await write(join(root, 'pkg/file.txt'), 'file')
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
pinHarnessHome(home)
await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 })
const agent = await stubAgent(join(root, 'pkg'))
@@ -34,6 +34,9 @@
},
{
"path": "../../util/home-paths"
},
{
"path": "../../session/working-directory"
}
]
}
@@ -25,7 +25,9 @@ Agents and host UIs can complete `@file` mentions with ranked paths from each ag
<a id="use-this-package"></a>
## Use this package
Mount this provider when `@file` completion should discover the Harness host's own filesystem — the namespace the shipped `read` tool operates on. Each agent's workspace is indexed from its session working directory, falling back to the host process directory when the session has none.
Changing the Session's working directory discards the previous directory's search index; the next completion searches the new directory.
Mount this provider when `@file` completion should discover the Harness host's own filesystem — the namespace the shipped `read` tool operates on. Each agent's workspace is indexed from its Session's current working directory.
### Enabling the provider
@@ -25,7 +25,9 @@ agent(智能体)及宿主 UI 可以用各 agent 本地工作区中经过排
<a id="use-this-package"></a>
## 使用本包
当 `@file` 补全应发现 Harness 宿主自身的文件系统——即随附 `read` 工具所操作的命名空间——时,挂载此提供方。每个 agent 的工作区从该会话的工作目录开始建立索引;会话没有工作目录时回退到宿主进程目录。
切换 Session 工作目录后,旧目录的搜索索引会被丢弃;下一次补全会搜索新目录。
当 `@file` 补全应发现 Harness 宿主自身的文件系统——即随附 `read` 工具所操作的命名空间——时,挂载此提供方。每个 agent 的工作区从其 Session 当前工作目录开始建立索引。
### 启用提供方
@@ -39,13 +39,17 @@
"@deepseek-ai/dsh-file-reference": "workspace:*",
"@deepseek-ai/dsh-system-prompt": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/cordis": "workspace:~"
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-working-directory": "workspace:*"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-file-reference": "workspace:*",
"@deepseek-ai/dsh-system-prompt": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/cordis": "workspace:~"
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-working-directory": "workspace:*",
"@deepseek-ai/dsh-fs-local": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*"
}
}
@@ -4,6 +4,7 @@
* @module @deepseek-ai/dsh-file-reference-local
*/
import type {} from '@deepseek-ai/dsh-working-directory'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -42,7 +43,7 @@ export interface Config {
/** Local-filesystem owner of the file-reference discovery service. */
export class LocalFileReferenceService extends FileReferenceService {
static inject = ['agents']
static inject = ['agents', 'workingDirectory']
static Config: z<Config> = z.object({
maxResults: z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS),
maxEntries: z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES),
@@ -50,7 +51,7 @@ export class LocalFileReferenceService extends FileReferenceService {
})
private readonly config: FileSearchConfig
private readonly searches = new Map<Agent, WorkspaceFileSearch>()
private readonly searches = new Map<Agent, { cwd: string; search: WorkspaceFileSearch }>()
private readonly promptFibers = new Map<Agent, ReturnType<Context['inject']>>()
private readonly promptDisposals = new Set<Promise<void>>()
@@ -91,17 +92,17 @@ export class LocalFileReferenceService extends FileReferenceService {
for (const agent of ctx.agents.list()) installPrompt(agent)
ctx.on('agent/created', async ({ agent }) => { await installPrompt(agent) })
ctx.on('agent/disposed', ({ agent }) => {
this.searches.get(agent)?.dispose()
this.searches.get(agent)?.search.dispose()
this.searches.delete(agent)
disposePrompt(agent)
})
ctx.on('session/event', (session, event) => {
if (event.type !== 'tool/result') return
const agent = ctx.agents.get(session.id)
if (agent !== undefined) this.searches.get(agent)?.invalidate()
if (agent !== undefined) this.searches.get(agent)?.search.invalidate()
})
ctx.effect(() => async () => {
for (const search of this.searches.values()) search.dispose()
for (const { search } of this.searches.values()) search.dispose()
this.searches.clear()
const promptFibers = [...this.promptFibers.values()]
this.promptFibers.clear()
@@ -112,17 +113,19 @@ export class LocalFileReferenceService extends FileReferenceService {
}, 'file-reference-local: search cache')
}
override list(
override async list(
agent: Agent,
query: string,
signal: AbortSignal,
): Promise<FileReferenceCandidate[]> {
let search = this.searches.get(agent)
if (search === undefined) {
search = new WorkspaceFileSearch(agent.session.header.cwd ?? process.cwd(), this.config)
this.searches.set(agent, search)
const cwd = await this.ctx.workingDirectory.ensure(agent, signal)
let cached = this.searches.get(agent)
if (cached?.cwd !== cwd) {
cached?.search.dispose()
cached = { cwd, search: new WorkspaceFileSearch(cwd, this.config) }
this.searches.set(agent, cached)
}
return search.list(query, signal)
return cached.search.list(query, signal)
}
}
@@ -1,6 +1,9 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import WorkingDirectory from '@deepseek-ai/dsh-working-directory'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -24,6 +27,9 @@ async function harness(): Promise<Context> {
await ctx.plugin(SystemPrompt, { personaPrefix: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalFileSystem)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(WorkingDirectory)
return ctx
}
@@ -98,6 +104,25 @@ describe('LocalFileReferenceService', () => {
expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain(FILE_REFERENCE_PROMPT)
})
it('rebuilds completion from the current directory after a Session directory change', async () => {
const ctx = await harness()
try {
const { agent } = await stubAgent(ctx)
const nextRoot = await mkdtemp(join(tmpdir(), 'dsh-file-reference-next-'))
roots.push(nextRoot)
await writeFile(join(nextRoot, 'NEXT.md'), 'next')
await ctx.plugin(LocalFileReferenceService)
const signal = new AbortController().signal
expect(await ctx.fileReferences.list(agent, '', signal)).toEqual([{ path: 'README.md', kind: 'file' }])
const original = agent.session.header.cwd
await ctx.workingDirectory.set(agent, nextRoot, signal)
expect(await ctx.fileReferences.list(agent, '', signal)).toEqual([{ path: 'NEXT.md', kind: 'file' }])
expect(agent.session.header.cwd).toBe(original)
} finally {
await ctx.fiber.dispose()
}
})
it('invalidates cached searches after tool results and disposes them with the agent', async () => {
const ctx = await harness()
const { agent, dispose } = await stubAgent(ctx)
@@ -106,12 +131,12 @@ describe('LocalFileReferenceService', () => {
await ctx.plugin(LocalFileReferenceService)
await ctx.fileReferences.list(agent, 'README', new AbortController().signal)
ctx.emit('session/event', agent.session, { type: 'tool/result' } as never)
ctx.emit('session/event', agent.session, { type: 'tool/result', seq: 0, data: {} } as never)
expect(invalidate).toHaveBeenCalledOnce()
ctx.emit('session/event', agent.session, { type: 'assistant/message' } as never)
ctx.emit('session/event', agent.session, { type: 'assistant/message', seq: 0, data: {} } as never)
expect(invalidate).toHaveBeenCalledOnce()
const orphan = ctx.sessions.create(SessionId('file-reference-orphan'))
ctx.emit('session/event', orphan, { type: 'tool/result' } as never)
ctx.emit('session/event', orphan, { type: 'tool/result', seq: 0, data: {} } as never)
expect(invalidate).toHaveBeenCalledOnce()
await dispose()
@@ -25,6 +25,9 @@
},
{
"path": "../file-reference"
},
{
"path": "../../session/working-directory"
}
]
}
+1 -1
View File
@@ -151,7 +151,7 @@ The package-level contract is enough for most consumers; read these when you nee
#### What the model sees
For each step, the loop sends the session's derived messages and visible tool schemas. Non-empty `system/message` nodes carry the prompt, with the latest as the effective version; an empty rendering clears all prompt versions from derived history. It supplies `provider`, `model`, and `cwd` variable values but no additional fixed prose. The request header always records active tools. Comparing their names with the preceding header emits one `developer/message` for additions and removals, independently of model capability; additions reference the new header. Requests also carry `Session.toolHistory()` for the LLM runtime to construct provider declarations.
For each step, the loop sends the session's derived messages and visible tool schemas. Non-empty `system/message` nodes carry the prompt, with the latest as the effective version; an empty rendering clears all prompt versions from derived history. It supplies `provider` and `model` variable values but no additional fixed prose. The working-directory service contributes the active directory as user context. The request header always records active tools. Comparing their names with the preceding header emits one `developer/message` for additions and removals, independently of model capability; additions reference the new header. Requests also carry `Session.toolHistory()` for the LLM runtime to construct provider declarations.
#### Token effect
+1 -1
View File
@@ -151,7 +151,7 @@ const handle = await ctx.agents.create({
#### 模型看到什么
每个步骤中,循环会发送会话的派生消息与可见工具 schema。非空的 `system/message` 节点承载提示词,最新一条是有效版本;空渲染文本会从派生历史中清除所有提示词版本。它提供 `provider`、`model` 与 `cwd` 变量值,但不添加固定文案。 请求头始终记录当前有效工具。将其名称与前一请求头比较后,添加和移除合并为一条 `developer/message`,不依赖模型能力;添加记录引用新请求头。请求还携带 `Session.toolHistory()`,供 LLM 运行时构造提供方声明。
每个步骤中,循环会发送会话的派生消息与可见工具 schema。非空的 `system/message` 节点承载提示词,最新一条是有效版本;空渲染文本会从派生历史中清除所有提示词版本。它提供 `provider` 与 `model` 变量值,但不添加固定文案。工作目录服务通过用户上下文提供当前目录。 请求头始终记录当前有效工具。将其名称与前一请求头比较后,添加和移除合并为一条 `developer/message`,不依赖模型能力;添加记录引用新请求头。请求还携带 `Session.toolHistory()`,供 LLM 运行时构造提供方声明。
#### Token 影响
-1
View File
@@ -369,7 +369,6 @@ export class AgentLoop extends Service implements AgentFactory {
ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
ctx.systemPrompt.variable('provider', context => context.agent?.options.provider)
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
for (const { id, sessionId, cwd, resumeSessionId, ...options } of this.config.agents) {
const meta = cwd === undefined ? {} : { cwd }
+10 -8
View File
@@ -545,9 +545,9 @@ describe('agent loop', () => {
expect(request!.tools?.map(t => t.name)).toEqual(['noop'])
})
it('resolves {{cwd}} from the agent session workspace (factory create with meta.cwd)', async () => {
it('keeps the Session directory out of system-prompt variables', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter, 'Working in {{cwd}}.')
const ctx = await harness(adapter, 'A coding agent.')
const handle = await ctx.agents.create({
sessionId: SessionId('s-cwd'),
meta: { cwd: '/work/space' },
@@ -558,13 +558,15 @@ describe('agent loop', () => {
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(systemOf(adapter.requests[0])).toBe('You are an AI agent powered by DeepSeek Harness.\n\nWorking in /work/space.')
expect(systemOf(adapter.requests[0])).toBe('You are an AI agent powered by DeepSeek Harness.\n\nA coding agent.')
expect((await ctx.systemPrompt.assemble({ agent, scope: agent })).variables).not.toHaveProperty('cwd')
})
it('contains a strict-variable render failure: the turn errors, the loop keeps serving turns', async () => {
// A missing cwd variable must fail one turn without preventing a later valid turn.
// A missing custom_directory variable must fail one turn without preventing a later valid turn.
const adapter = new MockAdapter([textResponse('ok after rescue')])
const ctx = await harness(adapter, 'In {{cwd}}.')
const ctx = await harness(adapter, 'In {{custom_directory}}.')
ctx.systemPrompt.variable('custom_directory', () => undefined)
const errors: Error[] = []
ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error)
@@ -576,7 +578,7 @@ describe('agent loop', () => {
expect(adapter.requests).toHaveLength(0) // the request was never sent
expect(errors.map(error => error.message)).toEqual([
'prompt variable "{{cwd}}" has no value for this assembly (section "deployment:persona-prefix")',
'prompt variable "{{custom_directory}}" has no value for this assembly (section "deployment:persona-prefix")',
])
const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error')
@@ -584,10 +586,10 @@ describe('agent loop', () => {
? turnEnd.data.reason.error.message
: '').toContain('no value for this assembly')
// The loop survived: a waterfall listener rescues {{cwd}} and the SAME
// The loop survived: a waterfall listener rescues {{custom_directory}} and the SAME
// agent completes a real model turn.
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
assembly.variables['cwd'] = '/rescued'
assembly.variables['custom_directory'] = '/rescued'
return next()
})
send(agent, 'again')
@@ -78,6 +78,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
'turn/start',
'user/message',
'web/deepseek-search-llm-request',
'working-directory/change',
'workspace/changes',
])
+6 -6
View File
@@ -44,7 +44,7 @@ The config owns the fixed opener, runtime context, deployment persona prefix and
| Field | Default | Meaning |
|---|---|---|
| `includeHarnessIdentity` | `true` | Include the fixed `You are an AI agent powered by DeepSeek Harness.` first-party opener at order −1000. Set false only when a compatibility deployment owns the complete system prompt. |
| `includeRuntimeContext` | `true` | Include ordered dynamic runtime context in assembly |
| `includeRuntimeContext` | `true` | Include optional ordered dynamic runtime context in assembly |
| `personaPrefix` | `''` | Global persona prefix template at order `0`, before first-party guidance |
| `personaSuffix` | `''` | Global `deployment:persona-suffix` template at order `10200`, after first-party guidance |
| `toolOrder` | — | Explicit model-facing tool order with one `'<unlisted-tools>'` rest entry |
@@ -63,14 +63,14 @@ ctx.systemPrompt.section({
})
```
Set `interpolate: false` on a section to preserve its text literally, including `{{…}}` groups in generated tool documentation. Other sections interpolate variables by default.
Set `interpolate: false` on a section or dynamic context to preserve its text literally, including `{{…}}` groups in generated tool documentation or directory names. Other contributions interpolate variables by default.
### Contribute a prompt variable
Variables are referenced from section text as `{{name}}` and resolved at each assembly; scoped variables shadow a same-named global for that agent. The loop supplies `model` and `cwd`; any plugin can register the facts it owns.
Variables are referenced from section or context text as `{{name}}` and resolved at each assembly; scoped variables shadow a same-named global for that agent. The loop supplies `provider` and `model`; plugins can register the facts they own. The working-directory service supplies directory text as literal user context.
```text
ctx.systemPrompt.variable('cwd', ({ agent }) => agent?.session.header.cwd)
ctx.systemPrompt.variable('response_language', () => 'English')
```
### Contribute tool schemas
@@ -79,7 +79,7 @@ Tool-schema providers are evaluated per assembly and contribute the model-visibl
### Suppress runtime context
`suppressRuntimeContext()` removes every dynamic runtime-context contribution for the calling scope without disabling the services that own the underlying facts; multiple suppressors compose and the effect restores context when none remains.
`suppressRuntimeContext()` removes optional dynamic context for the calling scope without disabling the owning services. Contributions marked `required` remain visible. Multiple suppressors compose; optional context returns when none remains.
-----
@@ -133,7 +133,7 @@ The package-level contract is enough for most consumers; read these when you nee
#### What the model sees
First-party sections render the harness identity, deployment persona prefix (including the model-name introduction), reusable instructions (including the generated tools SDK and structured-output guidance), then the environment-bearing suffix: harness source (`10000`), Web surface (`10100`), and deployment persona suffix (`10200`). External section orders and assembly listeners remain authoritative. `includeHarnessIdentity: false` omits only that fixed opener. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The `system-prompt/assemble` waterfall determines the delivered prompt and tool schemas unless one effective section declares itself complete — that exact section then becomes the whole system prompt while the waterfall's contexts, tools, and variables remain. The rendered prompt reaches the model as a system-role message of derived history — surface node 0, or the latest system node after an in-history update — neither the loop request nor `request/header` carries a separate `system` field. If the complete rendering is empty, the loop clears every active system node through logged empty replacements, so no older prompt remains in model history. Ordered dynamic contexts are separate from sections and become sourced user-role snapshots only when present; `includeRuntimeContext: false` or a scoped suppressor removes them all.
First-party sections render the harness identity, deployment persona prefix (including the model-name introduction), reusable instructions (including the generated tools SDK and structured-output guidance), then the environment-bearing suffix: harness source (`10000`), Web surface (`10100`), and deployment persona suffix (`10200`). External section orders and assembly listeners remain authoritative. `includeHarnessIdentity: false` omits only that fixed opener. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The `system-prompt/assemble` waterfall determines the delivered prompt and tool schemas unless one effective section declares itself complete — that exact section then becomes the whole system prompt while the waterfall's contexts, tools, and variables remain. The rendered prompt reaches the model as a system-role message of derived history — surface node 0, or the latest system node after an in-history update — neither the loop request nor `request/header` carries a separate `system` field. If the complete rendering is empty, the loop clears every active system node through logged empty replacements, so no older prompt remains in model history. Ordered dynamic contexts are separate from sections and become sourced user-role snapshots only when present; `includeRuntimeContext: false` or a scoped suppressor removes optional contributions while required operational context remains.
##### Harness identity
+6 -6
View File
@@ -44,7 +44,7 @@ kind: "package-reference"
| 字段 | 默认值 | 含义 |
|---|---|---|
| `includeHarnessIdentity` | `true` | 是否包含顺序为 −1000 的第一方固定开场白 `You are an AI agent powered by DeepSeek Harness.`。仅当兼容性部署拥有完整系统提示词时设为 false。 |
| `includeRuntimeContext` | `true` | 是否在组装中包含有序动态 runtime 上下文 |
| `includeRuntimeContext` | `true` | 是否在组装中包含可选的有序动态 runtime 上下文 |
| `personaPrefix` | `''` | 全局 persona 前缀模板,顺序为 `0`,位于第一方指导之前 |
| `personaSuffix` | `''` | 全局 `deployment:persona-suffix` 模板,顺序为 `10200`,位于第一方指导之后 |
| `toolOrder` | — | 显式面向模型工具顺序,含一个 `'<unlisted-tools>'` 其余项标记 |
@@ -63,14 +63,14 @@ ctx.systemPrompt.section({
})
```
在段上设置 `interpolate: false` 可原样保留文本,包括生成的工具文档中的 `{{…}}` 组。其他段默认执行变量插值。
在段或动态上下文上设置 `interpolate: false` 可原样保留文本,包括生成的工具文档或目录名中的 `{{…}}` 组。其他贡献默认执行变量插值。
### 贡献提示词变量
变量在段文本中以 `{{name}}` 引用,并在每次组装时解析;带作用域变量会为该 agent 遮蔽同名全局变量。循环提供 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实。
变量从段或上下文文本中以 `{{name}}` 引用,每次组装时解析;带作用域的变量会为该 agent 遮蔽同名全局变量。循环提供 `provider` 和 `model`;插件可注册自己拥有的事实。工作目录服务通过不执行插值的用户上下文提供目录文本。
```text
ctx.systemPrompt.variable('cwd', ({ agent }) => agent?.session.header.cwd)
ctx.systemPrompt.variable('response_language', () => 'English')
```
### 贡献工具 schema
@@ -79,7 +79,7 @@ ctx.systemPrompt.variable('cwd', ({ agent }) => agent?.session.header.cwd)
### 抑制运行时上下文
`suppressRuntimeContext()` 移除调用作用域的所有动态运行时上下文贡献,但不禁用拥有底层事实的服务;多个抑制器独立组合,当不再存在抑制器时该 effect 会恢复上下文。
`suppressRuntimeContext()` 移除调用作用域的可选动态上下文,但不禁用拥有这些事实的服务。标记为 `required` 的贡献仍然可见。多个抑制器独立组合;不再存在抑制器时,可选上下文恢复。
-----
@@ -133,7 +133,7 @@ ctx.systemPrompt.variable('cwd', ({ agent }) => agent?.session.header.cwd)
#### 模型看到什么
第一方段落依次渲染 harness 身份、部署 persona 前缀(含模型名称介绍)、可复用指令(包括生成的工具 SDK 和结构化输出指导),最后是携带环境信息的后缀:harness 源码(`10000`)、Web 表层(`10100`)和部署 persona 后缀(`10200`)。外部段落的顺序与组装监听器仍决定其最终结果。`includeHarnessIdentity: false` 仅省略这个固定开场白。空段会消失;带作用域的段与变量可以为一个 agent 遮蔽全局项。`system-prompt/assemble` waterfall 决定交付的提示词与工具 schema,除非一个有效段声明自身为 complete——此时该确切段会成为完整的系统提示词,而 waterfall 得到的上下文、工具与变量保持不变。渲染后的提示词作为派生历史中的 system 角色消息——surface 第 0 号节点,或历史内更新之后最新的系统节点——到达模型;循环请求与 `request/header` 均不含单独的 `system` 字段。完整渲染结果为空时,循环通过有日志记录的空内容替换清除所有生效的系统节点,模型历史不再保留任何旧提示词。有序动态上下文与段分离,只在存在时才会成为带来源的 user 角色快照;`includeRuntimeContext: false` 或带作用域的抑制器会移除全部这类上下文。
第一方段落依次渲染 harness 身份、部署 persona 前缀(含模型名称介绍)、可复用指令(包括生成的工具 SDK 和结构化输出指导),最后是携带环境信息的后缀:harness 源码(`10000`)、Web 表层(`10100`)和部署 persona 后缀(`10200`)。外部段落的顺序与组装监听器仍决定其最终结果。`includeHarnessIdentity: false` 仅省略这个固定开场白。空段会消失;带作用域的段与变量可以为一个 agent 遮蔽全局项。`system-prompt/assemble` waterfall 决定交付的提示词与工具 schema,除非一个有效段声明自身为 complete——此时该确切段会成为完整的系统提示词,而 waterfall 得到的上下文、工具与变量保持不变。渲染后的提示词作为派生历史中的 system 角色消息——surface 第 0 号节点,或历史内更新之后最新的系统节点——到达模型;循环请求与 `request/header` 均不含单独的 `system` 字段。完整渲染结果为空时,循环通过有日志记录的空内容替换清除所有生效的系统节点,模型历史不再保留任何旧提示词。有序动态上下文与段分离,只在存在时才会成为带来源的 user 角色快照;`includeRuntimeContext: false` 或带作用域的抑制器会移除可选贡献;必需的操作上下文仍然保留。
##### harness 身份
+26 -14
View File
@@ -83,6 +83,10 @@ export interface PromptContext {
readonly order: number
/** Static text or a provider evaluated for each assembly. Empty text contributes nothing. */
readonly text: string | ((context: AssembleContext) => string)
/** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
readonly interpolate?: boolean
/** Keep this operational context when optional runtime context is disabled. */
readonly required?: boolean
}
/** One section of an assembly: {@link PromptSection} with its text resolved. */
@@ -101,6 +105,8 @@ export interface AssembledContext {
name: string
/** The resolved text before variable interpolation. */
text: string
/** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
interpolate?: boolean
}
/** Tool schemas visible in one assembly and their pre-restriction name set. */
@@ -162,6 +168,7 @@ const SECTION_ORDERS = {
export type PromptSectionOrderName = keyof typeof SECTION_ORDERS
const CONTEXT_ORDERS = {
WORKING_DIRECTORY: 100,
SANDBOX_POLICY: 110,
APPROVAL_POLICY: 115,
SUBAGENT_DELEGATION: 120,
@@ -247,7 +254,7 @@ function compareToolNames(a: ToolSchema, b: ToolSchema): number {
export interface Config {
/** Include the fixed DeepSeek Harness identity before the deployment persona (default true). */
includeHarnessIdentity?: boolean
/** Include dynamic runtime-context snapshots in model history (default true). */
/** Include optional runtime-context snapshots in model history (default true); required context remains. */
includeRuntimeContext?: boolean
/**
* Deployment-wide persona prefix template before first-party guidance. A scoped section named
@@ -284,7 +291,7 @@ export function renderPrompt(assembly: PromptAssembly): string {
}
/**
* Render the complete dynamic context snapshot.
* Render the complete dynamic context snapshot, preserving literal contributions.
* @param assembly - the assembly whose contexts and variables to render.
* @returns the current full snapshot, or `''` when no context is active.
*/
@@ -311,13 +318,17 @@ export function joinContextSections(sections: readonly ContextSnapshotSection[])
*
* {@link renderContextSnapshot} joins these for the model; a consumer that
* presents the snapshot uses them to attribute each part to the subsystem that
* contributed it, without re-splitting the joined prose.
* contributed it, without re-splitting the joined prose. Contributions with
* `interpolate: false` preserve literal text instead of resolving prompt variables.
* @param assembly - the assembly whose contexts and variables to render.
* @returns one entry per contributing context that rendered to non-empty text.
*/
export function renderContextSections(assembly: PromptAssembly): ContextSnapshotSection[] {
return assembly.contexts
.map(context => ({ name: context.name, text: interpolate(context, assembly.variables, 'context') }))
.map(context => ({
name: context.name,
text: context.interpolate === false ? context.text : interpolate(context, assembly.variables, 'context'),
}))
.filter(section => section.text.length > 0)
}
@@ -498,7 +509,7 @@ export class SystemPrompt extends Service {
}
/**
* Suppress every dynamic runtime-context contribution in the calling
* Suppress optional dynamic runtime-context contributions in the calling
* context's scope without changing the services that own or enforce those
* facts. Multiple suppressors remain independently disposable.
* @returns the exact Cordis effect disposer.
@@ -611,14 +622,14 @@ export class SystemPrompt extends Service {
})
const assembly: PromptAssembly = {
sections,
contexts: runtimeContextSuppressed
? []
: [...contextByName.values()]
.sort((a, b) => a.order - b.order)
.map(entry => ({
name: entry.name,
text: typeof entry.text === 'function' ? entry.text(context) : entry.text,
})),
contexts: [...contextByName.values()]
.filter(entry => !runtimeContextSuppressed || entry.required === true)
.sort((a, b) => a.order - b.order)
.map(entry => ({
name: entry.name,
text: typeof entry.text === 'function' ? entry.text(context) : entry.text,
...entry.interpolate !== undefined ? { interpolate: entry.interpolate } : {},
})),
tools: orderTools(collected, this.toolOrder, knownNames),
variables,
}
@@ -627,10 +638,11 @@ export class SystemPrompt extends Service {
() => Promise.resolve(assembly),
)
if (completeSection === undefined && !runtimeContextSuppressed) return transformed
const requiredContexts = new Set([...contextByName.values()].filter(entry => entry.required === true).map(entry => entry.name))
return {
...transformed,
sections: completeSection === undefined ? transformed.sections : [completeSection],
contexts: runtimeContextSuppressed ? [] : transformed.contexts,
contexts: runtimeContextSuppressed ? transformed.contexts.filter(entry => requiredContexts.has(entry.name)) : transformed.contexts,
}
}
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SystemPrompt, {
AssembleContext, PromptAssembly, renderContextSnapshot, renderPrompt,
AssembleContext, PromptAssembly, renderContextSections, renderContextSnapshot, renderPrompt,
} from '@deepseek-ai/dsh-system-prompt'
import type { PromptContextOrderName, PromptSectionOrderName } from '@deepseek-ai/dsh-system-prompt'
@@ -24,6 +24,7 @@ const SECTION_ORDER_NAMES = [
'HARNESS_SOURCE', 'WEB_SURFACE', 'DEPLOYMENT_PERSONA_SUFFIX',
] as const satisfies readonly PromptSectionOrderName[]
const CONTEXT_ORDER_NAMES = [
'WORKING_DIRECTORY',
'SANDBOX_POLICY', 'APPROVAL_POLICY', 'SUBAGENT_DELEGATION',
] as const satisfies readonly PromptContextOrderName[]
function contributed(assembly: PromptAssembly): PromptAssembly['sections'] {
@@ -31,6 +32,51 @@ function contributed(assembly: PromptAssembly): PromptAssembly['sections'] {
}
describe('SystemPrompt', () => {
it('retains required user context when optional context is suppressed', async () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt, { includeRuntimeContext: false })
ctx.systemPrompt.context({ name: 'directory', order: 0, required: true, text: 'Directory: /project' })
ctx.systemPrompt.context({ name: 'optional', order: 1, text: () => { throw new Error('suppressed provider ran') } })
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
assembly.contexts.push({ name: 'unregistered', text: 'should stay suppressed' })
return next()
})
const assembly = await ctx.systemPrompt.assemble()
expect(renderContextSnapshot(assembly)).toContain('Directory: /project')
expect(assembly.contexts).toEqual([{ name: 'directory', text: 'Directory: /project' }])
expect(renderPrompt(assembly)).not.toContain('Directory: /project')
} finally {
await ctx.fiber.dispose()
}
})
it('preserves literal context in both the joined snapshot and attributed sections', async () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.variable('project', () => 'expanded')
const literal = '/workspace/{{project}}/{{unregistered}}/{{malformed-name}}'
ctx.systemPrompt.context({ name: 'literal', order: 0, text: literal, interpolate: false })
ctx.systemPrompt.context({ name: 'template', order: 1, text: '{{project}}', interpolate: true })
ctx.systemPrompt.context({ name: 'default', order: 2, text: '{{project}}' })
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.contexts).toEqual([
{ name: 'literal', text: literal, interpolate: false },
{ name: 'template', text: '{{project}}', interpolate: true },
{ name: 'default', text: '{{project}}' },
])
expect(renderContextSections(assembly)).toEqual([
{ name: 'literal', text: literal },
{ name: 'template', text: 'expanded' },
{ name: 'default', text: 'expanded' },
])
expect(renderContextSnapshot(assembly))
.toBe(`Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n${literal}\n\nexpanded\n\nexpanded`)
} finally {
await ctx.fiber.dispose()
}
})
it('keeps repository section placements unique, integral, and at least ten apart', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt, {})
+3 -1
View File
@@ -25,6 +25,8 @@ Use `dsh-tools` to expose typed capabilities to models, validate calls, enforce
<a id="use-this-package"></a>
## Use this package
Each `run_code` program captures the Session's current working directory when it starts. A nested directory change affects subsequent tool calls, while the running program keeps its initial directory.
Mount `dsh-tools` wherever agents call tools: it provides `ctx.tools`, the registry every tool plugin registers into and the loop dispatches through. Registering a tool is enough to make it visible — the registry feeds its schemas into the system-prompt assembly automatically.
### Register a tool
@@ -131,7 +133,7 @@ New sub-calls use `<parent>:ptc:<n>` ids. Consumers treat these ids as opaque an
Successful image-bearing subcall results become deferred user-message context with `source.kind` set to `ptc-mode`. Other additional contexts retain their producing tool's attribution.
`run_code` accepts `timeoutMs` when the mounted runtime supports an override; its schema reports the configured default and maximum, the runtime's usage instructions and the Session working directory. The Node default is 120,000 ms with a 600,000 ms cap, including nested tool and approval waits. A wider `sandbox_permissions` mode requires a non-empty `justification` and approval before the program starts. The schema asks the model to write that reason in the current user's language. The grant applies to that complete execution; standing Session policy and nested tools retain their own authority. Programs are never replayed automatically: inspect earlier effects before explicitly retrying a denied program.
`run_code` accepts `timeoutMs` when the mounted runtime supports an override; its schema reports the configured default and maximum, and the runtime's usage instructions. The Node default is 120,000 ms with a 600,000 ms cap, including nested tool and approval waits. A wider `sandbox_permissions` mode requires a non-empty `justification` and approval before the program starts. The schema asks the model to write that reason in the current user's language. The grant applies to that complete execution; standing Session policy and nested tools retain their own authority. Programs are never replayed automatically: inspect earlier effects before explicitly retrying a denied program.
<a id="extension-points"></a>
### Extension points
+3 -1
View File
@@ -25,6 +25,8 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
每个 `run_code` 程序启动时会固定使用 Session 当时的工作目录。嵌套目录切换会影响后续工具调用,而运行中的程序仍保留启动目录。
在任何 agent 调用工具的地方挂载 `dsh-tools`:它提供 `ctx.tools`,即每个工具插件注册进去、循环分发所经过的注册表。注册一个工具就足以让它可见——注册表会自动把其 schema 送入系统提示词组装。
### 注册工具
@@ -131,7 +133,7 @@ ctx.tools.register(defineTool({
成功且包含图片的子调用结果会成为延后的 user-message 上下文,其 `source.kind` 为 `ptc-mode`。其他 additional context 保留其生产工具的归属。
当已挂载运行时支持覆盖时,`run_code` 接受 `timeoutMs`;其 schema 报告配置的默认值和上限、运行时使用说明及 Session 工作目录。Node 默认值为 120,000 ms,上限为 600,000 ms,包含嵌套工具和审批等待。更宽的 `sandbox_permissions` 模式要求非空 `justification`,并在程序启动前获得审批。schema 提示模型使用当前用户的语言撰写理由。授权仅用于该次完整执行;常驻 Session 策略与嵌套工具保留各自权限。程序不会自动重放:显式重试被拒程序前,应检查先前已发生的效果。
当已挂载运行时支持覆盖时,`run_code` 接受 `timeoutMs`;其 schema 报告配置的默认值和上限以及运行时使用说明。Node 默认值为 120,000 ms,上限为 600,000 ms,包含嵌套工具和审批等待。更宽的 `sandbox_permissions` 模式要求非空 `justification`,并在程序启动前获得审批。schema 提示模型使用当前用户的语言撰写理由。授权仅用于该次完整执行;常驻 Session 策略与嵌套工具保留各自权限。程序不会自动重放:显式重试被拒程序前,应检查先前已发生的效果。
<a id="extension-points"></a>
### 扩展点
+5 -2
View File
@@ -45,7 +45,8 @@
"@deepseek-ai/dsh-system-prompt": "workspace:*",
"@deepseek-ai/dsh-user-approval": "workspace:*",
"@deepseek-ai/dsh-sandbox": "workspace:*",
"@deepseek-ai/dsh-sandbox-policy": "workspace:*"
"@deepseek-ai/dsh-sandbox-policy": "workspace:*",
"@deepseek-ai/dsh-working-directory": "workspace:*"
},
"dependencies": {
"@deepseek-ai/dsh-brand": "workspace:*",
@@ -63,6 +64,8 @@
"@deepseek-ai/dsh-system-prompt": "workspace:*",
"@deepseek-ai/dsh-user-approval": "workspace:*",
"@deepseek-ai/dsh-sandbox": "workspace:*",
"@deepseek-ai/dsh-sandbox-policy": "workspace:*"
"@deepseek-ai/dsh-sandbox-policy": "workspace:*",
"@deepseek-ai/dsh-working-directory": "workspace:*",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:*"
}
}
+7
View File
@@ -4,6 +4,7 @@
* @module @deepseek-ai/dsh-tools
*/
import type {} from '@deepseek-ai/dsh-working-directory'
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
@@ -943,6 +944,12 @@ export class ToolRuntime extends Service {
*/
private requirePtcTransport(): ToolDefinition {
this.ptcTransport ??= createRunCodeTool(this, {
resolveWorkingDirectory: async (exec) => {
if (exec.agent === undefined) return undefined
const directories = this.ctx.get('workingDirectory')
if (directories === undefined) throw new Error('dsh-tools: run_code with an Agent requires workingDirectory')
return directories.ensure(exec.agent, exec.signal)
},
requireRuntime: () => this.requirePtcRuntime(this.defaultMode),
peekApprover: () => this.ctx.get('approval'),
resolveSandboxPolicy: (exec) => {
+5 -2
View File
@@ -301,6 +301,8 @@ type RunCodeOutput = { logs: string[]; result?: JsonValue; sandbox?: PtcRunSandb
* off its public service API and flow here as closures instead.
*/
export interface RunCodeBridgeOptions {
/** Captures the calling Session current directory before program creation. */
resolveWorkingDirectory: (exec: ToolRunContext) => Promise<string | undefined>
/** Reads the approval channel when a program requests a wider sandbox mode. */
peekApprover: () => ApprovalService | undefined
/** Resolves standing Session authority only for a runtime that enforces file policy. */
@@ -404,6 +406,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
})
policy = { ...standingPolicy, mode: approvedMode }
}
const cwd = await options.resolveWorkingDirectory(exec)
exec.signal.throwIfAborted()
// The run-scoped abort: follows the outer signal in, and fires when the
@@ -703,7 +706,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
}],
signal: runController.signal,
...exec.agent?.session.header.cwd !== undefined ? { cwd: exec.agent.session.header.cwd } : {},
...cwd !== undefined ? { cwd } : {},
...policy !== undefined ? { sandboxPolicy: policy } : {},
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
}))
@@ -753,7 +756,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
const instructions = runtime?.executionInstructions
return resolveFlavor(peekRuntime).description
+ (instructions ? ` ${instructions}` : '')
+ (runtime === undefined ? '' : " The working directory is the Session's current directory.")
+ (runtime === undefined ? '' : " Each program starts in the Session's current directory. Running programs keep their initial directory.")
+ escalationGuidance(runtime)
},
})
@@ -39,7 +39,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
'str_replace_editor', 'subagent', 'team_task_create',
'team_task_get', 'team_task_list', 'team_task_update', 'terminal_close', 'terminal_list',
'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write',
'update_goal', 'wait_agent', 'web_fetch', 'web_search', 'workflow', 'write',
'update_goal', 'wait_agent', 'web_fetch', 'web_search', 'workflow', 'working_directory', 'write',
])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
@@ -47,6 +47,11 @@ describe('gen-tool-catalog collectToolCatalog', () => {
expect((schema.parameters as unknown as JsonSchema).type).toBe('object')
}
}
const directory = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-working-directory')
expect(directory?.sources.working_directory).toBe('packages/session/tool-working-directory/src/index.ts')
const parameters = directory?.schemas[0]?.parameters as unknown as JsonSchema
expect(parameters.properties?.cd?.type).toBe('string')
expect(parameters.required ?? []).not.toContain('cd')
})
it('resolves a runtime-spread enum to its literal members (the payoff over AST)', async () => {
+11 -2
View File
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
import { Context } from '@deepseek-ai/cordis'
import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
@@ -64,6 +65,7 @@ interface SetupOptions {
async function setup(options: SetupOptions = {}) {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(SystemPrompt, { ...options.toolOrder ? { toolOrder: options.toolOrder } : {} })
await ctx.plugin(ToolRuntime, { mode: options.mode ?? 'ptc', ...options.maxParallelSubCalls !== undefined ? { maxParallelSubCalls: options.maxParallelSubCalls } : {} })
let runtime: FakeRuntime | undefined
@@ -544,6 +546,7 @@ describe('mode-aware wire contribution', () => {
it('removes run_code and the SDK section when the registry fiber disposes (HMR safety)', async () => {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(SystemPrompt, {})
await ctx.plugin(FakeRuntime, {})
const fiber = await ctx.plugin(ToolRuntime, { mode: 'ptc' })
@@ -1582,6 +1585,7 @@ describe('the run_code dispatch bridge', () => {
it('executing run_code under a missing runtime is a structured isError, not a crash', async () => {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(SystemPrompt, {})
await ctx.plugin(ToolRuntime, { mode: 'ptc' })
const result = await runCode(ctx, 'program')
@@ -1934,6 +1938,7 @@ describe('the run_code dispatch bridge', () => {
it('direct construction rejects a non-positive parallel sub-call cap at load', async () => {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(SystemPrompt, {})
expect(() => new ToolRuntime(ctx, { mode: 'ptc', maxParallelSubCalls: 0 }))
.toThrow('maxParallelSubCalls must be a positive integer')
@@ -1941,6 +1946,7 @@ describe('the run_code dispatch bridge', () => {
it('direct construction in PTC mode defaults the parallel sub-call cap', async () => {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(SystemPrompt, {})
const registry = new ToolRuntime(ctx, { mode: 'ptc' })
expect(registry.get(RUN_CODE_NAME)).toBeDefined()
@@ -1948,6 +1954,7 @@ describe('the run_code dispatch bridge', () => {
it('defaults to native mode under direct construction with no config', async () => {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(SystemPrompt, {})
const registry = new ToolRuntime(ctx)
expect(registry.get(RUN_CODE_NAME)).toBeUndefined()
@@ -1956,6 +1963,7 @@ describe('the run_code dispatch bridge', () => {
})
it('denies a model-direct native-tool call under PTC mode as UNKNOWN_TOOL', async () => {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(SystemPrompt, {})
const registry = new ToolRuntime(ctx, { mode: 'ptc' })
registerEcho(ctx, 'write')
@@ -1976,6 +1984,7 @@ describe('the run_code dispatch bridge', () => {
it('routes a pre-aborted collapsed call through ABORTED_BEFORE_DISPATCH', async () => {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(SystemPrompt, {})
const registry = new ToolRuntime(ctx, { mode: 'ptc' })
registerEcho(ctx, 'write')
@@ -2243,7 +2252,7 @@ describe('per-program execution controls', () => {
expect(JSON.stringify(schema.parameters)).toContain('sandbox_permissions')
expect(schema.description).toContain('Nested tools retain their own policies')
expect(schema.description).toContain('Programs start with an empty environment.')
expect(schema.description).toContain("The working directory is the Session's current directory.")
expect(schema.description).toContain("Each program starts in the Session's current directory. Running programs keep their initial directory.")
} finally { await ctx.fiber.dispose() }
const python = await setup({ runtime: { language: 'python' } })
try {
@@ -2251,7 +2260,7 @@ describe('per-program execution controls', () => {
expect(JSON.stringify(schema.parameters)).not.toContain('timeoutMs')
expect(JSON.stringify(schema.parameters)).not.toContain('sandbox_permissions')
expect(schema.description).not.toContain('Programs start with an empty environment.')
expect(schema.description).toContain("The working directory is the Session's current directory.")
expect(schema.description).toContain("Each program starts in the Session's current directory. Running programs keep their initial directory.")
const rejected = await python.tools.execute({
callId: ToolCallId('hidden-timeout'), name: RUN_CODE_NAME, signal: testToolSignal,
arguments: { code: 'pass', description: 'Try unsupported timeout', timeoutMs: 5 },
+3
View File
@@ -43,6 +43,9 @@
},
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../session/working-directory"
}
]
}
+3 -1
View File
@@ -25,6 +25,8 @@ Use `present` to declare final files accessible through the Session filesystem,
<a id="use-this-package"></a>
## Use this package
`present` validates paths against the Session's current working directory and records absolute paths, so later directory changes do not redirect delivered files.
The `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must be regular files accessible through the Session filesystem. Relative paths resolve against the Session working directory; absolute paths may name files outside it, including `/tmp` or Downloads. Missing files, directories, final symbolic links, and provider-denied paths fail the call. Files in a shell sandbox’s private `/tmp` must first be written somewhere the Session filesystem can access.
Mount it in an agent's Cordis composition with `tools`, `fs`, and the `turnBoundary` Session projection available:
@@ -51,7 +53,7 @@ The file-count limit is validated at mount. The tool requires an agent Session w
The tool resolves paths through the configured filesystem provider and checks regular-file metadata without reading contents. Successful final `tools/result` notifications append `deliverables/presented`, including nested calls. A later enclosing program failure does not revoke an already completed declaration. Blocked results publish none. Each plugin instance records only calls it executed; scoped tools with the same name cannot publish through another instance.
The pure `./types` entry declares `PresentedFile` and the Session event without importing Host runtime code. The Web consumer validates persisted declarations before displaying or opening them. The event stores no Session ID, so forked history resolves relative paths against the viewed Session's workspace.
The pure `./types` entry declares `PresentedFile` and the Session event without importing Host runtime code. The Web consumer validates persisted declarations before displaying or opening them. New declarations store absolute paths. Legacy relative declarations retain the viewed Session's original workspace as their base.
</details>
@@ -25,6 +25,8 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
`present` 按 Session 的当前工作目录校验路径并记录绝对路径,因此后续目录切换不会让已交付文件指向其他位置。
`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须是 Session 文件系统可访问的普通文件。相对路径按 Session 工作目录解析;绝对路径可以指向工作区外的文件,包括 `/tmp` 或 Downloads。文件缺失、为目录、最终路径为符号链接或提供方拒绝访问时,调用失败。Shell 沙箱私有 `/tmp` 中的文件需要先写入 Session 文件系统可访问的位置。
在 Agent 的 Cordis 组合中挂载,并提供 `tools`、`fs` 和 `turnBoundary` Session 投影:
@@ -51,7 +53,7 @@ kind: "package-reference"
工具通过配置的文件系统提供方解析路径,检查普通文件元数据,不读取内容。成功的最终 `tools/result` 通知追加 `deliverables/presented`,嵌套调用也适用。外层程序随后失败不会撤销已完成的声明。被阻止的结果不发布声明。每个插件实例只记录其实际执行的调用;同名作用域工具不能通过其他实例发布交付。
纯 `./types` 入口声明 `PresentedFile` 与 Session 事件,不导入 Host 运行时代码。Web 消费方在展示或打开文件前校验持久声明。事件不保存 Session ID,因此 fork 历史中的相对路径按当前查看的 Session 工作区解析。
纯 `./types` 入口声明 `PresentedFile` 与 Session 事件,不导入 Host 运行时代码。Web 消费方在展示或打开文件前校验持久声明。新声明保存绝对路径。历史相对路径声明继续以当前查看的 Session 原始工作区作为基准。
</details>
@@ -41,7 +41,8 @@
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*"
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-working-directory": "workspace:*"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -55,6 +56,7 @@
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:*",
"@deepseek-ai/dsh-fs-local": "workspace:*",
"@deepseek-ai/dsh-system-prompt": "workspace:*",
"@deepseek-ai/dsh-scope": "workspace:*"
"@deepseek-ai/dsh-scope": "workspace:*",
"@deepseek-ai/dsh-working-directory": "workspace:*"
}
}
@@ -1,4 +1,5 @@
/** Scoped tool that declares filesystem deliveries in their owning Session. */
import type {} from '@deepseek-ai/dsh-working-directory'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { FsError } from '@deepseek-ai/dsh-fs'
@@ -23,7 +24,7 @@ export const Config: z<Config> = z.object({
})
/** Services used by the scoped delivery tool. */
export const inject = ['tools', 'fs', 'sessionProjections']
export const inject = ['tools', 'fs', 'sessionProjections', 'workingDirectory']
/**
* Register present with durable file references in its tool result.
@@ -78,8 +79,7 @@ export function apply(ctx: Context, config: Config): void {
const boundary = ctx.sessionProjections.stateOf(exec.agent.session, 'turnBoundary')
if (boundary === undefined || boundary.openTurnStartSeq === null) throw new Error('present requires an open turn')
if (args.files.length === 0 || args.files.length > config.maxFiles) throw new Error(`present accepts 1 to ${config.maxFiles} files`)
const cwd = exec.agent.session.header.cwd
if (cwd === undefined) throw new Error('present requires a workspace')
const cwd = await ctx.workingDirectory.ensure(exec.agent, exec.signal)
const options = { cwd, signal: exec.signal }
const files: PresentedFile[] = []
for (const file of args.files) {
@@ -90,7 +90,7 @@ export function apply(ctx: Context, config: Config): void {
const info = await ctx.fs.stat(target, exec.signal)
if (info === undefined) throw new FsError(`Cannot present ${file.path}: file not found. Check the path, create the file if needed, and retry.`, 'FS_NOT_FOUND')
if (info.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`)
files.push({ ...file })
files.push({ ...file, path: ctx.fs.processPath(target) })
}
exec.signal.throwIfAborted()
pending.set(exec, { session: exec.agent.session, turn: boundary.lastTurn, files })
@@ -22,6 +22,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
const root = await mkdtemp(join(tmpdir(), 'present-built-'))
const ctx = new Context()
ctx.provide('workingDirectory', { ensure: () => Promise.resolve(root) })
try {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
@@ -1,11 +1,11 @@
/** Explicit deliveries commit only after a successful final tool result. */
import { mkdtemp, rm, writeFile, symlink } from 'node:fs/promises'
import { provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
import { mkdtemp, rm, writeFile, symlink, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, relative } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
@@ -55,6 +55,7 @@ async function setup() {
const root = await mkdtemp(join(tmpdir(), 'dsh-present-minimal-'))
cleanups.push(() => rm(root, { recursive: true, force: true }))
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
cleanups.push(() => ctx.fiber.dispose())
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
@@ -85,7 +86,7 @@ describe('present', () => {
const files = (result.value as unknown as { files: PresentedFile[] }).files
expect(files).toHaveLength(1)
expect(owner.session.snapshotEvents().find(event => event.type === 'deliverables/presented')?.data.files).toEqual(files)
expect(files).toEqual([{ path: '报告.docx', description: 'Report' }])
expect(files).toEqual([{ path: await realpath(join(root, '报告.docx')), description: 'Report' }])
expect(read).not.toHaveBeenCalled()
expect(ctx.get('attachments')).toBeUndefined()
await fiber.dispose()
@@ -119,7 +120,7 @@ describe('present', () => {
expect((await execute([{ path: 'a' }])).isError).toBe(false)
const deliveries = owner.session.snapshotEvents().filter(event => event.type === 'deliverables/presented')
expect(deliveries).toHaveLength(1)
expect(deliveries[0]?.data.files[0]?.path).toBe('a')
expect(deliveries[0]?.data.files[0]?.path).toBe(await realpath(join(root, 'a')))
})
it('does not publish deliveries after post-execute blocks a successful declaration', async () => {
@@ -175,7 +176,7 @@ it('declares readable files outside the Session directory using absolute and rel
await writeFile(file, 'external report')
const files = [{ path: file }, { path: relative(root, file) }]
expect((await execute(files)).isError).toBe(false)
expect(owner.session.snapshotEvents().find(event => event.type === 'deliverables/presented')?.data.files).toEqual(files)
expect(owner.session.snapshotEvents().find(event => event.type === 'deliverables/presented')?.data.files).toEqual([{ path: await realpath(file) }, { path: await realpath(file) }])
})
it('refuses a final symlink to an ordinary file', async () => {
@@ -31,6 +31,9 @@
},
{
"path": "../../core/tools"
},
{
"path": "../../session/working-directory"
}
]
}
@@ -25,6 +25,8 @@ Add Auto review to the current-session permission pickers in a Web profile. Befo
<a id="use-this-package"></a>
## Use this package
The review environment records the Session's current working directory for the pending action.
### Install into a profile
From this source checkout, install the package into the Web profile through the existing CLI:
@@ -25,6 +25,8 @@ kind: "package-bundle"
<a id="use-this-package"></a>
## 使用本包
评审环境记录待执行操作对应的 Session 当前工作目录。
### 安装到 profile
从源码 checkout 通过既有 CLI 将包安装到 Web profile:
@@ -36,7 +36,8 @@
"@deepseek-ai/dsh-permission-presets": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/cordis": "workspace:~"
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-working-directory": "workspace:*"
},
"dependencies": {
"@deepseek-ai/dsh-util-values": "workspace:*"
@@ -70,7 +71,8 @@
"@deepseek-ai/dsh-tool-subagent": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-user-approval": "workspace:*",
"@deepseek-ai/cordis": "workspace:~"
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-working-directory": "workspace:*"
},
"publishConfig": {
"access": "public"
@@ -8,6 +8,7 @@
* @module @deepseek-ai/dsh-experimental-auto-review
*/
import type {} from '@deepseek-ai/dsh-working-directory'
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-instructions'
@@ -123,7 +124,7 @@ interface ScopedPtcStart {
/** Cordis plugin name used by loader diagnostics. */
export const name = 'experimental-auto-review'
/** Complete host services required before Auto may be advertised. */
export const inject = ['approval', 'llm', 'permissionPresets', 'sessions', 'tools']
export const inject = ['approval', 'llm', 'permissionPresets', 'sessions', 'tools', 'workingDirectory']
/** Return JSON text for one immutable logged value. */
function json(value: unknown): string {
@@ -358,7 +359,7 @@ function ptcAction(
* @param exec - immutable pending execution.
* @returns the exact route and four data sections paired with {@link REVIEW_POLICY}.
*/
function snapshotAutoReview(agent: Agent, exec: ToolExecution): ReviewSnapshot {
function snapshotAutoReview(agent: Agent, exec: ToolExecution, cwd: string): ReviewSnapshot {
const { session } = agent
// The reviewer's risk inputs are the whole action history: earlier native calls
// and PTC starts carry the authorizations and duplicate identities this call is
@@ -371,10 +372,6 @@ function snapshotAutoReview(agent: Agent, exec: ToolExecution): ReviewSnapshot {
if (header === undefined || header.config.provider.length === 0 || header.config.model.length === 0) {
throw new Error('auto-review: no complete request-header route is available')
}
const cwd = session.header.cwd
if (cwd === undefined || cwd.length === 0) {
throw new Error('auto-review: the session has no working directory')
}
const nativeCalls = events.filter((event): event is NativeCallEvent => event.type === 'tool/call')
const { starts, openStep: currentStep } = scopePtcStarts(events)
@@ -621,7 +618,8 @@ async function classifyRisk(
exec: ToolExecution,
signal: AbortSignal,
): Promise<AutoReviewDecision> {
const snapshot = snapshotAutoReview(agent, exec)
const cwd = await ctx.workingDirectory.ensure(agent, signal)
const snapshot = snapshotAutoReview(agent, exec, cwd)
// This review prompt is sent only through ctx.llm.stream and never enters a Session log.
const options: GenerateOptions = deepFreeze({
provider: snapshot.provider,
@@ -1,11 +1,11 @@
import { randomUUID } from 'node:crypto'
import { provideWorkingDirectoryFixture, mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import { NodePtcRuntime } from '@deepseek-ai/dsh-ptc-runtime-node'
import * as FsObservationPolicy from '@deepseek-ai/dsh-fs-observation-policy'
@@ -253,6 +253,7 @@ it('certifies eight Auto risk/authorization cases with zero retries and zero ski
if (REAL && !process.env.DEEPSEEK_API_KEY) throw new Error('Real Auto certification requires DEEPSEEK_API_KEY')
const root = await mkdtemp(join(tmpdir(), 'dsh-auto-review-'))
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
await chmod(root, 0o700)
const workspace = join(root, 'workspace')
@@ -360,6 +361,7 @@ it.each(['native', 'ptc-inner'] as const)('feeds denial back, re-reviews a new c
}, async (path) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-auto-review-recovery-'))
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
try {
const dshHome = join(root, 'home')
await mkdir(dshHome)
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-instructions'
@@ -27,7 +28,6 @@ import SessionStore, {
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SubagentRuntime, {
NO_START_CAPABILITIES,
resolveChildCwd,
snapshotSubagentDescriptor,
type ResolvedSubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
@@ -131,6 +131,7 @@ async function harness(
permissionConfig: NonNullable<Parameters<typeof PermissionPresetService.Config>[0]> = { presets: PRESETS, defaultPreset: 'workspace-write' },
): Promise<{ ctx: Context; adapter: RecordingAdapter; auto: PluginFiber }> {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
contexts.push(ctx)
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
@@ -1291,11 +1292,7 @@ describe('out-of-process delegation boundary', () => {
expect(timeline).toEqual(['review:deny', 'review:allow', 'provider:start'])
expect(adapter.requests).toHaveLength(2)
expect(providerRequest?.parent).toBe(agent)
expect(resolveChildCwd(
'remote-boundary',
undefined,
providerRequest?.parent.session.header.cwd,
)).toBe(process.cwd())
expect(providerRequest?.cwd).toBe(process.cwd())
expect(providerRequest?.agentOptions).toBeUndefined()
expect(providerRequest?.maxDepth).toBeUndefined()
expect(providerRequest?.persona).toBeUndefined()
@@ -1520,6 +1517,7 @@ describe('cancellation and integration teardown', () => {
it('publishes Auto without validating the preset table at load', async () => {
const invalid = new Context()
provideWorkingDirectoryFixture(invalid)
contexts.push(invalid)
await invalid.plugin(LlmRuntime)
await invalid.plugin(SessionStore)
@@ -1708,7 +1706,8 @@ describe('logged-fact failures', () => {
}), 'auto-review: the session has no working directory')
expect(probe.runs()).toBe(0)
expect(adapter.requests).toHaveLength(0)
expect(adapter.requests).toHaveLength(1)
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain(process.cwd())
})
it('fails closed for missing, ambiguous, or conflicting PTC facts', async () => {
@@ -43,6 +43,9 @@
},
{
"path": "../../util/values"
},
{
"path": "../../session/working-directory"
}
]
}
@@ -3042,7 +3042,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'suppressRuntimeContext(): () => void',
description: 'Suppress every dynamic runtime-context contribution in the calling context\'s scope without changing the services that own or enforce those facts. Multiple suppressors remain independently disposable.',
description: 'Suppress optional dynamic runtime-context contributions in the calling context\'s scope without changing the services that own or enforce those facts. Multiple suppressors remain independently disposable.',
parameters: [],
returns: 'the exact Cordis effect disposer.',
},
@@ -3587,6 +3587,38 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'workingDirectory',
summary: 'One owner for each Session\'s effective directory and its model-visible changes.',
description: 'One owner for each Session\'s effective directory and its model-visible changes.',
methods: [
{
signature: 'readonly defaultDirectory: string',
description: 'Absolute deployment fallback for Sessions with no recorded original directory.',
parameters: [],
},
{
signature: 'get(session: Session): string',
description: 'Read the committed directory without filesystem I/O.',
parameters: [{ name: 'session', description: 'Session whose directory is requested.' }],
returns: 'its effective absolute directory.',
},
{
signature: 'ensure(agent: Agent, signal?: AbortSignal): Promise<string>',
description: 'Validate the current directory and restore the original project when it disappeared.',
parameters: [{ name: 'agent', description: 'live or unpublished Agent owning the Session.' }, { name: 'signal', description: 'cancellation for filesystem inspection.' }],
returns: 'the existing directory; recovery is committed before fulfillment. A notice failure is warned without reverting the committed state.',
throws: ['when the original project is also unavailable.'],
},
{
signature: 'set(agent: Agent, path: string, signal?: AbortSignal): Promise<string>',
description: 'Change one Session\'s directory without changing existing processes or permissions.',
parameters: [{ name: 'agent', description: 'live or unpublished Agent owning the Session.' }, { name: 'path', description: 'absolute path or a path relative to its current directory.' }, { name: 'signal', description: 'cancellation before the durable change.' }],
returns: 'the canonical absolute directory, committed before fulfillment. A notice failure is warned; the next request still receives the committed directory.',
throws: ['when the requested path is not an existing directory.'],
},
],
},
{
key: 'workspaceChanges',
summary: 'Serves the summaries and file comparisons the recorder keeps for live Sessions.',
@@ -4633,7 +4665,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'AssembledContext',
declaration: 'export interface AssembledContext {\n name: string;\n text: string;\n}',
declaration: 'export interface AssembledContext {\n name: string;\n text: string;\n interpolate?: boolean;\n}',
},
{
name: 'AssembledSection',
@@ -4937,7 +4969,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ContinuableCreateRequest',
declaration: 'export interface ContinuableCreateRequest {\n readonly sessionId: SessionId;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n}',
declaration: 'export interface ContinuableCreateRequest {\n readonly cwd: string;\n readonly sessionId: SessionId;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n}',
},
{
name: 'ContinuableCreateSpec',
@@ -6205,7 +6237,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PromptContext',
declaration: 'export interface PromptContext {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}',
declaration: 'export interface PromptContext {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly interpolate?: boolean;\n readonly required?: boolean;\n}',
},
{
name: 'PromptContextOrderName',
@@ -6405,7 +6437,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ResolvedSubagentStartRequest',
declaration: 'export interface ResolvedSubagentStartRequest extends SubagentStartRequest {\n readonly descriptor: SubagentDescriptorData;\n}',
declaration: 'export interface ResolvedSubagentStartRequest extends SubagentStartRequest {\n readonly cwd: string;\n readonly descriptor: SubagentDescriptorData;\n}',
},
{
name: 'RestoredSessionOptions',
@@ -7473,7 +7505,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubagentRuntime',
declaration: 'export class SubagentRuntime extends TypertRemoteService {\n static Config;\n constructor(ctx: Context, private config: Config);\n resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined;\n async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart>;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise<MessageId>;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void>;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentCatalogEntry[]>;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt>;\n @Remote(\'interruptByParent\')\n interruptByParent(childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\'): SubagentInterruptReceipt;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>;\n}',
declaration: 'export class SubagentRuntime extends TypertRemoteService {\n static Config;\n static inject;\n constructor(ctx: Context, private config: Config);\n resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined;\n async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart>;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise<MessageId>;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void>;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentCatalogEntry[]>;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt>;\n @Remote(\'interruptByParent\')\n interruptByParent(childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\'): SubagentInterruptReceipt;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>;\n}',
},
{
name: 'SubagentSendMessageOptions',
@@ -7485,7 +7517,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubagentStartRequest',
declaration: 'export interface SubagentStartRequest {\n readonly label?: string;\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}',
declaration: 'export interface SubagentStartRequest {\n readonly cwd?: string;\n readonly label?: string;\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}',
},
{
name: 'SubagentStopReason',
Loaded 100 of 497 files, more files were not shown because too many files have changed in this diff. Show more