Merge remote-tracking branch 'origin/master' into feature/subagent-policy-inheritance

# Conflicts:
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/subagent/subagent-inprocess/README.i18n.yaml
This commit is contained in:
kingwl
2026-07-28 18:37:46 +08:00
59 changed files with 362 additions and 95 deletions

View File

@@ -1401,7 +1401,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'AgentOptions',
declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n}',
declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n}',
},
{
name: 'AgentStatus',

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: c12140f27aed400b0f7b4246700473e877d37632
README.zh.md: 6394cd86f5f3241be07ef711c76079624bce1bfe
README.md: cd8b76a0d4f181bdf4620a4a17c9725ff1132c3b
README.zh.md: d145dc785da8d78d24bc6bc014fb04ba005149fb

View File

@@ -42,13 +42,14 @@ interface Config {
id: string // required
provider?: string
model?: string
maxTokens?: number // positive per-request output-token cap
resumeSessionId?: string // load this persisted session instead of creating one
cwd?: string // optional workspace cwd for the fresh session
}>
}
```
Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`.
Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. An optional positive `maxTokens` seeds each conversation request's output cap and is logged in its request header. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`.
### Internal concrete driver

View File

@@ -42,13 +42,14 @@ interface Config {
id: string // required
provider?: string
model?: string
maxTokens?: number // positive per-request output-token cap
resumeSessionId?: string // load this persisted session instead of creating one
cwd?: string // optional workspace cwd for the fresh session
}>
}
```
通过配置创建的 agent 会自动启动。模型调用同时需要 `provider``model``agent/request` 可以在分发前补齐缺失的这一对值。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10``cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona编程式 setup 可以按 agent 遮蔽它。该插件提供逐 agent 的 `provider``model``cwd` 提示词变量harness 身份与部署 persona 属于 `dsh-system-prompt`
通过配置创建的 agent 会自动启动。模型调用同时需要 `provider``model``agent/request` 可以在分发前补齐缺失的这一对值。可选的正整数 `maxTokens` 会为每次对话请求提供初始输出上限,并记录在请求 header 中。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10``cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona编程式 setup 可以按 agent 遮蔽它。该插件提供逐 agent 的 `provider``model``cwd` 提示词变量harness 身份与部署 persona 属于 `dsh-system-prompt`
### 包内部实体驱动器

View File

@@ -577,11 +577,16 @@ export class ReactLoopAgent implements Agent {
&& persistedConfig.model === route.model
? persistedConfig.reasoningEffort
: undefined
const maxTokens = this.options.maxTokens
const seedConfig = deepFreeze(structuredClone(
this.requestHeaderLogged
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- the instance logged the header it now folds
? persistedConfig!
: { ...route, ...reasoningEffort === undefined ? {} : { reasoningEffort } },
: {
...route,
...reasoningEffort === undefined ? {} : { reasoningEffort },
...maxTokens === undefined ? {} : { maxTokens },
},
))
const proposedConfig = await this.loopCtx.waterfall(
agentCarrier(this), 'agent/request', this, turn, step, signal,

View File

@@ -112,6 +112,14 @@ function resolveMaxParallelToolCalls(value: number | undefined): number {
return maxParallelToolCalls
}
/** Reject an output-token cap that cannot be represented exactly on the request wire. */
function assertAgentOptions(options: AgentOptions): void {
if (options.maxTokens !== undefined
&& (!Number.isSafeInteger(options.maxTokens) || options.maxTokens <= 0)) {
throw new TypeError('agent maxTokens must be a positive safe integer')
}
}
/** Prepared-but-unpublished agent resources sharing one memoized teardown. */
interface PreparedAgent {
agent: ReactLoopAgent
@@ -196,6 +204,7 @@ export class AgentLoop extends Service implements AgentFactory {
sessionId: z.string().min(1),
provider: z.string(),
model: z.string(),
maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
cwd: z.string(),
resumeSessionId: z.string(),
})).default([]),
@@ -327,6 +336,7 @@ export class AgentLoop extends Service implements AgentFactory {
* fuses caller cancellation with lifecycle teardown for setup awaits.
*/
private prepare(ownerCtx: Context, id: SessionId, options: AgentOptions, session: Session, callerSignal?: AbortSignal): PreparedAgent {
assertAgentOptions(options)
ownerCtx.fiber.assertActive()
// Every caller reaches prepare() synchronously from a service method
// whose Cordis dispatch already requires the live factory fiber, or

View File

@@ -46,6 +46,19 @@ function send(agent: Agent, text: string) {
}
describe('agent loop', () => {
it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])(
'rejects invalid AgentOptions.maxTokens %s before publication',
async (maxTokens) => {
const ctx = await harness(new MockAdapter([]))
expect(() => ctx.agentLoop.create(
SessionId('invalid-max-tokens'),
{ provider: 'mock', model: 'mock', maxTokens },
)).toThrow('agent maxTokens must be a positive safe integer')
expect(ctx.agents.list()).toEqual([])
expect(ctx.sessions.list()).toEqual([])
},
)
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
const adapter = new MockAdapter([textResponse('hello there')])
const ctx = await harness(adapter)

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: bb48fd8b227484a43af8f9f9e55f8adc8b990ce6
README.zh.md: 531db9905b3c091a5c129d31e944e4095e66123b
README.md: 52e3269565776a15a2a244b36020069f193988fb
README.zh.md: 194918b401523548b2537829c65fd275d3e0eb49

View File

@@ -14,6 +14,8 @@ Tracks live agents and carries the initiating Agent through asynchronous driver
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
`AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop records the cap in the request header and applies it to each conversation-model request; callers that omit it leave provider defaults in control.
- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
- Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`.
- `ctx.agents.get(id: SessionId): Agent | undefined`

View File

@@ -14,6 +14,8 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事
带作用域的注册表层:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent。通过它注册工具变量监听器只对该 agent 生效,并在释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方模型推理reasoning强度选择将路由应用到提示词变量并将完整目标应用到一个步骤的请求路由如果没有选定推理强度则会清除继承的推理强度使该目标使用适配器提供方默认值。`CreateAgentOptions.setup(agentCtx)``ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。
`AgentOptions` 提供初始的提供方/模型路由,以及可选的正整数 `maxTokens` 输出上限。实体循环会把该上限记录到请求 header并应用到每次对话模型请求调用方省略时由提供方默认值控制。
- `ctx.agents.register(agent: Agent): () => void`:记录一个 **已经构造完成** 的 agent。随调用 fiber 释放。
- 高级有序生命周期:`enter(agent, owner): () => void` 强制 `agent.id === agent.session.id`,执行权威 ID 冲突检查,并在不通知的情况下插入;`owner` 显式记录实时创建方 agent 关系(根 agent 为 `undefined`),与持久会话谱系无关。`announce(agent)` 恰好发出一次 `agent/created`。创建监听器同步请求的 detach 会延后到该次分发结束;每次 detach 都会检查捕获的条目对象,因此陈旧能力无法删除后续使用同一 ID 的替代项。异步工厂使用这一拆分;普通插件使用 `register()`
- `ctx.agents.get(id: SessionId): Agent | undefined`

