Merge remote-tracking branch 'origin/master' into worktree/schedule-conversational-after

This commit is contained in:
Tianyi Cui
2026-08-11 19:05:13 +08:00
3631 changed files with 83576 additions and 36966 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/README.md
README.md: 8349371ab565f2e9e735cd959026936c7ec44081
README.md: 504686f8563f073fc8261c88275a5cdd172dd060
README.zh.md: e19c446584cfac75cb50833fa01586688b9c7c92

View File

@@ -11,10 +11,10 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, deploy
| [`system-prompt/`](system-prompt/README.md) | Prompt and tool-schema assembly registry | `ctx.systemPrompt` |
| [`tools/`](tools/README.md) | Scoped tool registry and execution pipeline | `ctx.tools` |
| [`agent/`](agent/README.md) | Agent interface, registry, and event vocabulary | `ctx.agents` |
| [`agent-default-model/`](agent-default-model/README.md) | Default model selection shared by Agent front doors | `ctx.agentDefaultModel` |
| [`agent-default-model/`](agent-default-model/README.md) | Default model selection shared by Agent entry points | `ctx.agentDefaultModel` |
| [`agent-loop/`](agent-loop/README.md) | Default concrete agent driver | `ctx.agentLoop` |
`scope` supplies the shared scoping primitive. `agent` owns the public contract, while `agent-loop` is its default implementation; extension plugins depend on the seam so the driver remains swappable. `agent-default-model` owns the deployment selection an Agent front door uses only when a session has no selection of its own.
`scope` supplies the shared scoping primitive. `agent` owns the public contract, while `agent-loop` is its default implementation; extension plugins depend on the seam so the driver remains swappable. `agent-default-model` owns the deployment selection an Agent entry point uses only when a session has no selection of its own.
Runnable compositions belong to [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md); this group owns only the swappable spine pieces.

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent-default-model/README.md
README.md: 02bcc9be3adee2293a20b3ae87ddaf4d52e70deb
README.zh.md: 807b612bd25e49aa318c13c8c8dc7595a6459080
README.md: e86be7c37a1f994ca52f018144ef6a2409bd1eea
README.zh.md: 00250c28ef8c03d4b33fe1c1bfca138a022f6638

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The deployment default used when a front door creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct front doors such as `dsh run` and Host-backed front doors such as ApiProxy read the same service instead of owning parallel provider/model defaults.
The deployment default used when an entry point creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct entry points such as `dsh --profile headless` and Host-backed entry points such as ApiProxy read the same service instead of owning parallel provider/model defaults.
The plugin config requires `{ provider, model }`. That composition entry is the base of the `agent-default-model` Settings section; a mounted settings provider layers the user's choice over it and changes are visible on the next `currentSelection()` read. `reasoningEffort` belongs to the Settings section but deliberately not to plugin config: a complete saved selection can clear an effort when the next selected model has none, while a composition value would be inherited again.
@@ -13,7 +13,7 @@ The service does not validate catalog membership. A provider route may serve an
## Model Experience
Indirectly, through the provider/model selection supplied to a front door; request assembly and adapters own the model-visible request.
Indirectly, through the provider/model selection supplied to an entry point; request assembly and adapters own the model-visible request.
#### KV Cache effect
@@ -21,5 +21,5 @@ Changing the default affects only Agents that subsequently resolve from it. An e
## Known Limitations and Deferred Work
- The service owns one process-wide default; per-session selection remains the front door's responsibility.
- The service owns one process-wide default; per-session selection remains the entry point's responsibility.
- Without a settings provider, `saveSelection()` cannot retain a selection for a later Agent.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
该部署默认值供入口在创建尚无会话级模型选择的 Agent 时使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`;`dsh run` 这类直接入口与 ApiProxy 这类由 Host 支撑的入口读取同一服务,而不是分别持有平行的提供方/模型默认值。
该部署默认值供入口在创建尚无会话级模型选择的 Agent 时使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`;`dsh --profile headless` 这类直接入口与 ApiProxy 这类由 Host 支撑的入口读取同一服务,而不是分别持有平行的提供方/模型默认值。
插件配置必须提供 `{ provider, model }`。该组合配置项构成 Settings 中 `agent-default-model` 分节的基础层;挂载的设置提供方在其上叠加用户选择,更改会在下一次调用 `currentSelection()` 时可见。`reasoningEffort` 属于该 Settings 分节,但特意不属于插件配置:完整保存的选择必须能在下一个选定模型没有推理(reasoning)强度时清除旧值,而组合配置值会再次被继承。

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-agent-default-model",
"description": "Default model selection shared by Agent front doors",
"version": "0.0.1",
"private": true,
"description": "Default model selection shared by Agent entry points",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/core/agent-default-model"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -25,20 +32,20 @@
],
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -4,13 +4,13 @@
* @module @deepseek-ai/dsh-agent-default-model
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { ModelSelection } from '@deepseek-ai/dsh-agent'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
/** Default model selection for Agents created without an explicit model. */
agentDefaultModel: AgentDefaultModelService
@@ -92,7 +92,7 @@ export class AgentDefaultModelService extends Service {
/**
* Save the complete default model selection. A deployment without a settings
* provider keeps its composition entry.
* @param next - resolved selection accepted by a front door.
* @param next - resolved selection accepted by an entry point.
* @returns fulfillment after the optional settings write settles.
*/
async saveSelection(next: ModelSelection): Promise<void> {

View File

@@ -8,7 +8,7 @@
* @module @deepseek-ai/dsh-agent-default-model/invariant
*/
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-agent-default-model'

View File

@@ -1,7 +1,7 @@
/** Default Agent model settings layered over a real settings provider. */
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import AgentDefaultModelService, { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '../src/index.ts'
import { Settings } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
README.md: 6092363fae2853d6c5d92aaf8cd01e41e18e0b52
README.md: 889fd74671eddc202b814cdf2749069ec3cea02c
README.zh.md: b65b5334d735a1e0b51fa517ce41c0c953f87cf7

View File

@@ -12,7 +12,7 @@ This is the only package in the harness that contains concrete loop logic. Every
Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible.
The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear.
The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service set. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear.
Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain → unwind scope → detach agent → detach session; the id becomes reusable after private scope cleanup. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, and per-step assembly goes through `assembleContextFor(agent)`.

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-agent-loop",
"description": "The concrete agent loop plugin for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/core/agent-loop"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -24,18 +31,18 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"schemastery": "^3.18.0"
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
@@ -47,6 +54,6 @@
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -31,7 +31,7 @@ import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, Us
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import { RuntimeContextProjection } from './runtime-context.ts'
import { executeToolCalls } from './tool-calls.ts'

View File

@@ -5,9 +5,9 @@
* @module @deepseek-ai/dsh-agent-loop
*/
import { Context, FiberState, Service } from 'cordis'
import { Context, FiberState, Service } from '@deepseek-ai/cordis'
import { randomUUID } from 'node:crypto'
import z from 'schemastery'
import z from '@deepseek-ai/schemastery'
import { emitAgentEvent } from '@deepseek-ai/dsh-agent'
import type {
Agent,
@@ -156,7 +156,7 @@ interface PreparedAgent {
dispose(): Promise<void>
}
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
agentLoop: AgentLoop
/**

View File

@@ -3,7 +3,7 @@
* @module @deepseek-ai/dsh-agent-loop/invariant
*/
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import { isAgentLoopRequest, type GenerateOptions } from '@deepseek-ai/dsh-llm'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { foldRequestHeader } from '@deepseek-ai/dsh-session'

View File

@@ -7,7 +7,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContextSnapshotSection } from '@deepseek-ai/dsh-llm'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
const SOURCE = '@deepseek-ai/dsh-system-prompt'
const CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'

View File

@@ -11,7 +11,7 @@
* @module dsh-agent-loop/tool-calls
*/
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import { assertNever, createToolResultMessage, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context, type Fiber } from 'cordis'
import { Context, type Fiber } from '@deepseek-ai/cordis'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LlmService, { createUserMessage, CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'

View File

@@ -1,6 +1,6 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LlmService from '@deepseek-ai/dsh-llm'

View File

@@ -8,7 +8,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'

View File

@@ -1,6 +1,6 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -113,27 +113,19 @@ describe('config-driven session id', () => {
const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), provider: 'mock', model: 'mock' }] }
const firstLoop = await ctx.plugin(AgentLoop, config)
let first: Agent | undefined
for (let i = 0; i < 50 && first === undefined; i++) {
await new Promise(resolve => setTimeout(resolve, 5))
first = ctx.agents.get(SessionId('config-exact-reload'))
}
expect(first).toBeDefined()
first!.followup(createUserMessage({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } }))
await waitForIdle(ctx, first!)
await expect.poll(() => ctx.agents.get(SessionId('config-exact-reload')), { timeout: 5_000 }).toBeDefined()
const first = ctx.agents.get(SessionId('config-exact-reload'))!
first.followup(createUserMessage({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } }))
await waitForIdle(ctx, first)
await firstLoop.dispose()
const secondLoop = await ctx.plugin(AgentLoop, config)
let second: Agent | undefined
for (let i = 0; i < 50 && second === undefined; i++) {
await new Promise(resolve => setTimeout(resolve, 5))
second = ctx.agents.get(SessionId('config-exact-reload'))
}
expect(second).toBeDefined()
expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me')
second!.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
await waitForIdle(ctx, second!)
await ctx.sessions.flush(second!.session)
await expect.poll(() => ctx.agents.get(SessionId('config-exact-reload')), { timeout: 5_000 }).toBeDefined()
const second = ctx.agents.get(SessionId('config-exact-reload'))!
expect(JSON.stringify(second.session.deriveMessages())).toContain('remember me')
second.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
await waitForIdle(ctx, second)
await ctx.sessions.flush(second.session)
const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload'))
expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
@@ -423,18 +415,14 @@ describe('config-driven session id', () => {
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
// The deferred resume runs on a microtask after the backend is available.
let resumed: Agent | undefined
for (let i = 0; i < 50 && !resumed; i++) {
await new Promise(r => setTimeout(r, 5))
resumed = ctx2.agents.get(SessionId('sticky-1'))
}
expect(resumed).toBeDefined()
// The deferred resume runs after the backend is available.
await expect.poll(() => ctx2.agents.get(SessionId('sticky-1')), { timeout: 5_000 }).toBeDefined()
const resumed = ctx2.agents.get(SessionId('sticky-1'))!
// The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>),
// and the prior turn's user message is in the derived history.
expect(resumed!.id).toBe(SessionId('sticky-1'))
expect(resumed!.session.id).toBe('sticky-1')
const derived = resumed!.session.deriveMessages()
expect(resumed.id).toBe(SessionId('sticky-1'))
expect(resumed.session.id).toBe('sticky-1')
const derived = resumed.session.deriveMessages()
expect(JSON.stringify(derived)).toContain('remember me')
await ctx2.fiber.dispose()
})

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import LlmService, { createUserMessage, CallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason, type UserMessage } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import LlmService, { createUserMessage, CallId, LlmError, StreamChunk, errorChain } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import LlmService, { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, {
SessionId,
@@ -23,7 +23,7 @@ import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
* `agent/session-start`, `agent/turn-stopping`, and the
* `tools/pre-execute` / `tools/post-execute`
* split with `additionalContexts` buffering. These verify the canonical event
* surface a hook bridge (or a native plugin) programs against, WITHOUT any
* API a hook bridge (or a native plugin) programs against, WITHOUT any
* external protocol — a native plugin uses the typed decisions directly.
*/

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import LlmService, { createUserMessage, CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'

View File

@@ -10,7 +10,7 @@
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'

View File

@@ -1,6 +1,6 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LlmService, { createUserMessage, LlmError } from '@deepseek-ai/dsh-llm'

View File

@@ -6,7 +6,7 @@
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import LlmService, { createUserMessage, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'

View File

@@ -1,6 +1,6 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import { RuntimeContextProjection } from '../src/runtime-context.ts'

View File

@@ -1,6 +1,6 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context, symbols, type EffectMeta, type Fiber } from 'cordis'
import { Context, symbols, type EffectMeta, type Fiber } from '@deepseek-ai/cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -605,7 +605,7 @@ describe('agent scope lifecycle', () => {
agentCtx.systemPrompt.section({
name: 'dependency-origin-section',
order: 1,
text: 'factory dependency surface',
text: 'factory dependency API',
})
},
})

View File

@@ -4,7 +4,7 @@
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { createUserMessage, CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'

View File

@@ -8,7 +8,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'

View 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/core/agent-tool-mode/README.md
README.md: 0ef7f32c0890e5ef1071368571bd78b400b656e2
README.zh.md: 974fc4ed574e44244f8d97682e2451440c8267ce

View File

@@ -0,0 +1,31 @@
# dsh-agent-tool-mode
English | [中文](README.zh.md)
The row an [agent preset](../../preset/agent-presets/README.md) carries to say which form of its tools the model sees: `native` (every schema), `code` (only `run_code` plus a generated TypeScript SDK), or `both`.
## Why a row rather than a registry
The tool registry cannot move into a preset. Its consumers are all host-plane — [`dsh-agent-loop`](../agent-loop/README.md) reads its scheduler, [`dsh-apiproxy`](../../host/apiproxy/README.md) reads its presenters to render tool cards, and every tool plugin registers into it — and a service only moves down when all of its consumers move with it.
What a preset can own is the **presentation** of that registry. `ctx.tools.presentAs()` declares it for the mounting agent alone, so a Code Mode session runs beside native ones in one process, each seeing its own catalog. The deployment's `mode` on the [`dsh-tools`](../tools/README.md) row remains the default that agents declaring nothing get.
## What it does
`native` applies immediately. A code mode instead waits for `ctx.codeRuntime`, which is a host-plane service ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)): a preset selecting Code Mode against a deployment composing no runtime then holds this row pending, and `dsh-agent-presets` refuses the mount naming this id. The alternative — applying optimistically — moves the failure to the session's first request, where the operator can act on neither the preset nor the composition.
`mode` is required rather than defaulted, because a preset without this row already gets the deployment default; an omitted value would mean the row was composed for nothing.
One agent declares one presentation. A second declaration in the same composition is refused rather than merged: two answers to "which form does the model see" is a contradiction, not an override.
## Model Experience
Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema.
#### KV Cache effect
No direct invalidation; the presentation is fixed when the agent is composed, so its request prefix is stable for the session's life.
## Known Limitations and Deferred Work
- **The runtime stays host-plane** — a preset can select Code Mode but cannot supply the TypeScript runtime it needs; a deployment that composes none can compose no code-mode preset.

View File

@@ -0,0 +1,31 @@
# dsh-agent-tool-mode
[English](README.md) | 中文
[agent preset](../../preset/agent-presets/README.md) 用来声明「模型看到的工具是哪一种形态」的那一行:`native`(全部 schema)、`code`(只有 `run_code` 加一份生成的 TypeScript SDK)或 `both`。
## 为什么是一行插件,而不是把注册表搬下来
工具注册表搬不进 preset。它的消费者全在宿主平面——[`dsh-agent-loop`](../agent-loop/README.md) 读它的调度器,[`dsh-apiproxy`](../../host/apiproxy/README.md) 读它的 presenter 来渲染工具卡,每个工具插件都往里注册——而一个服务只有在**所有**消费者一起下沉时才能下沉。
preset 能拥有的是这份注册表的**呈现方式**。`ctx.tools.presentAs()` 只为正在挂载的那个 agent 声明,于是一个 Code Mode 会话可以和多个 native 会话同进程并存,各自看到各自的清单。[`dsh-tools`](../tools/README.md) 那一行上的 `mode` 仍然是默认值,供未作声明的 agent 使用。
## 它做什么
`native` 立即生效。code 类模式则等待 `ctx.codeRuntime`——这是一个宿主平面服务([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)):若某个 preset 在未组装运行时的部署上选择 Code Mode,本行就停在 pending,`dsh-agent-presets` 会指名此 id 拒绝挂载。另一种做法——先乐观应用——会把失败推迟到该会话的第一次请求,那时操作者对 preset 和组装都已无从下手。
`mode` 是必填而非有默认值:不带这一行的 preset 本来就会拿到部署默认值,省略它等于这一行白组装了。
一个 agent 只声明一次呈现方式。同一份组装里的第二次声明会被拒绝而不是合并:对「模型看到哪种形态」给出两个答案是矛盾,不是覆盖。
## Model Experience
Indirectly, through the projection it selects in `dsh-tools`: `code` presents `run_code` plus a generated SDK section, `native` presents every tool schema.
#### KV Cache effect
没有直接的失效影响;呈现方式在 agent 组装时即固定,因此其请求前缀在该会话的整个生命周期内保持稳定。
## Known Limitations and Deferred Work
- **运行时仍在宿主平面** —— preset 可以选择 Code Mode,却无法自带它所需的 TypeScript 运行时;未组装运行时的部署也就无法组装任何 code 模式的 preset。

