Merge remote-tracking branch 'origin/master' into codex/fix-deepseek-keepalive
# Conflicts: # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml # examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml # packages/llm/llm-deepseek/README.i18n.yaml # packages/util/timeout/README.i18n.yaml
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/util/README.md
|
||||
README.md: 46904aba70c7cf0f98bb75cce79d97bb12b950a9
|
||||
README.zh.md: 59a2dcf7926c12d7005446393cadfd8b0be88f77
|
||||
README.md: b84f2cbb88981aa2edbde65d8a711b28cc5b48e9
|
||||
README.zh.md: 3814dffe1eb8c68f6d38d39aa750c80ce325f9af
|
||||
|
||||
@@ -2,21 +2,13 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Zero-dependency primitives shared across the other groups. A package lands here when it owns a tiny, foundational type or helper that several capability families need but that belongs to none of them — keeping it out of any one group avoids a capability package depending on an unrelated one just to reach a shared primitive. These are **support** packages: small, stable, and free of harness dependencies.
|
||||
These zero-dependency packages provide small primitives shared by multiple capability families. Business semantics remain with each consuming capability.
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `brand/` | The type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) |
|
||||
| `paths/` | Canonical single-root `DSH_HOME` resolution plus shared filesystem path constants and helpers for harness user data (no harness deps) |
|
||||
| `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability |
|
||||
| `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool |
|
||||
| `atomic-write/` | Atomic file replacement — `writeFileAtomic` (exclusive-create temp + rename carrying the caller-stated mode); shared by the settings and credentials stores |
|
||||
| `native-command/` | No-shell `execFile` runner for host-native OS integrations — utf8 capture, abort propagation, Windows hide (no harness deps); command choice stays in each caller |
|
||||
|
||||
`dsh-brand` is the canonical case: it owns ONLY the `Branded<B>` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`.
|
||||
|
||||
`dsh-paths` gives every package the same configurable Harness home without assigning that cross-cutting fact to bash, skills, telemetry, or a composition bundle. It resolves an explicit value before `$DSH_HOME`, falls back to `~/.dsh`, and returns an absolute path without caching, creating, or mutating anything. The harness keeps all user data under one root.
|
||||
|
||||
`dsh-timeout` follows the same shape for the timeout family: `dsh-bash` and `dsh-web-fetch-local` each fuse a caller's cancellation with a deadline and later classify "timed out" vs "cancelled" by depending on `dsh-timeout` alone. It deliberately owns only the timing/classification half — the *termination* (SIGKILL a process group, tear down a fetch socket) stays in each capability, because no shared layer can own every capability's kill (see [the timeout-library Agent Note](../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
|
||||
|
||||
`dsh-retention` is the same split for bounded tool output: a tool (`glob`/`grep`/`bash`/`web_fetch`/`web_search`) feeds items or text into a retainer and gets back what it kept and exactly what it omitted — while grouping, exit codes, provider errors, and recovery prose stay tool-owned. It deliberately owns only the retention mechanic; `truncated` is a budget fact, never an "incomplete inspection" state (see [the retention-library Agent Note](../../.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md)).
|
||||
| [`brand/`](brand/README.md) | Provides nominally branded types |
|
||||
| [`paths/`](paths/README.md) | Resolves the Harness data root and shared paths |
|
||||
| [`timeout/`](timeout/README.md) | Provides deadline and timeout classification primitives |
|
||||
| [`retention/`](retention/README.md) | Bounds retained text and item collections |
|
||||
| [`atomic-write/`](atomic-write/README.md) | Replaces files atomically |
|
||||
| [`native-command/`](native-command/README.md) | Runs host-native commands without a shell |
|
||||
|
||||
@@ -2,21 +2,13 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
其他分组共享的零依赖原语。当某个微小的基础类型或辅助工具被多个功能家族所需,但又不属于任何一个家族时,它就位于此处。这样可避免一个功能包仅为使用共享原语而依赖不相关的功能包。这些都是**支持** 包:规模小、稳定,且不依赖 harness。
|
||||
这些零依赖包提供由多个能力家族共享的小型原语。业务语义仍归各个消费这些原语的能力所有。
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| `brand/` | 仅包含类型的 `Branded<B>` 名义类型原语(无运行时代码,无 harness 依赖) |
|
||||
| `paths/` | 规范的单根 `DSH_HOME` 解析,以及 harness 用户数据的共享文件系统路径常量和辅助工具(无 harness 依赖) |
|
||||
| `timeout/` | 超时的时序/分类部分:`clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason`(纯函数,无 harness 依赖);终止机制保留在各个功能中 |
|
||||
| `retention/` | 有界的面向模型输出:`ItemRetainer`/`TextRetainer` 加上中性通知辅助工具(纯工具,无 harness 依赖);业务语义保留在各个工具中 |
|
||||
| `atomic-write/` | 原子文件替换:`writeFileAtomic`(独占创建临时文件 + 携带调用方所声明 mode 的 rename);由设置与凭据存储共用 |
|
||||
| `native-command/` | 宿主原生 OS 集成的免 shell `execFile` 运行器——utf8 捕获、abort 传播、Windows 窗口隐藏(无 harness 依赖);命令选择保留在各调用方 |
|
||||
|
||||
`dsh-brand` 是规范示例:它只负责 `Branded<B>` 辅助工具,因此功能包可以为自己拥有的 id 添加品牌(`dsh-tasks` 的 `TaskId`、`dsh-session` 的 `SessionId` 等),而只需依赖 `dsh-brand`,无需仅为使用 `Branded` 而引入不相关的包。
|
||||
|
||||
`dsh-paths` 为每个包提供同一个可配置的 Harness 主目录,而不将这项横切事实归属给 bash、skill、telemetry 或组合 bundle。它优先解析显式值,其次是 `$DSH_HOME`,最后回退到 `~/.dsh`;返回绝对路径,但不缓存、创建或修改任何内容。harness 将所有用户数据保存在同一根目录下。
|
||||
|
||||
`dsh-timeout` 对超时家族采用相同结构:`dsh-bash` 和 `dsh-web-fetch-local` 都只依赖 `dsh-timeout`,便可将调用方的取消与 deadline 融合,然后区分「已超时」和「已取消」。它刻意只负责时序/分类部分,*终止*机制(对进程组发送 SIGKILL、拆除 fetch socket)保留在各个功能中,因为没有任何共享层可以负责每个功能的终止操作(见[超时库 Agent Note](../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
|
||||
|
||||
`dsh-retention` 对有界工具输出采用同样的拆分方式:工具(`glob`/`grep`/`bash`/`web_fetch`/`web_search`)将项或文本送入 retainer,取回保留的内容以及被省略的精确内容;分组、退出码、提供方错误和恢复文案则仍由工具负责。它刻意只负责保留机制;`truncated` 是预算事实,绝不表示「检查不完整」状态(见[保留库 Agent Note](../../.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md))。
|
||||
| [`brand/`](brand/README.md) | 提供带名义品牌的类型 |
|
||||
| [`paths/`](paths/README.md) | 解析 Harness 数据根目录和共享路径 |
|
||||
| [`timeout/`](timeout/README.md) | 提供截止时间和超时分类原语 |
|
||||
| [`retention/`](retention/README.md) | 限制保留文本和项集合的大小 |
|
||||
| [`atomic-write/`](atomic-write/README.md) | 以原子方式替换文件 |
|
||||
| [`native-command/`](native-command/README.md) | 不经 shell 运行宿主原生命令 |
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/util/atomic-write/README.md
|
||||
README.md: 2ff4abb6ac10d8b592ccd2056b4f1f92cc8518b0
|
||||
README.zh.md: bd5d3f1f2583ff1b97a80ce2c1c6ca7989e13d15
|
||||
README.zh.md: 570a5243aefbe6870c1991786f711adc77faacae
|
||||
|
||||
@@ -20,7 +20,7 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => {
|
||||
})
|
||||
```
|
||||
|
||||
`writeFileAtomic` 提交一份已经渲染好的字符串。契约按故障利用它的先后顺序列出:
|
||||
`writeFileAtomic` 提交一份已经渲染好的字符串。约定按故障利用它的先后顺序列出:
|
||||
|
||||
- **独占创建临时文件**(`wx` + 随机后缀):open 拒绝跟随预先埋在可猜测临时路径上的符号链接。
|
||||
- **全新 inode 携带 `mode` 走完 rename**:替换权限过宽的旧文件时直接收窄,不存在 chmod 竞态。`mode` 为必填,让权限决策始终可见于每个调用点(与所有新建 inode 一样受进程 umask 影响)。
|
||||
@@ -30,15 +30,15 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => {
|
||||
|
||||
`withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `<filename>.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。竞争者绝不移除现有锁:锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方。
|
||||
|
||||
## Model Experience
|
||||
## 模型体验
|
||||
|
||||
无:本包是纯文件系统原语,此处没有任何内容会到达模型请求。
|
||||
|
||||
#### KV Cache effect
|
||||
#### KV Cache 影响
|
||||
|
||||
无;此处没有任何内容会进入请求前缀。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **原子但不保证持久**——不对文件或其所在目录做 `fsync`,因此崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,把持久性留作调用方的策略。
|
||||
- **仅支持字符串内容**——在有消费方需要之前,不提供 `Buffer` 或流式形态。
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/util/brand/README.md
|
||||
README.md: 68401d95a31ed2122386794ad5256a5cd93bb2a6
|
||||
README.zh.md: 98fcad0487af3ba1c2aaada4716744fb6088cdfe
|
||||
README.zh.md: 0eeb2c8afb5fe1b279f49c839e769b8d0eea0a5a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`Branded<B>` 名义类型原语:一个微小的**仅类型**包(package),无运行时代码,也不依赖其他 harness 包;所有负责跨边界 id 的包都会共享它。
|
||||
`Branded<B>` 名义类型原语:一个微小的**仅类型**包,无运行时代码,也不依赖其他 harness 包;所有负责跨边界 id 的包都会共享它。
|
||||
|
||||
## `Branded` 是什么
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
6
packages/util/environment/README.i18n.yaml
Normal file
6
packages/util/environment/README.i18n.yaml
Normal file
@@ -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 packages/util/environment/README.md
|
||||
README.md: 599a9ba747905b66452b57717dabcc6f4678a3dd
|
||||
README.zh.md: ca1da309fda28913ee7b90953d50fc0a7172ca87
|
||||
36
packages/util/environment/README.md
Normal file
36
packages/util/environment/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# dsh-environment
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This run's environment as one immutable snapshot that remembers **which layer supplied each value**. Consumers resolve user-facing values against it instead of `process.env`, because the layers are not equally trusted and a flattened view cannot tell them apart.
|
||||
|
||||
| Layer | Source id | What it is |
|
||||
|---|---|---|
|
||||
| Inherited process environment | `process` | What the launching shell, CI job, or container passed in — this run's explicit intent |
|
||||
| `<invocation cwd>/.env` | `project-env` | The project the harness was launched in, which the product trusts to configure its own agent |
|
||||
| `$DSH_HOME/.env` | `user-env` | The user's own machine-level defaults |
|
||||
|
||||
Values do also reach `process.env` — a user's `--config` tree and third-party libraries read it — but that flattened view is not the authority for anything the harness resolves.
|
||||
|
||||
## Resolving
|
||||
|
||||
`get(name)` searches every layer, most trusted first. `getFrom(name, sources)` searches only the named layers without changing that trust order.
|
||||
|
||||
**Omitting a layer is a refusal, not a demotion** — a caller that must never accept a layer leaves it out of the list, so no future reordering can let it back in. The provider adapters name all three, because the product trusts the project it runs in; the mechanism exists for the decisions where that is not true.
|
||||
|
||||
Names match the way the platform matches them: exactly on POSIX, case-insensitively on Windows. A case-sensitive lookup there would rank the wrong layer — a shell's `deepseek_api_key` and a project `.env`'s `DEEPSEEK_API_KEY` are one variable to the OS, and treating them as two would let the project win.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { environmentOf } from '@deepseek-ai/dsh-environment'
|
||||
|
||||
declare const ctx: Context
|
||||
const endpoint = environmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value
|
||||
```
|
||||
|
||||
`environmentOf(ctx)` returns the launcher's snapshot when the product CLI booted the tree, and otherwise the inherited environment as the only layer. That fallback does not weaken the rules: an SDK host or a bare `cordis.yml` discovered no files, so everything it has really is the environment it was launched with.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The snapshot is not a subprocess boundary** — every layer is also materialized into `process.env`, so ordinary project variables reach child processes under [`dsh-subprocess`](../../subprocess/subprocess/README.md)'s scrub. The product launcher's [`.env` contract](../../boot/app-boot/README.md#profiles) rejects bootstrap variables before materialization.
|
||||
- **No per-workspace layer** — the project layer is the *invoking* directory, fixed at launch. A workspace selected later in the Web UI contributes nothing, deliberately: following it would let a model's own workspace change the harness environment mid-session.
|
||||
36
packages/util/environment/README.zh.md
Normal file
36
packages/util/environment/README.zh.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# dsh-environment
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
把本次运行的环境冻结为一份不可变快照,并记住**每个值来自哪一层**。消费方用它而不是 `process.env` 解析面向用户的值,因为各层的可信程度并不相同,而压平后的视图无法区分它们。
|
||||
|
||||
| 层 | 来源 id | 它是什么 |
|
||||
|---|---|---|
|
||||
| 继承的进程环境 | `process` | 启动 shell、CI 任务或容器传入的东西——本次运行的明确意图 |
|
||||
| `<invocation cwd>/.env` | `project-env` | harness 被启动于其中的项目;产品信任它配置自己的 agent |
|
||||
| `$DSH_HOME/.env` | `user-env` | 用户自己的机器级默认值 |
|
||||
|
||||
这些值同样会进入 `process.env`——用户自己的 `--config` 树和第三方库要读它——但那份压平的视图不是 harness 解析任何值的依据。
|
||||
|
||||
## 解析
|
||||
|
||||
`get(name)` 按可信度从高到低搜索所有层。`getFrom(name, sources)` 只搜索指定的层,不改变这一可信顺序。
|
||||
|
||||
**省略某一层是拒绝,不是降级**——绝不能接受某一层的调用方直接不把它列进去,后续任何重新排序都无法让它回来。provider 适配器三层全列,因为产品信任它所运行的项目;该机制是为那些「并非如此」的决策准备的。
|
||||
|
||||
变量名按平台自身的规则匹配:POSIX 上精确匹配,Windows 上不区分大小写。在 Windows 上做大小写敏感的查找会选错层——shell 里的 `deepseek_api_key` 与项目 `.env` 里的 `DEEPSEEK_API_KEY` 对操作系统而言是同一个变量,把它们当成两个就会让项目胜出。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { environmentOf } from '@deepseek-ai/dsh-environment'
|
||||
|
||||
declare const ctx: Context
|
||||
const endpoint = environmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value
|
||||
```
|
||||
|
||||
当产品 CLI(命令行界面)启动了这棵树时,`environmentOf(ctx)` 返回启动器的快照;否则返回只含继承环境的那一层。该回退并不削弱规则:SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切确实就是它被启动时的环境。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **快照不是子进程边界**:每一层同样会被物化进 `process.env`,因此项目里的普通变量会按 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 的清洗规则抵达子进程。产品启动器的 [`.env` 约定](../../boot/app-boot/README.md#profiles) 会在物化之前拒绝 bootstrap 变量。
|
||||
- **没有按工作区划分的层**:项目层是*调用*目录,在启动时固定。之后在 Web UI 中选择的工作区不贡献任何内容,这是刻意的:跟随它等于让模型自己的工作区在会话中途改变 harness 的环境。
|
||||
35
packages/util/environment/package.json
Normal file
35
packages/util/environment/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-environment",
|
||||
"description": "Immutable launch-time environment snapshot with per-layer provenance for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
124
packages/util/environment/src/index.ts
Normal file
124
packages/util/environment/src/index.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Immutable launch-time environment snapshot with per-value source
|
||||
* provenance. Harness consumers resolve through it instead of a flattened
|
||||
* `process.env`; launchers may still materialize accepted values for config
|
||||
* expressions and third-party libraries.
|
||||
* @module @deepseek-ai/dsh-environment
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
/**
|
||||
* Which layer supplied a value, from most to least trusted: the environment
|
||||
* this process inherited, the invoking directory's `.env`, the Harness home's
|
||||
* `.env`.
|
||||
*/
|
||||
export type EnvironmentSource = 'process' | 'project-env' | 'user-env'
|
||||
|
||||
/** Layer order, most trusted first. */
|
||||
const SOURCE_ORDER: readonly EnvironmentSource[] = ['process', 'project-env', 'user-env']
|
||||
|
||||
/** One resolved variable and the layer it came from. */
|
||||
export interface EnvironmentEntry {
|
||||
/** The value as the layer supplied it; may be empty, which each owner judges for itself. */
|
||||
value: string
|
||||
/** The layer that supplied it. */
|
||||
source: EnvironmentSource
|
||||
/** Absolute path of the file that supplied it; absent for `process`. */
|
||||
path?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen environment of one launch. Construct through
|
||||
* {@link createEnvironmentSnapshot}; nothing mutates it afterwards, so a
|
||||
* later `chdir`, workspace switch, or resumed session observes the same
|
||||
* values a consumer resolved at boot.
|
||||
*/
|
||||
export interface EnvironmentSnapshot {
|
||||
/**
|
||||
* Resolve one name across every layer, most trusted first.
|
||||
* @param name - the variable name.
|
||||
* @returns the winning entry, or `undefined` when no layer supplies it.
|
||||
*/
|
||||
get(name: string): EnvironmentEntry | undefined
|
||||
/**
|
||||
* Resolve one name only from `sources`, retaining canonical trust order;
|
||||
* omitted layers are unreachable.
|
||||
* @param name - the variable name.
|
||||
* @param sources - the layers allowed in the canonical trust order.
|
||||
* @returns the first matching entry, or `undefined`.
|
||||
*/
|
||||
getFrom(name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The map key one variable name resolves under. Windows treats environment
|
||||
* names case-insensitively; every other platform does not.
|
||||
* @param name - the variable name as written.
|
||||
* @returns the key to store and look up by.
|
||||
*/
|
||||
function lookupKey(name: string): string {
|
||||
/* v8 ignore next -- native Windows coverage exercises the folding arm; POSIX covers the exact one */
|
||||
return process.platform === 'win32' ? name.toUpperCase() : name
|
||||
}
|
||||
|
||||
/** One layer's raw contents, as {@link createEnvironmentSnapshot} receives them. */
|
||||
export interface EnvironmentLayerInput {
|
||||
source: EnvironmentSource
|
||||
/** Absolute path of the file behind this layer; omit for `process`. */
|
||||
path?: string
|
||||
values: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the snapshot from each layer's contents.
|
||||
* @param layers - the layers in any order; the result searches them by canonical trust order.
|
||||
* @returns the immutable snapshot.
|
||||
*/
|
||||
export function createEnvironmentSnapshot(layers: readonly EnvironmentLayerInput[]): EnvironmentSnapshot {
|
||||
// Copy every layer so later mutations cannot change the snapshot. Fold names
|
||||
// on Windows so case variants cannot split precedence; POSIX remains exact.
|
||||
const bySource = new Map<EnvironmentSource, { path?: string; values: Map<string, string> }>()
|
||||
for (const layer of layers) {
|
||||
bySource.set(layer.source, {
|
||||
...layer.path === undefined ? {} : { path: layer.path },
|
||||
values: new Map(Object.entries(layer.values).map(([name, value]) => [lookupKey(name), value])),
|
||||
})
|
||||
}
|
||||
const getFrom = (name: string, sources: readonly EnvironmentSource[]): EnvironmentEntry | undefined => {
|
||||
const key = lookupKey(name)
|
||||
for (const source of SOURCE_ORDER) {
|
||||
if (!sources.includes(source)) continue
|
||||
const layer = bySource.get(source)
|
||||
const value = layer?.values.get(key)
|
||||
if (value === undefined) continue
|
||||
return { value, source, ...layer?.path === undefined ? {} : { path: layer.path } }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
get: name => getFrom(name, SOURCE_ORDER),
|
||||
getFrom,
|
||||
}
|
||||
}
|
||||
|
||||
/** Context slot the launcher fills with this run's snapshot before any config entry mounts. */
|
||||
export const DSH_ENVIRONMENT_KEY = 'launcherEnvironment'
|
||||
|
||||
/**
|
||||
* Return the launcher's snapshot, or the inherited environment as the sole
|
||||
* layer when the host provided none.
|
||||
* @param ctx - the consuming plugin's context.
|
||||
* @returns the snapshot to resolve user-facing values against.
|
||||
*/
|
||||
export function environmentOf(ctx: Context): EnvironmentSnapshot {
|
||||
return ctx.get(DSH_ENVIRONMENT_KEY)
|
||||
?? createEnvironmentSnapshot([{ source: 'process', values: process.env as Record<string, string> }])
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Launcher-owned snapshot of this run's environment; absent in compositions the product CLI did not boot. */
|
||||
launcherEnvironment?: EnvironmentSnapshot
|
||||
}
|
||||
}
|
||||
30
packages/util/environment/src/invariant.ts
Normal file
30
packages/util/environment/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-environment`.
|
||||
* @module @deepseek-ai/dsh-environment/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-environment'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'environment-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the snapshot is frozen before any fiber starts and this package owns no
|
||||
* event stream or mutable runtime data; its lookup and rejection rules are enforced by unit tests.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
69
packages/util/environment/tests/environment.spec.ts
Normal file
69
packages/util/environment/tests/environment.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import {
|
||||
createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, environmentOf,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const layered = createEnvironmentSnapshot([
|
||||
{ source: 'process', values: { SHARED: 'from-process', ONLY_PROCESS: 'p' } },
|
||||
{ source: 'project-env', path: '/work/.env', values: { SHARED: 'from-project', ONLY_PROJECT: 'j' } },
|
||||
{ source: 'user-env', path: '/home/.dsh/.env', values: { SHARED: 'from-user', ONLY_USER: 'u' } },
|
||||
])
|
||||
|
||||
describe('createEnvironmentSnapshot', () => {
|
||||
it('resolves across every layer, most trusted first, and reports the winning source', () => {
|
||||
expect(layered.get('SHARED')).toEqual({ value: 'from-process', source: 'process' })
|
||||
expect(layered.get('ONLY_PROJECT')).toEqual({ value: 'j', source: 'project-env', path: '/work/.env' })
|
||||
expect(layered.get('ONLY_USER')).toEqual({ value: 'u', source: 'user-env', path: '/home/.dsh/.env' })
|
||||
expect(layered.get('ABSENT')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('filters layers without changing their trust order', () => {
|
||||
// The point of getFrom: a routing field that must never come from a
|
||||
// project directory cannot be reached by reordering, only by listing it.
|
||||
expect(layered.getFrom('ONLY_PROJECT', ['process', 'user-env'])).toBeUndefined()
|
||||
expect(layered.getFrom('SHARED', ['user-env', 'process']))
|
||||
.toEqual({ value: 'from-process', source: 'process' })
|
||||
expect(layered.getFrom('SHARED', [])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('copies each layer, so a later mutation of the source object cannot change it', () => {
|
||||
const values: Record<string, string> = { KEY: 'first' }
|
||||
const snapshot = createEnvironmentSnapshot([{ source: 'process', values }])
|
||||
values.KEY = 'second'
|
||||
values.LATE = 'added'
|
||||
expect(snapshot.get('KEY')).toEqual({ value: 'first', source: 'process' })
|
||||
expect(snapshot.get('LATE')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps an empty value as a present value, for its owner to judge', () => {
|
||||
const snapshot = createEnvironmentSnapshot([{ source: 'process', values: { EMPTY: '' } }])
|
||||
expect(snapshot.get('EMPTY')).toEqual({ value: '', source: 'process' })
|
||||
})
|
||||
|
||||
it('orders lookups canonically regardless of construction order', () => {
|
||||
const reversed = createEnvironmentSnapshot([
|
||||
{ source: 'user-env', path: '/u', values: { K: 'u' } },
|
||||
{ source: 'process', values: { K: 'p' } },
|
||||
])
|
||||
expect(reversed.get('K')).toEqual({ value: 'p', source: 'process' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('environmentOf', () => {
|
||||
it('returns the launcher snapshot when the product CLI provided one', () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide(DSH_ENVIRONMENT_KEY, layered)
|
||||
expect(environmentOf(ctx)).toBe(layered)
|
||||
})
|
||||
|
||||
it('falls back to the inherited environment as the only layer', () => {
|
||||
vi.stubEnv('DSH_ENV_SPEC_FALLBACK', 'ambient')
|
||||
try {
|
||||
const snapshot = environmentOf(new Context())
|
||||
expect(snapshot.get('DSH_ENV_SPEC_FALLBACK')).toEqual({ value: 'ambient', source: 'process' })
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
})
|
||||
15
packages/util/environment/tsconfig.json
Normal file
15
packages/util/environment/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/util/native-command/README.md
|
||||
README.md: 7fc8b1f4640ef87ada62b6656854feb37080e4e6
|
||||
README.zh.md: 4bc66c4047194de03f236fa7591dad88e5c3fb57
|
||||
README.md: dd9d1ddaf817b2ff77ec0cd01710053e918b4296
|
||||
README.zh.md: 3ff8a392f2c511295adb0bf5f62b6e0527cbaea6
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
A **zero-dependency no-shell `execFile` runner** shared by host-native OS integrations: one `runNativeCommand(command, args, signal)` call spawns the executable directly (never a shell string), captures utf8 stdout/stderr, propagates the caller's abort into child termination, and hides the transient console window on Windows. Failures reject with the exit `code` and both captured streams attached, so callers classify (missing tool, cancelled, real failure) without re-running anything.
|
||||
|
||||
Its two consumers are the host-side native integrations: the [`directory-picker-native`](../../host/directory-picker-native/README.md) backend's OS chooser commands and the gateway's open-with-default-application hand-off ([`dsh-host-apiproxy`](../../host/apiproxy/README.md) `host.openPath`). The `NativeCommandRunner` type is the injectable command boundary those callers expose for deterministic tests.
|
||||
Its two consumers are the host-side native integrations: the [`directory-picker-native`](../../host/directory-picker-native/README.md) backend's OS chooser commands and the gateway's open-with-default-application hand-off ([`dsh-host-apiproxy`](../../host/apiproxy/README.md) `host.openPath`). The `NativeCommandRunner` type is their injectable command boundary.
|
||||
|
||||
It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds no state, emits no events.
|
||||
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
宿主原生 OS 集成共享的**零依赖免 shell `execFile` 运行器**:一次 `runNativeCommand(command, args, signal)` 调用直接派生可执行文件(绝不拼 shell 字符串),以 utf8 捕获 stdout/stderr,把调用方的 abort 传播为子进程终止,并在 Windows 上隐藏瞬时控制台窗口。失败时以附带退出 `code` 与两路已捕获输出的错误拒绝,调用方无需重跑即可分类(工具缺失、已取消、真实失败)。
|
||||
宿主原生 OS 集成共享的**零依赖免 shell `execFile` 运行器**:一次 `runNativeCommand(command, args, signal)` 调用直接 spawn 可执行文件(绝不拼 shell 字符串),以 utf8 捕获 stdout/stderr,把调用方的 abort 传播为子进程终止,并在 Windows 上隐藏瞬时控制台窗口。失败时,调用会以错误拒绝;该错误附带退出 `code` 与两路已捕获输出,调用方无需重跑即可分类(工具缺失、已取消、真实失败)。
|
||||
|
||||
它的两个消费者都是宿主侧原生集成:[`directory-picker-native`](../../host/directory-picker-native/README.md) 后端的 OS 选择器命令,以及网关的按默认应用打开转交([`dsh-host-apiproxy`](../../host/apiproxy/README.md) 的 `host.openPath`)。`NativeCommandRunner` 类型是这些调用方为确定性测试暴露的可注入命令边界。
|
||||
它的两个消费方都是宿主侧原生集成:[`directory-picker-native`](../../host/directory-picker-native/README.md) 后端的 OS 选择器命令,以及网关将路径交由默认应用打开的操作([`dsh-host-apiproxy`](../../host/apiproxy/README.md) 的 `host.openPath`)。`NativeCommandRunner` 类型是这些调用方的可注入命令边界。
|
||||
|
||||
它是**库,不是服务或插件**:没有 `ctx`、不注册任何东西、不持有状态、不发事件。
|
||||
|
||||
## Surface
|
||||
## 接口面
|
||||
|
||||
```ts
|
||||
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
|
||||
```
|
||||
|
||||
## Model Experience
|
||||
## 模型体验
|
||||
|
||||
无;这是宿主侧子进程管道,这里没有任何东西进入模型请求。
|
||||
|
||||
#### KV Cache effect
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送 provider 请求。
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **不做输出限量**——两路流在内存中无界缓冲;当前每个调用方只运行输出为一个路径或一行错误的小型原生工具。把它指向输出量可观的命令之前,先接入 `dsh-retention` 限量。
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/util/paths/README.md
|
||||
README.md: 2b3272e019ef2f37386da9156b06a5c151836d8c
|
||||
README.zh.md: 75cc22581e0e0c5ab18573eb0785251243009121
|
||||
README.zh.md: 7fe0ec04117ae439ade653cefd1c8f5da094d8fd
|
||||
|
||||
@@ -18,7 +18,7 @@ DeepSeek Harness 用户数据的共享文件系统路径辅助工具。
|
||||
|
||||
`expandHomePath()` 使用操作系统主目录展开 `~`、`~/...` 和 Windows 风格的 `~\...` 前缀。它会保留非波浪号路径和 `~user/...` 原样不变。
|
||||
|
||||
该包(package)刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。
|
||||
该包刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/util/retention/README.md
|
||||
README.md: d257075a67b35e92bce53a88fc6d002f4f4d5d9b
|
||||
README.zh.md: c97c1d5afe504c54851f35b86d7bb893b32bcea4
|
||||
README.md: 45c88e9fc2d8df98f935fd1c3b43c4622802bd2a
|
||||
README.zh.md: 9419245908d7331c9db24019f1181fcaed848060
|
||||
|
||||
@@ -47,7 +47,7 @@ Text caps and `omittedBytes` count **bytes**, for process/body safety (a child's
|
||||
|
||||
## Tool mappings
|
||||
|
||||
Every current retention consumer maps to the library below. A broad migration is out of scope for the library's first landing — these are the intended shapes.
|
||||
Current retention consumers use these mappings:
|
||||
|
||||
| Tool | Retainer & strategy | Notes |
|
||||
|---|---|---|
|
||||
@@ -57,7 +57,7 @@ Every current retention consumer maps to the library below. A broad migration is
|
||||
| `web_fetch` | `TextRetainer`, `head` or `headTail` | Provider/resource caps stay provider facts; the retainer supplies only retained text and omission metadata. |
|
||||
| `web_search` | `ItemRetainer<WebSearchSource>`, `head` | Standardizes the "sources capped" notice when providers return more sources than the model-facing result should include. |
|
||||
|
||||
`read` is **intentionally out of scope for v1.** Its `read-render` helper owns a file-specific pagination contract — `offset`/`limit`, line numbers, `totalLines`, offset-out-of-range errors, per-line preview truncation, a byte cap over the selected window — which is a line-window renderer, not generic retention. A single `Omitted` count cannot represent both sides of a line window.
|
||||
`read` remains outside this generic library. Its `read-render` helper owns a file-specific pagination contract — `offset`/`limit`, line numbers, `totalLines`, offset-out-of-range errors, per-line preview truncation, and a byte cap over the selected window — which is a line-window renderer. A single `Omitted` count cannot represent both sides of that window.
|
||||
|
||||
## Usage shape
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
一个轻依赖的**保留**库:为必须限制返回上下文量的工具提供有界的面向模型输出。调用方将项或文本分片送入有界对象,然后取回保留的内容和精确的省略元数据。
|
||||
|
||||
该库**只**负责这个机制问题:*「我们保留了什么,又省略了什么?」*。工具专用代码保留其业务语义:文件分组、行号、退出码、提供方错误状态、每行预览截断、spill 文件以及面向模型的文案。这就是 [Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md) 划定的边界。
|
||||
该库**只**负责这个机制问题:*「我们保留了什么,又省略了什么?」*。工具专用代码保留其业务语义:文件分组、行号、退出码、提供方错误状态、每行预览截断、spill 文件以及面向模型的文案。这就是 [Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md) 划定的边界。
|
||||
|
||||
它是**库,而非服务或插件**:没有 `ctx`,不注册任何内容,不发出任何事件。状态只存在于每个 retainer(一次累积)中,绝不跨调用。工具包(package)直接导入它。
|
||||
它是**库,而非服务或插件**:没有 `ctx`,不注册任何内容,不发出任何事件。状态只存在于每个 retainer(一次累积)中,绝不跨调用。工具包直接导入它。
|
||||
|
||||
## 对外接口
|
||||
|
||||
@@ -47,7 +47,7 @@ import type {
|
||||
|
||||
## 工具映射
|
||||
|
||||
当前每个保留消费方都按下表映射到该库。广泛迁移不属于该库首次落地的范围;下表是预期形态。
|
||||
当前的保留机制消费方采用以下映射:
|
||||
|
||||
| 工具 | Retainer 与策略 | 说明 |
|
||||
|---|---|---|
|
||||
@@ -57,7 +57,7 @@ import type {
|
||||
| `web_fetch` | `TextRetainer`,`head` 或 `headTail` | 提供方/资源上限保留为提供方事实;retainer 只提供保留文本和省略元数据。 |
|
||||
| `web_search` | `ItemRetainer<WebSearchSource>`,`head` | 当提供方返回的来源超过面向模型的结果应包含的数量时,标准化「来源已达上限」通知。 |
|
||||
|
||||
`read` **刻意不在 v1 范围内**。其 `read-render` 辅助工具负责文件专用的分页契约:`offset`/`limit`、行号、`totalLines`、偏移越界错误、每行预览截断、针对已选窗口的字节上限。这是行窗口渲染器,而非通用保留机制。单个 `Omitted` 数量无法表示行窗口两侧。
|
||||
`read` 仍不属于这个通用库。其 `read-render` 辅助工具负责文件专用的分页约定:`offset`/`limit`、行号、`totalLines`、偏移越界错误、每行预览截断,以及所选窗口的字节上限。这是行窗口渲染器。单个 `Omitted` 数量无法表示该窗口两侧。
|
||||
|
||||
## 使用形态
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/util/timeout/README.md
|
||||
README.md: 8892b2dce53b5c315c088430ed3fcf386a0f3101
|
||||
README.zh.md: ec99f38ff92890c854a1f902e56b2278a42318b6
|
||||
README.md: e0d3595e7771fd19c808e89d440e69bca40da286
|
||||
README.zh.md: 99b7f281c2af7746fc2a105ee08df870fd3a9583
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function runWithDeadline(upstream: AbortSignal | undefined, timeout
|
||||
|
||||
The signal only *notifies* — the caller MUST attach its own termination (`d.signal.addEventListener('abort', kill)`, or hand `d.signal` to `fetch`). Racing a promise against a timer would resolve the tool-call while the child process or socket leaks on; handing out a signal forces a real termination path to exist.
|
||||
|
||||
Pass your own `code` to `timeoutOf` so classification composes under nesting: when the `upstream` you were handed is *itself* a deadline signal (a future `tools/execute` middleware arming a per-call deadline), `AbortSignal.any` preserves the outer `TimeoutReason` if the outer timer fires first. Scoping to your `code` makes a foreign timeout read as an ordinary upstream cancel — the correct classification from your capability's view — instead of your own timeout firing when your local timer never expired.
|
||||
Pass your own `code` to `timeoutOf` so classification composes under nesting. When `upstream` is itself a deadline signal, `AbortSignal.any` preserves its `TimeoutReason` if that timer fires first. Scoping to your code makes a foreign timeout read as an ordinary upstream cancel instead of claiming that the local timer expired.
|
||||
|
||||
For a streamed transport, create one `idleWatchdog`, pass its stable `signal` into the transport, and call `watchdog.next(iterator)` for each provider read. Call `watchdog.pulse()` when transport activity does not yield an iterator value. The interval must be positive, finite, and no greater than `MAX_TIMER_DELAY_MS`; Node otherwise clamps it to one millisecond. It measures only outstanding demand, so no timer runs while downstream code renders or otherwise waits before asking for the next chunk. The primitive still only notifies, so the transport must observe the stable signal; the DeepSeek and pi-ai adapters prove that timeout closes their real response body or SDK request.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
超时的**时序与分类**部分:一个零依赖纯函数库(无运行时 harness 依赖),由每个需要限制调用方超时提示、启动 deadline,并在之后区分「已超时」与「已取消」的功能共享。
|
||||
|
||||
它**不负责终止**。它发出的信号只会*通知*;真正停止工作仍由各功能负责,因为机制各不相同:bash 对操作系统进程组发送 SIGKILL,web 关闭 `fetch` 套接字,没有任何共享层能够承担全部终止机制。[Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md) 将边界划定为:共享时序/分类,将强制终止保留在本地。
|
||||
它**不负责终止**。它发出的信号只会*通知*;真正停止工作仍由各功能负责,因为机制各不相同:bash 对操作系统进程组发送 SIGKILL,web 关闭 `fetch` 套接字,没有任何共享层能够承担全部终止机制。[Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md) 将边界划定为:共享时序/分类,将强制终止保留在本地。
|
||||
|
||||
它是**库,而非服务或插件**:没有 `ctx`,不注册任何内容,不持有状态,也不发出事件。「超时服务」必须了解如何停止每项功能的工作,这正是微内核要排除在共享层之外的知识。
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function runWithDeadline(upstream: AbortSignal | undefined, timeout
|
||||
|
||||
该信号只会*通知*;调用方必须接入自己的终止机制(`d.signal.addEventListener('abort', kill)`,或将 `d.signal` 传给 `fetch`)。让 promise 与 timer 竞速,会在子进程或套接字仍在泄漏时就让工具调用完成;发出信号则会强制要求存在真正的终止路径。
|
||||
|
||||
将你自己的 `code` 传给 `timeoutOf`,以便分类可在嵌套中组合:当你收到的 `upstream` *本身*就是 deadline 信号时(未来启动每次调用 deadline 的 `tools/execute` 中间件),如果外层 timer 首先触发,`AbortSignal.any` 会保留外层 `TimeoutReason`。将范围限定为你的 `code`,可将外部超时视为普通 upstream 取消,这才是你所属功能视角下的正确分类,而不会在本地 timer 尚未到期时就声称自己超时。
|
||||
将你自己的 `code` 传给 `timeoutOf`,使分类可在嵌套场景中正确组合。当 `upstream` 本身是 deadline 信号时,如果该 timer 先触发,`AbortSignal.any` 会保留它的 `TimeoutReason`。将匹配范围限定为你的 code,会把外部超时视为普通的 upstream 取消,而不会声称本地 timer 已到期。
|
||||
|
||||
对于流式传输,创建一个 `idleWatchdog`,将其稳定的 `signal` 传给传输层,并为提供方的每次读取调用 `watchdog.next(iterator)`。当传输活动不产生迭代器值时,调用 `watchdog.pulse()`。间隔必须为正有限数,且不得超过 `MAX_TIMER_DELAY_MS`;否则 Node 会将其限制为 1 毫秒。它只对尚未完成的读取请求计时,因此当下游代码进行渲染或在请求下一个分片前以其他方式等待时,timer 不会运行。该原语仍然只会通知,因此传输层必须观察稳定信号;DeepSeek 和 pi-ai 适配器证明,超时会关闭它们的真实响应正文或 SDK 请求。
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
Reference in New Issue
Block a user