View File

@@ -24,6 +24,8 @@ export interface AgentOptions {
provider?: string
/** Model id interpreted by the selected provider adapter. */
model?: string
/** Maximum output tokens for each conversation-model request. */
maxTokens?: number
}
/**

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/sdk/sdk-client/README.md
README.md: 33a933e10abfa865cf9ce34b87c377d07081cc68
README.zh.md: 9f4453a00efef2685acec0194f83fcec2edf1409
README.md: 3ac4de540401f6f40dab3e84f7005f91d024aee8
README.zh.md: 1d9f8fbded8b477d519a5244735179fba581cdd0

View File

@@ -15,12 +15,13 @@ await using harness = new DeepSeekHarness({
launch: { command: 'node', args: ['lib/bin.js', 'cordis.yml'] },
provider: 'deepseek',
model: 'deepseek-v4-flash',
maxTokens: 49_152,
})
const result = await harness.run('say hi')
console.log(result.status, result.finalResponse)
```
The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), root-session `events`, and raw `notifications` for that session plus descendants discovered from `subagent.started`, all in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation.
The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route and optional positive `maxTokens` output cap); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). The cap applies to each root-agent request and is inherited by in-process descendants; compaction plugins own their separate summary limits. `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), root-session `events`, and raw `notifications` for that session plus descendants discovered from `subagent.started`, all in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation.
## HarnessClient

View File

@@ -15,12 +15,13 @@ await using harness = new DeepSeekHarness({
launch: { command: 'node', args: ['lib/bin.js', 'cordis.yml'] },
provider: 'deepseek',
model: 'deepseek-v4-flash',
maxTokens: 49_152,
})
const result = await harness.run('say hi')
console.log(result.status, result.finalResponse)
```
子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被收割。`start()` 记忆化 `initialize` 握手(工作区 cwd——在跨越线之前解析为绝对路径——加 provider/model 路由);握手失败会收割运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个 prompt 回合,在配对的 `session.finished` 到达时尘埃落定,返回 `TurnResult``status`(按部署映射的 `ok`/`error`)、结构化 `reason``TurnEndReason`)、`finalResponse`(最后一条助手消息文本)、根会话的 `events`,以及该会话和通过 `subagent.started` 发现的后代的原始 `notifications`,均按线序排列。模型层失败是 `status: 'error'` 的结果,绝不是拒绝;拒绝意味着传输丢失、超时或协议违例。
子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被收割。`start()` 记忆化 `initialize` 握手(工作区 cwd——在跨越线之前解析为绝对路径——加 provider/model 路由和可选的正整数 `maxTokens` 输出上限);握手失败会收割运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。该上限作用于根 agent 的每次请求,并由进程内后代继承;压缩插件单独持有摘要上限。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个 prompt 回合,在配对的 `session.finished` 到达时尘埃落定,返回 `TurnResult``status`(按部署映射的 `ok`/`error`)、结构化 `reason``TurnEndReason`)、`finalResponse`(最后一条助手消息文本)、根会话的 `events`,以及该会话和通过 `subagent.started` 发现的后代的原始 `notifications`,均按线序排列。模型层失败是 `status: 'error'` 的结果,绝不是拒绝;拒绝意味着传输丢失、超时或协议违例。
## HarnessClient

View File

@@ -25,6 +25,7 @@ export class DeepSeekHarness implements AsyncDisposable {
private readonly cwd: string
private readonly provider: string
private readonly model: string
private readonly maxTokens: number | undefined
private initialized: Promise<void> | undefined
private closed = false
@@ -38,6 +39,7 @@ export class DeepSeekHarness implements AsyncDisposable {
this.cwd = resolve(options.cwd ?? options.launch.cwd ?? process.cwd())
this.provider = options.provider ?? 'deepseek'
this.model = options.model ?? 'deepseek-v4-flash'
this.maxTokens = options.maxTokens
}
/**
@@ -61,7 +63,12 @@ export class DeepSeekHarness implements AsyncDisposable {
this.initialized ??= (async () => {
try {
this.clientInstance.start()
await this.clientInstance.initialize({ cwd: this.cwd, provider: this.provider, model: this.model })
await this.clientInstance.initialize({
cwd: this.cwd,
provider: this.provider,
model: this.model,
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
})
} catch (error) {
this.initialized = undefined
await this.clientInstance.close()

View File

@@ -55,6 +55,8 @@ export interface DeepSeekHarnessOptions {
provider?: string
/** Model for SDK-created agents (default `deepseek-v4-flash`). */
model?: string
/** Maximum output tokens for each conversation-model request. */
maxTokens?: number
}
/** The settled outcome of one {@link HarnessSession.run} turn. */