View File

@@ -0,0 +1,52 @@
{
"name": "@deepseek-ai/dsh-agent-tool-mode",
"description": "Agent-plane presentation selector: composes one agent's tools as Code Mode, native, or both",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/core/agent-tool-mode"
},
"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",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-code-runtime": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,72 @@
/**
* Agent-plane presentation selector: the row an agent preset carries to say
* which form of its tools the model sees.
*
* The tool registry itself stays on the host plane — the agent loop's
* scheduler, the API proxy's presenters, and every tool plugin are all its
* consumers, so it cannot move into a preset. What a preset CAN own is the
* presentation: `ctx.tools.presentAs()` declares it for the mounting SCOPE,
* which is the preset's standing mount, so the declaration covers every agent
* joined to that preset and a Code Mode preset runs beside native ones in one
* process. One row per composition, not one per session.
*
* A code mode needs a TypeScript code runtime, which is a host-plane service
* ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)).
* This row therefore waits for it rather than assuming it: a preset selecting
* Code Mode against a deployment that composes no runtime fails at mount, named
* in the preset's own activation audit, instead of at the first prompt.
* @module @deepseek-ai/dsh-agent-tool-mode
*/
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { ToolPresentationMode } from '@deepseek-ai/dsh-tools'
// Type-only: brings the `ctx.tools` Context merge into this program.
import type {} from '@deepseek-ai/dsh-tools'
/** Cordis plugin name. */
export const name = 'tool-mode'
/**
* Required services. `codeRuntime` is NOT listed: a `native` row must mount in
* a deployment that composes no runtime, and the mode-dependent wait is
* declared inside {@link apply} instead.
*/
export const inject = ['tools']
/** Plugin config. */
export interface Config {
/**
* The form this agent's model sees. `native` sends every visible schema,
* `code` sends only `run_code` plus a generated SDK, `both` sends both.
* Required rather than defaulted: the deployment default is what a preset
* without this row already gets, so an omitted value would mean the row was
* composed for nothing.
*/
mode: ToolPresentationMode
}
/** Runtime schema. */
export const Config: z<Config> = z.object({
mode: z.union(['native', 'code', 'both'] as const).required(),
})
/**
* Declare the tool presentation for every agent this composition covers.
* @param ctx - the mounting composition's scope context (a preset's standing scope).
* @param config - the selected presentation.
*/
export function apply(ctx: Context, config: Config): void {
// `presentAs` is itself the effect — it registers through the calling
// context and hands back that exact disposer — so the declaration unwinds
// with this row without a second wrapper owning it.
if (config.mode === 'native') {
ctx.tools.presentAs('native')
return
}
// The wait is the loud failure: an entry still pending on `codeRuntime` is
// what `dsh-agent-presets` reports as an unusable row, naming this id.
ctx.inject(['codeRuntime'], (runtimeCtx: Context) => {
runtimeCtx.tools.presentAs(config.mode)
})
}

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-agent-tool-mode`.
* @module @deepseek-ai/dsh-agent-tool-mode/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-agent-tool-mode'
/** Cordis companion plugin name. */
export const name = 'tool-mode-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this package makes exactly one scoped call into
* `ctx.tools` and owns no event or snapshot of its own; the relation it
* establishes — which presentation one agent's assembly uses — is the tool
* registry's to hold, and `dsh-tools` observes it there.
*/
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 */

View File

@@ -0,0 +1,129 @@
/**
* The row an agent preset carries to pick its tool presentation. What it owes
* its caller: the choice reaches THIS agent and no other, it unwinds with the
* agent, and a code mode composed against a deployment with no code runtime
* stops at mount — where a preset's activation audit can name it — rather
* than at the first prompt assembly.
*/
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { createScope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import ToolRegistry, { RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { apply, Config, inject, name } from '@deepseek-ai/dsh-agent-tool-mode'
/** A runtime that never runs anything: presentation never dispatches. */
class StubRuntime extends CodeRuntime {
readonly language = 'typescript'
readonly isolation = 'stub'
run(_request: CodeRunRequest): Promise<CodeRunResult> {
return Promise.resolve({ logs: [] })
}
}
/** A host plane with one tool, optionally carrying a code runtime. */
async function host(options: { runtime?: boolean } = {}) {
const ctx = new Context()
await ctx.plugin(SystemPrompt, {})
await ctx.plugin(ToolRegistry, {})
if (options.runtime !== false) await ctx.plugin(StubRuntime)
ctx.tools.register(defineTool({
name: 'echo',
description: 'Echo tool.',
parameters: { value: { type: 'string', required: true } },
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
execute: args => Promise.resolve(args.value),
}))
return ctx
}
/** Mount the row under one agent's scope, as a preset subtree does. */
async function mount(ctx: Context, config: Config, id = 'agent') {
const agent = { id: SessionId(id) } as Agent
let inner!: Context
const fiber = ctx.plugin(Object.assign((host: Context) => {
inner = createScope(host, agent).ctx
}, { inject: ['tools', 'systemPrompt'] }))
await fiber.await()
const row = inner.plugin({ name, inject: [...inject], Config, apply }, config)
await row.await()
return { agent, fiber, row }
}
describe('the tool-mode row', () => {
it('declares the services it uses without holding a code runtime hostage', () => {
// A `native` row must mount where no runtime is composed, so the wait is
// conditional inside apply rather than static metadata.
expect(inject).toEqual(['tools'])
})
it('gives its own agent Code Mode and leaves the rest native', async () => {
const ctx = await host()
const coded = await mount(ctx, { mode: 'code' }, 'coded')
const plain = await mount(ctx, { mode: 'native' }, 'plain')
const codedAssembly = await ctx.systemPrompt.assemble({ scope: coded.agent })
const plainAssembly = await ctx.systemPrompt.assemble({ scope: plain.agent })
expect(codedAssembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
expect(codedAssembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('echo')
expect(plainAssembly.tools.map(tool => tool.name)).toEqual(['echo'])
})
it('presents both forms when asked for both', async () => {
const ctx = await host()
const { agent } = await mount(ctx, { mode: 'both' })
const assembly = await ctx.systemPrompt.assemble({ scope: agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['echo', RUN_CODE_NAME])
})
it('restores the deployment default when the agent unloads', async () => {
const ctx = await host()
const { agent, row } = await mount(ctx, { mode: 'code' })
await row.dispose()
// HMR safety: the preset subtree is torn down with its agent, and the
// presentation must go with it rather than outliving the composition.
const assembly = await ctx.systemPrompt.assemble({ scope: agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['echo'])
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
})
it('waits for a code runtime the deployment does not compose', async () => {
const ctx = await host({ runtime: false })
const { agent, row } = await mount(ctx, { mode: 'code' })
// Pending, not applied: `dsh-agent-presets` rejects a mount holding a row
// that never reached a usable state, naming this id — so the preset fails
// where the operator can act, instead of at the first request.
expect(row.ctx.get('codeRuntime')).toBeUndefined()
const assembly = await ctx.systemPrompt.assemble({ scope: agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['echo'])
})
it('applies once the runtime arrives', async () => {
const ctx = await host({ runtime: false })
const { agent } = await mount(ctx, { mode: 'code' })
await ctx.plugin(StubRuntime)
const assembly = await ctx.systemPrompt.assemble({ scope: agent })
expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
})
it('requires a mode rather than defaulting one', () => {
// An omitted value would mean the row was composed for nothing: a preset
// without this row already gets the deployment default.
expect(() => Config({} as never)).toThrow()
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/tools"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
README.md: 0fb65b94a3b311aa9f0df09d39dd937cba4cc7b4
README.zh.md: 44f67483343a98c280317793ece544bd0b984596
README.md: f6b698e93b254c97786155e7d2c7e81f07c0d981
README.zh.md: 8b108ca93b11ce5e4af57f9d9c823a4b10eac5ab

View File

@@ -58,6 +58,8 @@ Inbox live notifications are deliberately per-message and minimal: `agent/inbox/
Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
`foldConsumedWork(events)` reads that feed back for the one question the turn sequence cannot answer alone: what became of the work a log consumed. It returns the latest `turn/end` that accounts for consumed work — a turn that entered a model step, or one that claimed inbox input and then failed, was stopped, or was rejected before reaching one — plus whether accepted work was later cancelled out of the inbox unrun. Both facts come from the log, so a cancellation reads the same whichever owner issued it. A no-step turn that took nothing, or emptied its claim and completed, describes no work and is skipped; a `blocked` end over claimed input is an account, because rejection discarded that input.
### Agent interface (`types.ts`)
The handle every plugin programs against:
@@ -114,6 +116,6 @@ Prefix-stable while an agent's scoped registrations are unchanged. Setup or relo
- **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work.
- **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam.
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
- **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items. There is still no step-only abort that keeps the in-flight turn running ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
- **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items. There is still no step-only abort that keeps the in-flight turn running ([stop API Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-api.md)).
- **Each additional `UserMessage` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source, so the message cannot name several producers.
- **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`).

View File

@@ -58,6 +58,8 @@ inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserte
轮次和步骤边界以及模型 token 流是持久 `session/event` 事实,而不是镜像的 `agent/*` 通知。消费方从会话事件流读取 `turn/*`、`step/*` 和 `assistant/chunk`;工具策略与结果观测属于 [`dsh-tools`](../tools/README.md) 记录的完整流水线。
`foldConsumedWork(events)` 把这条事件流读回来,回答仅凭轮次序列无法回答的那个问题:一份日志消费掉的工作最终怎样了。它返回能够为已消费工作作出交代的最新 `turn/end`——即进入过模型 step 的轮次,或者认领了 inbox 输入、但在进入 step 之前失败、被停下或被拒绝的轮次——并额外给出「已接受的工作此后是否被从 inbox 中取消且从未运行」。两项事实都来自日志,因此无论由哪个所有者发起取消,读出来都一样。没有取走任何输入、或认领批次被改写清空后正常结束的无 step 轮次不描述工作,会被跳过;认领过输入、以 `blocked` 结束的轮次则是一份交代,因为拒绝把这些输入一并丢弃了。
### Agent 接口(`types.ts`)
每个插件面向的 handle:
@@ -114,6 +116,6 @@ inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserte
- **环境身份可能比存活状态更久**:消费方在生命周期敏感工作前,仍要检查 `agent.status`、取消状态和所属能力约定。
- **委派以外的 agent 间通道**:共享状态、流式子输出和后台/轮询语义仍在当前同步 `ctx.subagents` seam 之外。
- **`agent/session-start` 不能为启动设置门禁**:它仍是同步且不可 veto 的通知;必须在发布前完成的异步组合属于工厂的 `setup(agentCtx)` 事务。
- **`cancel()` 默认清空 inbox**:它会中止正在处理的轮次以及排队和 steering 工作;`cancel(cause, { keepInbox: true })` 只中止轮次并保留待处理项。仍不存在只中止步骤、同时让正在处理的轮次继续运行的操作([关于停止操作接口的 Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md))。
- **`cancel()` 默认清空 inbox**:它会中止正在处理的轮次以及排队和 steering 工作;`cancel(cause, { keepInbox: true })` 只中止轮次并保留待处理项。仍不存在只中止步骤、同时让正在处理的轮次继续运行的操作([关于停止操作接口的 Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-api.md))。
- **每条附加 `UserMessage` 恰好携带一个 `MessageSource`**:多个插件合并到一次工具调用上的贡献会归入同一来源,因此该消息无法列出多个生产者。
- **`SessionStartSource` 预留 `'clear'`/`'compact'`,但还没有发出方**:在驱动子系统落地前,只会出现 `'startup'`/`'resume'`(`TODO(compaction)`)。

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-agent",
"description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/core/agent"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -30,13 +37,13 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-type-meta": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
@@ -46,6 +53,6 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,108 @@
/**
* How one agent log accounts for the work it consumed.
*
* The turn and step vocabulary alone cannot answer this. A turn that stops
* before its first step leaves a `turn/end` shaped exactly like the balanced
* no-op turns a rejection or an empty claim produces, so reading turns in
* isolation either credits cut-short work as finished or convicts every no-op.
* The missing fact is the inbox's own record: {@link Inbox} logs each mutation
* with `removedCount` and marks a cancellation `outcome: 'canceled'`, which
* separates a turn claiming its input from work being dropped unrun.
*
* @module @deepseek-ai/dsh-agent/consumed-work
*/
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
/** How one agent log accounts for the work it consumed. */
export interface ConsumedWork {
/**
* The latest closed turn that accounts for consumed work: one that entered a
* model step, or one that claimed inbox input and then failed, was stopped,
* or was rejected. Absent when no turn closed over any work.
*/
readonly end?: SessionEvent<'turn/end'>
/**
* Whether accepted work was cancelled out of the inbox, unrun, after that
* turn. This is the only account of input a cancellation took before any turn
* could open over it — no `turn/end` describes it.
*/
readonly droppedUnrun: boolean
}
/**
* Whether a turn that consumed input but never reached a step ends in a way
* that accounts for that input. Only a `completed` end does not: it had
* nothing left to run once its claim was rewritten away. A `blocked` end is
* that input's ending too — the pre-step rejection that produced it discarded
* the claimed messages, so the work it took will never run.
* @param reason - the turn's recorded ending.
* @returns whether the ending accounts for the input the turn took.
*/
function accountsForClaim(reason: TurnEndReason): boolean {
switch (reason.kind) {
case 'completed':
return false
case 'blocked':
case 'aborted':
case 'interrupted':
case 'error':
return true
/* v8 ignore next 4 -- unreachable: the one unnamed built-in, `max-tokens`, requires a step,
* so its turn short-circuits as stepped before this call, and `TurnEndReasonMap` is
* merge-extensible, so a backend-added variant cannot be listed; an unnameable ending over
* consumed input must not read as success. */
default:
return true
}
}
/**
* Fold one agent log, or an owned suffix of one, into its account of consumed
* work. Single pass, and every input is the log itself: no caller has to sample
* live state before cancelling, so a cancellation issued by anyone — the owner's
* teardown, an ancestor's interrupt, an unloading plugin — reads the same.
* @param events - the log, or an owned suffix, to fold.
* @returns the accounting turn when one closed, and whether work was dropped unrun after it.
*/
export function foldConsumedWork(events: readonly SessionEvent[]): ConsumedWork {
const stepped = new Set<number>()
const claimed = new Set<number>()
let open: number | undefined
let end: SessionEvent<'turn/end'> | undefined
let droppedUnrun = false
for (const event of events) {
switch (event.type) {
case 'turn/start':
open = event.data.turn
break
case 'step/start':
stepped.add(event.data.turn)
break
case 'agent/inbox/spliced': {
const { removedCount, outcome, inserted } = event.data
if (removedCount === undefined) break
// A replacement keeps the work pending under a new identity, so only a
// cancellation that leaves nothing behind drops it.
if (outcome === 'canceled') droppedUnrun ||= inserted.length === 0
// Claims are the loop's own step-boundary reads, always inside a turn.
else if (open !== undefined) claimed.add(open)
break
}
case 'turn/end': {
const { turn, reason } = event.data
open = undefined
if (stepped.delete(turn) || (claimed.delete(turn) && accountsForClaim(reason))) {
end = event
// Anything dropped before this turn closed is what its own ending
// reports; only a later drop is still unaccounted for.
droppedUnrun = false
}
break
}
default:
break
}
}
return { ...end === undefined ? {} : { end }, droppedUnrun }
}

View File

@@ -6,7 +6,7 @@
* @module @deepseek-ai/dsh-agent/dispatch
*/
import type { Context, Events } from 'cordis'
import type { Context, Events } from '@deepseek-ai/cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'

View File

@@ -5,8 +5,8 @@
* @module @deepseek-ai/dsh-agent
*/
import { Context, FiberState, getTraceable, Service, symbols } from 'cordis'
import type { Fiber } from 'cordis'
import { Context, FiberState, getTraceable, Service, symbols } from '@deepseek-ai/cordis'
import type { Fiber } from '@deepseek-ai/cordis'
import { AsyncLocalStorage } from 'node:async_hooks'
import { isPromise } from 'node:util/types'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
@@ -18,6 +18,7 @@ import type { Agent, AgentOptions } from './runtime-types.ts'
export * from './runtime-types.ts'
export * from './types.ts'
export * from './inbox.ts'
export * from './consumed-work.ts'
export * from './model-selection.ts'
export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
@@ -32,7 +33,7 @@ declare module '@deepseek-ai/dsh-type-meta' {
}
}
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
agents: AgentRegistry
/**
@@ -96,6 +97,7 @@ export interface CreateAgentOptions {
readonly seedLength?: number
readonly origin?: 'subagent'
readonly delegationDepth?: number
readonly agentPreset?: string
}
/**
* Initial replay/fork history. A fork supplies a balanced completed-turn
@@ -158,7 +160,7 @@ export interface ResumeAgentOptions {
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: among consumers,
* only the holder can tear this agent down. The registered factory provider is
* also a structural owner because the scoped agent depends on that provider's
* service surface; provider unload stops and drains every live handle it made.
* service API; provider unload stops and drains every live handle it made.
* `dispose()` stops the loop, awaits its exit, unregisters the agent, removes
* its session from the store, and finally unwinds its scoped world.
*

View File

@@ -1,6 +1,6 @@
/** Package-owned agent lifecycle invariants. @module @deepseek-ai/dsh-agent/invariant */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'

View File

@@ -1,9 +1,9 @@
/**
* Agent-scoped model selection shared by interactive front doors.
* Agent-scoped model selection shared by runtime entry points.
* @module @deepseek-ai/dsh-agent/model-selection
*/
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { LlmCallConfig, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
/** Complete provider, model, and optional reasoning effort selected for one live Agent. */
@@ -33,7 +33,7 @@ export interface ModelSelectionRef {
* the selected model's provider/default behavior.
*
* @param agentCtx - The selected Agent's scoped context.
* @param selection - Mutable selection owned by the calling front door.
* @param selection - Mutable selection owned by the calling entry point.
* @returns Disposer for both scoped waterfall listeners.
*/
export function installModelSelection(agentCtx: Context, selection: ModelSelectionRef): () => void {

View File

@@ -5,7 +5,7 @@
* @module @deepseek-ai/dsh-agent
*/
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
@@ -143,7 +143,7 @@ export interface Agent {
inject(message: UserMessage): void
}
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Events {
// ---- lifecycle (emit) ----
/**

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { runInNewContext } from 'node:vm'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'

View File

@@ -1,5 +1,5 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context, Service, symbols } from 'cordis'
import { Context, Service, symbols } from '@deepseek-ai/cordis'
import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
import AgentRegistry, {

View File

@@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import { foldConsumedWork } from '@deepseek-ai/dsh-agent'
/** One pending message, as the inbox records it. */
function message(text: string) {
return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
/** Log an accepted message the way `Inbox.append()` does. */
function accept(session: Session, text: string): void {
session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message(text)] })
}
/** Log the step-boundary read of one pending message, as `Inbox.claim()` does. */
function claim(session: Session): void {
session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] })
}
/** Log a cancellation of one pending message, as `Inbox.clear()` does. */
function cancelPending(session: Session): void {
session.append('agent/inbox/spliced', {
target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
})
}
/** Run one whole turn that reached a model step. */
function steppedTurn(session: Session, turn: number, reason: TurnEndReason): void {
session.append('turn/start', { turn })
claim(session)
session.append('step/start', { turn, step: 1 })
session.append('step/end', { turn, step: 1 })
session.append('turn/end', { turn, reason })
}
describe('foldConsumedWork', () => {
it('reports nothing for a log that consumed no work', () => {
const session = Session.create(SessionId('empty'))
accept(session, 'queued')
expect(foldConsumedWork(session.events)).toEqual({ droppedUnrun: false })
})
it('reports the latest turn that entered a model step', () => {
const session = Session.create(SessionId('stepped'))
steppedTurn(session, 1, { kind: 'completed' })
steppedTurn(session, 2, { kind: 'max-tokens' })
expect(foldConsumedWork(session.events).end?.data)
.toEqual({ turn: 2, reason: { kind: 'max-tokens' } })
})
it('reports a turn that claimed its input and then failed before any step', () => {
const session = Session.create(SessionId('failed-claim'))
steppedTurn(session, 1, { kind: 'completed' })
// The step boundary runs the durability checkpoint and prompt assembly, so a
// turn can take its input and then fail without entering a step.
session.append('turn/start', { turn: 2 })
claim(session)
session.append('turn/end', { turn: 2, reason: { kind: 'error', error: { message: 'ENOSPC', code: 'UNKNOWN' } } })
expect(foldConsumedWork(session.events).end?.data.turn).toBe(2)
})
it('reports a turn that claimed its input and was then stopped before any step', () => {
const session = Session.create(SessionId('stopped-claim'))
steppedTurn(session, 1, { kind: 'completed' })
session.append('turn/start', { turn: 2 })
claim(session)
session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } })
expect(foldConsumedWork(session.events).end?.data.turn).toBe(2)
})
it('ignores a turn stopped, failed, or rejected without taking any input', () => {
const session = Session.create(SessionId('no-claim'))
steppedTurn(session, 1, { kind: 'completed' })
session.append('turn/start', { turn: 2 })
session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'parent' } } })
session.append('turn/start', { turn: 3 })
session.append('turn/end', { turn: 3, reason: { kind: 'error', error: { message: 'x', code: 'UNKNOWN' } } })
session.append('turn/start', { turn: 4 })
session.append('turn/end', { turn: 4, reason: { kind: 'blocked' } })
// None of these turns describes work: they opened, found nothing of their own, and closed.
expect(foldConsumedWork(session.events).end?.data.turn).toBe(1)
})
it('reports a turn whose claimed input a pre-step rejection discarded', () => {
const session = Session.create(SessionId('rejected-claim'))
steppedTurn(session, 1, { kind: 'completed' })
session.append('turn/start', { turn: 2 })
claim(session)
session.append('turn/end', { turn: 2, reason: { kind: 'blocked' } })
// Rejection does not retain the claimed messages, so the `blocked` end is
// the only account of input that will never run.
expect(foldConsumedWork(session.events).end?.data.turn).toBe(2)
})
it('ignores a claim its own turn emptied', () => {
const session = Session.create(SessionId('emptied-claim'))
steppedTurn(session, 1, { kind: 'completed' })
session.append('turn/start', { turn: 2 })
claim(session)
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
// An emptied claim ran nothing and dropped nothing: a listener rewrote the
// batch away, which is not this log's account of the work.
expect(foldConsumedWork(session.events).end?.data.turn).toBe(1)
})
it('credits a claim with no open turn to no turn at all', () => {
const session = Session.create(SessionId('mid-turn-suffix'))
steppedTurn(session, 1, { kind: 'completed' })
// An owned suffix can begin inside a turn whose start it does not contain,
// so a claim may appear with no turn to attribute it to.
claim(session)
session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } })
expect(foldConsumedWork(session.events).end?.data.turn).toBe(1)
})
it('reports work cancelled out of the inbox after the last accounting turn', () => {
const session = Session.create(SessionId('dropped'))
steppedTurn(session, 1, { kind: 'completed' })
accept(session, 'never runs')
cancelPending(session)
// No turn opened over it, so only the cancellation says the work was cut short.
expect(foldConsumedWork(session.events)).toEqual({
end: session.events.find(event => event.type === 'turn/end'),
droppedUnrun: true,
})
})
it('keeps a replacement pending rather than counting it as dropped', () => {
const session = Session.create(SessionId('replaced'))
steppedTurn(session, 1, { kind: 'completed' })
session.append('agent/inbox/spliced', {
target: 'next-turn', start: 0, removedCount: 1, inserted: [message('rewritten')], outcome: 'canceled',
})
expect(foldConsumedWork(session.events).droppedUnrun).toBe(false)
})
it('lets a later accounting turn absorb an earlier drop', () => {
const session = Session.create(SessionId('absorbed'))
steppedTurn(session, 1, { kind: 'completed' })
cancelPending(session)
steppedTurn(session, 2, { kind: 'completed' })
expect(foldConsumedWork(session.events)).toEqual({
end: session.events.findLast(event => event.type === 'turn/end'),
droppedUnrun: false,
})
})
})

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import { scopeTarget } from '@deepseek-ai/dsh-scope'

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import {
agentEvents,

View File

@@ -1,5 +1,5 @@
/**
* Negative-path tests for the export-surface JSDoc gate (`scripts/verify-export-jsdoc.ts`).
* Negative-path tests for the exported-API JSDoc gate (`scripts/verify-export-jsdoc.ts`).
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
@@ -46,7 +46,7 @@ export function hiddenFn(value: string): string { return value }
expect(violations.every(violation => violation.includes('publicFn'))).toBe(true)
})
it('accepts a fully documented surface', () => {
it('accepts a fully documented API', () => {
expect(collectExportJsdocViolations(make(`
/**
* Add one to a count.
@@ -106,7 +106,7 @@ export const halve = (n: number): number => n / 2
'/**\n * Docs.\n * @param ghost - not real.\n */\nexport function f({ a }: { a: number }): void {}\n',
))
expect(violations).toEqual([
expect.stringMatching(/parameter '\{ a \}' is a binding pattern; the export surface needs simple identifier parameters/),
expect.stringMatching(/parameter '\{ a \}' is a binding pattern; the exported API needs simple identifier parameters/),
expect.stringMatching(/@param ghost does not match any parameter \(stale tag\?\)/),
])
})
@@ -154,7 +154,7 @@ describe('verify-export-jsdoc type-level exports', () => {
it('skips `declare module` augmentation bodies (the cordis gate owns them)', () => {
expect(collectExportJsdocViolations(make(
"declare module 'cordis' {\n interface Events {\n 'fix/x'(): void\n }\n}\nexport {}\n",
"declare module '@deepseek-ai/cordis' {\n interface Events {\n 'fix/x'(): void\n }\n}\nexport {}\n",
))).toEqual([])
})
})
@@ -166,9 +166,9 @@ describe('verify-export-jsdoc export forms', () => {
))).toEqual([expect.stringMatching(/exported function 'f' .* has no JSDoc\./)])
})
it('does not treat a never-exported sibling declarator as surface', () => {
it('does not treat a never-exported sibling declarator as API', () => {
// `export { publicValue }` resolves to the whole variable statement; only
// the named declarator is surface — the gate must not demand JSDoc for
// the named declarator is exported — the gate must not demand JSDoc for
// the private sibling sharing the statement.
expect(collectExportJsdocViolations(make(
'/** The public knob. */\nconst publicValue = 1, privateHelper = 2\nexport { publicValue }\nvoid privateHelper\n',
@@ -177,7 +177,7 @@ describe('verify-export-jsdoc export forms', () => {
it('unions declarators across multiple export lists over one statement', () => {
// Two lists each name one declarator of the same undocumented statement:
// both are surface (deduplicating on first resolution would drop `b`),
// both are exported (deduplicating on first resolution would drop `b`),
// while the never-exported `c` stays out.
const violations = collectExportJsdocViolations(make(
'const a = 1, b = 2, c = 3\nexport { a }\nexport { b }\nvoid c\n',
@@ -344,7 +344,7 @@ describe('verify-export-jsdoc fail-closed forms', () => {
))).toEqual([])
})
it('treats an inline function-type annotation as the surface signature', () => {
it('treats an inline function-type annotation as the API signature', () => {
expect(collectExportJsdocViolations(make(
'/** Maps a number. */\nexport declare const f: (x: number) => number\n',
))).toEqual([
@@ -402,7 +402,7 @@ describe('verify-export-jsdoc fail-closed forms', () => {
])
})
it('treats a single-call-signature type literal as the surface signature', () => {
it('treats a single-call-signature type literal as the API signature', () => {
expect(collectExportJsdocViolations(make(
'/** Maps. */\nexport declare const f: { (x: number): number }\n',
))).toEqual([

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/scope/README.md
README.md: ecb442e39e40d5b97a07ccf8a71a190c4009ede8
README.zh.md: 019e4c59dd788866e26b3b8a20b0023999f35ed2
README.md: 1ed09acd7ccb864b16fb5d2ac4390264085d1c9e
README.zh.md: af238232987c74e89cdc4e009d3d0c40f71b02d8

View File

@@ -2,20 +2,21 @@
English | [中文](README.zh.md)
Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. The agent loop creates one scope per live agent, but the mechanism is key-agnostic so lower-level packages can use it without depending on agents.
Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. Keys form an optional parent chain (`bindScopeParent`): registration views inherit DOWN it — a child scope sees its ancestors' layers, nearest shadowing farthest — and event admission extends UP it — a listener tagged with an ancestor receives a descendant key's events, never the reverse. The agent loop creates one scope per live agent and an agent preset's standing mount is a parent scope over its agents, but the mechanism is key-agnostic so lower-level packages can use it without depending on either.
## Public API
- `createScope(ctx: Context, key: ScopeKey): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`).
- `createScope(ctx: Context, key: ScopeKey, options?): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`). `options.parent` binds the enclosing scope via `bindScopeParent` before the scope is usable; the binding stays internal.
- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)` The parent relation behind both chain directions. Binding is once: a key that already has a parent throws, and only the returned binding's `rebind(parent)` may re-link it — the blank-session recompose operation, valid only while nothing produced under the old parent is retained (the holder's contract — this relation cannot see what a session logged). Both the bind and every rebind reject a link closing a cycle. `scopeChainOf` returns `[key, parent, …]` nearest-first.
- `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins).
- `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling).
- `Scope.dispose(): Promise<void>` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first.
- `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global.
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics).
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff its tag is the key or an ancestor of it; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics).
- `Scoped<T>` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties.
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
- `ScopeLayer` Aggregate contract for one registry's complete global or exact-scope contribution; `isEmpty()` controls scoped-layer reclamation.
- `ScopedLayers<L>` Own one eager global layer and lazy exact-scope layers. `peek()` never creates, `merge()` materializes insertion-ordered named shadows, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer.
- `ScopedLayers<L>` Own one eager global layer and lazy exact-scope layers. `peek()` never creates and stays chain-blind (a scope's OWN contributions — restrictions, guards — must not silently pick up an ancestor's), `chainLayers()` returns existing overlays farthest-ancestor-first, `merge()` materializes insertion-ordered named shadows along the chain, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer.
- `NamedEntries<V>` Insertion-ordered named storage with caller-owned duplicate diagnostics, lookup, and live iteration within one nonempty table generation; draining the table detaches existing iterators from later insertions, and `insert()` returns an idempotent exact-entry undo.
- `AnonymousEntries<V>` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; it uses the same drained-generation iterator boundary, and `append()` returns an idempotent exact-entry undo.
@@ -27,10 +28,10 @@ The registration context determines both visibility and ownership, preventing a
Scope-aware services define a concrete `ScopeLayer` that aggregates their heterogeneous tables and domain helpers. `ScopedLayers.effect()` accepts one synchronous action returning one synchronous undo, installs that undo before optional notification, and reclaims an exact-scope layer only when the complete aggregate is empty. `notify` defaults to `true`; the supplied callback owns whether observer failures throw or are contained. `EntryValues` remains internal, the storage classes are imported from the package root rather than a `/store` subpath, and the shared storage does not define registry-specific filtering or iteration policy. See the [shared scoped-layer storage Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md).
Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve.
Handing out a scoped context hands out the minting plugin's service-resolution API (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve.
## Known Limitations and Deferred Work
- **Only scope-aware surfaces isolate state** — registries must file by `scopeOf()` and events must dispatch through `scopeTarget()`; an arbitrary Cordis service remains context-global merely because it is called through a scoped context.
- **A context carries one nearest scope key** — nested scopes shadow their parent's tag rather than forming hierarchical or multi-membership policy sets.
- **Service reachability comes from the scope minter** — handing out `Scope.ctx` also hands out the minting plugin's injected service surface, so a broader minter cannot later be narrowed by the holder.
- **Only scope-aware APIs isolate state** — registries must file by `scopeOf()` and events must dispatch through `scopeTarget()`; an arbitrary Cordis service remains context-global merely because it is called through a scoped context.
- **A context carries one nearest scope key** — the hierarchy lives in the key-level parent relation, not in context tags; nested scope CONTEXTS still shadow to a single tag, and multi-membership policy sets remain unsupported.
- **Service reachability comes from the scope minter** — handing out `Scope.ctx` also hands out the minting plugin's injected services, so a broader minter cannot later be narrowed by the holder.

View File

@@ -2,11 +2,12 @@
[English](README.md) | 中文
带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。agent loop(智能体循环)为每个实时 agent 创建一个作用域,但该机制与键的具体含义无关,因此底层包无需依赖 agent 即可使用。
带作用域的注册原语。`createScope(ctx, key)` 创建一个带标签的 Cordis 上下文,其底层 fiber 拥有通过该上下文进行的每项注册。`scopeOf(ctx)` 读取标签;`scopeTarget(base, key)` 将带作用域的事件路由到键相同的监听器,同时让无作用域监听器保持全局可见。键可以构成可选的父链(`bindScopeParent`):注册视图沿链**向下**继承——子作用域看得见祖先各层,近者遮蔽远者——事件放行沿链**向上**扩展——标签为祖先的监听器能收到子孙键的事件,反向永不成立。agent loop(智能体循环)为每个实时 agent 创建一个作用域,agent preset 的常驻挂载则是其 agent 们的父作用域,但该机制与键的具体含义无关,底层包无需依赖两者即可使用。
## 公开 API
- `createScope(ctx: Context, key: ScopeKey): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。
- `createScope(ctx: Context, key: ScopeKey, options?): Scope`:在 `ctx` 的 fiber 下创建作用域。可以同步使用(effect 收集受 uid 门禁约束;服务解析会沿创建该作用域的插件依赖范围继续查找)。同进程、带类型的键受信任;处于非活动状态的创建上下文仍会通过 Cordis 失败(`INACTIVE_EFFECT`)。`options.parent` 在作用域可用之前经 `bindScopeParent` 绑定其外围作用域;绑定句柄不外泄。
- `bindScopeParent(key, parent): ScopeParentBinding` / `scopeParentOf(key)` / `scopeChainOf(key)`:支撑两条链方向的父关系。绑定仅此一次:已有父级的键直接抛错,只有返回的绑定句柄的 `rebind(parent)` 才能重新认父——即空白会话 recompose 的操作,仅当旧父之下产出的东西一概不被保留时才合法(这是持有方的约定——该关系看不见会话记录了什么)。绑定与每次 rebind 都拒绝会闭环的链接。`scopeChainOf` 返回 `[key, parent, …]`,最近者在前。
- `Scope.ctx`:带标签的上下文。通过它进行的注册既具备作用域可见性,也服从作用域生命周期。派生上下文(一次 `extend`、挂载于其下的 fiber)继承标签;嵌套作用域会遮蔽外层标签(最近的标签生效)。
- `Scope.rawDispose`:底层 fiber 的原样 Cordis disposer。组合式(generator)effect 会 yield 此函数,从而把作用域 teardown 嵌套在该 yield 位置(Cordis 按函数标识去重嵌套 effect;yield 一个包装函数会使作用域 teardown 成为并行的同级操作)。
- `Scope.dispose(): Promise<void>`:通过作用域进行的每项注册所共用的幂等完全停稳边界。竞态调用或重复调用会等待同一次 teardown;即使 `rawDispose` 先调用了底层单次 Cordis disposer 也是如此。
@@ -15,7 +16,7 @@
- `Scoped<T>`:编译期不透明载体 brand。按作用域筛选的事件要求它作为 `this` 类型,因此使用裸主体分发会产生编译错误。类型参数记录主体类型,但不公开其属性。
- `isScopeCarrier(value)`/`carrierKeyOf(value)`:运行时载体标记,开发不变式使用它们断言每次按作用域筛选的分发都携带载体,而且载体键与参数所指名的主体一致。
- `ScopeLayer`:一个注册表的完整全局贡献或精确作用域贡献的聚合约定;`isEmpty()` 控制带作用域层的回收。
- `ScopedLayers<L>`:拥有一个立即创建的全局层和按需创建的精确作用域层。`peek()` 从不创建;`merge()` 物化按插入顺序排列的具名遮蔽项;`effect()` 从同一上下文推导可见性与所有权,同时返回原样 Cordis disposer。
- `ScopedLayers<L>`:持有一个立即构造的全局层与惰性的精确作用域层。`peek()` 从不创建且刻意不看链(某作用域**自己**的贡献——限制、守卫——不得悄悄继承祖先的),`chainLayers()` 按最远祖先在前返回已存在的各层,`merge()` 沿链物化按插入序的具名遮蔽,`effect()` 从同一上下文推导可见性与所有权,并返回精确的 Cordis disposer。
- `NamedEntries<V>`:按插入顺序排列的具名存储,调用方拥有重复项诊断、查找,以及一个非空表世代内的实时迭代。表清空后,现有迭代器与后续插入项脱离;`insert()` 返回幂等的精确条目撤销函数。
- `AnonymousEntries<V>`:按插入顺序排列的匿名存储;唯一内部键使相同值仍作为独立注册存在。它使用相同的清空世代迭代器边界;`append()` 返回幂等的精确条目撤销函数。
@@ -32,5 +33,5 @@
## 已知限制与暂缓事项
- **只有感知作用域的表层才会隔离状态**:注册表必须按 `scopeOf()` 归档,事件必须通过 `scopeTarget()` 分发;仅仅通过带作用域的上下文调用任意 Cordis 服务,并不会改变该服务仍为上下文全局这一事实。
- **一个上下文只携带一个最近的作用域键**:嵌套作用域会遮蔽父作用域的标签,而不会形成层级策略集或多成员策略集。
- **一个上下文只携带一个最近的作用域键**:层级关系存在于键级父关系中而非上下文标签里;嵌套作用域**上下文**仍遮蔽为单一标签,多成员策略集仍不受支持。
- **服务可达性来自作用域创建者**:交出 `Scope.ctx` 也会交出创建插件注入的服务表层,因此,若作用域创建者提供的服务范围较宽,持有者之后也无法将其收窄。

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-scope",
"description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/core/scope"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -25,11 +32,11 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -5,8 +5,8 @@
* @module @deepseek-ai/dsh-scope
*/
import type { Context, Fiber } from 'cordis'
import { Context as CordisContext } from 'cordis'
import type { Context, Fiber } from '@deepseek-ai/cordis'
import { Context as CordisContext } from '@deepseek-ai/cordis'
export { AnonymousEntries, NamedEntries, ScopedLayers } from './store.ts'
export type { ScopeLayer } from './store.ts'
@@ -29,6 +29,78 @@ export type Scoped<T extends object> = object & { readonly [ScopedBrand]: T }
/** The key associated with each carrier. Presence distinguishes an unkeyed carrier from a non-carrier. */
const carrierKeys = new WeakMap<object, ScopeKey | undefined>()
/**
* The enclosing scope of each key. One relation powers both directions of
* scope nesting: registration views inherit DOWN the chain (a child scope
* sees its ancestors' layers — {@link ScopedLayers}), and event admission
* extends UP it (a listener tagged with an ancestor receives events dispatched
* to a descendant key — {@link scopeTarget}).
*/
const scopeParents = new WeakMap<ScopeKey, ScopeKey>()
/** The privileged handle to move one scope key's parent link. */
export interface ScopeParentBinding {
/**
* Re-link the bound key to a different parent, with the same cycle check as
* the bind. Valid only while nothing produced under the old parent is
* retained — the blank-session recompose contract, which the holder upholds
* because this relation cannot see what a session logged.
* @param parent - the new enclosing scope key.
*/
rebind(parent: ScopeKey): void
}
/** Cycle-checked write shared by the bind and every rebind. */
function linkScopeParent(key: ScopeKey, parent: ScopeKey): void {
for (let cursor: ScopeKey | undefined = parent; cursor !== undefined; cursor = scopeParents.get(cursor)) {
if (cursor === key) throw new Error('dsh-scope: scope parent link would form a cycle')
}
scopeParents.set(key, parent)
}
/**
* Bind `parent` as `key`'s enclosing scope, once.
*
* A key that already has a parent throws: there is no open re-link path, so a
* scope's ancestry cannot be moved by anyone but the original binder, who
* alone receives the {@link ScopeParentBinding}. A link that would close a
* cycle is rejected, because every chain consumer walks parents to the root.
* @param key - the child scope key.
* @param parent - its enclosing scope key.
* @returns the binding that alone may re-link this key.
*/
export function bindScopeParent(key: ScopeKey, parent: ScopeKey): ScopeParentBinding {
if (scopeParents.has(key)) {
throw new Error('dsh-scope: scope key is already bound to a parent; re-linking requires the binding returned by the original bind')
}
linkScopeParent(key, parent)
return {
rebind(next: ScopeKey): void {
linkScopeParent(key, next)
},
}
}
/**
* Read one key's enclosing scope.
* @param key - the scope key to inspect.
* @returns its parent key, or `undefined` for a root scope.
*/
export function scopeParentOf(key: ScopeKey): ScopeKey | undefined {
return scopeParents.get(key)
}
/**
* The chain from a key to its root ancestor.
* @param key - the starting key, or `undefined` for the empty chain.
* @returns keys nearest-first: `[key, parent, grandparent, …]`.
*/
export function scopeChainOf(key: ScopeKey | undefined): ScopeKey[] {
const chain: ScopeKey[] = []
for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) chain.push(cursor)
return chain
}
/** A minted registration scope and its quiescent disposal boundaries. */
export interface Scope {
/** Context through which scope-owned registrations are made. */
@@ -48,14 +120,22 @@ async function quiesceFiber(fiber: Fiber): Promise<void> {
/** Shared no-op plugin used as the backing scope fiber. */
function scope(): void {}
/** Options accepted by {@link createScope}. */
export interface CreateScopeOptions {
/** Enclosing scope bound via {@link bindScopeParent} before the scope is usable; the binding stays internal. */
parent?: ScopeKey
}
/**
* Mint a scope under `ctx`. The scoped context inherits the minting plugin's
* dependency surface and owns every registration made through it.
* @param ctx - active context whose dependency surface the scope inherits.
* dependency API and owns every registration made through it.
* @param ctx - active context whose dependency API the scope inherits.
* @param key - opaque identity used for listener routing.
* @param options - optional scope-chain placement.
* @returns the scoped context and exact/shared disposal boundaries.
*/
export function createScope(ctx: Context, key: ScopeKey): Scope {
export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope {
if (options?.parent !== undefined) bindScopeParent(key, options.parent)
const fiber = ctx.plugin(scope)
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
let disposing: Promise<void> | undefined
@@ -77,7 +157,12 @@ export function scopeOf(ctx: Context): ScopeKey | undefined {
/**
* Build an opaque receiver that preserves the base filter, admits untagged
* listeners globally, and admits tagged listeners only for a matching key.
* listeners globally, and admits tagged listeners for a matching key or any
* of its ancestors ({@link bindScopeParent}): a listener owned by an enclosing
* scope receives every descendant scope's events, which is what lets one
* standing composition observe each of the agents composed under it. A tag
* BELOW the dispatch key stays excluded — events flow up the chain, never
* down.
* @param base - subject or service whose existing Cordis filter is preserved.
* @param key - routed scope identity, or `undefined` for an unscoped subject.
* @returns a carrier whose subject remains available only through event arguments.
@@ -88,7 +173,11 @@ export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined
[CordisContext.filter](ctx: Context): boolean {
if (baseFilter !== undefined && !baseFilter.call(base, ctx)) return false
const tag = scopeOf(ctx)
return tag === undefined || tag === key
if (tag === undefined) return true
for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) {
if (cursor === tag) return true
}
return false
},
}
carrierKeys.set(carrier, key)

View File

@@ -1,6 +1,6 @@
/** Package-owned scoped-dispatch invariants. @module @deepseek-ai/dsh-scope/invariant */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope'
import { scopedSubjectResolverFor } from './scoped-events.generated.ts'

View File

@@ -4,8 +4,8 @@
* @module @deepseek-ai/dsh-scope
*/
import type { Context } from 'cordis'
import { scopeOf } from './index.ts'
import type { Context } from '@deepseek-ai/cordis'
import { scopeChainOf, scopeOf } from './index.ts'
import type { ScopeKey } from './index.ts'
/** One scope's aggregate contribution to a registry. */
@@ -170,7 +170,10 @@ export class ScopedLayers<L extends ScopeLayer> {
}
/**
* Read an existing exact-scope overlay.
* Read an existing exact-scope overlay. Deliberately chain-blind: callers
* addressing one scope's OWN contributions (its restrictions, its guards)
* must not silently pick up an ancestor's — use {@link chainLayers} where
* inheritance is the point.
* @param scope - exact scope key; `undefined` denotes no overlay.
* @returns the existing scoped layer, or `undefined` without creating one.
*/
@@ -180,8 +183,25 @@ export class ScopedLayers<L extends ScopeLayer> {
}
/**
* Materialize global named entries followed by exact-scope shadows.
* @param scope - exact viewing scope, or `undefined` for the global view.
* Existing overlays along the scope's parent chain ({@link scopeChainOf}),
* farthest ancestor first and the exact scope last, so a caller layering
* them in order gives the nearest scope the final word.
* @param scope - viewing scope, or `undefined` for no overlays.
* @returns the existing layers, nearest last; absent overlays are skipped.
*/
chainLayers(scope: ScopeKey | undefined): L[] {
const layers: L[] = []
for (const key of scopeChainOf(scope).reverse()) {
const layer = this.scoped.get(key)
if (layer !== undefined) layers.push(layer)
}
return layers
}
/**
* Materialize global named entries followed by scope-chain shadows,
* farthest ancestor first, so the nearest scope's entry wins a name.
* @param scope - viewing scope, or `undefined` for the global view.
* @param pick - select the named table from a layer.
* @returns an insertion-ordered effective map.
*/
@@ -190,9 +210,9 @@ export class ScopedLayers<L extends ScopeLayer> {
pick: (layer: L) => NamedEntries<V>,
): Map<string, V> {
const merged = new Map(pick(this.global).entries())
const layer = this.peek(scope)
if (layer === undefined) return merged
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
for (const layer of this.chainLayers(scope)) {
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
}
return merged
}

View File

@@ -1,7 +1,7 @@
import { freezeMessage, MessageId } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Events } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import type { Events } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant'

View File

@@ -1,9 +1,9 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from 'cordis'
import { carrierKeyOf, createScope, isScopeCarrier, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import { Context } from '@deepseek-ai/cordis'
import { bindScopeParent, carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scope, Scoped } from '@deepseek-ai/dsh-scope'
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* Test-only event for scope-filtered dispatch.
@@ -153,3 +153,70 @@ describe('scopeTarget', () => {
expectTypeOf(carrier).toEqualTypeOf<Scoped<typeof subject>>()
})
})
describe('scope parent chain', () => {
it('links at mint, walks to the root, and rejects cycles', () => {
const ctx = new Context()
const preset = { kind: 'preset' }
const agent = { kind: 'agent' }
createScope(ctx, preset)
createScope(ctx, agent, { parent: preset })
expect(scopeParentOf(agent)).toBe(preset)
expect(scopeParentOf(preset)).toBeUndefined()
expect(scopeChainOf(agent)).toEqual([agent, preset])
expect(scopeChainOf(undefined)).toEqual([])
expect(() => { bindScopeParent(preset, agent) }).toThrow(/cycle/)
expect(() => { bindScopeParent(preset, preset) }).toThrow(/cycle/)
})
it('re-links only through the binding held by the original binder', () => {
const ctx = new Context()
const presetA = { id: 'a' }
const presetB = { id: 'b' }
const agent = { id: 'agent' }
createScope(ctx, presetA)
createScope(ctx, presetB)
const binding = bindScopeParent(agent, presetA)
createScope(ctx, agent)
// A bound key cannot be re-bound from the outside; only the binding moves it.
expect(() => bindScopeParent(agent, presetB)).toThrow(/already bound/)
binding.rebind(presetB)
expect(scopeChainOf(agent)).toEqual([agent, presetB])
// The rebind keeps the cycle check: a parent may not adopt its ancestor.
const child = { id: 'child' }
const childBinding = bindScopeParent(child, agent)
void childBinding
expect(() => { binding.rebind(child) }).toThrow(/cycle/)
})
it('admits an ancestor-tagged listener for a descendant dispatch, never the reverse', () => {
const ctx = new Context()
const preset = { kind: 'preset' }
const agent = { kind: 'agent' }
const other = { kind: 'other-preset' }
const presetScope = createScope(ctx, preset)
const agentScope = createScope(ctx, agent, { parent: preset })
const otherScope = createScope(ctx, other)
const seen: string[] = []
ctx.on('probe/event' as never, ((): void => { seen.push('untagged') }) as never)
presetScope.ctx.on('probe/event' as never, ((): void => { seen.push('preset') }) as never)
agentScope.ctx.on('probe/event' as never, ((): void => { seen.push('agent') }) as never)
otherScope.ctx.on('probe/event' as never, ((): void => { seen.push('other') }) as never)
const emit = ctx as unknown as { emit: (carrier: object, type: string) => void }
// Dispatch at the AGENT key: its own tag and its ancestor's admit; a
// sibling root does not.
emit.emit(scopeTarget({}, agent), 'probe/event')
expect(seen.sort()).toEqual(['agent', 'preset', 'untagged'])
// Dispatch at the PRESET key: the agent-tagged listener sits BELOW the
// dispatch key and stays excluded — events flow up the chain, not down.
seen.length = 0
emit.emit(scopeTarget({}, preset), 'probe/event')
expect(seen.sort()).toEqual(['preset', 'untagged'])
})
})

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import {
AnonymousEntries,
createScope,

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/session/README.md
README.md: db477d94037d3463870fc8e66ea35d5e607fb6fe
README.zh.md: 1ce1e823a7e0fdbcf7b6898764a89c52b74adf6a
README.md: 57569e9c0dbfa7cb696e3a561a9ff108c2ac981f
README.zh.md: 16629dc70c79ca838ba7088aeafcc5b38b124f87

View File

@@ -76,10 +76,11 @@ Also defines `TurnEndReasonMap`, the merge-extensible `kind`-tagged sum type for
An interrupted live turn ends with `{ kind: 'aborted', reason: AgentCancelCause }`, preserving the typed cancellation cause in the durable transcript. Persistence imports the coarse aborted outcome from the supported older format as `{ kind: 'aborted', reason: { kind: 'legacy' } }`, because that record did not retain its caller. A turn failure carries `{ kind: 'error', error }`; crash recovery alone synthesizes `{ kind: 'interrupted' }`.
Every `SessionEvent` carries two optional top-level fields (structural metadata):
Every `SessionEvent` carries three optional top-level fields (structural metadata):
- `sourceEventSeqs?: number[]` — seq numbers of earlier events cited as sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed entries behind a compaction replacement entry). On `assistant/message`, a present `[]` records a known empty provider stream, while omission means a legacy or foreign event did not record the source stream; other surface events require a non-empty list when this field is present.
- `surfaceOp?: SurfaceOp` — how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors).
- `ignorable?: true` — marks an event a reader may safely skip when it does not recognize the type; absent means required, so an unknown-type event refuses session reconstruction ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)).
### Metadata types (`types.ts`)
@@ -139,5 +140,5 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`.
- **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md).
- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes and a backend rejects any other version. Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)).
- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes, and a backend refuses any other version naming the direction (newer: "written by a newer harness — upgrade"; older: no upgrade path ships yet). Unknown event types refuse the same way unless marked `ignorable` in the envelope; the versioning mechanism is the [session-log-version-mechanism note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)).
- **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them.

View File

@@ -76,10 +76,11 @@
被中断的实时轮次以 `{ kind: 'aborted', reason: AgentCancelCause }` 结束,在持久 transcript(文本记录)中保留类型化取消原因。持久化会将受支持旧格式中的粗粒度中止结果导入为 `{ kind: 'aborted', reason: { kind: 'legacy' } }`,因为该记录没有保留调用方。轮次失败携带 `{ kind: 'error', error }`;只有崩溃恢复会合成 `{ kind: 'interrupted' }`。
每个 `SessionEvent` 都有两个可选顶层字段(结构元数据):
每个 `SessionEvent` 都有三个可选顶层字段(结构元数据):
- `sourceEventSeqs?: number[]`:被引用为来源的较早事件 seq(例如 `assistant/message` 引用的 `assistant/chunk` seq,或压缩替换条目引用的已遮蔽条目)。对于 `assistant/message`,存在的 `[]` 表示已知提供方流为空;省略则表示旧版或外部事件没有记录源流。其他 surface 事件若有此字段,则要求非空列表。
- `surfaceOp?: SurfaceOp`:事件进入 surface 的方式。非 surface 事件(边界、分片、用量、错误)不含该字段。
- `ignorable?: true`:标记读取器在不认识事件类型时可以安全跳过该事件;缺失表示必需,不认识的事件类型会使会话重建被拒绝([机制](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md))。
### 元数据类型(`types.ts`)
@@ -139,5 +140,5 @@
- **会话分支/树**(pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。
- **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。
- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端会拒绝其他任何版本。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。
- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端拒绝其他任何版本并说明方向(更新的版本提示"由更新的 harness 写入,请升级";更旧的版本说明尚无升级路径)。不认识的事件类型同样被拒绝,除非信封带 `ignorable` 标记;版本机制见 [session-log 版本机制 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。
- **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-session",
"description": "Event-sourced session store for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/core/session"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -34,12 +41,12 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-type-meta": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
@@ -48,6 +55,6 @@
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -6,7 +6,7 @@
* @module @deepseek-ai/dsh-session
*/
import { Context, Service } from 'cordis'
import { Context, Service } from '@deepseek-ai/cordis'
import { isAbsolute } from 'node:path'
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
@@ -32,29 +32,9 @@ export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
export { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts'
/**
* Find the latest closed turn that entered at least one model step, ignoring
* balanced no-step turns produced by rejection, empty input, or cancellation.
* @param events - session events, or an owned suffix, to inspect.
* @returns the latest matching turn end, or `undefined`.
*/
export function findLastMessageTurnEnd(
events: readonly SessionEvent[],
): SessionEvent<'turn/end'> | undefined {
const steppedTurns = new Set<number>()
let latest: SessionEvent<'turn/end'> | undefined
for (const event of events) {
if (event.type === 'step/start') {
steppedTurns.add(event.data.turn)
continue
}
if (event.type === 'turn/end' && steppedTurns.delete(event.data.turn)) latest = event
}
return latest
}
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
sessions: SessionStore
}
@@ -149,6 +129,9 @@ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
&& (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
throw new Error('session header delegationDepth must be a non-negative safe integer')
}
if (record.agentPreset !== undefined && typeof record.agentPreset !== 'string') {
throw new Error('session header agentPreset must be a string')
}
return deepFreeze(record as unknown as SessionHeader)
}
@@ -240,6 +223,7 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
case 'data':
case 'surfaceOp':
case 'sourceEventSeqs':
case 'ignorable':
break
default:
throw new Error(`seed event at index ${index} has an invalid event envelope`)
@@ -251,7 +235,8 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
if (typeof type !== 'string'
|| typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0
|| typeof time !== 'number' || !Number.isSafeInteger(time)
|| event['data'] === undefined) {
|| event['data'] === undefined
|| (event['ignorable'] !== undefined && event['ignorable'] !== true)) {
throw new Error(`seed event at index ${index} has an invalid event envelope`)
}
switch (type) {
@@ -500,8 +485,8 @@ export class Session {
/**
* Restore a detached session by taking ownership of fresh persistence values.
* Storage shape, event envelopes, sequence continuity, surface transitions,
* and header fields are validated before the graphs are frozen in place.
* The storage format, event envelopes, sequence continuity, surface transitions,
* and header fields are validated before the restored objects are frozen.
* @param id - restored session identity.
* @param seed - fresh detached events whose ownership is transferred.
* @param header - fresh detached metadata whose ownership is transferred.
@@ -898,6 +883,7 @@ export class SessionStore extends Service {
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
...meta?.origin === undefined ? {} : { origin: meta.origin },
...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
...meta?.agentPreset === undefined ? {} : { agentPreset: meta.agentPreset },
}
return Session.create(sessionId, seed, header)
}

View File

@@ -5,7 +5,7 @@
* @module @deepseek-ai/dsh-session/invariant
*/
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import { assertNever } from '@deepseek-ai/dsh-llm'
import type { CallId } from '@deepseek-ai/dsh-llm'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'

View File

@@ -0,0 +1,60 @@
/**
* GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run
* `pnpm run gen-persistence-catalog` to regenerate (verified fresh by
* `pnpm run verify-persistence-catalog`, part of `doc-sync`).
* @module @deepseek-ai/dsh-session/known-event-types
*/
/**
* Every `SessionEventMap` member declared in this repository — the event
* vocabulary this build understands. The persistence read path refuses to
* interpret a log containing a type outside this set unless the event
* carries the envelope's `ignorable` marker (see `SessionEvent.ignorable`
* in `./types.ts`): such a log was likely written by a newer harness, and
* silently skipping a required event would reconstruct a wrong session.
* Downstream (out-of-repo) plugin events are outside this list by
* construction; a registration surface for them is deferred until such a
* consumer exists.
*/
export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
'agent-preset/selected',
'agent/inbox/spliced',
'approval/asked',
'approval/decided',
'approval/policy',
'assistant/chunk',
'assistant/message',
'command/done',
'command/run',
'compact/end',
'compact/prune',
'compact/start',
'compact/summary',
'feedback/record',
'goal/change',
'hook/invoked',
'hook/result',
'llm/retry',
'llm/retry-started',
'permission/preset',
'plan/mode',
'request/context',
'request/header',
'sandbox/mode',
'schedule/change',
'session/end-seed',
'session/title',
'session/title-llm-request',
'step/end',
'step/start',
'subagent/descriptor',
'todo/write',
'tool/call',
'tool/code-dispatch',
'tool/code-dispatch-start',
'tool/result',
'turn/end',
'turn/start',
'user/message',
'web/deepseek-search-llm-request',
])

View File

@@ -30,8 +30,23 @@ export function SessionId(id: string): SessionId {
* and enforced by every persistence backend on load. The single source of truth for the
* version — write sites and the load-time check all read it.
* While the harness is unreleased it is pinned at `0`: no compatibility is
* implied, incompatible logs are rejected, and no migration is provided. A
* monotonic version policy starts with the first tagged release.
* implied, incompatible logs are rejected, and no migration is provided.
*
* The version is a single monotonic integer with no major/minor split. Whether
* a bump is needed is decided by what the WRITER emits, never by what a newer
* reader can accept: bump exactly when an older runtime could no longer handle
* a new log with full semantic correctness ("parses without error" is not
* correctness — silently skipping content that shapes reconstruction is a
* wrong read). Only structural changes reach that bar: the header shape, the
* {@link SessionEvent} envelope, core event semantics, or the surface
* mechanism (the {@link SurfaceEventType} set and {@link SurfaceOp} variants).
* Adding an ordinary event type does not bump — the per-event
* {@link SessionEvent.ignorable} guard covers vocabulary growth instead. When
* in doubt, bump: a near-identity upgrade step is almost free, a missed bump
* makes older runtimes read new logs wrong silently. The full mechanism
* (upgrade-step chain, in-memory view conversion, migrate-on-continue) is
* recorded in the session-log-version-mechanism Agent Note
* (`.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md`).
*/
export const SESSION_FORMAT_VERSION = 0
@@ -69,6 +84,13 @@ export interface SessionHeader {
* resume — a runtime-only depth would reset a resumed child to top-level.
*/
readonly delegationDepth?: number
/**
* Id of the agent preset this session's agent was composed from, when the
* deployment composes per session. Durable because the preset decides the
* session's tools and prompt: a resume that restored a different composition
* would replay history the model can no longer act on.
*/
readonly agentPreset?: string
}
/**
@@ -90,6 +112,7 @@ export interface CreateSessionOptions {
readonly seedLength?: number
readonly origin?: 'subagent'
readonly delegationDepth?: number
readonly agentPreset?: string
}
}
@@ -381,6 +404,17 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
/** Unix epoch milliseconds. */
time: number
data: SessionEventMap[K]
/**
* Marks an event a reader may safely skip when it does not recognize
* `type`. Absent means required: a reader meeting an unrecognized type
* without this marker MUST refuse to reconstruct the session instead of
* silently dropping the event, because an unrecognized required event may
* change how the rest of the log is interpreted. A writer sets `true` only
* on purely informational records whose loss cannot affect reconstruction;
* defaulting to required means a forgotten marker over-refuses (an
* inconvenience) rather than silently resuming a gutted session.
*/
ignorable?: true
} & (K extends SurfaceEventType ? {
/**
* Seq numbers of earlier events that this event cites as sources

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope'
import { createUserMessage, CallId, createMessage, createToolResultMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TOOL_NOT_STARTED } from '@deepseek-ai/dsh-session'

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
import SessionStore from '@deepseek-ai/dsh-session'

View File

@@ -1,5 +1,5 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import SessionStore, {
adoptSessionEvent,
@@ -7,28 +7,11 @@ import SessionStore, {
Session,
SessionEvent,
SessionId,
findLastMessageTurnEnd,
snapshotSessionEvent,
} from '@deepseek-ai/dsh-session'
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
describe('Session', () => {
it('finds the latest closed turn that entered a model step', () => {
const session = Session.create(SessionId('last-message-turn'))
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } })
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
session.append('turn/start', { turn: 2 })
session.append('step/start', { turn: 2, step: 1 })
session.append('step/end', { turn: 2, step: 1 })
session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
expect(findLastMessageTurnEnd(session.events)?.data)
.toEqual({ turn: 2, reason: { kind: 'max-tokens' } })
})
it('exposes one stable readonly surface view', () => {
const session = Session.create(SessionId('surface-view'))
const surface = session.surface
@@ -1090,12 +1073,20 @@ describe('Session', () => {
{ ...base, time: '1' },
{ ...base, time: 0.5 },
{ type: base.type, seq: base.seq, time: base.time },
{ ...base, ignorable: false },
{ ...base, ignorable: 'yes' },
]
for (const [index, event] of cases.entries()) {
expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
.toThrow(/invalid event envelope/)
}
// `ignorable: true` is the one accepted marker value (unknown-type skip contract).
const marked = Session.create(SessionId('ignorable-envelope'), [
{ ...base, ignorable: true } as SessionEvent,
])
expect(marked.events[0]?.ignorable).toBe(true)
})
})
@@ -1319,6 +1310,7 @@ describe('SessionStore', () => {
{ meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ },
{ meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ },
{ meta: { delegationDepth: -1 }, error: /delegationDepth must be a non-negative safe integer/ },
{ meta: { agentPreset: 1 }, error: /agentPreset must be a string/ },
]
for (const [index, { meta, error }] of cases.entries()) {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md
README.md: 13b05bfcd19212ade42f22ece455871d022e6260
README.zh.md: 0f9e7a2358134018975db1bc3c6b7206a274b3ec
README.md: cedda783d549633f5be9765a9a074e968d99500d
README.zh.md: 41729cdd1cfe6ebbd86f38c15bab5c50bd6ff7d2

View File

@@ -16,19 +16,19 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem
### Public API
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber.
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. A `complete: true` section becomes the exact complete prompt after the assembly waterfall; more than one effective complete section rejects assembly. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform waterfall. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform waterfall. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores an effective complete section as the sole prompt section. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects for multiple complete sections, when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
### Live events
`system-prompt/assemble` is authoritative; listeners that replace entries must preserve any active Code Mode or structured-output protocol. Use [`ToolRegistry.restrict()`](../tools/README.md) when filtering must stay aligned across presentation, lookup, and execution. Registry-change notifications are unfiltered. The generated region of [system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) owns signatures and dispatch contracts.
`system-prompt/assemble` is authoritative for ordinary sections; a complete section is the final prompt constraint applied after the waterfall. Listeners that replace entries must preserve any active Code Mode or structured-output protocol. Use [`ToolRegistry.restrict()`](../tools/README.md) when filtering must stay aligned across presentation, lookup, and execution. Registry-change notifications are unfiltered. The generated region of [system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) owns signatures and dispatch contracts.
### Key types
- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent, signal)`). Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame.
- `PromptSection` — `{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`.
- `PromptSection` — `{ name, order, text, complete? }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. One effective `complete` section suppresses all other sections after cooperative assembly.
- `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned.
@@ -39,7 +39,7 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse
- Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`.
- Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …).
- Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically.
- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller.
- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller before any complete-section constraint is enforced.
Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md).
@@ -49,7 +49,7 @@ Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/imple
#### What the model sees
By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `includeHarnessIdentity: false` omits only that fixed opener for a deployment that owns the complete compatibility persona. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas.
By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `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.
##### Harness identity

View File

@@ -16,21 +16,21 @@
### 公开 API
- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose(资源释放)。
- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。一个 `complete: true` 段会在组装 waterfall 之后成为精确的完整提示词;有效 complete 段超过一个时,组装会被拒绝。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose(资源释放)。
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void`:贡献工具 schema;每次组装时使用该次组装的上下文求值。`ToolProviderResult` = `{ schemas, knownNames? }`:`schemas` 是限制后的可见集合;`knownNames` 是限制前由 `toolOrder` 使用的全集。提供方不得返回名为 `TOOL_ORDER_REST` 的 schema。带作用域提供方只在其作用域的组装中查询。随调用 fiber 一并 dispose。
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void`:贡献提示词变量,在段文本中以 `{{name}}` 引用。带作用域变量会为该 agent 遮蔽同名全局变量。同层重复或无法引用的名称会抛出;`undefined` 表示「本次组装没有值」。随调用 fiber 一并 dispose。
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 waterfall 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,并返回其权威结果。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。当已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 waterfall 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,之后将一个有效的 complete 段恢复为唯一的提示词段落。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。存在多个 complete 段、已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。
<a id="live-events"></a>
### 实时事件
`system-prompt/assemble` 是权威来源;替换条目的监听器必须保留任何活动 Code Mode 或结构化输出协议。筛选需要在呈现、查找与执行之间保持一致时,应使用 [`ToolRegistry.restrict()`](../tools/README.md)。注册表变更通知不经过筛选。[system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) 的生成区块拥有签名与分发约定。
`system-prompt/assemble` 对普通段落具有权威性;complete 段是在 waterfall 之后应用的最终提示词约束。替换条目的监听器必须保留任何活动 Code Mode 或结构化输出协议。筛选需要在呈现、查找与执行之间保持一致时,应使用 [`ToolRegistry.restrict()`](../tools/README.md)。注册表变更通知不经过筛选。[system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) 的生成区块拥有签名与分发约定。
### 关键类型
- `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent`(类型化 DX 字段;绝不能在没有 `scope` 时设置,应使用 `assembleContextFor(agent, signal)`)。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。
- `PromptSection`:`{ name, order, text }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。
- `PromptSection`:`{ name, order, text, complete? }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。协作式组装完成后,一个有效的 `complete` 段会抑制其他所有段落。
- `PromptAssembly`:`{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`。段文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。
- `renderPrompt(assembly)`:插值每个段中的 `{{variable}}` 引用,删除空段,并用空行连接。严格规则:未知引用(使用 `Object.hasOwn` 查找,因此 `{{constructor}}` 等原型名称未知)、已注册但无值的引用、格式错误的完整 `{{…}}` 组,或一个起始 `{{` 没有打开完整组、但后面仍有 `}}`(`{{{model}}}`),都会抛出;明确失败胜过交付格式错误的提示词。孤立的 `{{` 如果后面任何位置都没有 `}}`,会按字面量通过;替换值绝不再次扫描。
@@ -41,7 +41,7 @@
- 段提供方:工具包拥有跨调用引导(`tool:bash`、`tool:read` 等);此插件拥有 `harness:identity` 与 `deployment:persona`。
- 变量提供方:agent loop(智能体循环)注册 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)。
- 工具 schema 提供方:`ToolRegistry` 自动将自身注册为工具提供方。
- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果。
- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果,之后再实施 complete 段约束。
设计原理:[提示词变量 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)。
@@ -51,7 +51,7 @@
#### 模型看到的内容
默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅为拥有完整兼容 persona 的部署省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。
默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。`system-prompt/assemble` waterfall 决定交付的提示词与工具 schema,除非一个有效段声明自身为 complete;此时,该确切段落会成为完整的系统提示词,而 waterfall 得到的上下文、工具和变量保持不变。
##### Harness 身份

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-system-prompt",
"description": "System prompt assembly registry for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/core/system-prompt"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -25,18 +32,18 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"schemastery": "^3.18.0"
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -4,13 +4,13 @@
* @module @deepseek-ai/dsh-system-prompt
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
import type { ContextSnapshotSection, ToolSchema } from '@deepseek-ai/dsh-llm'
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
systemPrompt: SystemPrompt
}
@@ -21,7 +21,9 @@ declare module 'cordis' {
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners
* receive only that scope's assemblies. The returned value is authoritative.
* A supplied signal controls only this explicit assembly request and must not
* be retained to control later turns.
* be retained to control later turns. A registered complete section is
* restored after this waterfall, so listeners cannot add to or replace
* that scope's system prompt.
* @param assembly - the mutable assembly built from registered providers.
* @param context - the caller's per-assembly context.
* @mode waterfall
@@ -63,6 +65,13 @@ export interface PromptSection {
* interpolated later, by {@link renderPrompt}.
*/
readonly text: string | ((context: AssembleContext) => string)
/**
* Treat this contribution as the complete system prompt. Assembly still
* runs the cooperative waterfall so tools, contexts, and variables can be
* resolved, then restores this exact section as the sole prompt section.
* More than one effective complete section makes assembly fail.
*/
readonly complete?: boolean
}
/** Dynamic model context materialized as a durable user-role snapshot. */
@@ -110,6 +119,17 @@ export interface PromptAssembly {
variables: Record<string, string | undefined>
}
/**
* The deployment persona's section name and order. Exported because a
* composition can replace this slot — an agent preset shadows the
* deployment's persona with its own — and both sides naming the same section
* is what makes the replacement work rather than duplicate.
*/
export const PERSONA_SECTION = 'deployment:persona'
/** Prompt order of the persona slot; the first section a model reads. */
export const PERSONA_ORDER = 0
/** Valid variable names: how they are written between the braces. */
const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
@@ -173,7 +193,7 @@ export interface Config {
persona?: string
/**
* Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once.
* Shape errors fail at load and unknown names fail at assembly; known names
* Invalid fields fail at load and unknown names fail at assembly; known names
* hidden in one scope may be absent there. Omitted means lexicographic order.
*/
toolOrder?: string[]
@@ -337,8 +357,8 @@ export class SystemPrompt extends Service {
})
}
this.section({
name: 'deployment:persona',
order: 0,
name: PERSONA_SECTION,
order: PERSONA_ORDER,
// The fallback narrows the optional input type; the schema already defaults it.
text: config.persona ?? '',
})
@@ -417,9 +437,11 @@ export class SystemPrompt extends Service {
/**
* Assemble global and scoped providers, detach tool parameters, apply
* canonical ordering, then run the assembly waterfall. Scoped sections and
* variables shadow globals; the returned waterfall value is authoritative.
* variables shadow globals. The returned waterfall value is authoritative
* except that an effective complete section is restored afterwards as the
* sole prompt section.
* @param context - the optional scope and plugin-defined assembly fields.
* @returns the authoritative post-waterfall assembly.
* @returns the post-waterfall assembly with any complete prompt enforced.
*/
// Keep configuration failures on the declared asynchronous error path.
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
@@ -429,9 +451,11 @@ export class SystemPrompt extends Service {
for (const [name, provider] of this.layers.global.variables.entries()) {
variables[name] = provider(context)
}
const scopedVariables = this.layers.peek(scope)?.variables
for (const [name, provider] of scopedVariables?.entries() ?? []) {
variables[name] = provider(context)
// Scope-chain variables, farthest first, so the nearest scope wins a name.
for (const layer of this.layers.chainLayers(scope)) {
for (const [name, provider] of layer.variables.entries()) {
variables[name] = provider(context)
}
}
// Scoped sections shadow globals before the stable order sort.
const sectionByName = this.layers.merge(scope, layer => layer.sections)
@@ -439,7 +463,7 @@ export class SystemPrompt extends Service {
// Validate order against pre-restriction names while collecting visible schemas.
const providers = [
...this.layers.global.toolProviders.values(),
...(this.layers.peek(scope)?.toolProviders.values() ?? []),
...this.layers.chainLayers(scope).flatMap(layer => [...layer.toolProviders.values()]),
]
const collected: ToolSchema[] = []
const knownNames = new Set<string>()
@@ -454,13 +478,23 @@ export class SystemPrompt extends Service {
collected.push(...schemas)
for (const name of acceptedKnownNames) knownNames.add(name)
}
const assembly: PromptAssembly = {
sections: [...sectionByName.values()]
.sort((a, b) => a.order - b.order)
.map(section => ({
const sectionDefinitions = [...sectionByName.values()].sort((a, b) => a.order - b.order)
const completeSections = sectionDefinitions.filter(section => section.complete === true)
if (completeSections.length > 1) {
throw new Error(`multiple complete prompt sections are active: ${completeSections.map(section => JSON.stringify(section.name)).join(', ')}`)
}
let completeSection: AssembledSection | undefined
const sections = sectionDefinitions
.map((section) => {
const assembled = {
name: section.name,
text: typeof section.text === 'function' ? section.text(context) : section.text,
})),
}
if (section.complete === true) completeSection = { ...assembled }
return assembled
})
const assembly: PromptAssembly = {
sections,
contexts: [...contextByName.values()]
.sort((a, b) => a.order - b.order)
.map(entry => ({
@@ -470,10 +504,12 @@ export class SystemPrompt extends Service {
tools: orderTools(collected, this.toolOrder, knownNames),
variables,
}
return this.ctx.waterfall(
const transformed = await this.ctx.waterfall(
scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,
() => Promise.resolve(assembly),
)
if (completeSection === undefined) return transformed
return { ...transformed, sections: [completeSection] }
}
}

View File

@@ -1,6 +1,6 @@
/** Package-owned prompt-assembly invariants. @module @deepseek-ai/dsh-system-prompt/invariant */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { PromptAssembly } from './index.ts'

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import * as SystemPromptInvariant from '@deepseek-ai/dsh-system-prompt/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
import SystemPrompt, { TOOL_ORDER_REST, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import SystemPrompt, { AssembleContext, PromptAssembly, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
/**
@@ -264,6 +264,34 @@ describe('SystemPrompt', () => {
expect(assembly.sections).toHaveLength(0)
})
it('restores one complete section after the assembly waterfall', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'complete', order: 10, text: 'Exact prompt.', complete: true })
ctx.systemPrompt.section({ name: 'extra', order: 20, text: 'extra' })
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
const complete = assembly.sections.find(section => section.name === 'complete')
if (complete === undefined) throw new Error('complete section missing before waterfall')
complete.text = 'mutated'
assembly.sections.push({ name: 'late', text: 'late' })
return next()
}, { prepend: true })
expect((await ctx.systemPrompt.assemble()).sections).toEqual([
{ name: 'complete', text: 'Exact prompt.' },
])
})
it('rejects multiple effective complete sections', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'first', order: 10, text: 'first', complete: true })
ctx.systemPrompt.section({ name: 'second', order: 20, text: 'second', complete: true })
await expect(ctx.systemPrompt.assemble())
.rejects.toThrow('multiple complete prompt sections are active: "first", "second"')
})
it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import SystemPrompt, { PromptAssembly, TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/tools/README.md
README.md: b1de96293f8ec823ec52d6142a46de877f7fc5e6
README.zh.md: fa42f7c02a09579bd7b1c995246696d8808889de
README.md: 75d18712a02ecc72c2ea3a7203d2a7377cef87c7
README.zh.md: 8d4ae42596483f77aa82b23a0b41168465e2b165

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the definition-owned `finalizeContent` boundary → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both.
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the definition-owned `finalizeContent` boundary → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both, and one agent shadows that default for itself with `presentAs`.
## Service: `ToolRegistry` (ctx key: `tools`)
@@ -13,12 +13,13 @@ tools:
mode: native # native (default) | code | both
```
`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol.
`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. This is the default for agents that declare none of their own — an agent preset selects its own with [`dsh-agent-tool-mode`](../agent-tool-mode/README.md). The reserved transport cannot be registered, shadowed, restricted, or removed, and its name is reserved whatever the configured mode, because any agent may select a code mode. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol.
### Public API
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing or unsupported output declarations and a non-positive or non-finite `timeoutMs` fail at registration. The optional synchronous `finalizeContent` callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber.
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void` selects this agent's model-facing presentation, shadowing the `mode` config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own `tools:sdk` section. The catalog is unchanged — `schemas(agent)` still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber.
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to the tools that scope INHERITS — the global layer and every ancestor scope on its chain — and throws from a plain context. The scope's OWN registrations are exempt and merge afterwards, which is what keeps a delegated child's reporting and structured-output tools alive under a filter naming only the capabilities it may use. The filter is snapshotted at registration; multiple masks intersect, and a mask on an ancestor reaches every scope nested inside it. Deny masks admit later unnamed inherited tools, while allow masks exclude later names. Unknown, own-layer, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals).
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
@@ -35,7 +36,7 @@ Cancellation is cooperative and quiescent. Every typed invocation supplies a cal
### Live events
The live registry pipeline has three transformable waterfalls, then the definition-owned content finalizer, then the observe-only `tools/result` boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated region of [tools.md](../../../docs/subsystems/tools.md#cordis-surface), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards.
The live registry pipeline has three transformable waterfalls, then the definition-owned content finalizer, then the observe-only `tools/result` event; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure containment contracts live in the generated region of [tools.md](../../../docs/subsystems/tools.md#cordis-surface), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards.
### Key types
@@ -65,7 +66,7 @@ First-party plugin authors can use the `defineTool()` helper (exported from this
```ts
import { readFile } from 'node:fs/promises'
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
declare const ctx: Context
@@ -110,7 +111,7 @@ Tools optionally own pure `presentCall()` and `presentResult()` render intents,
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search — grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob), with `truncated`/`total` so a UI never presents a capped result as complete; the view carries no result text and a search has no `card: 'search'` call-time analogue), `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.
Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct top-level calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.
### Code Mode
@@ -119,7 +120,7 @@ Under `code` or `both`, the registry exposes the reserved `run_code` transport a
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating the language-appropriate SDK text at each assembly. In the TypeScript flavor it emits `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions; the Python flavor (`ctx.codeRuntime.language === 'python'`) emits the equivalent named `TypedDict`s and a `tools` object with matching usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). Both codegens are exported and never throw during prompt assembly: `jsonSchemaToTs` handles every unified schema construct and degrades unsupported raw constructs to `unknown`; `jsonSchemaToPy` does the same, degrading to `Any` (and a whole object to `dict[str, Any]` when a field name is not a legal `TypedDict` attribute, or whenever it is called outside the SDK render, which supplies the naming context a `TypedDict` declaration needs).
- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `<parent>:code:<n>`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
- **Result boundary**: intermediate binding values cross the worker boundary whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that ledger. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill.
- **Result size**: intermediate binding values cross the worker process whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that limit. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill.
### Parallel execution
@@ -145,7 +146,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat
#### What the model sees
Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode surface. The instructions and SDK block match the loaded runtime's language; the TypeScript flavor (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python flavor (for any runtime reporting `language: 'python'`) is the same shape with Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`).
Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode API. The instructions and SDK block match the loaded runtime's language; the TypeScript version (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python version (for any runtime reporting `language: 'python'`) has the same operations and types in Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`).
##### Code Mode SDK instructions
@@ -190,6 +191,6 @@ Append-only; newly visible content follows the reusable request prefix and does
- **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
- **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root.
- **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper.
- **Code Mode's SDK language follows the one loaded runtime and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (`typescript` via the worker backend, `python` for any runtime reporting that language); scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only, and a single runtime fixes the language service-wide (the [language-dispatch Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) owns the lookup, and why the registry reads the loaded runtime instead of carrying a language field of its own).
- **Code Mode's SDK language follows the one loaded runtime, and a presentation is per agent rather than per tool** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (TypeScript or Python); scoped restrictions/shadows and `presentAs` choose each agent's visible bindings and their form, but within one agent no tool can be native-only while another is code-only.
- **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The durable log copy of each sub-call IS bounded: the `tools/code-dispatch-log` waterfall lets the spill policy replace an oversized `tool/code-dispatch` content with a preview + locator ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)).
- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md).

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
工具注册表与执行流水线。工具插件注册各自的 schema 和执行器;agent loop(智能体循环)依次让每次调用经过 `tools/pre-execute`(可扩展的允许/拒绝门禁)→ 已注册的单调守卫 → `tools/execute`(供超时/重试/指标插件使用的环绕分发包装层)→ `tools/post-execute`(检查/替换结果、附加上下文)→ 由定义拥有的 `finalizeContent` 边界 → 仅观测的 `tools/result` 通知。注册表还负责决定如何向模型呈现其工具:`mode` 配置可以选择原生 Function Calling(函数调用)、[Code Mode](#code-mode),或同时选择两者。
工具注册表与执行流水线。工具插件注册各自的 schema 和执行器;agent loop(智能体循环)依次让每次调用经过 `tools/pre-execute`(可扩展的允许/拒绝门禁)→ 已注册的单调守卫 → `tools/execute`(供超时/重试/指标插件使用的环绕分发包装层)→ `tools/post-execute`(检查/替换结果、附加上下文)→ 由定义拥有的 `finalizeContent` 边界 → 仅观测的 `tools/result` 通知。注册表还负责决定如何向模型呈现其工具:`mode` 配置可以选择原生 Function Calling(函数调用)、[Code Mode](#code-mode),或同时选择两者;单个 agent 可用 `presentAs` 为自己遮蔽该默认值。
## 服务:`ToolRegistry`(ctx 键:`tools`)
@@ -13,12 +13,13 @@ tools:
mode: native # native (default) | code | both
```
`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求所加载 `ctx.codeRuntime` 的 `language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付;Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会导致提示词组装明确失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。
`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。这是「未作声明的 agent」的默认值——agent preset 用 [`dsh-agent-tool-mode`](../agent-tool-mode/README.md) 为自己选择。不能注册、遮蔽、限制或移除该保留传输,且无论配置何种模式,该名称都是保留的,因为任何 agent 都可能选择 code 模式。非原生模式要求所加载 `ctx.codeRuntime` 的 `language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付;Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会导致提示词组装明确失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。
### 公开 API
- `ctx.tools.register(definition: ToolDefinition): () => void`:注册一个受信任、带类型的同进程定义,其中必须包含规范的 `output` 声明。所在层由调用上下文的作用域决定:普通插件上下文会全局注册;agent 的 `agent.ctx` 只为该 agent 注册,并在此处遮蔽同名全局工具。同一层内名称重复会抛出;非原生模式还会拒绝保留的 `run_code` 传输名称。缺失或不受支持的输出声明,以及非正数或非有限的 `timeoutMs`,都会使注册失败。可选的同步 `finalizeContent` 回调会在调用开始时创建快照;在所有流水线结果规范化之后,它只能替换最终面向模型的内容,包括实体化其他结果字段时发现的错误。随调用 fiber dispose(资源释放)。
- `ctx.tools.restrict(filter)`:对全局工具应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。筛选器在注册时创建快照;多个掩码取交集,随后再合并作用域本地工具。拒绝掩码会接纳后来出现且未点名的全局工具,而允许掩码会排除后来出现的名称。未知、本地或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
- `ctx.tools.presentAs(mode: ToolPresentationMode): () => void`:为本 agent 选择面向模型的呈现方式,仅对该 agent 遮蔽 `mode` 配置;从普通上下文调用会抛出(进程级呈现方式是那个配置字段),同一 scope 内第二次声明也会抛出。code 类模式还会为该 agent 注册它自己的 `tools:sdk` 段。清单本身不变——`schemas(agent)` 报告的仍是该 agent 的能力,坍缩的只是 assembly 里的工具。随调用方 fiber 一同释放。
- `ctx.tools.restrict(filter)`:对该作用域**继承来的**工具——全局层以及其链上的每个祖先作用域——应用 agent 作用域的允许/拒绝掩码;从普通上下文调用会抛出。作用域**自身**的注册不受掩码约束,并在其后合并进来,这正是让被委派子 agent 的回报与结构化输出工具能在只点名其可用能力的筛选器下存活的机制。筛选器在注册时创建快照;多个掩码取交集,祖先上的掩码作用于其内嵌套的每个作用域。拒绝掩码会接纳后来出现且未点名的继承工具,而允许掩码会排除后来出现的名称。未知、自身层或保留名称以及空筛选器都会被拒绝。这是实时可见性组合,不是权限边界;参见[作用域安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined`:按某个作用域所见的结果解析(应用遮蔽;被限制掉的全局工具视为不存在)。呈现器会传入发起调用的 agent,使卡片与实际执行内容一致。
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]`:返回该作用域可见的所有 schema(不含 `execute` 函数)。已交付工具的 schema 收录在 [docs/tool-catalog.md](../../../docs/tool-catalog.md) 中;该目录通过启动每个工具插件并采集此方法的结果生成(参见[工具 schema 目录 Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md))。
- `ctx.tools.guard(guard: ToolGuard): () => void`:在 `tools/pre-execute` 之后注册单调同步执行守卫:返回理由会拒绝调用,返回 `undefined` 则保持原决定。普通上下文守卫全局生效;`agent.ctx` 守卫只对该 agent 生效。后续 waterfall(瀑布式事件)监听器无法将守卫的拒绝重新变为允许。随调用 fiber dispose。
@@ -35,7 +36,7 @@ tools:
### 实时事件
实时注册表流水线先经过 3 个可变换的 waterfall,再经过由定义拥有的内容终结器,最后到达仅观测的 `tools/result` 边界;注册表变更有意作为不过滤的共享状态通知。确切签名、分发 mode、作用域筛选和故障收容约定位于 [tools.md](../../../docs/subsystems/tools.md#cordis-surface) 的生成区块,完整顺序则在生成的[工具执行流水线](../../../docs/tool-execution-pipeline.md)中可视化。`tools/result` 是实时事件;名称相近的 `tool/result` 是 agent loop 随后追加的持久会话事件。
实时注册表流水线先经过 3 个可变换的 waterfall,再经过由定义拥有的内容终结器,最后发布仅供观测的 `tools/result` 事件;注册表变更有意作为不过滤的共享状态通知。确切签名、分发 mode、作用域筛选和失败隔离约定位于 [tools.md](../../../docs/subsystems/tools.md#cordis-surface) 的生成区块,完整顺序则在生成的[工具执行流水线](../../../docs/tool-execution-pipeline.md)中可视化。`tools/result` 是实时事件;名称相近的 `tool/result` 是 agent loop 随后追加的持久会话事件。
### 关键类型
@@ -65,7 +66,7 @@ tools:
```ts
import { readFile } from 'node:fs/promises'
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
declare const ctx: Context
@@ -119,7 +120,7 @@ ctx.tools.register(defineTool({
- **SDK 段**(`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成与所加载运行时语言相符的 SDK 文本。TypeScript 形态发出 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明;Python 形态(`ctx.codeRuntime.language === 'python'`)发出等价的具名 `TypedDict` 与一个带相同用法说明的 `tools` 对象。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。两个代码生成器都已导出,且绝不会在提示词组装期间抛出:`jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown`;`jsonSchemaToPy` 同理,降级为 `Any`(当某字段名不是合法的 `TypedDict` 属性时,或在 SDK 渲染之外被调用时——`TypedDict` 声明所需的命名上下文由该渲染提供——整个对象降级为 `dict[str, Any]`)。
- **分发桥接层**(`run_code` 的 execute):每个绑定调用都会在分发前快照为无损 JSON(`undefined`、`BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发约定的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10;设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker,并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `<parent>:code:<n>`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。
- **结算纪律**:桥接层拥有一个运行作用域的中止机制;该中止会跟随传入的外层信号,并在运行因任何原因结算时触发,因此预算耗尽会中止正在运行的子工具,而不会将其遗留。桥接层随后会在返回之前排空队列,使每个 `tool/code-dispatch` 都落在仍打开的轮次内。失败的运行会抛出 `CodeRunFailedError`(`code: 'CODE_RUN_FAILED'`,message = 失败类型 + 已捕获日志),流水线会将其转换为模型可据以自我修正的结构化 `isError`。
- **结果边界**:中间绑定值会完整跨越 worker 边界,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB)只应用于组合序列化后的外层日志数组、完成值或失败消息载荷;固定的结果 envelope 语法和呈现空白不计入该账本。无效和超限的完成会明确失败,只有此外层结果可以使用普通 spill。
- **结果大小**:中间绑定值会完整传入 worker 进程,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB)只应用于组合序列化后的外层日志数组、完成值或失败消息载荷;固定的结果 envelope 语法和呈现空白不计入该上限。无效和超限的完成会明确失败,只有此外层结果可以使用普通 spill。
### 并行执行
@@ -145,7 +146,7 @@ agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `e
#### 模型看到的内容
Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode 接口。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 风格(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 风格(用于任何报告 `language: 'python'` 的运行时)形状相同,只是换成 Python 语法(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。
Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode API。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 版本(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 版本(用于任何报告 `language: 'python'` 的运行时)以 Python 语法提供相同操作和类型(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。
##### Code Mode SDK 说明
@@ -190,6 +191,6 @@ The available tools:
- **`tools/pre-execute` 有意不允许改写 `exec.arguments`**:否则日志记录和呈现的参数会与实际运行内容失去同步;改写设计记录在[拟议的 Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)中。
- **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇和工具输出支持任意 JSON 根。
- **定义上的 `timeoutMs` 仅为声明**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-timeout-policy` 包装层。
- **Code Mode 的 SDK 语言跟随唯一加载的运行时,且呈现模式在服务内统一**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK 渲染器(`typescript` 经 worker 后端,`python` 用于任何报告该语言的运行时);作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native、另一个仅使用 Code,且单个运行时把语言固定为服务级([语言分发 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) 负责这次查表,以及注册表为何读取所加载的运行时而不自带 language 字段)。
- **Code Mode 的 SDK 语言跟随已加载的那个运行时,且呈现方式按 agent 而非按工具**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK renderer(TypeScript 或 Python);作用域限制/遮蔽与 `presentAs` 会选择每个 agent 的可见绑定及其形态,但在同一个 agent 内不能让一个工具仅使用 Native,而另一个仅使用 Code。
- **Code Mode 中间值只存在于执行局部,且没有字节上限**:这些规范的类型化值无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。每个子调用的持久日志副本则确实有上限:`tools/code-dispatch-log` waterfall 允许 spill 策略把过大的 `tool/code-dispatch` 内容替换为预览加定位符([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md))。
- **每次运行都会获得全新的 `run_code` 状态**:MVP 不采用持久 REPL 风格内核(跨调用状态不会出现在日志中);参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-tools",
"description": "Tool registry and execution pipeline for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/core/tools"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -34,18 +41,18 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-code-runtime": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-code-runtime": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"schemastery": "^3.18.0"
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
@@ -56,6 +63,6 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -28,7 +28,7 @@ export const SDK_SECTION_ORDER = 150
* strings share one source of truth. Keyed by `CodeRuntime.language`, mirroring
* `SDK_RENDERERS` in {@link ./index.ts}. The emitted flavor MUST match the
* semantics the same language's SDK instructions promise, so the model never
* receives a TypeScript-shaped schema beside a Python SDK (or vice versa).
* receives a TypeScript schema beside a Python SDK (or vice versa).
*/
interface RunCodeFlavor {
/** The tool `description` the model sees for this language. */
@@ -260,7 +260,7 @@ type RunCodeOutput = { logs: string[]; result?: JsonValue }
/**
* Registry-private capabilities the bridge receives at construction — the
* `requireRuntime` idiom: operations only the owning registry can mint stay
* off its public service surface and flow here as closures instead.
* off its public service API and flow here as closures instead.
*/
export interface RunCodeBridgeOptions {
/** Resolves `ctx.codeRuntime` or throws the loud misconfiguration error (shared with the registry's assembly-time checks). */
@@ -338,8 +338,8 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
exec.signal.addEventListener('abort', onOuterAbort, { once: true })
let dispatches = 0
// The per-run scheduler, reusing the NATIVE concurrency contract through
// the registry's staged view (the loop scheduler's own boundary) — and the
// The per-run scheduler uses the registry's staged interface and follows
// the same concurrency rules as the native loop. It also follows the
// native loop's SEQUENCING: every ordered stage (the dispatch-start
// append, prepare = pre-execute/guards, finalize/finish = post-execute,
// context deferral, the settle append) runs inside ONE driver lane, so
@@ -369,7 +369,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
}
const pendingQueue: PendingDispatch[] = []
const inFlight = new Set<Promise<void>>()
/** Tracked settle-event side work (log shaping + append), drained at run settlement. */
/** Tracked settle-event side work (log-content listener + append), drained at run settlement. */
const logWork = new Set<Promise<void>>()
const commitQueue: PendingDispatch[] = []
let exclusiveActive = false
@@ -394,7 +394,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
driverRun = (async () => {
try {
for (;;) {
// Arm before inspecting state so a settle or submission landing
// Create the wakeup promise before inspecting state so a settle or submission arriving
// between the checks and the await below cannot be lost.
const signal = new Promise<void>((resolve) => { wake = resolve })
const commitHead = commitQueue[0]
@@ -449,7 +449,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
// entries, awaits the live pool, and drains the ordered commit lane —
// including a commit already in progress when the program returned.
await drive()
// Every settle's shaped append lands inside the open run_code turn
// Every settle event is appended inside the open run_code turn
// (tasks self-remove on settlement).
while (logWork.size > 0) await Promise.allSettled([...logWork])
}
@@ -483,10 +483,10 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
| { kind: 'post-result' | 'final-result'; exec: ToolRunContext; result: ToolExecutionResult }
| undefined
const settle = (result: ToolExecutionResult): void => {
// The program gets its value NOW: log shaping (e.g. a spill
// backend) must never delay the binding or occupy a dispatch
// slot. The shaped append is tracked side work; the run's
// settlement drains logWork so every settle event still lands
// The program gets its value NOW: the log-content listener (for
// example, a spill backend) must never delay the binding or occupy
// a dispatch slot. The event append is tracked side work; the run's
// settlement drains logWork so every settle event is still appended
// inside the open turn (shapeDispatchLog is contained, so this
// chain cannot reject).
resolve(result.isError
@@ -495,9 +495,9 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
const agent = exec.agent
if (agent === undefined) return
const task: Promise<void> = (async () => {
// The durable copy may be reshaped (e.g. spilled to a preview +
// locator) by the log-shaping waterfall; the program's value
// and the model contract are untouched.
// The listener may replace the durable copy with a preview and
// locator; the program's value and model-visible result are
// untouched.
const logged = await shapeDispatchLog({
exec, agent, subCallId, name, isError: result.isError,
// The registry deep-froze this projection at result
@@ -560,16 +560,16 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
for (const context of result.additionalContexts ?? []) {
exec.deferContext(context)
}
// Like the context forwarding above, cross-boundary facts travel
// on the nested result and the composite forwards them: only a
// successful nested result can carry the terminal marker
// The composite forwards `additionalContexts` above and
// `concludesTurn` here from the nested result. Only a successful
// nested result can carry the terminal marker
// (ToolExecutionFailure types it never), so a policy-converted
// failure cannot stop the turn through a recovering program.
if (result.concludesTurn) exec.concludeTurn()
settle(result)
// Backpressure on the shaped-append side channel: pending log
// tasks (each retaining a full result while a slow backend
// stores it) are bounded by the pool cap — beyond it the
// Backpressure on pending event-append tasks: each task retains
// a full result while a slow backend stores it, so the pool cap
// bounds their count. Beyond the cap, the
// ordered lane waits, so later sub-calls cannot start and
// pending I/O/memory cannot grow without bound.
while (logWork.size > maxParallel) await Promise.race(logWork)
@@ -578,7 +578,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
wakeup()
void drive()
})
// A budget expiry or outer cancel that lands while this call was in
// A budget expiry or outer cancel that occurs while this call was in
// flight already aborted the dispatch; stop the program now rather
// than hand it a result from a run that is over.
if (runOver()) {
@@ -646,7 +646,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
kind: 'execute',
rawInput: args.code,
}),
// Deliberately no presentResult: the generic surface fallback keeps this
// Deliberately no presentResult: the generic card fallback keeps this
// title and reads durable result content without duplicating a large raw
// result into the host view payload.
})
@@ -661,7 +661,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
Object.defineProperty(definition, 'parameters', {
enumerable: true,
// Recompile through the same spec→schema projection defineTool used, so
// the emitted shape can never drift from the validated one.
// the emitted schema always matches the validated specification.
get: () => parameterSchemaSpecToJsonSchema({
code: { type: 'string', required: true, description: resolveFlavor(peekRuntime).codeDescription },
description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION },

View File

@@ -4,8 +4,8 @@
* @module @deepseek-ai/dsh-tools
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
@@ -94,7 +94,7 @@ export { defineContentToolFixture, type ContentToolFixtureOptions } from './test
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
// stays the single public surface for tool producers and UI adapters.
// stays the single public API for tool producers and UI adapters.
export type {
ToolCallKind,
FileLocation,
@@ -120,7 +120,7 @@ export type {
WebSource,
} from './presentation.ts'
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
tools: ToolRegistry
}
@@ -160,13 +160,14 @@ declare module 'cordis' {
*/
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
/**
* Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before
* the bridge appends its `tool/code-dispatch` event. `next()` keeps the
* Allow a listener to replace content in the DURABLE LOG COPY of one
* `run_code` sub-dispatch outcome before the bridge appends its
* `tool/code-dispatch` event. `next()` keeps the
* content unchanged; a listener may return replacement blocks (e.g. the
* spill policy's preview + locator for an oversized text result). Only the
* logged copy is affected — the program already received the complete
* value, and the model sees neither. A throwing listener is contained:
* the bridge falls back to logging the unshaped content.
* the bridge falls back to logging the original settled content.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches.
* @param dispatch - the parent execution, sub-call identity, and the settled content to log.
* @mode waterfall
@@ -199,7 +200,7 @@ export interface ToolOutputDefinition {
readonly schema: JsonSchemaNode
/** Pure projection from validated arguments and value to Native/model content. */
render(args: unknown, value: JsonValue): ContentBlock[]
/** Pure replayable presentation projection, computed only for surface calls. */
/** Pure replayable presentation projection, computed only for top-level calls. */
presentationMeta?(args: unknown, value: JsonValue): JsonValue
}
@@ -623,11 +624,16 @@ export type ToolPresentationMode = 'native' | 'code' | 'both'
/** Plugin config: how the registered tools are presented to the model. */
export interface Config {
/**
* Model presentation. `native` (default) sends every visible schema; `code`
* sends only `run_code` plus a generated SDK prompt; `both` sends both forms.
* Code modes require a `ctx.codeRuntime` whose `language` has a registered
* SDK renderer (TypeScript or Python) and fail prompt assembly when it is
* absent or has no renderer. Under `code`, native names in `toolOrder` are invalid.
* Model presentation for agents that declare none of their own. `native`
* (default) sends every visible schema; `code` sends only `run_code` plus a
* generated SDK prompt; `both` sends both forms. Code modes require a
* `ctx.codeRuntime` whose `language` has a registered SDK renderer
* (TypeScript or Python) and fail prompt assembly when it is absent or has
* no renderer. Under `code`, native names in `toolOrder` are invalid.
*
* One agent overrides this for itself with {@link ToolRegistry.presentAs},
* which is how an agent preset composes a Code Mode agent beside native
* ones in the same process.
*/
mode?: ToolPresentationMode
/**
@@ -641,13 +647,14 @@ export interface Config {
}
/**
* Per-scope filter over global tools. Restrictions intersect and do not affect
* scoped registrations or the reserved Code Mode transport.
* Per-scope filter over the tools a scope INHERITS — the global layer and
* every ancestor layer on its chain. Restrictions intersect, and do not affect
* the scope's own registrations or the reserved Code Mode transport.
*/
export interface ToolRestriction {
/** Global tool names that stay visible; everything else is removed. */
/** Inherited tool names that stay visible; every other inherited one is removed. */
readonly allow?: readonly string[]
/** Global tool names removed from visibility. */
/** Inherited tool names removed from visibility. */
readonly deny?: readonly string[]
}
@@ -663,7 +670,7 @@ interface ToolView {
readonly visible: ReadonlyMap<string, ToolDefinition>
/** Pre-restriction capability names used by prompt-order validation. */
readonly knownNames: ReadonlySet<string>
/** Current global names that a scoped restriction may name. */
/** Current inherited names a scoped restriction may name; its own are exempt. */
readonly restrictableNames: ReadonlySet<string>
}
@@ -682,6 +689,12 @@ class ToolLayer implements ScopeLayer {
readonly tools: NamedEntries<ToolDefinition>
readonly restrictions = new AnonymousEntries<CompiledToolRestriction>()
readonly guards = new AnonymousEntries<ToolGuard>()
/**
* Presentation this scope's agent declared for itself, shadowing the
* deployment default. One cell rather than an entry table: two answers to
* "which form does the model see" is a contradiction, not a merge.
*/
mode: ToolPresentationMode | undefined
constructor(scope: ScopeKey | undefined) {
this.tools = new NamedEntries(name => new Error(scope === undefined
@@ -692,9 +705,10 @@ class ToolLayer implements ScopeLayer {
/** Whether every contribution table in this aggregate layer is empty. */
isEmpty(): boolean {
return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty()
&& this.mode === undefined
}
/** Whether every compiled restriction in this layer admits a global tool name. */
/** Whether every compiled restriction in this layer admits an inherited tool name. */
admits(name: string): boolean {
for (const filter of this.restrictions.values()) {
if ((filter.allow !== undefined && !filter.allow.has(name))
@@ -772,60 +786,145 @@ export class ToolRegistry extends Service {
scope => new ToolLayer(scope),
() => { this.ctx.emit('tools/change') },
)
private readonly mode: ToolPresentationMode
/** Reserved presentation transport, kept outside the filterable registration layers. */
private readonly codeTransport: ToolDefinition | undefined
/** Presentation for scopes that declare none; {@link presentAs} shadows it per scope. */
private readonly defaultMode: ToolPresentationMode
private readonly maxParallelSubCalls: number
/**
* Reserved presentation transport, kept outside the filterable registration
* layers. Built on first need rather than at construction: which agents run
* a code mode is no longer known when the service is constructed, and the
* transport is stateless beyond its closures over `this`.
*/
private codeTransport: ToolDefinition | undefined
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'tools')
// The schema already defaulted an omitted mode; the ?? narrows the
// optional-input type for direct (non-Loader) construction in tests.
this.mode = config.mode ?? 'native'
// `run_code` is presentation infrastructure, not an end capability. It
// therefore does not enter the global layer: per-agent restrictions must
// not remove it, and a scoped registration must not shadow it. The
// visibility resolver appends this reserved definition after resolving
// the filterable global/scoped capability layers.
this.codeTransport = this.mode === 'native'
? undefined
: createRunCodeTool(this, {
requireRuntime: () => this.requireCodeRuntime(),
peekRuntime: () => this.ctx.get('codeRuntime'),
maxParallel: resolveMaxParallelSubCalls(config.maxParallelSubCalls),
shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch),
})
this.defaultMode = config.mode ?? 'native'
this.maxParallelSubCalls = resolveMaxParallelSubCalls(config.maxParallelSubCalls)
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
if (this.mode !== 'native') {
ctx.systemPrompt.section({
name: 'tools:sdk',
order: SDK_SECTION_ORDER,
// Regenerate from the calling scope's visible tools in stable order,
// picking the renderer that matches the loaded runtime's language.
// `requireCodeRuntime` already validated the language is in the table,
// so the guard below is defense-in-depth against a caller that bypassed
// it (impossible under normal composition).
text: (context) => {
const runtime = this.requireCodeRuntime()
// Own-property read: a language like `toString`/`constructor` would
// otherwise resolve an inherited Object.prototype member as a renderer.
const render = SDK_RENDERERS[runtime.language]
/* v8 ignore next 3 -- requireCodeRuntime rejects an unknown language before this ever runs. */
if (!Object.hasOwn(SDK_RENDERERS, runtime.language) || render === undefined) {
throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ')})`)
}
return render(this.sdkSchemas(context.scope))
},
})
if (this.defaultMode !== 'native') {
ctx.systemPrompt.section(this.sdkSection())
}
}
/**
* The generated-SDK prompt section, registered globally by a code-mode
* deployment and per scope by {@link presentAs}.
*
* The body regenerates from the CALLING scope, and renders empty for an
* agent presenting natively — an agent that opted out under a code-mode
* deployment still sees the global registration, and an empty section is
* dropped from the rendered prompt.
* @returns the section registration.
*/
private sdkSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } {
return {
name: 'tools:sdk',
order: SDK_SECTION_ORDER,
// Regenerate from the calling scope's visible tools in stable order.
text: (context) => {
const mode = this.modeFor(context.scope)
if (mode === 'native') return ''
const runtime = this.requireCodeRuntime(mode)
// Own-property read: a language like `toString`/`constructor` would
// otherwise resolve an inherited Object.prototype member as a renderer.
const render = SDK_RENDERERS[runtime.language]
/* v8 ignore next -- requireCodeRuntime rejects an unknown language before this runs. */
if (render === undefined) throw new Error(`dsh-tools: no SDK renderer for ${runtime.language}`)
return render(this.sdkSchemas(context.scope))
},
}
}
/**
* The presentation one scope's agent sees: its own declaration, else the
* deployment default.
* @param scope - the calling agent, or undefined for the global view.
* @returns the resolved presentation mode.
*/
private modeFor(scope?: ScopeKey): ToolPresentationMode {
// Nearest scope wins along the chain: a preset's standing declaration
// covers every agent parented under it, and an agent's own (were one ever
// declared) would override its preset's. The mode decides what the model
// SEES, which is exactly the class of fact the chain inherits.
const layers = this.layers.chainLayers(scope)
for (let index = layers.length - 1; index >= 0; index -= 1) {
const mode = layers[index]?.mode
if (mode !== undefined) return mode
}
return this.defaultMode
}
/**
* The reserved `run_code` transport, built on first need.
*
* It never enters the global layer: per-agent restrictions must not remove
* it, and a scoped registration must not shadow it. The visibility resolver
* appends it after resolving the filterable global/scoped capability layers,
* and only for scopes whose mode actually presents it.
* @returns the shared transport definition.
*/
private requireCodeTransport(): ToolDefinition {
this.codeTransport ??= createRunCodeTool(this, {
requireRuntime: () => this.requireCodeRuntime(this.defaultMode),
// The language-aware description/parameters getters read the runtime
// without demanding one, so a native-default process can still project
// the transport for an agent that chose code.
peekRuntime: () => this.ctx.get('codeRuntime'),
maxParallel: this.maxParallelSubCalls,
shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch),
})
return this.codeTransport
}
/**
* Present the calling scope's tools in `mode` instead of the deployment
* default. Nearest scope on the chain wins, so a preset's standing
* declaration covers every agent joined under it.
*
* Scoped only, and one declaration per scope: this is how an agent preset
* composes Code Mode agents beside native ones in the same process, and a
* process-global override would be the `mode` config field instead.
* @param mode - the presentation the covered agents' models see.
* @returns the exact disposer that restores the deployment default.
*/
presentAs(mode: ToolPresentationMode): () => void {
const ctx = this.ctx
if (scopeOf(ctx) === undefined) {
throw new Error('tools.presentAs() requires a scoped context (agent.ctx): a context-global presentation is the `mode` config field on the tools row')
}
const dispose = ctx.effect(function* (this: ToolRegistry) {
yield this.layers.effect(
ctx,
(layer) => {
if (layer.mode !== undefined) {
throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this scope; one composition selects one presentation`)
}
layer.mode = mode
return () => { layer.mode = undefined }
},
{ label: 'tools.presentAs()' },
)
// The SDK section is per scope for the same reason the mode is. Under a
// deployment that already defaults to a code mode this shadows the
// global registration with an identical body, which costs nothing and
// keeps one rule instead of a case analysis.
if (mode !== 'native') yield ctx.systemPrompt.section(this.sdkSection())
}.bind(this), 'tools.presentAs()')
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown; direct return preserves disposer identity
return dispose
}
/**
* Build one scope's wire schemas and names for prompt-order validation.
* Restrictions do not make known tools invalid, but a mode collapse does.
*/
private wireSchemas(scope?: ScopeKey): ToolProviderResult {
const view = this.view(scope)
if (this.mode === 'native') {
const mode = this.modeFor(scope)
if (mode === 'native') {
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
return { schemas, knownNames: [...view.knownNames] }
}
@@ -834,9 +933,9 @@ export class ToolRegistry extends Service {
// flavor-table guard would otherwise surface first. This keeps the
// renderer-table rejection the canonical assembly-time error for a
// language with no SDK renderer.
this.requireCodeRuntime()
this.requireCodeRuntime(mode)
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
if (this.mode === 'code') {
if (mode === 'code') {
return {
schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME),
knownNames: [RUN_CODE_NAME],
@@ -861,10 +960,10 @@ export class ToolRegistry extends Service {
* point it is testable); rationale in the
* [language-dispatch note](../../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md).
*/
private requireCodeRuntime(): CodeRuntime {
private requireCodeRuntime(mode: ToolPresentationMode): CodeRuntime {
const runtime = this.ctx.get('codeRuntime')
if (!runtime) {
throw new Error(`dsh-tools: mode "${this.mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`)
throw new Error(`dsh-tools: mode "${mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`)
}
if (!Object.hasOwn(SDK_RENDERERS, runtime.language)) {
const known = Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ')
@@ -893,7 +992,10 @@ export class ToolRegistry extends Service {
&& (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`)
}
if (this.codeTransport !== undefined && name === RUN_CODE_NAME) {
// Reserved unconditionally: any agent may select a code mode for itself,
// so a name free to take under the deployment default would become a
// collision the moment a preset mounted.
if (name === RUN_CODE_NAME) {
throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
}
return this.layers.effect(
@@ -907,7 +1009,7 @@ export class ToolRegistry extends Service {
* Restrict global tools for the calling agent scope. Empty filters, unknown
* names, scope-local names, and reserved transport names fail. Restrictions
* intersect; scoped registrations remain visible.
* @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).
* @param filter - global-tool mask: `allow` (keep only) and/or `deny` (remove).
* @returns the exact disposer that lifts this restriction.
*/
restrict(filter: ToolRestriction): () => void {
@@ -924,14 +1026,13 @@ export class ToolRegistry extends Service {
...allow !== undefined ? { allow: new Set(allow) } : {},
...deny !== undefined ? { deny: new Set(deny) } : {},
}
if (this.codeTransport !== undefined
&& [...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) {
if ([...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) {
throw new Error(`tools.restrict() cannot name reserved Code Mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`)
}
const known = this.view(scope).restrictableNames
const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name))
if (unknown.length > 0) {
throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)
throw new Error(`tools.restrict() names unknown inherited tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; a restriction filters what this scope inherits, never what it registers itself. Restrictable tools: ${[...known].sort().join(', ') || '(none)'}`)
}
return this.layers.effect(
this.ctx,
@@ -958,42 +1059,79 @@ export class ToolRegistry extends Service {
)
}
/** First monotonic denial from the global then matching scoped guard layers. */
/** First monotonic denial from the global then the scope chain's guard layers, farthest first. */
private guardReason(exec: ToolExecution): string | undefined {
const globalReason = this.layers.global.guardReason(exec)
if (globalReason !== undefined) return globalReason
return exec.agent === undefined ? undefined : this.layers.peek(exec.agent)?.guardReason(exec)
if (exec.agent === undefined) return undefined
for (const layer of this.layers.chainLayers(exec.agent)) {
const reason = layer.guardReason(exec)
if (reason !== undefined) return reason
}
return undefined
}
/**
* Resolve every registry fact one scope needs in one layer traversal. The
* visible map applies global restrictions, scoped shadowing, and the reserved
* presentation transport; the other sets retain the pre-restriction facts
* needed by restriction and prompt-order validation.
* visible map applies restrictions to the INHERITED surface, then the
* scope's own registrations and the reserved presentation transport; the
* other sets retain the pre-restriction facts needed by restriction and
* prompt-order validation.
*
* A restriction filters what a scope inherits — the global layer and every
* ancestor layer on its chain — and never what its OWN layer registers.
* That exemption is what a per-child capability filter has to keep intact:
* the delegation runtime registers a child's reporting and structured-output
* tools into the child's own layer, and a filter naming the capabilities the
* child may use must not strip the machinery it answers through.
*
* Reading the exempt set as "the global layer" instead of "not mine" held
* only while every model-facing tool sat in the host composition. Once
* presets moved them onto the agent plane they became an ANCESTOR
* contribution, so a child's filter silently stopped constraining anything
* it was given.
* @param scope - the viewing scope (the agent), or undefined for the global view.
* @returns the complete derived view for that scope.
*/
private view(scope?: ScopeKey): ToolView {
const layer = this.layers.peek(scope)
// Scope-chain layers, farthest ancestor first, the exact scope last.
const layers = this.layers.chainLayers(scope)
// Chain-blind on purpose: this is the ONE layer whose registrations the
// scope owns rather than inherits, and it is absent until the scope
// contributes something.
const own = this.layers.peek(scope)
// Inherited surface, nearest ancestor last: a nearer scope's same-name
// entry shadows a farther one, and the global layer is the farthest.
const inherited = new Map<string, ToolDefinition>(this.layers.global.tools.entries())
for (const layer of layers) {
if (layer === own) continue
for (const [name, definition] of layer.tools.entries()) inherited.set(name, definition)
}
const visible = new Map<string, ToolDefinition>()
const knownNames = new Set<string>()
const restrictableNames = new Set<string>()
for (const [name, definition] of this.layers.global.tools.entries()) {
for (const [name, definition] of inherited) {
knownNames.add(name)
restrictableNames.add(name)
if (layer?.admits(name) ?? true) visible.set(name, definition)
// Restrictions intersect across the whole chain: any scope on it may
// mask an inherited name for everything nested inside it.
if (layers.every(layer => layer.admits(name))) visible.set(name, definition)
}
// Scoped layer second: same-name entries REPLACE (shadow) the global ones,
// and scope-local registrations are never part of the global filter above.
for (const [name, definition] of layer?.tools.entries() ?? []) {
knownNames.add(name)
visible.set(name, definition)
// The scope's own registrations last, shadowing an inherited name and
// outside the filter above.
if (own !== undefined) {
for (const [name, definition] of own.tools.entries()) {
knownNames.add(name)
visible.set(name, definition)
}
}
// Presentation infrastructure is resolved last and outside capability
// filtering. Registration rejects this reserved name, so the insertion is
// an invariant assertion as well as protection against future layer changes.
if (this.codeTransport !== undefined) {
visible.set(RUN_CODE_NAME, this.codeTransport)
// an invariant assertion as well as protection against future layer
// changes. Per scope: a native agent must not find `run_code` in its
// dispatch table because some other agent in the process presents it.
if (this.modeFor(scope) !== 'native') {
visible.set(RUN_CODE_NAME, this.requireCodeTransport())
}
return { visible, knownNames, restrictableNames }
}
@@ -1073,8 +1211,8 @@ export class ToolRegistry extends Service {
/**
* Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch
* and return the content the bridge should log on `tool/code-dispatch`.
* Contained: a throwing listener falls back to the unshaped content — log
* shaping must never fail the dispatch or lose the settle event. Private:
* Contained: when a listener throws, the method logs the original settled
* content; that failure must not fail the dispatch or omit the settle event. Private:
* the ONE consumer is the `run_code` bridge this registry constructs, which
* receives it as a capability parameter (the `requireRuntime` idiom) — the
* waterfall, not this invoker, is the public extension point.
@@ -1086,7 +1224,7 @@ export class ToolRegistry extends Service {
() => Promise.resolve(dispatch.content),
)
} catch (error: unknown) {
this.ctx.logger.warn(`tools: code-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the unshaped content`)
this.ctx.logger.warn(`tools: code-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the original settled content`)
return dispatch.content
}
}

View File

@@ -1,6 +1,6 @@
/** Package-owned tool-pipeline invariants. @module @deepseek-ai/dsh-tools/invariant */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { ToolExecution, ToolExecutionResult } from './index.ts'

View File

@@ -7,7 +7,7 @@
*
* Unsupported or misplaced keywords reject rather than being accepted without
* enforcement. Consumers that require an object root apply
* {@link assertObjectJsonSchema} at their own boundary.
* {@link assertObjectJsonSchema} before accepting input.
* @module dsh-tools/json-schema
*/
@@ -25,7 +25,7 @@ type JsonSchemaScalarType = Exclude<JsonSchemaType, 'object' | 'array'>
/**
* One raw JSON Schema node in the enforced subset. The optional fields express
* the external wire shape; {@link assertSupportedJsonSchema} rejects invalid
* the external wire schema; {@link assertSupportedJsonSchema} rejects invalid
* combinations before a caller treats the node as trusted.
*/
export interface JsonSchemaNode {

Some files were not shown because too many files have changed in this diff Show More