View File

@@ -105,7 +105,7 @@ describe('DeepSeekHarness', () => {
await harness.close()
})
it('sends the configured cwd/provider/model in the handshake exactly once', async () => {
it('sends the configured cwd/provider/model/maxTokens in the handshake exactly once', async () => {
const dir = await tempDir('sdk-client-init-')
const recordFile = join(dir, 'init.jsonl')
const harness = new DeepSeekHarness({
@@ -113,13 +113,19 @@ describe('DeepSeekHarness', () => {
cwd: dir,
provider: 'custom-provider',
model: 'custom-model',
maxTokens: 4096,
})
cleanups.push(() => harness.close())
await harness.run('one')
await harness.run('two')
await harness.close()
const records = (await readFile(recordFile, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as object)
expect(records).toEqual([{ cwd: dir, provider: 'custom-provider', model: 'custom-model' }])
expect(records).toEqual([{
cwd: dir,
provider: 'custom-provider',
model: 'custom-model',
maxTokens: 4096,
}])
})
it('resolves a relative launch cwd to an absolute workspace before the handshake', async () => {

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/sdk/sdk-protocol/README.md
README.md: 79e6bc36a656ce0d68c8e01ab2f75e26b4ac8ca5
README.zh.md: 8322da0f2bf7251f2b958c6a15f1738b9d8c41a4
README.md: 62b26d4a82d358fa4efcb7ab84036e5f4848057f
README.zh.md: c08c32b2fbf0b63b28826bae8fd6c2a73bfe09d0

View File

@@ -22,7 +22,7 @@ The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delim
| server→client | `subagent.started` | `SubagentStartedNotification` |
| server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) |
`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission leaves the provider default in control. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
## Model Experience

View File

@@ -22,7 +22,7 @@ DeepSeek Harness SDK 运行时的共享线协议:一个按换行分帧的 JSON
| server→client | `subagent.started` | `SubagentStartedNotification` |
| server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内 run |
`HarnessSdkRequestMap``HarnessSdkNotificationMap` 按方法名索引这些类型。通知载荷类型依赖 `SessionEvent``dsh-session`)、`ContentBlock``dsh-llm`)与 `SubagentStopReason``dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇表是线契约的一部分。`serverInfo.name` 保持线上稳定值 `deepseek-harness-sdk-runtime`
`HarnessSdkRequestMap``HarnessSdkNotificationMap` 按方法名索引这些类型。`InitializeParams.maxTokens` 是可选的正安全整数,用于限制 SDK 创建的 agent 及其进程内后代每次对话模型输出;省略时由提供方默认值控制。通知载荷类型依赖 `SessionEvent``dsh-session`)、`ContentBlock``dsh-llm`)与 `SubagentStopReason``dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇表是线契约的一部分。`serverInfo.name` 保持线上稳定值 `deepseek-harness-sdk-runtime`
## Model Experience

View File

@@ -20,6 +20,8 @@ export interface InitializeParams {
provider: string
/** Model name every SDK-created agent runs on (the server may mount a fallback adapter; see `HarnessSdkServer.initialize`). */
model: string
/** Optional positive output-token cap inherited by SDK-created agents and their in-process descendants. */
maxTokens?: number
}
/** Wire-stable server identity returned by initialization. */

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/subagent/subagent-dsh-sdk/README.md
README.md: 95ddd154c8262e8854280e74618bdd9be9c938c0
README.zh.md: c10145f34cd785d10f4b660a5f6414455ad42464
README.md: e904ce3c09a1b44f8f5a0072b9ca85812898e74f
README.zh.md: b11cb9c8e0bf2577be71230292d73878b22896a0

View File

@@ -6,7 +6,7 @@ The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a
## Start and ownership
`start(request)` resolves the child's working directory, spawns the runtime through `DeepSeekHarness`, and completes the `initialize` handshake (with the configured `provider`/`model` route) before it fulfills. Fulfillment therefore means the child runtime is ready and ownership has transferred to the caller. A spawn, handshake, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned.
`start(request)` resolves the child's working directory, spawns the runtime through `DeepSeekHarness`, and completes the `initialize` handshake (with the configured `provider`/`model` route and optional `maxTokens` output cap) before it fulfills. Fulfillment therefore means the child runtime is ready and ownership has transferred to the caller. A spawn, handshake, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned.
The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session.
@@ -32,6 +32,7 @@ The provider advertises no start-time capabilities (`outputSchema`/`depthLimit`/
| `cwd` | parent session cwd | Working-directory override; same validation as [`subagent-acp`](../subagent-acp/README.md). |
| `provider` | `deepseek` | Provider route sent in the child's `initialize`. |
| `model` | `deepseek-v4-flash` | Model sent in the child's `initialize`. |
| `maxTokens` | provider default | Per-request output-token cap sent in the child's `initialize`; it applies to the child root agent and its in-process descendants. |
| `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment (e.g. the child's own `DEEPSEEK_API_KEY`, or `DSH_CORDIS_CONFIG`). |
| `shutdownTimeoutMs` | `1000` | Bound on the protocol `shutdown` exchange during dispose. |
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. |
@@ -44,6 +45,7 @@ The provider advertises no start-time capabilities (`outputSchema`/`depthLimit`/
providerName: dsh-sdk
command: node
args: ['./packages/examples/jsonrpc-demo/lib/bin.js', './examples/jsonrpc-agent/cordis.yml']
maxTokens: 49152
env:
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
- id: tool-subagent

View File

@@ -6,7 +6,7 @@ SDK provider 把每个子代理作为一个完整的 DeepSeek Harness 运行时
## 启动与所有权
`start(request)` 先解析子进程工作目录,经 `DeepSeekHarness` 生成运行时,并在履行前完成 `initialize` 握手(携带配置的 `provider`/`model` 路由)。因此履行意味着子运行时已就绪、所有权已移交调用方。生成、握手或发布前取消的失败只在子进程被收割之后拒绝;工作目录解析失败在生成任何东西之前拒绝。
`start(request)` 先解析子进程工作目录,经 `DeepSeekHarness` 生成运行时,并在履行前完成 `initialize` 握手(携带配置的 `provider`/`model` 路由及可选的 `maxTokens` 输出上限)。因此履行意味着子运行时已就绪、所有权已移交调用方。生成、握手或发布前取消的失败只在子进程被收割之后拒绝;工作目录解析失败在生成任何东西之前拒绝。
工作目录的解析与 ACP 后端完全一致,经由接缝共享的进程外助手([`dsh-subagent`](../subagent/README.md)):设置了 `cwd` 覆盖则用之(加载时校验一次),否则用发起委托的父会话 cwd——绝不用服务器进程自己的 cwd。解析出的路径同时成为子进程 cwd 与其 SDK 会话的工作区 cwd。
@@ -32,6 +32,7 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte
| `cwd` | 父会话 cwd | 工作目录覆盖;校验规则与 [`subagent-acp`](../subagent-acp/README.md) 相同。 |
| `provider` | `deepseek` | 写入子进程 `initialize` 的 provider 路由。 |
| `model` | `deepseek-v4-flash` | 写入子进程 `initialize` 的模型。 |
| `maxTokens` | provider 默认值 | 写入子进程 `initialize` 的单次请求输出 token 上限;对子根 Agent 及其进程内后代生效。 |
| `env` | `{}` | 在凭据擦除后的父环境之上叠加的显式子环境(例如子进程自己的 `DEEPSEEK_API_KEY`,或 `DSH_CORDIS_CONFIG`)。 |
| `shutdownTimeoutMs` | `1000` | 处置期间协议 `shutdown` 交换的时限。 |
| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限。 |
@@ -44,6 +45,7 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte
providerName: dsh-sdk
command: node
args: ['./packages/examples/jsonrpc-demo/lib/bin.js', './examples/jsonrpc-agent/cordis.yml']
maxTokens: 49152
env:
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
- id: tool-subagent

View File

@@ -46,6 +46,8 @@ export interface Config {
provider: string
/** Model the child runtime initializes with (default `deepseek-v4-flash`). */
model: string
/** Optional per-request output-token cap for the child runtime. */
maxTokens?: number
/**
* Extra environment variables for the child process — e.g. the child
* runtime's own `DEEPSEEK_API_KEY`, or `DSH_CORDIS_CONFIG` naming its
@@ -73,14 +75,15 @@ export const Config: z<Config> = z.object({
cwd: z.string(),
provider: z.string().default('deepseek'),
model: z.string().default('deepseek-v4-flash'),
maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
env: z.dict(z.string()).default({}),
shutdownTimeoutMs: z.number().default(DEFAULT_SHUTDOWN_TIMEOUT_MS),
disposeEofGraceMs: z.number().default(DEFAULT_DISPOSE_EOF_GRACE_MS),
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
})
/** The shape after schemastery applied the defaults (cwd has none). */
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
/** The shape after schemastery applied the defaults (`cwd` and `maxTokens` have none). */
type ResolvedConfig = Required<Omit<Config, 'cwd' | 'maxTokens'>> & Pick<Config, 'cwd' | 'maxTokens'>
/**
* The SDK provider. Advertises NO start-time capabilities: an out-of-process
@@ -101,6 +104,7 @@ class SdkProvider implements SubagentProvider {
cwd: resolveChildCwd('subagent-dsh-sdk', this.config.cwd, request.parent.session.header.cwd),
provider: this.config.provider,
model: this.config.model,
...this.config.maxTokens === undefined ? {} : { maxTokens: this.config.maxTokens },
env: this.config.env,
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
disposeEofGraceMs: this.config.disposeEofGraceMs,
@@ -121,6 +125,9 @@ export function apply(ctx: Context, config: Config): void {
assertPositiveFinite('subagent-dsh-sdk', 'shutdownTimeoutMs', resolved.shutdownTimeoutMs)
assertPositiveFinite('subagent-dsh-sdk', 'disposeEofGraceMs', resolved.disposeEofGraceMs)
assertPositiveFinite('subagent-dsh-sdk', 'disposeGraceMs', resolved.disposeGraceMs)
if (resolved.maxTokens !== undefined && (!Number.isSafeInteger(resolved.maxTokens) || resolved.maxTokens <= 0)) {
throw new TypeError('subagent-dsh-sdk maxTokens must be a positive safe integer')
}
// Interpret a relative configured cwd against the harness launch directory
// ONCE, at load, and fail a misconfigured directory here — not per start.
const configuredCwd = validateConfiguredCwd('subagent-dsh-sdk', resolved.cwd)

View File

@@ -35,6 +35,8 @@ export interface SdkRunSpec {
provider: string
/** Model the child runtime initializes with. */
model: string
/** Optional per-request output-token cap sent in the child runtime's initialize handshake. */
maxTokens?: number
/**
* Extra environment variables to ADD for the child (e.g. the child
* runtime's own `DEEPSEEK_API_KEY`, or `DSH_CORDIS_CONFIG`). Merged after
@@ -126,6 +128,7 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe
cwd: spec.cwd,
provider: spec.provider,
model: spec.model,
...spec.maxTokens === undefined ? {} : { maxTokens: spec.maxTokens },
})
// Cancellation settles the result without waiting for a cooperative child.

View File

@@ -105,17 +105,22 @@ describe('dsh-subagent-dsh-sdk provider', () => {
await ctx.fiber.dispose()
})
it('initializes the child with the configured provider/model and the parent cwd', async () => {
it('initializes the child with the configured provider/model/maxTokens and the parent cwd', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'subagent-dsh-sdk-init-'))
const recordFile = join(tmp, 'init.jsonl')
try {
const ctx = await setup({ FAKE_RECORD_INIT: recordFile })
const ctx = await setup({ FAKE_RECORD_INIT: recordFile }, { maxTokens: 4096 })
const run = await ctx.subagents.start('dsh-sdk', request())
await run.result
await run.dispose()
const { readFileSync } = await import('node:fs')
const records = readFileSync(recordFile, 'utf8').trim().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
expect(records).toEqual([{ cwd: process.cwd(), provider: 'fake-provider', model: 'fake-model' }])
expect(records).toEqual([{
cwd: process.cwd(),
provider: 'fake-provider',
model: 'fake-model',
maxTokens: 4096,
}])
await ctx.fiber.dispose()
} finally {
rmSync(tmp, { recursive: true, force: true })
@@ -364,6 +369,45 @@ describe('dsh-subagent-dsh-sdk provider', () => {
await ctx.fiber.dispose()
})
it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])(
'rejects invalid maxTokens %s at load',
async (maxTokens) => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await expect(ctx.plugin(sdk, {
providerName: 'sdk',
command: 'true',
args: [],
provider: 'p',
model: 'm',
maxTokens,
env: {},
})).rejects.toThrow('maxTokens')
await ctx.fiber.dispose()
},
)
it.each([0, 1.5])(
'defensively rejects invalid maxTokens %s when apply is called directly',
async (maxTokens) => {
const ctx = new Context()
await ctx.plugin(SubagentService)
expect(() => { sdk.apply(ctx, {
providerName: 'sdk',
command: 'true',
args: [],
provider: 'p',
model: 'm',
maxTokens,
env: {},
shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS,
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
}) }).toThrow('maxTokens must be a positive safe integer')
await ctx.fiber.dispose()
},
)
it('rejects an empty config cwd at load', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)

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/subagent/subagent-inprocess/README.md
README.md: cb5149de732970873caa292db05508d5d4e24fe7
README.zh.md: 150a69b1191d57cd3cef8cef0d4d58178e7f73da
README.md: 95a45cd7a1f4510601f7f8d8bf396e7262f1a3cf
README.zh.md: c20f5106d0009f9c5507e830361c0fcba54d6280

View File

@@ -16,7 +16,7 @@ The driver follows this sequence:
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later between-turn records.
The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.
The child also inherits the parent's session POLICY overrides. The driver captures `ctx.sandboxPolicy.overrideOf(parent.session)` and `ctx.approval.overrideOf(parent.session)` synchronously before its first await — the delegation moment is the snapshot point, so a parent switch racing the asynchronous child creation belongs to the parent's future — and carries the captured values in the child's creation meta into its immutable `SessionHeader` (`sandboxMode`/`approvalPolicy`), durable from the moment the session exists: no listener ordering can starve the baseline and no crash window can lose it, including an idle SessionStart-style injection persisting a complete turn before any prompt turn opens. Both services are consumed opportunistically — compositions without them delegate policy-free. Only the override chain is copied, so an unswitched parent writes no baseline and the child follows the live deployment default; `overrideOf` folds only events past the seed boundary, so a fork seed's stale switch is subsumed by the baseline while the child's own later switches outrank it. Nesting composes: each capture resolves the delegating session's own chain ([rationale](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)).

View File

@@ -16,7 +16,7 @@
4. 发布子 agent保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
5. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续轮次间记录。
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 模型。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
子 agent 会获得父 agent 的工作目录会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
子 agent 还会继承父 agent 的会话策略覆盖项。驱动器在自己的第一个 await 之前同步捕获 `ctx.sandboxPolicy.overrideOf(parent.session)``ctx.approval.overrideOf(parent.session)`——委派时刻即快照点,因此与异步的子 agent 创建过程赛跑的父 agent 切换属于父 agent 的未来——并把捕获值作为创建元数据带入子 agent 不可变的 `SessionHeader``sandboxMode`/`approvalPolicy`),从会话存在的那一刻起就具备持久性:任何监听器顺序都不可能饿死该基线,任何崩溃窗口也不可能丢失它,包括空闲时的 SessionStart 式注入在任何提示词轮次开启之前就持久化一个完整轮次的情况。两个服务均以可选方式消费:未挂载它们的组合照旧进行无策略委派。只复制覆盖链,因此未切换过的父 agent 不写入任何基线,子 agent 继续跟随实时部署默认值;`overrideOf` 只折叠初始内容边界之后的事件,因此 fork 初始内容携带的陈旧切换已被基线所涵盖,而子 agent 自己之后的切换仍优先于基线。嵌套按构造即可组合:每次捕获解析的都是发起委派的会话自身的覆盖链(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md))。

View File

@@ -93,9 +93,11 @@ export async function startInProcessRun(
const parentHeader = parent.session.header
const parentProvider = parent.options.provider
const parentModel = parent.options.model
const parentMaxTokens = parent.options.maxTokens
const agentOptions: AgentOptions = {
...parentProvider !== undefined ? { provider: parentProvider } : {},
...parentModel !== undefined ? { model: parentModel } : {},
...parentMaxTokens !== undefined ? { maxTokens: parentMaxTokens } : {},
...request.agentOptions,
subagentDepth: childDepth,
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { type Agent } from '@deepseek-ai/dsh-agent'
import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -21,7 +21,7 @@ async function mountInvariants(ctx: Context): Promise<void> {
await ctx.plugin(AgentLoopInvariant)
}
async function setup(script: Script) {
async function setup(script: Script, parentOptions: Partial<AgentOptions> = {}) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await mountInvariants(ctx)
@@ -29,7 +29,7 @@ async function setup(script: Script) {
await ctx.plugin(SubagentService)
const adapter = new MockAdapter(script)
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock', ...parentOptions })
return { ctx, parent, adapter }
}
@@ -110,6 +110,27 @@ describe('startInProcessRun', () => {
await run.dispose()
})
it('inherits the parent output-token cap and accepts an explicit child override', async () => {
const { ctx, parent, adapter } = await setup(
[textResponse('inherited'), textResponse('overridden')],
{ maxTokens: 111 },
)
const inherited = await startInProcessRun(request(parent), {})
await inherited.result
expect(adapter.requests[0]?.maxTokens).toBe(111)
expect(ctx.agents.get(inherited.id)?.options.maxTokens).toBe(111)
await inherited.dispose()
const overridden = await startInProcessRun({
...request(parent),
agentOptions: { maxTokens: 222 },
}, {})
await overridden.result
expect(adapter.requests[1]?.maxTokens).toBe(222)
expect(ctx.agents.get(overridden.id)?.options.maxTokens).toBe(222)
await overridden.dispose()
})
it('counts a RESUMED child by its persisted header depth, not the absent runtime depth', async () => {
// Resume rebuilds runtime options, so the durable header must keep this
// depth-1 child from delegating as though it were top-level.

View File

@@ -1,6 +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
README.md: 0f1bc6eae00dce7b1832f76d0267edd2c6ef2a93
README.zh.md: 49fca6af9fd039d4436f8c209a328d2fb0efcf07
# pnpm run verify-translation-pairing --write packages/subagent/tool-subagent/README.md
README.md: 20bb6b9c59a13f23301368faefe18849ccc0b1e9
README.zh.md: 9435ff638b0d021507af487a54fabac3018ca400

View File

@@ -21,7 +21,7 @@ With `run_in_background: true`, the tool registers the parent-owned task before
| `provider` (required) | Provider name (`spawn`, `fork`, `acp`, ...). |
| `toolName` | Model-facing name, default `subagent`; distinct for every loaded instance. |
| `enableRunInBackground` | Exposes background mode, default `true`; disabling also rejects forced background calls. |
| `agentOptions` | Default child options, currently including `model`. |
| `agentOptions` | Provider-specific child `provider`, `model`, and positive `maxTokens`; the in-process provider treats explicit values as overrides of inherited parent options. |
| `persona` | Per-child persona; requires provider `persona` capability. |
| `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. |
| `maxDepth` | Absolute delegation-depth cap, default `3` (`0` forbids delegation); a numeric cap requires the `depthLimit` capability and fails the mount without it. `'provider-managed'` sends no cap for an out-of-process provider whose budget belongs to the child harness. The tool stays visible at the cap; each attempted start checks the calling agent's current depth and returns an errored tool result when rejected. |

View File

@@ -21,7 +21,7 @@
| `provider`(必填) | 提供方名称(`spawn``fork``acp` 等)。 |
| `toolName` | 面向模型的名称,默认 `subagent`;每个已加载实例必须不同。 |
| `enableRunInBackground` | 公开后台模式,默认 `true`;禁用时也会拒绝强制后台调用。 |
| `agentOptions` | 默认子 agent 选项,目前包括 `model`。 |
| `agentOptions` | 传给具体 provider 的子 agent `provider``model` 和正整数 `maxTokens`;进程内 provider 会用显式值覆盖继承的父级选项。 |
| `persona` | 每个子 agent 独立的 persona要求提供方具备 `persona` 能力。 |
| `toolFilter` | 每个子 agent 独立的全局工具限制;要求提供方具备 `toolFilter` 能力。 |
| `maxDepth` | 绝对委派深度上限,默认 `3``0` 禁止委派);数值上限要求 `depthLimit` 能力,缺失时挂载失败。对于预算由子 harness 拥有的进程外提供方,`'provider-managed'` 不发送上限。工具在达到上限时仍然可见;每次尝试启动都会检查调用 agent 的当前深度,被拒绝时返回出错的工具结果。 |

View File

@@ -74,7 +74,8 @@ export const Config: z<Config> = z.object({
agentOptions: z.object({
provider: z.string(),
model: z.string(),
}).default(undefined as unknown as { provider: string; model: string }),
maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
}).default(undefined as unknown as { provider: string; model: string; maxTokens: number }),
persona: z.string(),
// Preserve omission; Schemastery's `{ allow: [] }` default would deny every tool.
toolFilter: z.object({

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/ui/jsonrpc/README.md
README.md: 48eb6106fe4b015f114b264a312249a92128266f
README.zh.md: 8ec2d57a206d770d0b77ee69036457e3b2864303
README.md: b1219ba10269fc7d046da22c280ff1b91424a5ae
README.zh.md: 63615654769bf4ed7a69c09dc818af034c3a3c3c

View File

@@ -22,7 +22,7 @@ The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to qu
## Wire notes
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`.
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no cap and preserves provider defaults. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`.
## Model Experience

View File

@@ -22,7 +22,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger诊断应写
## 协议说明
`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。
`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送上限并保留提供方默认值。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。
## 模型体验

View File

@@ -56,6 +56,7 @@ export class HarnessSdkServer {
private cwd = process.cwd()
private provider = 'deepseek'
private model = 'deepseek'
private maxTokens: number | undefined
private llmFiber: { dispose(): Promise<void> } | undefined
private readonly sessions = new Map<string, SessionRecord>()
private readonly sessionCreations = new Map<string, Promise<SessionRecord>>()
@@ -113,9 +114,14 @@ export class HarnessSdkServer {
* @returns server identity for the handshake.
*/
async initialize(params: InitializeParams): Promise<InitializeResult> {
if (params.maxTokens !== undefined
&& (!Number.isSafeInteger(params.maxTokens) || params.maxTokens <= 0)) {
throw new TypeError('initialize maxTokens must be a positive safe integer')
}
this.cwd = resolve(params.cwd)
this.provider = params.provider
this.model = params.model
this.maxTokens = params.maxTokens
if (!this.hasAdapterFor(this.provider)) {
if (this.provider !== 'deepseek') throw new Error(`no adapter registered for provider "${this.provider}"`)
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, {})
@@ -231,7 +237,11 @@ export class HarnessSdkServer {
const handle = await this.ctx.agents.create({
sessionId: SessionId(sessionId),
meta: { cwd: this.cwd },
agentOptions: { provider: this.provider, model: this.model },
agentOptions: {
provider: this.provider,
model: this.model,
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
},
})
const rec: SessionRecord = { handle, lastTurnEnd: undefined, activePrompt: false }
this.sessions.set(sessionId, rec)

View File

@@ -122,6 +122,7 @@ describe('HarnessSdkServer', () => {
cwd: storageDir,
provider: 'deepseek',
model: 'dsagent-model',
maxTokens: 321,
}) as { serverInfo: { name: string } }
expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
@@ -131,8 +132,9 @@ describe('HarnessSdkServer', () => {
})
expect(llmServer.requests).toHaveLength(1)
const body = llmServer.requests[0] as { model: string; messages: { role: string }[] }
const body = llmServer.requests[0] as { model: string; messages: { role: string }[]; max_tokens?: number }
expect(body.model).toBe('dsagent-model')
expect(body.max_tokens).toBe(321)
expect(body.messages[0]?.role).toBe('system')
expect(body.messages.at(-1)?.role).toBe('user')
expect(llmServer.headers[0]?.authorization).toBe('Bearer test-key')
@@ -859,6 +861,27 @@ describe('HarnessSdkServer', () => {
}
})
it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])(
'rejects invalid initialize maxTokens %s at the wire boundary',
async (maxTokens) => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-invalid-max-tokens-'))
const ctx = await makeHarness(storageDir)
try {
const server = new HarnessSdkServer(ctx, new FakeTransport())
await expect(server.initialize({
cwd: storageDir,
provider: 'deepseek',
model: 'model',
maxTokens,
})).rejects.toThrow('initialize maxTokens must be a positive safe integer')
await server.shutdown()
} finally {
await ctx.fiber.dispose()
await rm(storageDir, { recursive: true, force: true })
}
},
)
it('classifies defensive finish states', async () => {
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-finish-states-'))
const ctx = await makeHarness(storageDir)
@@ -973,15 +996,18 @@ describe('HarnessSdkServer', () => {
get: () => ({ listProviders: () => [{ id: 'mock', name: 'Mock' }] }),
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
initialize(params: { cwd: string; provider: string; model: string }): Promise<unknown>
initialize(params: { cwd: string; provider: string; model: string; maxTokens?: number }): Promise<unknown>
getOrCreateSession(sessionId: string): Promise<unknown>
shutdown(): Promise<Record<string, never>>
}
await server.initialize({ cwd: '.', provider: 'mock', model: 'model' })
await server.initialize({ cwd: '.', provider: 'mock', model: 'model', maxTokens: 123 })
await server.getOrCreateSession('relative')
expect(create).toHaveBeenCalledWith(expect.objectContaining({ meta: { cwd: process.cwd() } }))
expect(create).toHaveBeenCalledWith(expect.objectContaining({
meta: { cwd: process.cwd() },
agentOptions: { provider: 'mock', model: 'model', maxTokens: 123 },
}))
await server.shutdown()
})