From cd6bd5c1882b455df0574f2d3dc260f3cdd67419 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 11:48:34 +0800 Subject: [PATCH 001/293] fix(llm): honor DeepSeek SSE keep-alives --- ...-21-bounded-llm-request-recovery.i18n.yaml | 4 +-- ...2026-06-21-bounded-llm-request-recovery.md | 4 +-- ...6-06-21-bounded-llm-request-recovery.zh.md | 4 +-- .../fixtures/deepseek-defaults.cordis.yml | 1 + .../headless-agent/tests/headless.snapshot.ts | 23 +++++++++---- packages/llm/llm-deepseek/README.i18n.yaml | 4 +-- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 11 ++++-- packages/llm/llm-deepseek/src/sse.ts | 19 +++++++---- .../llm/llm-deepseek/tests/adapter.spec.ts | 34 +++++++++++++++++++ packages/llm/llm-deepseek/tests/sse.spec.ts | 10 ++++++ packages/util/timeout/README.i18n.yaml | 4 +-- packages/util/timeout/README.md | 4 +-- packages/util/timeout/README.zh.md | 4 +-- packages/util/timeout/src/index.ts | 17 ++++++++-- packages/util/timeout/tests/timeout.spec.ts | 22 ++++++++++++ 17 files changed, 134 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml index bf6f030683..d527bb9e7b 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md -2026-06-21-bounded-llm-request-recovery.md: 24725dcf300cf69e9cc72580d0c8afe937d4e2b9 -2026-06-21-bounded-llm-request-recovery.zh.md: 5f03a65b00be8d3349addce82e4f3faa2af1fe7e +2026-06-21-bounded-llm-request-recovery.md: 122f30118ebe3f213a887e56a9d3505e78b23070 +2026-06-21-bounded-llm-request-recovery.zh.md: 8af62cef952eba929dd770df15fdc0666f575666 diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 24725dcf30..122f30118e 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -74,9 +74,9 @@ Adapters perform one provider request per `stream()` call. The pi-ai adapter rem ### Bound stalled streams where they can be stopped -Each adapter exposes a validated `streamIdleTimeoutMs` configuration field with the five-minute prior-art default cited above. The interval is capped at Node's maximum timer delay so it cannot be clamped to one millisecond. It covers each outstanding iterator `next()` from demand to the next valid `StreamChunk`; time a consumer spends between `next()` calls is not provider idle time. +Each adapter exposes a validated `streamIdleTimeoutMs` configuration field with the five-minute prior-art default cited above. The interval is capped at Node's maximum timer delay so it cannot be clamped to one millisecond. It covers each outstanding iterator `next()` from demand to adapter-recognized provider activity; time a consumer spends between `next()` calls is not provider idle time. DeepSeek SSE comments count as transport activity but never become `StreamChunk` values or session-log events. -`@deepseek-ai/dsh-timeout` exposes a rearmable idle-watchdog primitive. One stable local `AbortController` is fused with the caller signal and passed to the transport for the whole adapter call; each outstanding `next()` arms the watchdog, resolution disarms it, and the next demand rearms it. Timeout aborts that stable controller with a capability-owned `TimeoutReason`, and `finally` clears the timer. The adapter classifies its watchdog as `TIMEOUT` and an earlier upstream abort as `ABORTED`. The existing one-shot `deadline()` is not presented as a sliding timer. +`@deepseek-ai/dsh-timeout` exposes a rearmable idle-watchdog primitive. One stable local `AbortController` is fused with the caller signal and passed to the transport for the whole adapter call; each outstanding `next()` arms the watchdog, resolution disarms it, and the next demand rearms it. Out-of-band transport activity calls `pulse()` to rearm an outstanding demand without yielding a value. Timeout aborts that stable controller with a capability-owned `TimeoutReason`, and `finally` clears the timer. The adapter classifies its watchdog as `TIMEOUT` and an earlier upstream abort as `ABORTED`. The existing one-shot `deadline()` is not presented as a sliding timer. Boundary tests prove termination at both actual transports. The hand-written adapter aborts its fetch/reader, and the pi-ai adapter maps the stable signal through the SDK and proves the SDK closes the response. A timer that merely rejects a consumer promise while leaving the request running does not satisfy the contract. diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md index 5f03a65b00..8af62cef95 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -74,9 +74,9 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ### 在能够终止停滞流的位置施加边界 -每个适配器都公开一个经过验证的 `streamIdleTimeoutMs` 配置字段,默认值采用上文引用的五分钟先例。该间隔不超过 Node 的最大定时器延迟,因此不会被钳制为 1 毫秒。它覆盖每个尚未完成的迭代器 `next()`:从消费方请求下一项开始,到下一条有效 `StreamChunk` 到达为止;消费方在两次 `next()` 调用之间花费的时间不属于提供方空闲时间。 +每个适配器都公开一个经过验证的 `streamIdleTimeoutMs` 配置字段,默认值采用上文引用的五分钟先例。该间隔不超过 Node 的最大定时器延迟,因此不会被钳制为 1 毫秒。它覆盖每个尚未完成的迭代器 `next()`:从消费方请求下一项开始,到适配器识别到提供方活动为止;消费方在两次 `next()` 调用之间花费的时间不属于提供方空闲时间。DeepSeek SSE(Server-Sent Events)注释计为传输活动,但绝不会成为 `StreamChunk` 值或会话日志事件。 -`@deepseek-ai/dsh-timeout` 公开一个可重新布防的空闲看门狗原语。一个稳定的局部 `AbortController` 会与调用方信号融合,并在整个适配器调用期间传给传输层;每个尚未完成的 `next()` 都会布防看门狗,该调用完成时解除布防,下一次请求数据时再重新布防。超时会使用能力自身拥有的 `TimeoutReason` 中止这个稳定控制器,`finally` 则会清除定时器。适配器将自身看门狗归类为 `TIMEOUT`,将更早发生的上游中止归类为 `ABORTED`。现有的一次性 `deadline()` 不会被描述为滑动计时器。 +`@deepseek-ai/dsh-timeout` 公开一个可重新布防的空闲看门狗原语。一个稳定的局部 `AbortController` 会与调用方信号融合,并在整个适配器调用期间传给传输层;每个尚未完成的 `next()` 都会布防看门狗,该调用完成时解除布防,下一次请求数据时再重新布防。带外传输活动会调用 `pulse()`,在不产生值的情况下为尚未完成的需求重新布防。超时会使用能力自身拥有的 `TimeoutReason` 中止这个稳定控制器,`finally` 则会清除定时器。适配器将自身看门狗归类为 `TIMEOUT`,将更早发生的上游中止归类为 `ABORTED`。现有的一次性 `deadline()` 不会被描述为滑动计时器。 边界测试证明两个实际传输层都能终止。手写适配器会中止其 fetch/reader,pi-ai 适配器会把稳定信号映射到 SDK,并证明 SDK 会关闭响应。如果定时器只拒绝消费方 promise,却让请求继续运行,就不满足此契约。 diff --git a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml index cd472f737d..829af9f813 100644 --- a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml +++ b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml @@ -8,6 +8,7 @@ apiKey: snapshot-key baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL thinking: disabled + streamIdleTimeoutMs: 100 - id: cli-agent config: provider: deepseek-official diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 8b48165a83..fd1bbd3086 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -66,12 +66,21 @@ async function deepseekDefaultsServer(): Promise { request.on('end', () => { requests.push(JSON.parse(body) as JsonObject) response.writeHead(200, { 'content-type': 'text/event-stream' }) - response.end([ - 'data: {"choices":[{"delta":{"content":"DEFAULTS_OK"}}]}', - 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ].join('\n\n')) + let keepAlives = 3 + const write = (): void => { + if (keepAlives-- > 0) { + response.write(': keep-alive\n\n') + setTimeout(write, 60) + return + } + response.end([ + 'data: {"choices":[{"delta":{"content":"DEFAULTS_OK"}}]}', + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ].join('\n\n')) + } + setTimeout(write, 60) }) }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) @@ -297,7 +306,7 @@ describe('headless stream-json snapshots', () => { `) }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('logs and sends the DeepSeek adapter maxTokens default through the one-shot app', async () => { + it('keeps provider comments alive and sends DeepSeek defaults through the one-shot app', async () => { const server = await deepseekDefaultsServer() try { const result = await runLoaderSmoke({ diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 45d9cee054..cb18c83eff 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 020aa65073495526be3f32912b7cd06667c52a2e -README.zh.md: 4c655e90ba00340c056f6ac16159621f7a8c1ddb +README.md: 2ecdb4330e5871bbaf0da6fc583083a06c935486 +README.zh.md: 63eb7be330806b668e12867ed906d0d88acaff1f diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 020aa65073..2ecdb4330e 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -46,7 +46,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und `thinking: disabled` is a deployment lock that publishes only `off` with `off` as its default. Omitting `reasoningEffort` or configuring it as `off` is valid; configuring `high` or `max` fails plugin loading, and a direct per-request attempt to enable thinking fails before network I/O. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults. -`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. +`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. DeepSeek SSE comments rearm an outstanding read as transport activity but never become `StreamChunk` values or session-log events. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. ## Dynamic configuration (settings + credentials) diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 4c655e90ba..63eb7be330 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -46,7 +46,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: `thinking: disabled` 是部署锁定:它只公布 `off`,并以 `off` 为默认值。省略 `reasoningEffort` 或将其配置为 `off` 均有效;配置 `high` 或 `max` 会使插件加载失败,直接按请求启用思考也会在网络 I/O 前失败。携带 `GenerateOptions.purpose: 'session-title'` 的请求也会强制禁用思考并省略已解析的推理强度,将有界输出保留给可见标题文本,不改变会话或压缩(compaction)默认值。 -`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 +`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。DeepSeek SSE 注释会作为传输活动使尚未完成的读取重新布防,但绝不会成为 `StreamChunk` 值或会话日志事件。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 ## 动态配置(settings + credentials) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 85985c41d8..c5f9655fa1 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -215,7 +215,13 @@ export class DeepSeekAdapter extends LlmAdapter { ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]) using watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE) - const iterator = this.request(options, watchdog.signal, connection, apiKey)[Symbol.asyncIterator]() + const iterator = this.request( + options, + watchdog.signal, + connection, + apiKey, + () => { watchdog.pulse() }, + )[Symbol.asyncIterator]() let exhausted = false try { while (true) { @@ -256,6 +262,7 @@ export class DeepSeekAdapter extends LlmAdapter { signal: AbortSignal, connection: DeepSeekConnectionOptions, apiKey: string, + onComment: () => void, ): AsyncIterable { const body = serializeRequest(options, connection.defaults) // Prepared outside the try so the TRANSPORT label below covers exactly the @@ -321,6 +328,6 @@ export class DeepSeekAdapter extends LlmAdapter { throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') } - yield* translate(parseSse(response.body)) + yield* translate(parseSse(response.body, onComment)) } } diff --git a/packages/llm/llm-deepseek/src/sse.ts b/packages/llm/llm-deepseek/src/sse.ts index a8807a857c..9126a18940 100644 --- a/packages/llm/llm-deepseek/src/sse.ts +++ b/packages/llm/llm-deepseek/src/sse.ts @@ -1,11 +1,12 @@ /** * Decode an SSE byte stream into event `data` payloads. Framing — chunk * reassembly, UTF-8/CRLF/BOM handling, comment and non-data field skipping, - * multi-`data:` joining — is `eventsource-parser`'s; this module keeps only - * the DeepSeek protocol: the literal `[DONE]` is yielded so the caller owns - * final flushing, and EOF before it raises {@link LlmError}. Framing is - * spec-strict: an event dispatches only on its blank-line terminator, so an - * unterminated tail at EOF is truncation, not a flushable payload. + * multi-`data:` joining — is `eventsource-parser`'s. Comments are reported + * only through an optional transport-activity callback. This module keeps the + * DeepSeek protocol: the literal `[DONE]` is yielded so the caller owns final + * flushing, and EOF before it raises {@link LlmError}. Framing is spec-strict: + * an event dispatches only on its blank-line terminator, so an unterminated + * tail at EOF is truncation, not a flushable payload. * * @module dsh-llm-deepseek/sse */ @@ -21,12 +22,16 @@ export const DONE = '[DONE]' * value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends * without it (truncated response — the model call cannot be trusted). * @param stream - raw SSE bytes; reads may split anywhere, including mid-UTF-8 sequence. + * @param onComment - optional transport-activity callback; comments never enter the yielded payload stream. * @returns each event's data payload in arrival order, the `[DONE]` sentinel last. */ -export async function* parseSse(stream: ReadableStream): AsyncGenerator { +export async function* parseSse( + stream: ReadableStream, + onComment?: (comment: string) => void, +): AsyncGenerator { const events = stream .pipeThrough(new TextDecoderStream()) - .pipeThrough(new EventSourceParserStream()) + .pipeThrough(new EventSourceParserStream({ onComment })) for await (const { data } of events) { yield data if (data === DONE) return diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index ec4a271f15..8240dea7e6 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -545,6 +545,40 @@ describe('DeepSeekAdapter against a mock server', () => { fetchSpy.mockRestore() } }) + + it('keeps an idle provider read alive through SSE comments', async () => { + vi.useFakeTimers() + const encoder = new TextEncoder() + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { + const body = new ReadableStream({ + start(controller) { + setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 75) + setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 150) + setTimeout(() => { + controller.enqueue(encoder.encode(textEvents.map(event => `data: ${event}\n\n`).join(''))) + controller.close() + }, 225) + }, + }) + return Promise.resolve(new Response(body, { status: 200 })) + }) + const adapter = adapterOf({ baseURL: 'https://example.invalid', streamIdleTimeoutMs: 100 }) + try { + const chunks: string[] = [] + const drain = (async () => { + for await (const chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { + chunks.push(chunk.type) + } + })() + await vi.advanceTimersByTimeAsync(75) + await vi.advanceTimersByTimeAsync(75) + await vi.advanceTimersByTimeAsync(75) + await expect(drain).resolves.toBeUndefined() + expect(chunks).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) + } finally { + fetchSpy.mockRestore() + } + }) }) describe('plugin registration and config', () => { diff --git a/packages/llm/llm-deepseek/tests/sse.spec.ts b/packages/llm/llm-deepseek/tests/sse.spec.ts index 7ebb494a4d..67160d8079 100644 --- a/packages/llm/llm-deepseek/tests/sse.spec.ts +++ b/packages/llm/llm-deepseek/tests/sse.spec.ts @@ -31,6 +31,16 @@ describe('parseSse', () => { expect(events).toEqual(['{"a":1}', DONE]) }) + it('reports comments out of band without yielding them', async () => { + const comments: string[] = [] + const events = await collect(parseSse( + bytes(': keep-alive\n\ndata: {"a":1}\n\ndata: [DONE]\n\n'), + (comment) => { comments.push(comment) }, + )) + expect(comments).toEqual(['keep-alive']) + expect(events).toEqual(['{"a":1}', DONE]) + }) + it('stops yielding after DONE even when more data follows', async () => { const events = await collect(parseSse(bytes('data: [DONE]\n\ndata: {"late":1}\n\n'))) expect(events).toEqual([DONE]) diff --git a/packages/util/timeout/README.i18n.yaml b/packages/util/timeout/README.i18n.yaml index 0436cc4d34..a578a0ac4f 100644 --- a/packages/util/timeout/README.i18n.yaml +++ b/packages/util/timeout/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/timeout/README.md -README.md: 11c55a45a1255e14fb551e42ba3965453dbd94ae -README.zh.md: 8b63f00595139f9af2e9a31e8ab3c3494088e0ff +README.md: 8892b2dce53b5c315c088430ed3fcf386a0f3101 +README.zh.md: ec99f38ff92890c854a1f902e56b2278a42318b6 diff --git a/packages/util/timeout/README.md b/packages/util/timeout/README.md index 11c55a45a1..8892b2dce5 100644 --- a/packages/util/timeout/README.md +++ b/packages/util/timeout/README.md @@ -18,7 +18,7 @@ import { clampTimeout, deadline, idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf, Ti |---|---| | `clampTimeout(requested, def, max, name?)` | Validate the caller's optional positive-finite hint, fill from `def`, cap at `max`. Throws (with `name`) on a non-positive/non-finite hint. | | `deadline(upstream, timeoutMs, code)` | Fuse `upstream` cancellation with a timeout into one `AbortSignal` (`AbortSignal.any`); the timeout carries a `TimeoutReason`. `[Symbol.dispose]` clears the timer. | -| `idleWatchdog(upstream, timeoutMs, code)` | Keep one stable fused signal and arm only while its guarded async-iterator `next()` is outstanding. Resolution disarms; later demand rearms; disposal clears; concurrent demand rejects. | +| `idleWatchdog(upstream, timeoutMs, code)` | Keep one stable fused signal and arm only while its guarded async-iterator `next()` is outstanding. Resolution disarms; later demand or `pulse()` activity rearms; disposal clears; concurrent demand rejects. | | `MAX_TIMER_DELAY_MS` | Largest delay Node schedules without clamping it to one millisecond (`2_147_483_647`). Timer-owning config must not exceed it. | | `timeoutOf(signal \| { reason }, code?)` | Recover the `TimeoutReason` from an aborted signal/error, else `undefined` — the timeout-vs-cancel classifier. Pass `code` to match only THIS deadline's timer (see nesting below). | | `TimeoutReason` | The internal reason (`code` + `timeoutMs`) stamped on a timeout abort. Not a public error — providers translate it into their own error/field. | @@ -48,7 +48,7 @@ The signal only *notifies* — the caller MUST attach its own termination (`d.si Pass your own `code` to `timeoutOf` so classification composes under nesting: when the `upstream` you were handed is *itself* a deadline signal (a future `tools/execute` middleware arming a per-call deadline), `AbortSignal.any` preserves the outer `TimeoutReason` if the outer timer fires first. Scoping to your `code` makes a foreign timeout read as an ordinary upstream cancel — the correct classification from your capability's view — instead of your own timeout firing when your local timer never expired. -For a streamed transport, create one `idleWatchdog`, pass its stable `signal` into the transport, and call `watchdog.next(iterator)` for each provider read. The interval must be positive, finite, and no greater than `MAX_TIMER_DELAY_MS`; Node otherwise clamps it to one millisecond. It measures only outstanding demand, so no timer runs while downstream code renders or otherwise waits before asking for the next chunk. The primitive still only notifies, so the transport must observe the stable signal; the DeepSeek and pi-ai adapters prove that timeout closes their real response body or SDK request. +For a streamed transport, create one `idleWatchdog`, pass its stable `signal` into the transport, and call `watchdog.next(iterator)` for each provider read. Call `watchdog.pulse()` when transport activity does not yield an iterator value. The interval must be positive, finite, and no greater than `MAX_TIMER_DELAY_MS`; Node otherwise clamps it to one millisecond. It measures only outstanding demand, so no timer runs while downstream code renders or otherwise waits before asking for the next chunk. The primitive still only notifies, so the transport must observe the stable signal; the DeepSeek and pi-ai adapters prove that timeout closes their real response body or SDK request. ## What does NOT get a timeout diff --git a/packages/util/timeout/README.zh.md b/packages/util/timeout/README.zh.md index 8b63f00595..ec99f38ff9 100644 --- a/packages/util/timeout/README.zh.md +++ b/packages/util/timeout/README.zh.md @@ -18,7 +18,7 @@ import { clampTimeout, deadline, idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf, Ti |---|---| | `clampTimeout(requested, def, max, name?)` | 验证调用方可选的、值为正且有限的提示,从 `def` 填充,并限制在 `max` 以内。如果提示为非正数或非有限数,则抛出错误(包含 `name`)。 | | `deadline(upstream, timeoutMs, code)` | 将 `upstream` 取消与超时融合为一个 `AbortSignal`(`AbortSignal.any`);超时携带 `TimeoutReason`。`[Symbol.dispose]` 清除 timer。 | -| `idleWatchdog(upstream, timeoutMs, code)` | 保持一个稳定的融合信号,并且只在受保护的异步迭代器 `next()` 尚未完成时启动 timer。完成后停止 timer;后续需求重新启动 timer;dispose(资源释放)时清除;并发需求被拒绝。 | +| `idleWatchdog(upstream, timeoutMs, code)` | 保持一个稳定的融合信号,并且只在受保护的异步迭代器 `next()` 尚未完成时启动 timer。完成后停止 timer;后续需求或 `pulse()` 活动会重新启动 timer;dispose(资源释放)时清除;并发需求被拒绝。 | | `MAX_TIMER_DELAY_MS` | Node 在不将延迟限制为 1 毫秒时可调度的最大延迟(`2_147_483_647`)。负责 timer 的配置不得超过该值。 | | `timeoutOf(signal \| { reason }, code?)` | 从已中止的信号/错误中恢复 `TimeoutReason`,否则返回 `undefined`,即超时与取消的分类器。传入 `code` 可仅匹配这个 deadline 的 timer(见下文的嵌套)。 | | `TimeoutReason` | 标记在超时中止上的内部原因(`code` + `timeoutMs`)。它不是公开错误;提供方将其转换为自己的错误/字段。 | @@ -48,7 +48,7 @@ export async function runWithDeadline(upstream: AbortSignal | undefined, timeout 将你自己的 `code` 传给 `timeoutOf`,以便分类可在嵌套中组合:当你收到的 `upstream` *本身*就是 deadline 信号时(未来启动每次调用 deadline 的 `tools/execute` 中间件),如果外层 timer 首先触发,`AbortSignal.any` 会保留外层 `TimeoutReason`。将范围限定为你的 `code`,可将外部超时视为普通 upstream 取消,这才是你所属功能视角下的正确分类,而不会在本地 timer 尚未到期时就声称自己超时。 -对于流式传输,创建一个 `idleWatchdog`,将其稳定的 `signal` 传给传输层,并为提供方的每次读取调用 `watchdog.next(iterator)`。间隔必须为正有限数,且不得超过 `MAX_TIMER_DELAY_MS`;否则 Node 会将其限制为 1 毫秒。它只对尚未完成的读取请求计时,因此当下游代码进行渲染或在请求下一个分片前以其他方式等待时,timer 不会运行。该原语仍然只会通知,因此传输层必须观察稳定信号;DeepSeek 和 pi-ai 适配器证明,超时会关闭它们的真实响应正文或 SDK 请求。 +对于流式传输,创建一个 `idleWatchdog`,将其稳定的 `signal` 传给传输层,并为提供方的每次读取调用 `watchdog.next(iterator)`。当传输活动不产生迭代器值时,调用 `watchdog.pulse()`。间隔必须为正有限数,且不得超过 `MAX_TIMER_DELAY_MS`;否则 Node 会将其限制为 1 毫秒。它只对尚未完成的读取请求计时,因此当下游代码进行渲染或在请求下一个分片前以其他方式等待时,timer 不会运行。该原语仍然只会通知,因此传输层必须观察稳定信号;DeepSeek 和 pi-ai 适配器证明,超时会关闭它们的真实响应正文或 SDK 请求。 ## 哪些操作不设置超时 diff --git a/packages/util/timeout/src/index.ts b/packages/util/timeout/src/index.ts index a9bd47eb08..3fc4d2387b 100644 --- a/packages/util/timeout/src/index.ts +++ b/packages/util/timeout/src/index.ts @@ -72,6 +72,8 @@ export interface IdleWatchdog { * @returns the iterator's next result. */ next(iterator: AsyncIterator): Promise> + /** Rearm an outstanding demand after transport activity that yields no iterator value; otherwise a no-op. */ + pulse(): void /** Clear an armed timer; safe to call once at the owning stream's exit. */ [Symbol.dispose](): void } @@ -135,15 +137,20 @@ export function idleWatchdog( let outstanding = false let disposed = false + const arm = (): void => { + if (timer !== undefined) clearTimeout(timer) + timer = setTimeout(() => { + timeout.abort(new TimeoutReason(code, timeoutMs)) + }, timeoutMs) + } + return { signal, async next(iterator: AsyncIterator): Promise> { if (disposed) throw new Error('idleWatchdog is disposed') if (outstanding) throw new Error('idleWatchdog next is already outstanding') outstanding = true - timer = setTimeout(() => { - timeout.abort(new TimeoutReason(code, timeoutMs)) - }, timeoutMs) + arm() try { return await iterator.next() } finally { @@ -152,6 +159,10 @@ export function idleWatchdog( outstanding = false } }, + pulse(): void { + if (disposed || !outstanding) return + arm() + }, [Symbol.dispose](): void { if (disposed) return disposed = true diff --git a/packages/util/timeout/tests/timeout.spec.ts b/packages/util/timeout/tests/timeout.spec.ts index 11779c915f..af0389d3d7 100644 --- a/packages/util/timeout/tests/timeout.spec.ts +++ b/packages/util/timeout/tests/timeout.spec.ts @@ -229,6 +229,28 @@ describe('idleWatchdog', () => { await expect(secondNext).rejects.toBe(stableSignal.reason) }) + it('rearms outstanding demand on an out-of-band activity pulse', async () => { + vi.useFakeTimers() + const pending = Promise.withResolvers>() + const watchdog = idleWatchdog(undefined, 100, 'LLM_STREAM_IDLE_TIMEOUT') + watchdog.pulse() + await vi.advanceTimersByTimeAsync(1_000) + expect(watchdog.signal.aborted).toBe(false) + + const next = watchdog.next({ next: () => pending.promise }) + await vi.advanceTimersByTimeAsync(99) + watchdog.pulse() + await vi.advanceTimersByTimeAsync(99) + expect(watchdog.signal.aborted).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT')).toMatchObject({ timeoutMs: 100 }) + pending.reject(watchdog.signal.reason) + await expect(next).rejects.toBe(watchdog.signal.reason) + + watchdog[Symbol.dispose]() + watchdog.pulse() + }) + it('keeps an earlier upstream abort distinct from its own timeout', async () => { vi.useFakeTimers() const upstream = new AbortController() From bf70345d81a35c1e821aa88a96b1dac7e3f38bb2 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 12:00:14 +0800 Subject: [PATCH 002/293] test(snapshot): widen keep-alive timing margin --- .../headless-agent/tests/fixtures/deepseek-defaults.cordis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml index 829af9f813..8a1ca177f4 100644 --- a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml +++ b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml @@ -8,7 +8,7 @@ apiKey: snapshot-key baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL thinking: disabled - streamIdleTimeoutMs: 100 + streamIdleTimeoutMs: 150 - id: cli-agent config: provider: deepseek-official From 18fe174897272a905e433856df8c88c363c652d3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 3 Aug 2026 20:30:33 +0800 Subject: [PATCH 003/293] feat(agent-presets): compose each session's agent from a preset cordis.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preset is a directory holding one `agent.cordis.yml`. Mounting it under an agent's scope context during `setup(agentCtx)` gives that one session its own tools and prompt sections while every other live session keeps its own. No registry gains a tier. `dsh-tools` and `dsh-system-prompt` already file registrations into the calling context's scope layer, and entry contexts chain to the context a subtree was plugged into, so a composition mounted under `agent.ctx` is that agent's alone and unwinds with it. The mount audits itself because a directly-plugged subtree is absent from `ctx.loader.entries()` and no boot audit covers it. It rejects an unscoped target, a row that never became usable, and a row that published a service into the root service realm — that last one is process-global rather than per-session, and its collision with the next session surfaces as an unhandled rejection `setup` never observes, leaving a half-composed agent that looks healthy. The package invariant re-checks that rule on every service notification, since a row publishing from a timer would escape a one-shot audit. Raises the `packages/README.md` word ceiling from 920 to 980: the group table must enumerate every group, and the new `preset/` row is necessary content. Design: .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md --- ...-08-03-per-session-agent-presets.i18n.yaml | 6 + .../2026-08-03-per-session-agent-presets.md | 46 ++++ ...2026-08-03-per-session-agent-presets.zh.md | 46 ++++ docs/capability-seams.md | 4 + docs/config-catalog.md | 31 +++ docs/cordis-catalog/services.md | 37 ++++ docs/event-producer-consumer.md | 1 + packages/README.i18n.yaml | 4 +- packages/README.md | 1 + packages/README.zh.md | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 26 +++ packages/preset/README.i18n.yaml | 6 + packages/preset/README.md | 13 ++ packages/preset/README.zh.md | 13 ++ .../preset/agent-presets/README.i18n.yaml | 6 + packages/preset/agent-presets/README.md | 62 ++++++ packages/preset/agent-presets/README.zh.md | 62 ++++++ packages/preset/agent-presets/package.json | 54 +++++ .../preset/agent-presets/src/discovery.ts | 75 +++++++ packages/preset/agent-presets/src/index.ts | 100 +++++++++ .../preset/agent-presets/src/invariant.ts | 48 ++++ packages/preset/agent-presets/src/mount.ts | 205 ++++++++++++++++++ packages/preset/agent-presets/src/types.ts | 34 +++ .../agent-presets/tests/discovery.spec.ts | 77 +++++++ .../tests/fixtures/plugins/contribute.js | 20 ++ .../tests/fixtures/plugins/global-service.js | 5 + .../tests/fixtures/plugins/late-service.js | 6 + .../tests/fixtures/plugins/needs-missing.js | 5 + .../fixtures/system/minimal/agent.cordis.yml | 4 + .../fixtures/system/standard/agent.cordis.yml | 12 + .../fixtures/user/broken/agent.cordis.yml | 6 + .../fixtures/user/isolated/agent.cordis.yml | 9 + .../tests/fixtures/user/late/agent.cordis.yml | 6 + .../fixtures/user/leaky/agent.cordis.yml | 13 ++ .../fixtures/user/not-a-preset/notes.txt | 1 + .../fixtures/user/pending/agent.cordis.yml | 2 + .../fixtures/user/standard/agent.cordis.yml | 5 + .../agent-presets/tests/invariant.spec.ts | 75 +++++++ .../preset/agent-presets/tests/mount.spec.ts | 204 +++++++++++++++++ packages/preset/agent-presets/tsconfig.json | 31 +++ pnpm-lock.yaml | 43 ++++ scripts/doc-budgets.manifest.json | 2 +- scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 7 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 2 + tsconfig.host.json | 1 + 47 files changed, 1416 insertions(+), 3 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md create mode 100644 .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md create mode 100644 packages/preset/README.i18n.yaml create mode 100644 packages/preset/README.md create mode 100644 packages/preset/README.zh.md create mode 100644 packages/preset/agent-presets/README.i18n.yaml create mode 100644 packages/preset/agent-presets/README.md create mode 100644 packages/preset/agent-presets/README.zh.md create mode 100644 packages/preset/agent-presets/package.json create mode 100644 packages/preset/agent-presets/src/discovery.ts create mode 100644 packages/preset/agent-presets/src/index.ts create mode 100644 packages/preset/agent-presets/src/invariant.ts create mode 100644 packages/preset/agent-presets/src/mount.ts create mode 100644 packages/preset/agent-presets/src/types.ts create mode 100644 packages/preset/agent-presets/tests/discovery.spec.ts create mode 100644 packages/preset/agent-presets/tests/fixtures/plugins/contribute.js create mode 100644 packages/preset/agent-presets/tests/fixtures/plugins/global-service.js create mode 100644 packages/preset/agent-presets/tests/fixtures/plugins/late-service.js create mode 100644 packages/preset/agent-presets/tests/fixtures/plugins/needs-missing.js create mode 100644 packages/preset/agent-presets/tests/fixtures/system/minimal/agent.cordis.yml create mode 100644 packages/preset/agent-presets/tests/fixtures/system/standard/agent.cordis.yml create mode 100644 packages/preset/agent-presets/tests/fixtures/user/broken/agent.cordis.yml create mode 100644 packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml create mode 100644 packages/preset/agent-presets/tests/fixtures/user/late/agent.cordis.yml create mode 100644 packages/preset/agent-presets/tests/fixtures/user/leaky/agent.cordis.yml create mode 100644 packages/preset/agent-presets/tests/fixtures/user/not-a-preset/notes.txt create mode 100644 packages/preset/agent-presets/tests/fixtures/user/pending/agent.cordis.yml create mode 100644 packages/preset/agent-presets/tests/fixtures/user/standard/agent.cordis.yml create mode 100644 packages/preset/agent-presets/tests/invariant.spec.ts create mode 100644 packages/preset/agent-presets/tests/mount.spec.ts create mode 100644 packages/preset/agent-presets/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml new file mode 100644 index 0000000000..0f7bd16552 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +2026-08-03-per-session-agent-presets.md: 408e5a52b15efde162fa1bc6ae1e9ede8a7f0d98 +2026-08-03-per-session-agent-presets.zh.md: a06aea8d7e89c77d374c06908c10a9b0e5029548 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md new file mode 100644 index 0000000000..ee6303e5f5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -0,0 +1,46 @@ +# Agent Note: A session's agent is composed from a preset cordis.yml + +Status: implemented + +English | [中文](2026-08-03-per-session-agent-presets.zh.md) + +## Problem + +One `dsh` process serves many sessions, but the composition that decides what an agent *is* — its tools, persona, prompt sections, delegation backends — is fixed for the whole process by the `cordis.yml` the launcher booted. A deployment that wants a benchmark-minimal agent beside a full coding agent has to run two processes, and the shipped workaround (`apps/cli/config/core-web.cordis.yml`, a `--config` overlay that disables tool rows) changes every session at once. + +The obvious reading of "let a session pick its composition" is that the loader needs a new tier. It does not. [`dsh-tools`](../../../../packages/core/tools/README.md) and [`dsh-system-prompt`](../../../../packages/core/system-prompt/README.md) already file registrations into the calling context's scope layer, and [the agent is a registration scope](2026-07-08-agent-scope-contexts.md). What was missing is a way to point a whole `cordis.yml` at one agent's scope. + +## Decision + +A **preset** is a directory holding one `agent.cordis.yml`. The agent factory's `setup(agentCtx)` mounts it as a Cordis `include` subtree plugged into that agent's scope context. Entry contexts chain to the context a subtree was plugged into, so every registration inside the preset lands in that agent's layer and unwinds with the agent. No registry gains a tier, and no session already running is touched. + +Composition splits into two planes, decided by what must be shared rather than by what feels agent-related: + +| Plane | Instances | Contents | +|---|---|---| +| Host | one | The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), cross-session facilities (persistence, query, projections, storage, settings, credentials, telemetry), and the web host | +| Agent | one per session | What a single agent contributes to those registries: tool plugins, persona and prompt sections, delegation backends, compaction policy | + +Model routing stays out of presets. `installAgentLlmTarget` is already the per-agent seam for provider, model, and reasoning effort, and an LLM adapter mounted inside a preset would never be resolved by `agent-loop`, which lives in the host plane. + +Mounting is per-session by default. Measured cost for a twelve-row composition is ~3ms and ~600KB per session, so isolation is the cheaper default than any sharing scheme, and a preset authored by a user or by an agent then has the smallest possible blast radius. A preset that genuinely owns an expensive singleton opts into sharing with Cordis's own `isolate` vocabulary: a named realm label is process-global, so two subtrees naming the same label resolve one instance. + +## Consequences + +**A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it. + +**A preset may not publish into the root service realm.** Such a service is process-global rather than per-session, so the second session mounting the same preset collides with the first — and the collision surfaces as an unhandled rejection that `setup` never observes, leaving a half-composed agent that looks healthy. The mount rejects it instead, and the package invariant re-checks on every service notification because a row publishing from a timer or an asynchronous continuation would escape a one-shot audit. + +**Failure rolls the agent back.** `setup` runs before publication, so a rejected mount fails `ctx.agents.create()` and leaves nothing behind. This is why `setup` is the one supported call site. + +**Fiber membership is object identity, not `uid`.** A `uid` is a per-registry counter, so fibers in two different roots collide on it; comparing by `uid` made one runtime's subtree answer for a service published in another. `ctx.plugin()` returns a thenable `Object.create(fiber)` wrapper that is never identical to the fiber in a parent chain, so the subtree captures its own fiber during construction. + +**The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. + +## Alternatives considered + +**Add a preset tier to the scoped registries.** `ScopedLayers.merge()` combines the global layer with exactly one exact-scope layer. A middle tier would let many sessions share one mounted composition, but it changes `dsh-scope` and every scope-aware registry to save a cost measured in milliseconds, and it gives a preset's registrations a lifetime no agent owns. + +**Make the agent's scope key the preset.** Sessions on one preset would share a layer for free, but per-agent registrations — `installAgentLlmTarget`, per-agent tool restrictions — would then collide across sessions. + +**Run each preset as a child process.** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) already proves a full child harness works, and isolation would be absolute. It also means proxying streaming, approvals, and projections per session, which is a transport project rather than a composition one. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md new file mode 100644 index 0000000000..5a2e1c3d8d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -0,0 +1,46 @@ +# Agent Note:会话的 agent 由一份 preset cordis.yml 组装而成 + +Status: implemented + +[English](2026-08-03-per-session-agent-presets.md) | 中文 + +## 问题 + +一个 `dsh` 进程服务多个会话,但决定 agent(智能体)究竟是什么的那套组装——它的工具、人设、提示词段落、委派后端——由启动器所引导的 `cordis.yml` 一次性固定给整个进程。若某个部署希望一个 benchmark 精简 agent 与一个完整编码 agent 并存,就必须跑两个进程;而现有的变通方案(`apps/cli/config/core-web.cordis.yml`,一个用来禁用工具行的 `--config` 覆盖层)会一次性改变所有会话。 + +对"让会话自选组装"最直觉的理解,是 loader 需要新增一层。其实不需要。[`dsh-tools`](../../../../packages/core/tools/README.md) 与 [`dsh-system-prompt`](../../../../packages/core/system-prompt/README.md) 本就按调用方上下文的 scope 分层归档注册,而且 [agent 本身就是一个注册 scope](2026-07-08-agent-scope-contexts.md)。此前缺的只是一种把整份 `cordis.yml` 指向某一个 agent scope 的办法。 + +## 决策 + +**preset** 是一个目录,其中放置一份 `agent.cordis.yml`。agent 工厂的 `setup(agentCtx)` 把它作为 Cordis `include` 子树,挂载到该 agent 的 scope 上下文之下。entry 上下文沿原型链连到子树被挂载时所在的上下文,因此 preset 内部的每一次注册都落进该 agent 的分层,并随 agent 一起卸载。没有任何注册表新增分层,也没有任何已在运行的会话被触及。 + +组装划分为两个平面,依据是什么必须共享,而不是什么感觉上与 agent 有关: + +| 平面 | 实例数 | 内容 | +|---|---|---| +| 宿主 | 一份 | 注册表本身(`tools`、`systemPrompt`、`agents`、`agent-loop`、`sessions`)、跨会话设施(持久化、查询、投影、存储、设置、凭据、遥测),以及 web 宿主 | +| agent | 每会话一份 | 单个 agent 对这些注册表的贡献:工具插件、人设与提示词段落、委派后端、压缩策略 | + +模型路由不进 preset。`installAgentLlmTarget` 已经是 provider、model 与 reasoning effort 的按 agent 可替换点;而挂在 preset 内部的 LLM 适配器永远不会被 `agent-loop` 解析到,因为后者位于宿主平面。 + +挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB,因此隔离比任何共享方案都更划算;而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset,可以用 Cordis 自身的 `isolate` 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。 + +## 后果 + +**直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。 + +**preset 不得把服务发布进根 realm。** 这类服务是进程级全局而非按会话的,因此第二个挂载同一 preset 的会话会与第一个相撞——而这次相撞表现为 `setup` 永远观察不到的未处理 rejection,留下一个看起来健康、实则组装到一半的 agent。挂载改为直接拒绝它;本包的运行时不变量还会在每次服务通知时复查,因为从定时器或异步续体中发布的行会绕过一次性审计。 + +**失败会让 agent 回滚。** `setup` 在发布之前运行,因此挂载被拒绝会让 `ctx.agents.create()` 失败且不留残留。这正是 `setup` 是唯一受支持调用点的原因。 + +**fiber 归属判定用对象同一性,而非 `uid`。** `uid` 是按 registry 计数的序号,因此两个不同根下的 fiber 会在它上面撞号;按 `uid` 比较曾导致一个运行时的子树为另一个运行时中发布的服务背锅。`ctx.plugin()` 返回的是 thenable 的 `Object.create(fiber)` 包装对象,与父链中出现的 fiber 永远不同一,因此子树在构造时捕获自己的 fiber。 + +**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。 + +## 考虑过的替代方案 + +**在 scope 注册表中新增 preset 分层。** `ScopedLayers.merge()` 把全局层与恰好一个精确 scope 层合并。新增中间层可以让多个会话共用一份已挂载的组装,但它要改动 `dsh-scope` 及每个 scope 感知的注册表,换来的只是毫秒级的开销节省,而且会让 preset 的注册获得一个没有任何 agent 拥有的生命周期。 + +**把 agent 的 scope 键设为 preset。** 同一 preset 上的会话就能免费共享一层,但按 agent 的注册——`installAgentLlmTarget`、按 agent 的工具限制——会跨会话相撞。 + +**把每个 preset 作为子进程运行。** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) 已经证明完整的子 harness 可行,隔离性也会是绝对的。但这同时意味着要按会话代理流式输出、审批与投影,那是一个传输层项目,而非组装问题。 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index cb24cee7d5..9099207264 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -80,6 +80,8 @@ flowchart LR svc_userInteraction["ctx.userInteraction
Human question/answer seam"] pkg_plan_mode["plan-mode"] svc_planMode["ctx.planMode
Plan collaboration state"] + pkg_agent_presets["agent-presets"] + svc_agentPresets["ctx.agentPresets
Per-session agent composition"] pkg_commands["commands"] svc_commands["ctx.commands
Human command registry"] pkg_session_projection["session-projection"] @@ -168,6 +170,7 @@ flowchart LR pkg_acp --> svc_approval pkg_agent --> svc_agents pkg_agent_loop --> svc_agentLoop + pkg_agent_presets --> svc_agentPresets pkg_approval --> svc_approval pkg_bash --> svc_bash pkg_bash_env --> svc_bashEnv @@ -370,6 +373,7 @@ flowchart LR | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | - | [`tool-ask-user`](../packages/ui/tool-ask-user) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | +| `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers profile directories over trusted and user-authored roots and mounts one profile cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. | | `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session-projection/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session-title/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0963f4d490..775387ca6f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -110,6 +110,37 @@ Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core Source: [`packages/core/agent-loop/src/index.ts:236`](../packages/core/agent-loop/src/index.ts) +## `@deepseek-ai/dsh-agent-presets` + +Requires: `loader` + +```ts config-catalog +/** Plugin config: which profile is the default, and where profiles live. */ +export interface Config { + /** Profile id mounted when a caller names none. Missing at mount time fails loud. */ + default: string + /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ + roots: PresetRoot[] +} + +/** One directory scanned for profile subdirectories. */ +export interface PresetRoot { + /** Directory holding one subdirectory per profile; a leading `~` expands. */ + path: string + /** Trust recorded on every profile discovered under this root. */ + trust: PresetTrust +} + +/** + * Where a profile's composition came from. A `system` profile ships with the + * deployment; a `user` profile was authored locally, by a person or by an + * agent, and therefore carries the same trust as shell access. + */ +export type PresetTrust = 'system' | 'user' +``` + +Source: [`packages/preset/agent-presets/src/types.ts:29`](../packages/preset/agent-presets/src/types.ts) + ## `@deepseek-ai/dsh-agent-spine-demo` ```ts config-catalog diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 44a44a139e..3030e2c348 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -46,6 +46,43 @@ Types: [Agent](../core-data-structures/core.md) · [AgentOptions](../core-data-s Source: [`packages/core/agent-loop/src/index.ts:277`](../../packages/core/agent-loop/src/index.ts) +## `ctx.agentPresets` — `AgentPresets` + +Registry over the deployment's agent presets. + +Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a profile authored while the process runs is visible immediately, and a profile deleted underneath a picker disappears from the next read. + +```ts cordis-catalog +/** + * Every profile the configured roots currently supply. + * @returns the profiles, first-root-wins per id. + */ +async list(): Promise + +/** + * Resolve one profile by id. + * @param id - the profile id, or `undefined` for {@link defaultId}. + * @returns the resolved profile. + * @throws when no configured root supplies that id. + */ +async resolve(id?: string): Promise + +/** + * Compose one agent from a profile, installing it under that agent alone. + * + * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls + * the agent creation back, so a broken profile never yields a half-composed + * session. + * @param agentCtx - the agent's scope context. + * @param id - the profile id, or `undefined` for {@link defaultId}. + * @returns the profile that was mounted, for the caller to record. + * @throws when the profile is unknown or its composition is unusable. + */ +async mount(agentCtx: Context, id?: string): Promise +``` + +Source: [`packages/preset/agent-presets/src/index.ts:36`](../../packages/preset/agent-presets/src/index.ts) + ## `ctx.agents` — `AgentRegistry` Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 23794b5c9d..a805b4530c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -66,6 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | +| `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets) | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index e721814e79..7980b809d6 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: dec4d71ca2d323fe05f918dd3bf4709cfa01878e -README.zh.md: 9596dfe8bf8d2d6144ffe7820886342707dd3009 +README.md: 365659617c97c44dd0f30fbcd3347b6438024eb3 +README.zh.md: 9edabd67ea728e77e2863a32c250675a5b9359f8 diff --git a/packages/README.md b/packages/README.md index dec4d71ca2..b736aa5dc9 100644 --- a/packages/README.md +++ b/packages/README.md @@ -31,6 +31,7 @@ Packages live at `packages///`; groups are containers, while names r | [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface | | [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface | | [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface | +| [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | Product — stable surface | | [`timeout/`](timeout/README.md) | Tool-call `tools/execute` deadline enforcement | Product — stable surface | | [`guard/`](guard/README.md) | Loop-hygiene advisory repeat-call reminders | Product — stable surface | | [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 9596dfe8bf..53081b5e8c 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -31,6 +31,7 @@ | [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 | | [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定表面 | | [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 | +| [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定表面 | | [`timeout/`](timeout/README.md) | 工具调用 `tools/execute` 截止时间强制执行 | 产品:稳定表面 | | [`guard/`](guard/README.md) | 循环卫生建议性重复调用提醒 | 产品:稳定表面 | | [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定表面 | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 8a75107eea..55a50977a7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -80,6 +80,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'agentPresets', + summary: 'Registry over the deployment\'s agent presets.', + methods: [ + { + signature: 'async list(): Promise', + jsDoc: '/**\n * Every profile the configured roots currently supply.\n * @returns the profiles, first-root-wins per id.\n */', + }, + { + signature: 'async resolve(id?: string): Promise', + jsDoc: '/**\n * Resolve one profile by id.\n * @param id - the profile id, or `undefined` for {@link defaultId}.\n * @returns the resolved profile.\n * @throws when no configured root supplies that id.\n */', + }, + { + signature: 'async mount(agentCtx: Context, id?: string): Promise', + jsDoc: '/**\n * Compose one agent from a profile, installing it under that agent alone.\n *\n * Call from the agent factory\'s `setup(agentCtx)`; a rejection there rolls\n * the agent creation back, so a broken profile never yields a half-composed\n * session.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the profile id, or `undefined` for {@link defaultId}.\n * @returns the profile that was mounted, for the caller to record.\n * @throws when the profile is unknown or its composition is unusable.\n */', + }, + ], + }, { key: 'agents', summary: 'Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain.', @@ -1601,6 +1619,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentOptions', declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n}', }, + { + name: 'AgentPreset', + declaration: 'export interface AgentPreset {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly path: string;\n}', + }, { name: 'AgentSetup', declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise | void;', @@ -2193,6 +2215,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PresetSpec', declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}', }, + { + name: 'PresetTrust', + declaration: 'export type PresetTrust = \'system\' | \'user\';', + }, { name: 'ProjectionChangeListener', declaration: 'export type ProjectionChangeListener = (session: Session, key: Extract, value: unknown, seq: number) => void;', diff --git a/packages/preset/README.i18n.yaml b/packages/preset/README.i18n.yaml new file mode 100644 index 0000000000..b554512392 --- /dev/null +++ b/packages/preset/README.i18n.yaml @@ -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/preset/README.md +README.md: e7940642166f81e370e3a328f3097d15fd367151 +README.zh.md: 0767ca5074071e9ef2fa38769d27d8ef2344188e diff --git a/packages/preset/README.md b/packages/preset/README.md new file mode 100644 index 0000000000..7baac391c2 --- /dev/null +++ b/packages/preset/README.md @@ -0,0 +1,13 @@ +# preset/ — per-session agent composition + +English | [中文](README.zh.md) + +An **agent preset** is a directory holding one `agent.cordis.yml`. Mounting it under an agent's scope context gives that session its own tools and prompt sections while every other live session keeps its own, so one process can run several differently composed agents at once. + +| Package | Role | ctx key | +|---|---|---| +| `agent-presets/` | Preset vocabulary, filesystem discovery over trusted and user-authored roots, and the guarded per-agent mount | `ctx.agentPresets` | + +The composition split this group assumes: registries and cross-session facilities are process singletons and stay in the host composition, while a preset carries what one agent contributes to them. A preset that names a row publishing a process-global service is rejected at mount rather than allowed to collide with the next session. + +Design: [the per-session agent-preset note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md). diff --git a/packages/preset/README.zh.md b/packages/preset/README.zh.md new file mode 100644 index 0000000000..4d8c350b28 --- /dev/null +++ b/packages/preset/README.zh.md @@ -0,0 +1,13 @@ +# preset/:按会话组装 agent + +[English](README.md) | 中文 + +**agent preset** 是一个目录,其中放置一份 `agent.cordis.yml`。把它挂载到某个 agent(智能体)的 scope 上下文之下,该会话就获得自己的工具与提示词段落,而其他在运行的会话各自保持不变,因此一个进程可以同时运行多个组装方式不同的 agent。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| `agent-presets/` | preset 词汇、在受信任目录与用户自建目录上的文件系统发现,以及带校验的按 agent 挂载 | `ctx.agentPresets` | + +本组假定的组装划分是:注册表与跨会话设施是进程单例,留在宿主组装中;preset 只承载单个 agent 对它们的贡献。若 preset 中某一行发布了进程级全局服务,挂载时即被拒绝,而不是留到与下一个会话相撞。 + +设计详见 [按会话组装 agent preset 的 Agent Note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md)。 diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml new file mode 100644 index 0000000000..9106494073 --- /dev/null +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -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/preset/agent-presets/README.md +README.md: 6068a68d3c81081074165077a8afa6b42af48d1f +README.zh.md: 9f951f566a51a7b7acb666c7d9ea80061aa45d73 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md new file mode 100644 index 0000000000..5d66c23f24 --- /dev/null +++ b/packages/preset/agent-presets/README.md @@ -0,0 +1,62 @@ +# dsh-agent-presets + +English | [中文](README.zh.md) + +Per-session agent composition. A **preset** is a directory holding one `agent.cordis.yml`; mounting it under an agent's scope context gives that one session its own tools, prompt sections, and other model-facing contributions, while every other live session keeps its own. + +The mechanism is entirely Cordis: entry contexts chain to the context a subtree was plugged into, and both [`dsh-tools`](../../core/tools/README.md) and [`dsh-system-prompt`](../../core/system-prompt/README.md) file registrations into the calling context's scope layer. Mounting a composition under `agent.ctx` therefore makes it that agent's alone, and unwinds it with the agent, without any new layering in those registries. + +## Service: `AgentPresets` (ctx key: `agentPresets`) + +Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call, so a preset authored while the process runs is visible immediately and a deleted one disappears from the next read. + +- `ctx.agentPresets.defaultId: string` The preset id mounted when a caller names none. +- `ctx.agentPresets.list(): Promise` Every preset the configured roots currently supply, earlier root winning a duplicate id. +- `ctx.agentPresets.resolve(id?): Promise` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it. +- `ctx.agentPresets.mount(agentCtx, id?): Promise` Compose one agent from a preset and return the preset that was mounted, for the caller to record. + +`AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), and `path` (the absolute composition file). + +### Where to call `mount()` + +The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the composition installed while the agent is still unpublished, so a rejected mount rolls the whole creation back rather than leaving a half-composed session. The subtree is owned by `agentCtx`'s fiber, so it unwinds with the agent and the caller receives no disposer. + +## Config + +| Field | Default | Meaning | +|---|---|---| +| `default` | required | Preset id mounted when a caller names none | +| `roots` | `[]` | Scanned directories in precedence order; each supplies `path` (a leading `~` expands) and `trust` (defaults to `user`) | + +An absent root supplies no presets rather than failing: the user root does not exist until the first locally authored preset, and naming a default no root supplies already fails loud at resolution. + +## What a mount rejects + +A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it. `mount()` therefore proves the result usable itself, and rejects three things. + +**An unscoped target.** Mounting into a context that carries no agent scope would register the preset's tools globally, for every agent in the process. + +**A row that never became usable.** The loader already rejects a row whose module failed to import or whose plugin threw; what remains is a row still waiting for a service the composition never supplies, which the audit names. + +**A row that published a service into the root realm.** Such a service is process-global rather than per-session, so the second session mounting the same preset collides with the first. A preset that genuinely owns a service puts it behind an `isolate` realm — entry-local for one session's private instance, or a shared label when several sessions should share one — or the service belongs in the host composition instead. + +The package invariant re-checks that last rule on every service notification, because a row that publishes from a timer or an asynchronous continuation would escape the one-shot audit. + +## Trust + +Presets are compositions, so a preset is exactly as privileged as the plugins it names. A `user` preset — authored by a person or by an agent — carries the same trust as shell access; the `trust` field exists so consumers can present that difference, not to enforce it. + +## Model Experience + +Indirectly, through the plugins a mounted composition registers, which own every tool schema and prompt section the preset makes visible to its one agent. + +#### KV Cache effect + +Prefix-stable for the life of an agent: a composition is installed once, before the agent is published and therefore before its first request, and is never re-read while the agent runs. Choosing a different preset for a new session establishes a different prefix for that session alone and cannot invalidate reuse for any session already running. + +## Known Limitations and Deferred Work + +- **A preset cannot be changed on a live agent** — the mount happens once during creation, so switching a running session's composition would mean unwinding its subtree mid-turn, dropping tools the model may already have called. Changing the default affects only sessions created afterwards. +- **Display names are the directory id** — a preset carries no manifest, so pickers and settings surfaces show the id until a consumer needs richer metadata. +- **`isolate` realms cannot be expressed across rows without `cordis:group`** — an entry-local realm works on a single row, but grouping a provider with its consumers under one shared realm needs the group builtin, which `dsh-app-boot` does not register. +- **Root scans are not watched** — every read hits the filesystem instead, which keeps the roster fresh but puts one `readdir` per root on each `list()`. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md new file mode 100644 index 0000000000..9cdcd8b11a --- /dev/null +++ b/packages/preset/agent-presets/README.zh.md @@ -0,0 +1,62 @@ +# dsh-agent-presets + +[English](README.md) | 中文 + +按会话组装 agent(智能体)。**preset** 是一个目录,其中放置一份 `agent.cordis.yml`;把它挂载到某个 agent 的 scope 上下文之下,该会话就拥有自己的工具、提示词段落以及其他面向模型的贡献,而其他在运行的会话各自保持不变。 + +其机制完全来自 Cordis:entry 上下文沿原型链连到子树被挂载时所在的上下文,而 [`dsh-tools`](../../core/tools/README.md) 与 [`dsh-system-prompt`](../../core/system-prompt/README.md) 本就按调用方上下文的 scope 分层归档注册。因此把一份组装挂到 `agent.ctx` 之下,它就只属于该 agent,并随 agent 一起卸载,无需在这些注册表中新增任何分层。 + +## 服务:`AgentPresets`(ctx 键:`agentPresets`) + +发现过程不做缓存:`list()` 与 `resolve()` 每次调用都重新读取各个根目录,因此进程运行期间新写的 preset 立即可见,被删除的 preset 也会在下一次读取时消失。 + +- `ctx.agentPresets.defaultId: string` 调用方未指定时挂载的 preset id。 +- `ctx.agentPresets.list(): Promise` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出。 +- `ctx.agentPresets.resolve(id?): Promise` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。 +- `ctx.agentPresets.mount(agentCtx, id?): Promise` 用一个 preset 组装一个 agent,并返回所挂载的 preset 供调用方记录。 + +`AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)以及 `path`(组装文件的绝对路径)。 + +### 应在何处调用 `mount()` + +agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,组装是在 agent 尚未发布时装入的,因此挂载被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。子树归 `agentCtx` 的 fiber 所有,随 agent 一起卸载,调用方无需持有 disposer。 + +## 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `default` | 必填 | 调用方未指定时挂载的 preset id | +| `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) | + +根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。 + +## 挂载会拒绝什么 + +直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有任何启动审计能覆盖它。`mount()` 因此自行校验结果可用,并拒绝三种情况。 + +**目标上下文没有 scope。** 挂载到不带 agent scope 的上下文,会把该 preset 的工具注册成全局的,作用于进程内每一个 agent。 + +**某一行始终未进入可用状态。** 模块导入失败或插件抛错的行,loader 已经会拒绝;剩下的情况是某一行仍在等待该组装从未提供的服务,审计会指名这种情况。 + +**某一行把服务发布进了根 realm。** 这类服务是进程级全局而非按会话的,因此第二个挂载同一 preset 的会话会与第一个相撞。确实需要自带服务的 preset,应把它放在 `isolate` realm 之后——用 entry 本地 realm 得到该会话私有的实例,或用共享 label 让多个会话共用一个——否则该服务应改放进宿主组装。 + +最后一条规则由本包的运行时不变量在每次服务通知时复查,因为从定时器或异步续体中发布的行会绕过一次性审计。 + +## 信任 + +preset 就是组装,因此一个 preset 的权限恰好等于它所引用的插件。`user` preset——无论由人还是由 agent 写出——与 shell 访问权限同级;`trust` 字段的存在是为了让消费方呈现这一差异,而不是用来强制隔离。 + +## Model Experience + +Indirectly, through the plugins a mounted composition registers, which own every tool schema and prompt section the preset makes visible to its one agent. + +#### KV Cache effect + +在一个 agent 的整个生命周期内保持前缀稳定:组装只装入一次,发生在 agent 发布之前、因而也在它的首个请求之前,且在 agent 运行期间不再重新读取。为新会话选择不同的 preset,只会为该会话建立不同的前缀,无法让任何已在运行的会话失去缓存复用。 + +## Known Limitations and Deferred Work + +- **无法在存活的 agent 上更换 preset** —— 挂载只在创建时发生一次,因此切换运行中会话的组装意味着要在轮次进行途中卸载其子树,抽走模型可能已经调用的工具。更改默认值只影响此后创建的会话。 +- **展示名称就是目录 id** —— preset 不携带 manifest,因此选择器与设置界面在有消费方需要更丰富的元数据之前,只显示 id。 +- **跨多行的 `isolate` realm 需要 `cordis:group` 才能表达** —— 单行可用 entry 本地 realm,但要把一个提供方与它的消费方归入同一个共享 realm,需要 group 内建插件,而 `dsh-app-boot` 并未注册它。 +- **根目录扫描不做监听** —— 每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json new file mode 100644 index 0000000000..9dae4e584a --- /dev/null +++ b/packages/preset/agent-presets/package.json @@ -0,0 +1,54 @@ +{ + "name": "@deepseek-ai/dsh-agent-presets", + "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@cordisjs/plugin-include": "^1.0.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts new file mode 100644 index 0000000000..2b3af0aa11 --- /dev/null +++ b/packages/preset/agent-presets/src/discovery.ts @@ -0,0 +1,75 @@ +/** + * Filesystem discovery of agent presets. A preset is a directory holding + * {@link COMPOSITION_FILE}; the directory name is the preset id. Discovery + * re-reads the roots on every call so a preset authored while the process is + * running is visible without a restart. + * @module @deepseek-ai/dsh-agent-presets/discovery + */ + +import { readdir, stat } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { expandHomePath } from '@deepseek-ai/dsh-paths' +import type { AgentPreset, PresetRoot } from './types.ts' + +/** The composition file that makes a directory a preset. */ +export const COMPOSITION_FILE = 'agent.cordis.yml' + +/** + * Whether `path` names an existing regular file. + * @param path - absolute path to test. + * @returns true when the path resolves to a file. + */ +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile() + } catch { + // Any stat failure — absent, unreadable, a dangling link — means this + // directory does not present a composition, which is not an error: the + // directory simply is not a preset. + return false + } +} + +/** + * Scan one root for preset directories. + * + * An absent root yields no presets rather than throwing: the user root does + * not exist until the first locally authored preset, and naming a default + * that no root supplies already fails loud at resolution. + * @param root - the directory and the trust its presets inherit. + * @returns the root's presets ordered by id. + */ +export async function scanRoot(root: PresetRoot): Promise { + const dir = resolve(expandHomePath(root.path)) + let children + try { + children = await readdir(dir, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw new Error(`agent-presets: cannot read preset root ${dir}: ${String(error)}`, { cause: error }) + } + const found: AgentPreset[] = [] + for (const child of children) { + if (!child.isDirectory()) continue + const path = join(dir, child.name, COMPOSITION_FILE) + if (!await isFile(path)) continue + found.push({ id: child.name, trust: root.trust, path }) + } + return found.sort((left, right) => left.id.localeCompare(right.id)) +} + +/** + * Scan every root in precedence order. + * @param roots - roots in precedence order; an earlier root wins a duplicate id. + * @returns every discovered preset, first-root-wins per id. + */ +export async function discoverPresets(roots: readonly PresetRoot[]): Promise { + const byId = new Map() + for (const root of roots) { + for (const preset of await scanRoot(root)) { + if (byId.has(preset.id)) continue + byId.set(preset.id, preset) + } + } + return [...byId.values()] +} diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts new file mode 100644 index 0000000000..778b0d7275 --- /dev/null +++ b/packages/preset/agent-presets/src/index.ts @@ -0,0 +1,100 @@ +/** + * Agent presets: each session composes its model-facing plugin set from one + * preset `cordis.yml` mounted under that agent's scope context. + * + * This package owns the preset vocabulary, filesystem discovery, and the + * guarded mount. It does not decide when an agent is created — the agent + * factory's `setup(agentCtx)` hook is the one supported call site, because + * only there is the composition installed while the agent is still + * unpublished, so a rejected mount rolls the whole creation back. + * @module @deepseek-ai/dsh-agent-presets + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import { discoverPresets } from './discovery.ts' +import { mountPreset } from './mount.ts' +import type { AgentPreset, Config } from './types.ts' + +export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' +export { inactiveRows, leakedServices, livePresetMounts, mountPreset, type PresetMount } from './mount.ts' +export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts' + +declare module 'cordis' { + interface Context { + agentPresets: AgentPresets + } +} + +/** + * Registry over the deployment's agent presets. + * + * Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every + * call so a preset authored while the process runs is visible immediately, + * and a preset deleted underneath a picker disappears from the next read. + */ +export class AgentPresets extends Service { + static inject = ['loader'] + + /** Runtime schema for the preset roster. */ + static Config = z.object({ + default: z.string().required(), + roots: z.array(z.object({ + path: z.string().required(), + trust: z.union(['system', 'user'] as const).default('user'), + })).default([]), + }) as z + + constructor(ctx: Context, public config: Config) { + super(ctx, 'agentPresets') + } + + /** The preset id mounted when a caller names none. */ + get defaultId(): string { + return this.config.default + } + + /** + * Every preset the configured roots currently supply. + * @returns the presets, first-root-wins per id. + */ + async list(): Promise { + return await discoverPresets(this.config.roots) + } + + /** + * Resolve one preset by id. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the resolved preset. + * @throws when no configured root supplies that id. + */ + async resolve(id?: string): Promise { + const wanted = id ?? this.config.default + const presets = await this.list() + const found = presets.find(preset => preset.id === wanted) + if (found === undefined) { + const known = presets.map(preset => preset.id).join(', ') + throw new Error(`agent-presets: preset "${wanted}" not found (available: ${known || 'none'})`) + } + return found + } + + /** + * Compose one agent from a preset, installing it under that agent alone. + * + * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls + * the agent creation back, so a broken preset never yields a half-composed + * session. + * @param agentCtx - the agent's scope context. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the preset that was mounted, for the caller to record. + * @throws when the preset is unknown or its composition is unusable. + */ + async mount(agentCtx: Context, id?: string): Promise { + const preset = await this.resolve(id) + await mountPreset(agentCtx, preset) + return preset + } +} + +export default AgentPresets diff --git a/packages/preset/agent-presets/src/invariant.ts b/packages/preset/agent-presets/src/invariant.ts new file mode 100644 index 0000000000..7a08eab7f1 --- /dev/null +++ b/packages/preset/agent-presets/src/invariant.ts @@ -0,0 +1,48 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-agent-presets`. + * @module @deepseek-ai/dsh-agent-presets/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +// Imported through the package name, not `./mount.ts`: a module shared between +// the two build entry points becomes a third chunk that the published `files` +// list does not carry, which `verify-built-package-invariants` rejects. +import { leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-presets' + +/** Cordis companion plugin name. */ +export const name = 'agent-presets-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * Assert that no installed preset composition reaches the root service realm. + * + * `mountPreset` proves this once, when the subtree settles. A row that + * publishes later — from a timer, or an asynchronous continuation after its + * plugin returned — would escape that one-shot audit, so re-check every live + * mount whenever a service registration changes. + */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.on('internal/service', function (this: Context, name) { + for (const mount of livePresetMounts()) { + const leaked = leakedServices(ctx, mount.fiber) + if (leaked.length === 0) continue + fail( + `preset "${mount.presetId}" published process-global service(s) [${leaked.join(', ')}] ` + + `after its mount was audited (observed while notifying "${name}") — ` + + 'a preset service must sit behind an `isolate` realm or move to the host composition', + ) + } + }, { global: true }) +} + +/** + * 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)) diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts new file mode 100644 index 0000000000..ba09869cb4 --- /dev/null +++ b/packages/preset/agent-presets/src/mount.ts @@ -0,0 +1,205 @@ +/** + * Mount one preset composition under an agent's scope context, then prove the + * result is usable before the agent is published. + * + * The scope context is what makes the composition per-session: entry contexts + * chain to the context the subtree was plugged into, so every `ctx.tools` + * and `ctx.systemPrompt` registration inside the preset files into that + * agent's layer and unwinds with it. Two guards make that safe. A row that + * never reached a usable state is rejected, because a directly-plugged subtree + * is absent from `ctx.loader.entries()` and no boot audit covers it. A row that + * published a service into the ROOT realm is rejected, because such a service + * is process-global rather than per-session and the second session mounting the + * same preset collides with the first. + * @module @deepseek-ai/dsh-agent-presets/mount + */ + +import { pathToFileURL } from 'node:url' +import { Context, type Fiber } from 'cordis' +import { Include } from '@cordisjs/plugin-include' +import type { EntryTree } from '@cordisjs/plugin-loader' +import { scopeOf } from '@deepseek-ai/dsh-scope' +import type { AgentPreset } from './types.ts' + +/** What one mounted subtree publishes about itself for the audit to read. */ +interface MountedTree { + /** The rows the composition created. */ + readonly tree: EntryTree + /** + * The subtree's own fiber. Captured here rather than taken from + * `ctx.plugin()`, which hands back a thenable `Object.create(fiber)` wrapper + * that is never identical to the fiber appearing in a parent chain. + */ + readonly fiber: Fiber +} + +/** + * Subtrees captured by config identity. A subtree plugged directly (rather than + * created as a loader entry) never links itself to an `Entry`, so this is the + * only handle to the rows it created; config objects are minted per mount, so + * concurrent mounts cannot collide. + */ +const mounted = new WeakMap() + +/** Include subclass whose only addition is publishing its tree and fiber for the audit. */ +class PresetTree extends Include { + constructor(ctx: Context, config: Include.Config) { + super(ctx, config) + mounted.set(config, { tree: this, fiber: ctx.fiber }) + } +} + +/** One preset composition currently installed under some agent. */ +export interface PresetMount { + /** The preset the subtree was composed from. */ + readonly presetId: string + /** The mounted subtree's fiber. */ + readonly fiber: Fiber +} + +const mounts = new Set() + +/** + * Every preset composition still installed, pruning fibers disposed since the + * last read. Records are dropped lazily rather than through a disposal hook + * because a subtree can be torn down by its owning agent, by a failed mount, or + * by the whole tree unloading, and a cleared `uid` is what all three share. + * @returns the live mounts. + */ +export function livePresetMounts(): PresetMount[] { + for (const mount of mounts) { + if (mount.fiber.uid === null) mounts.delete(mount) + } + return [...mounts] +} + +/** + * Whether `fiber` is `root` itself or is mounted anywhere inside its subtree. + * + * Membership is object identity. `uid` looks like a cheaper key but is a + * per-registry counter, so fibers in two different roots collide on it and a + * subtree in one runtime would be blamed for a service published in another. + * @param fiber - the fiber to locate. + * @param root - the subtree root to test membership against. + * @returns true when `fiber` belongs to `root`'s subtree. + */ +function withinFiber(fiber: Fiber, root: Fiber): boolean { + let current = fiber + while (true) { + if (current === root) return true + const parent = current.parent.fiber + if (parent === current) return false + current = parent + } +} + +/** + * Service names the mounted subtree published into the root realm. + * + * A provider without an `isolate` realm stores its implementation under the + * root's symbol for that name, which is exactly the comparison below; a + * provider inside an `isolate` realm stores under a realm-private symbol and + * is correctly absent here. + * @param ctx - any context of the runtime whose service store is inspected. + * @param mount - the mounted subtree's fiber. + * @returns the leaked service names in lexical order. + */ +export function leakedServices(ctx: Context, mount: Fiber): string[] { + const store = ctx.reflect.store + const rootIsolate = ctx.root[Context.isolate] + const leaked: string[] = [] + for (const key of Object.getOwnPropertySymbols(store)) { + const impl = store[key] + /* v8 ignore next -- cordis deletes a store slot on disposal rather than + clearing it, so an own symbol always resolves; the guard exists only + because the store's index signature is optional. */ + if (impl === undefined) continue + if (!withinFiber(impl.fiber, mount)) continue + if (rootIsolate[impl.name] === key) leaked.push(impl.name) + } + return leaked.sort((left, right) => left.localeCompare(right)) +} + +/** + * Rows that did not reach a usable state, each rendered as one diagnostic line. + * + * A row whose module failed to import or whose plugin threw already rejects the + * mount through the loader; what remains observable here is a row still waiting + * for a service the composition never supplies. + * @param tree - the mounted subtree. + * @returns one line per unusable row, empty when every enabled row is usable. + */ +export function inactiveRows(tree: EntryTree): string[] { + const lines: string[] = [] + for (const entry of tree.entries()) { + if (entry.disabled) continue + const fiber = entry.fiber + /* v8 ignore next 4 -- the loader rejects an entry whose module or plugin failed, + so a settled tree never holds an enabled fiber-less entry; the branch exists + only because `Entry.fiber` is declared optional. */ + if (fiber === undefined) { + lines.push(`${entry.options.id} (${entry.options.name}): never started`) + continue + } + const missing = Object.keys(fiber.inject).filter(name => fiber.ctx.get(name) === undefined) + if (missing.length > 0) { + lines.push(`${entry.options.id} (${entry.options.name}): waiting for ${missing.join(', ')}`) + } + } + return lines +} + +/** + * Mount `preset` under `agentCtx` and return only once every row is usable. + * + * The subtree is owned by `agentCtx`'s fiber, so it unwinds with the agent and + * the caller receives no disposer. A rejection leaves nothing mounted. + * @param agentCtx - the agent's scope context, from the agent factory's `setup`. + * @param preset - the resolved preset to compose the agent from. + * @throws when `agentCtx` carries no scope, a row is unusable, or a row + * published a service into the root realm. + */ +export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promise { + if (scopeOf(agentCtx) === undefined) { + throw new Error( + `agent-presets: refusing to mount preset "${preset.id}" into an unscoped context; ` + + 'its registrations would apply to every agent in the process', + ) + } + const config: Include.Config = { path: pathToFileURL(preset.path).href } + const handle = agentCtx.plugin(PresetTree, config) + try { + await handle.await() + const subtree = mounted.get(config) + /* v8 ignore next -- the subclass constructor runs before `await()` settles for every mounted tree */ + if (subtree === undefined) throw new Error('mounted subtree did not publish its entry tree') + const { tree, fiber } = subtree + const unusable = inactiveRows(tree) + if (unusable.length > 0) { + throw new Error(`${String(unusable.length)} row(s) did not activate:\n${unusable.join('\n')}`) + } + const leaked = leakedServices(agentCtx, fiber) + if (leaked.length > 0) { + throw new Error( + `row(s) published process-global service(s) [${leaked.join(', ')}]; ` + + 'a preset service must sit behind an `isolate` realm or move to the host composition', + ) + } + mounts.add({ presetId: preset.id, fiber }) + } catch (error) { + try { + await handle.dispose() + /* v8 ignore next 5 -- teardown of a subtree nothing else references has no + observed failure mode; the guard exists so a teardown error cannot + replace the mount diagnostic the caller needs. */ + } catch { + // Swallows only this subtree's teardown failure. The mount error below is + // the actionable one, and the discarded fiber is unreachable either way. + } + /* v8 ignore next -- every path into this catch throws an Error: the loader + wraps a row's thrown value before it propagates, and this module's own + rejections are Errors. The fallback keeps a hostile value readable. */ + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`agent-presets: preset "${preset.id}" (${preset.path}) failed to mount: ${detail}`, { cause: error }) + } +} diff --git a/packages/preset/agent-presets/src/types.ts b/packages/preset/agent-presets/src/types.ts new file mode 100644 index 0000000000..64fa2cdf07 --- /dev/null +++ b/packages/preset/agent-presets/src/types.ts @@ -0,0 +1,34 @@ +/** Agent-preset vocabulary shared by discovery, mounting, and consumers. @module @deepseek-ai/dsh-agent-presets/types */ + +/** + * Where a preset's composition came from. A `system` preset ships with the + * deployment; a `user` preset was authored locally, by a person or by an + * agent, and therefore carries the same trust as shell access. + */ +export type PresetTrust = 'system' | 'user' + +/** One preset directory that carries a mountable agent composition. */ +export interface AgentPreset { + /** Stable identifier; the preset directory's name. */ + readonly id: string + /** Trust recorded from the root this preset was discovered under. */ + readonly trust: PresetTrust + /** Absolute path of the preset's agent composition file. */ + readonly path: string +} + +/** One directory scanned for preset subdirectories. */ +export interface PresetRoot { + /** Directory holding one subdirectory per preset; a leading `~` expands. */ + path: string + /** Trust recorded on every preset discovered under this root. */ + trust: PresetTrust +} + +/** Plugin config: which preset is the default, and where presets live. */ +export interface Config { + /** Preset id mounted when a caller names none. Missing at mount time fails loud. */ + default: string + /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ + roots: PresetRoot[] +} diff --git a/packages/preset/agent-presets/tests/discovery.spec.ts b/packages/preset/agent-presets/tests/discovery.spec.ts new file mode 100644 index 0000000000..79b7ffb793 --- /dev/null +++ b/packages/preset/agent-presets/tests/discovery.spec.ts @@ -0,0 +1,77 @@ +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const } +const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const } + +describe('preset discovery', () => { + it('reports one preset per directory holding a composition, ordered by id', async () => { + const found = await scanRoot(SYSTEM) + + expect(found.map(preset => preset.id)).toEqual(['minimal', 'standard']) + expect(found[0]).toEqual({ + id: 'minimal', + trust: 'system', + path: join(SYSTEM.path, 'minimal', COMPOSITION_FILE), + }) + }) + + it('skips a directory that holds no composition file', async () => { + const found = await scanRoot(USER) + + expect(found.map(preset => preset.id)).not.toContain('not-a-preset') + }) + + it('records the root trust on every preset it discovers', async () => { + const found = await scanRoot(USER) + + expect(found.every(preset => preset.trust === 'user')).toBe(true) + }) + + it('lets the earlier root win a duplicate id', async () => { + const found = await discoverPresets([SYSTEM, USER]) + + const standard = found.filter(preset => preset.id === 'standard') + expect(standard).toHaveLength(1) + expect(standard[0]?.trust).toBe('system') + }) + + it('treats an absent root as supplying no presets', async () => { + const found = await scanRoot({ path: join(FIXTURES, 'no-such-root'), trust: 'user' }) + + expect(found).toEqual([]) + }) + + it('ignores a plain file sitting beside the preset directories', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-presets-')) + await writeFile(join(root, 'stray.yml'), '- id: x\n') + await mkdir(join(root, 'real')) + await writeFile(join(root, 'real', COMPOSITION_FILE), '[]\n') + + const found = await scanRoot({ path: root, trust: 'user' }) + + expect(found.map(preset => preset.id)).toEqual(['real']) + }) + + it('reports a root it cannot read rather than treating it as empty', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-presets-')) + const notADirectory = join(root, 'file-as-root') + await writeFile(notADirectory, 'not a directory\n') + + await expect(scanRoot({ path: notADirectory, trust: 'user' })) + .rejects.toThrow(/cannot read preset root/) + }) + + it('expands a leading tilde in a root path', async () => { + // `~` alone resolves to the home directory, which exists but holds no + // preset directories; the point is that it did not throw on a literal `~`. + const found = await scanRoot({ path: '~/.dsh-agent-presets-absent', trust: 'user' }) + + expect(found).toEqual([]) + }) +}) diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/contribute.js b/packages/preset/agent-presets/tests/fixtures/plugins/contribute.js new file mode 100644 index 0000000000..b7b67be5d6 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/plugins/contribute.js @@ -0,0 +1,20 @@ +// A preset row: registers one tool and one prompt section, both named from +// config. Import-free on purpose — the Loader resolves entry modules through +// Node's ESM resolver, which cannot see this workspace's TypeScript sources. +export const name = 'contribute' +export const inject = ['tools', 'systemPrompt'] + +export function apply(ctx, config) { + ctx.effect(() => ctx.tools.register({ + name: config.tool, + description: `fixture tool ${config.tool}`, + parameters: { type: 'object', properties: {}, additionalProperties: false }, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] }, + execute: () => Promise.resolve(config.tool), + })) + ctx.effect(() => ctx.systemPrompt.section({ + name: `preset:${config.tool}`, + order: 10, + text: `section for ${config.tool}`, + })) +} diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/global-service.js b/packages/preset/agent-presets/tests/fixtures/plugins/global-service.js new file mode 100644 index 0000000000..b30e37356b --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/plugins/global-service.js @@ -0,0 +1,5 @@ +// Publishes a service with no `isolate` realm, so it lands in the ROOT realm. +export const name = 'global-service' +export function apply(ctx, config) { + ctx.effect(() => ctx.reflect.provide(config.service, { label: config.label })) +} diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/late-service.js b/packages/preset/agent-presets/tests/fixtures/plugins/late-service.js new file mode 100644 index 0000000000..d0381a9979 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/plugins/late-service.js @@ -0,0 +1,6 @@ +// Publishes into the ROOT realm only after its plugin body returned, escaping +// the one-shot mount audit. Exercises the package invariant. +export const name = 'late-service' +export function apply(ctx, config) { + globalThis.__PUBLISH_LATE__ = () => ctx.effect(() => ctx.reflect.provide(config.service, { label: 'late' })) +} diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/needs-missing.js b/packages/preset/agent-presets/tests/fixtures/plugins/needs-missing.js new file mode 100644 index 0000000000..b4f732eeac --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/plugins/needs-missing.js @@ -0,0 +1,5 @@ +// Waits forever for a service the composition never supplies: the row stays +// pending rather than failing, which only the mount audit can catch. +export const name = 'needs-missing' +export const inject = ['serviceThatDoesNotExist'] +export function apply() {} diff --git a/packages/preset/agent-presets/tests/fixtures/system/minimal/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/system/minimal/agent.cordis.yml new file mode 100644 index 0000000000..ebd0a74c33 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/system/minimal/agent.cordis.yml @@ -0,0 +1,4 @@ +- id: beta + name: ../../plugins/contribute.js + config: + tool: beta diff --git a/packages/preset/agent-presets/tests/fixtures/system/standard/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/system/standard/agent.cordis.yml new file mode 100644 index 0000000000..9a434aec6e --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/system/standard/agent.cordis.yml @@ -0,0 +1,12 @@ +# Shipped preset: one tool plus its guidance section. +- id: alpha + name: ../../plugins/contribute.js + config: + tool: alpha + +# A row switched off in the composition stays off without failing the mount. +- id: alpha-extra + name: ../../plugins/contribute.js + disabled: true + config: + tool: alpha-extra diff --git a/packages/preset/agent-presets/tests/fixtures/user/broken/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/broken/agent.cordis.yml new file mode 100644 index 0000000000..ae9baeee11 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/broken/agent.cordis.yml @@ -0,0 +1,6 @@ +- id: ok + name: ../../plugins/contribute.js + config: + tool: ok +- id: missing + name: ../../plugins/does-not-exist.js diff --git a/packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml new file mode 100644 index 0000000000..ccb3a9037c --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/isolated/agent.cordis.yml @@ -0,0 +1,9 @@ +# Accepted: the same provider behind an entry-local realm never reaches the +# root realm, so it is per-session rather than process-global. +- id: svc + name: ../../plugins/global-service.js + isolate: + fixtureIsolatedSvc: true + config: + service: fixtureIsolatedSvc + label: ISOLATED diff --git a/packages/preset/agent-presets/tests/fixtures/user/late/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/late/agent.cordis.yml new file mode 100644 index 0000000000..895268e67b --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/late/agent.cordis.yml @@ -0,0 +1,6 @@ +# Publishes into the root realm only after the mount audit ran, which only the +# package invariant can catch. +- id: late + name: ../../plugins/late-service.js + config: + service: fixtureLateSvc diff --git a/packages/preset/agent-presets/tests/fixtures/user/leaky/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/leaky/agent.cordis.yml new file mode 100644 index 0000000000..f95329ce46 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/leaky/agent.cordis.yml @@ -0,0 +1,13 @@ +# Rejected: publishes services into the root realm, which would be +# process-global rather than per-session. Two rows, so the diagnostic has to +# order the names it reports. +- id: leak-z + name: ../../plugins/global-service.js + config: + service: zzzFixtureLeakedSvc + label: LEAKED-Z +- id: leak-a + name: ../../plugins/global-service.js + config: + service: aaaFixtureLeakedSvc + label: LEAKED-A diff --git a/packages/preset/agent-presets/tests/fixtures/user/not-a-preset/notes.txt b/packages/preset/agent-presets/tests/fixtures/user/not-a-preset/notes.txt new file mode 100644 index 0000000000..b4a2550351 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/not-a-preset/notes.txt @@ -0,0 +1 @@ +placeholder, not a preset diff --git a/packages/preset/agent-presets/tests/fixtures/user/pending/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/pending/agent.cordis.yml new file mode 100644 index 0000000000..67f7ffb09a --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/pending/agent.cordis.yml @@ -0,0 +1,2 @@ +- id: waits + name: ../../plugins/needs-missing.js diff --git a/packages/preset/agent-presets/tests/fixtures/user/standard/agent.cordis.yml b/packages/preset/agent-presets/tests/fixtures/user/standard/agent.cordis.yml new file mode 100644 index 0000000000..4cfbbcb20c --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/user/standard/agent.cordis.yml @@ -0,0 +1,5 @@ +# Same id as the shipped preset: proves the earlier root wins. +- id: shadowed + name: ../../plugins/contribute.js + config: + tool: shadowed diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts new file mode 100644 index 0000000000..cf3dc80ea0 --- /dev/null +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -0,0 +1,75 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { describe, expect, it } from 'vitest' +import AgentPresets, { livePresetMounts } from '@deepseek-ai/dsh-agent-presets' +import * as AgentPresetsInvariant from '@deepseek-ai/dsh-agent-presets/invariant' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const ROOTS = [ + { path: join(FIXTURES, 'system'), trust: 'system' as const }, + { path: join(FIXTURES, 'user'), trust: 'user' as const }, +] + +async function harness(): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS }) + await ctx.plugin(InvariantService) + await ctx.plugin(AgentPresetsInvariant) + return ctx +} + +describe('agent-presets invariants', () => { + it('tracks a mounted composition and forgets it once the agent is gone', async () => { + const ctx = await harness() + const handle = await ctx.agents.create({ + sessionId: SessionId('inv-live'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + expect(livePresetMounts().map(mount => mount.presetId)).toContain('standard') + + await handle.dispose() + + expect(livePresetMounts().map(mount => mount.presetId)).not.toContain('standard') + }) + + it('rejects a composition that publishes a process-global service after its audit', async () => { + const ctx = await harness() + await ctx.agents.create({ + sessionId: SessionId('inv-late'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'late'), + }) + const publishLate = (globalThis as { __PUBLISH_LATE__?: () => void }).__PUBLISH_LATE__ + expect(publishLate).toBeTypeOf('function') + + expect(() => { publishLate?.() }).toThrow(/published process-global service\(s\) \[fixtureLateSvc\]/) + }) + + it('stays quiet while every composition keeps its services out of the root realm', async () => { + const ctx = await harness() + + await expect(ctx.agents.create({ + sessionId: SessionId('inv-isolated'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'isolated'), + })).resolves.toBeDefined() + }) +}) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts new file mode 100644 index 0000000000..36810c61d1 --- /dev/null +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -0,0 +1,204 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { beforeEach, describe, expect, it } from 'vitest' +import AgentPresets, { leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const ROOTS = [ + { path: join(FIXTURES, 'system'), trust: 'system' as const }, + { path: join(FIXTURES, 'user'), trust: 'user' as const }, +] + +/** A composition carrying the registries a preset contributes to, plus the preset roster. */ +async function harness(): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS }) + return ctx +} + +/** Create one agent composed from `presetId`, exactly as a factory `setup` would. */ +async function agentOn(ctx: Context, id: string, presetId?: string): Promise { + const handle = await ctx.agents.create({ + sessionId: SessionId(id), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, presetId), + }) + return handle.agent +} + +const toolNames = (ctx: Context, agent?: Agent): string[] => + ctx.tools.schemas(agent).map(schema => schema.name).sort() + +/** Every service registration in the runtime, regardless of which realm holds it. */ +function providedServiceNames(ctx: Context): string[] { + const store = ctx.reflect.store + return Object.getOwnPropertySymbols(store) + .map(key => store[key]?.name) + .filter((name): name is string => name !== undefined) +} + +/** Whether the root realm maps `name` to a live registration. */ +function rootResolves(ctx: Context, name: string): boolean { + const key = ctx.root[Context.isolate][name] + return key !== undefined && ctx.reflect.store[key] !== undefined +} + +let ctx: Context +beforeEach(async () => { + ctx = await harness() +}) + +describe('composing an agent from a preset', () => { + it('gives each session only its own preset\'s tools', async () => { + const alpha = await agentOn(ctx, 'sess-alpha', 'standard') + const beta = await agentOn(ctx, 'sess-beta', 'minimal') + + expect(toolNames(ctx, alpha)).toEqual(['alpha']) + expect(toolNames(ctx, beta)).toEqual(['beta']) + expect(toolNames(ctx)).toEqual([]) + }) + + it('scopes prompt sections and assembled schemas to the same session', async () => { + const alpha = await agentOn(ctx, 'sess-alpha', 'standard') + const beta = await agentOn(ctx, 'sess-beta', 'minimal') + + const alphaPrompt = await ctx.systemPrompt.assemble(assembleContextFor(alpha)) + const betaPrompt = await ctx.systemPrompt.assemble(assembleContextFor(beta)) + + expect(alphaPrompt.sections.map(section => section.name)).toContain('preset:alpha') + expect(alphaPrompt.sections.map(section => section.name)).not.toContain('preset:beta') + expect(betaPrompt.sections.map(section => section.name)).toContain('preset:beta') + expect(alphaPrompt.tools.map(schema => schema.name)).toEqual(['alpha']) + }) + + it('mounts the default preset when the caller names none', async () => { + const agent = await agentOn(ctx, 'sess-default') + + expect(toolNames(ctx, agent)).toEqual(['alpha']) + }) + + it('lets two sessions share one preset without colliding', async () => { + const first = await agentOn(ctx, 'sess-first', 'standard') + const second = await agentOn(ctx, 'sess-second', 'standard') + + expect(toolNames(ctx, first)).toEqual(['alpha']) + expect(toolNames(ctx, second)).toEqual(['alpha']) + }) + + it('unwinds one session\'s composition without touching another\'s', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-gone'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + const survivor = await agentOn(ctx, 'sess-stays', 'minimal') + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + + await handle.dispose() + + expect(ctx.agents.get(SessionId('sess-gone'))).toBeUndefined() + expect(toolNames(ctx, survivor)).toEqual(['beta']) + expect(toolNames(ctx)).toEqual([]) + }) +}) + +describe('rejecting a composition that cannot be used', () => { + it('refuses to mount into a context that carries no agent scope', async () => { + await expect(ctx.agentPresets.mount(ctx, 'standard')) + .rejects.toThrow(/unscoped context/) + }) + + it('rolls the whole agent back when a row fails to load', async () => { + await expect(agentOn(ctx, 'sess-broken', 'broken')).rejects.toThrow(/failed to mount/) + + expect(ctx.agents.get(SessionId('sess-broken'))).toBeUndefined() + expect(toolNames(ctx)).toEqual([]) + }) + + it('names the unresolved service when a row never activates', async () => { + await expect(agentOn(ctx, 'sess-pending', 'pending')) + .rejects.toThrow(/waiting for serviceThatDoesNotExist/) + }) + + it('rejects a row that publishes a process-global service', async () => { + await expect(agentOn(ctx, 'sess-leaky', 'leaky')) + .rejects.toThrow(/process-global service\(s\) \[aaaFixtureLeakedSvc, zzzFixtureLeakedSvc\]/) + + // The rejected subtree is fully unwound, so its registrations are gone from + // the store rather than merely unreachable. + expect(providedServiceNames(ctx)).not.toContain('aaaFixtureLeakedSvc') + expect(providedServiceNames(ctx)).not.toContain('zzzFixtureLeakedSvc') + }) + + it('accepts the same provider behind an isolate realm', async () => { + const agent = await agentOn(ctx, 'sess-isolated', 'isolated') + + expect(agent.id).toBe(SessionId('sess-isolated')) + // The provider ran, but under a realm-private symbol the root cannot reach. + expect(providedServiceNames(ctx)).toContain('fixtureIsolatedSvc') + expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false) + }) + + it('reports the known ids when a preset is unknown', async () => { + await expect(ctx.agentPresets.resolve('nope')) + .rejects.toThrow(/preset "nope" not found \(available: .*standard/) + }) +}) + +describe('the preset roster', () => { + it('lists every root\'s presets with the earlier root winning', async () => { + const listed = await ctx.agentPresets.list() + + expect(listed.map(preset => preset.id).sort()) + .toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'pending', 'standard']) + expect(listed.find(preset => preset.id === 'standard')?.trust).toBe('system') + }) + + it('exposes the configured default id', () => { + expect(ctx.agentPresets.defaultId).toBe('standard') + }) +}) + +describe('a roster with nothing in it', () => { + it('says so instead of naming an empty list of candidates', async () => { + const bare = new Context() + await bare.plugin(Loader) + await bare.plugin(AgentPresets, { default: 'standard', roots: [] }) + + await expect(bare.agentPresets.resolve()) + .rejects.toThrow(/preset "standard" not found \(available: none\)/) + }) +}) + +describe('attributing a service to a subtree', () => { + it('attributes nothing to a subtree that is already torn down', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-torn'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + const [mount] = livePresetMounts().filter(entry => entry.presetId === 'standard') + expect(mount).toBeDefined() + + await handle.dispose() + + // A disposed subtree owns nothing, so it can never be blamed for a service + // some other subtree published under the same name afterwards. + expect(leakedServices(ctx, mount!.fiber)).toEqual([]) + }) +}) diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json new file mode 100644 index 0000000000..d8494fdfd5 --- /dev/null +++ b/packages/preset/agent-presets/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/include" + }, + { + "path": "../../core/scope" + }, + { + "path": "../../util/paths" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aaaed3c423..f18d43bbc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4160,6 +4160,49 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/preset/agent-presets: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/pty/pty: devDependencies: '@deepseek-ai/dsh-agent': diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index a6ad066add..1ca6dac5e1 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -7,5 +7,5 @@ "docs/testing.md": 1150, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 920 + "packages/README.md": 980 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index bcf90d1e82..cc7b76fc2c 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -251,6 +251,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', InsertTextRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md', + AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md', BashEnvContributor: 'service-local extension type is owned by packages/bash/tool-bash/src/index.ts', BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts', CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index dd00068fe4..b7faa4bc6c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -259,6 +259,13 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions.', }, + { + key: 'agentPresets', + pkg: 'agent-presets', + title: 'Per-session agent composition', + mode: 'core', + note: 'Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm.', + }, { key: 'commands', pkg: 'commands', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 316a4233de..5ef1edc4f9 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -47,6 +47,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' }, 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, 'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' }, + 'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' }, 'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' }, 'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' }, 'packages/client/hmr': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index cba3a9972d..3c7c2ba1d2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -86,6 +86,7 @@ "./packages/goal/*/src/invariant.ts", "./packages/guard/*/src/invariant.ts", "./packages/plan/*/src/invariant.ts", + "./packages/preset/*/src/invariant.ts", "./packages/subagent/*/src/invariant.ts", "./packages/tasks/*/src/invariant.ts", "./packages/workflow/*/src/invariant.ts", @@ -185,6 +186,7 @@ "./packages/goal/*/src", "./packages/guard/*/src", "./packages/plan/*/src", + "./packages/preset/*/src", "./packages/subagent/*/src", "./packages/tasks/*/src", "./packages/workflow/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 38e909fe63..021ff2e23b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -217,6 +217,7 @@ { "path": "./packages/workflow/tool-ralph" }, { "path": "./packages/todo/tool-todo" }, { "path": "./packages/plan/plan-mode" }, + { "path": "./packages/preset/agent-presets" }, { "path": "./packages/guard/repeat-tool-guard" }, { "path": "./packages/cordis/tool-cordis" }, { "path": "./packages/cordis/repository-plugin" }, From e4256a1684b23de0eaa9190e6841eab1e9690718 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 6 Aug 2026 21:34:38 +0800 Subject: [PATCH 004/293] feat(web): steer the whole queue with an empty-draft Cmd/Ctrl+Enter --- ...8-06-web-queue-steer-all-gesture.i18n.yaml | 6 + .../2026-08-06-web-queue-steer-all-gesture.md | 30 +++++ ...26-08-06-web-queue-steer-all-gesture.zh.md | 30 +++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 2 +- .../src/client/input/contract.ts | 6 + .../src/client/input/facade.ts | 15 +++ .../ui-conversation/src/client/input/hub.ts | 33 +++++- .../src/client/skeleton/InputBar.tsx | 13 ++- .../ui-conversation/tests/input-bar.spec.tsx | 104 ++++++++++++++++-- .../tests/service-orchestration.spec.ts | 78 ++++++++++++- 13 files changed, 305 insertions(+), 20 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml new file mode 100644 index 0000000000..5b51304fe6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md +2026-08-06-web-queue-steer-all-gesture.md: e65089fca763a4226a40b090f3a5f8f56284b9bc +2026-08-06-web-queue-steer-all-gesture.zh.md: abccf60a9da54ba1c639e93c63107d97913d9d66 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md new file mode 100644 index 0000000000..e65089fca7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md @@ -0,0 +1,30 @@ +# Agent Note: Steer the whole Web queue with an empty-draft Cmd/Ctrl+Enter + +Status: implemented + +English | [中文](2026-08-06-web-queue-steer-all-gesture.zh.md) + +## Problem + +While a primary session runs, the Web queue accumulates messages the user typed with plain Enter (or queued while the busy-Enter preference was Queue). Flushing them into the current turn required clicking the per-row 插话发送 button once per message; an empty composer draft had no keyboard gesture at all — the input machine rejects empty drafts, so Enter and Cmd/Ctrl+Enter were both no-ops. With several queued messages, steering them one by one is the obvious multi-click friction, and the empty-draft accelerated chord is the natural slot for "steer everything". + +## Decision + +Empty-draft Cmd/Ctrl+Enter now steers every still-pending `queued`-placement inbox row into the running turn, in FIFO order, on a primary session that reports running. The gesture decodes in `InputBar.onKeyDown`: accelerated Enter with a trimmed-empty draft, `running`, no subagent address, and at least one `queued` row calls the new `ComposerKeyboard.steerQueue()` verb instead of `submit()`. `SessionInputShell.steerQueue()` delegates to a hub-wired choreography that re-reads the authoritative `session/queue` snapshot, filters `placement: 'queued'` (pending steering rows are already in the turn), and applies the queue dock's exact strict-steer operation — `session.updateQueue(itemId, { kind: 'steer' })` — sequentially, so FIFO ordering is guaranteed at the host. A `steer-unavailable` (turn closed mid-flush) or `queue-item-not-found` (row claimed meanwhile) converges silently; any other failure surfaces one composer notice (`插话发送失败,请重试。`). No wire, on-disk, or agent-loop change: the host already owns the strict-steer boundary. + +The gesture is strictly the accelerated chord. Plain Enter with an empty draft stays a no-op even under the busy-Enter Steer preference, draft content outranks the queue (accelerated Enter steers only the draft), and idle or subagent sessions keep the existing empty-draft no-op because steering has no live turn to enter. + +## Consequences + +One keyboard gesture now replaces N clicks while keeping a single strict-steer path and a single authority for convergence. The per-row button and the gesture are the same host operation, so races and failure semantics stay identical. The cost is a presentation-layer branch that must stay in sync with the dock's gating window (running, non-subagent) — the hub re-checks the snapshot at execution time, so the gate is advisory and the host remains authoritative. + +## Related + +The per-row 插话发送 action and its strict-steer boundary are owned by [Steer a queued Web message into the active turn](../feature/2026-07-30-web-queue-steer-action.md); this note only adds the whole-queue keyboard gesture on top of that decision. + +## Alternatives considered + +- **Intercepting inside the input machine.** Rejected: the machine is queue-agnostic by design (the wiring layer overlays the queue projection) and cannot distinguish the accelerated chord from plain Enter, which must stay a no-op. +- **Steering via `session.prompt(mode: 'steer')` per row.** Rejected: that mints new messages instead of transferring the pending occurrences and would split the dock's immutable-message contract; `updateQueue({ kind: 'steer' })` already atomically transfers the exact occurrence. +- **Firing all row steers concurrently.** Rejected: arrival order at the host is not guaranteed, and steering order is model-visible; sequential awaits preserve FIFO. +- **A new host RPC for steer-all.** Rejected: the existing per-item operation is idempotent enough — each row is one strict steer, and mid-flush closure converges silently — so a protocol change buys nothing. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md new file mode 100644 index 0000000000..abccf60a9d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 空输入时 Cmd/Ctrl+Enter 将 Web 排队消息全部插话 + +Status: implemented + +[English](2026-08-06-web-queue-steer-all-gesture.md) | 中文 + +## Problem + +主会话运行时,用户用普通 Enter(或在 busy-Enter 偏好为 Queue 时)输入的消息会在 Web 队列里累积。把它们灌进当前轮次需要逐条点击「插话发送」按钮;而输入框草稿为空时没有任何键盘手势——输入机对空草稿直接拒绝,Enter 与 Cmd/Ctrl+Enter 都是空操作。排队消息一多,逐条插话是明显的多点摩擦,空草稿 + 加速 Enter 正是「全部插话」的自然位置。 + +## Decision + +空草稿的 Cmd/Ctrl+Enter 现在会把仍在排队(`placement: 'queued'`)的 Inbox 行按 FIFO 顺序全部插话进运行中的轮次,仅限报告 running 的主会话。手势在 `InputBar.onKeyDown` 解码:加速 Enter + 去空白后为空草稿 + `running` + 无 subagent 地址 + 至少一条 `queued` 行时,改走新的 `ComposerKeyboard.steerQueue()` 动词而不是 `submit()`。`SessionInputShell.steerQueue()` 委托给 hub 编排的流程:重新读取权威的 `session/queue` 快照,过滤 `placement: 'queued'`(pending steering 行已经在本轮内),并逐条顺序执行 Queue 面板的严格 steer 操作 `session.updateQueue(itemId, { kind: 'steer' })`,从而在 host 侧保证 FIFO 顺序。`steer-unavailable`(flush 中途轮次关闭)或 `queue-item-not-found`(行已被占用)静默收敛;其他失败弹出一条 composer 通知(「插话发送失败,请重试。」)。无 wire、磁盘或 agent-loop 改动:严格 steer 边界本来就在 host 侧。 + +该手势严格限定为加速组合键。空草稿 + 普通 Enter 仍然无操作(即使 busy-Enter 偏好为 Steer);草稿内容优先于队列(加速 Enter 只插话当前草稿);idle 或 subagent 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。 + +## Consequences + +一个键盘手势替代 N 次点击,同时保持单一严格 steer 路径与单一收敛权威。逐条按钮与手势是同一个 host 操作,竞态与失败语义完全一致。代价是呈现层多了一个分支,必须与 dock 的门控窗口(running、非 subagent)保持同步——hub 在执行时重新读取快照,所以该门控只是建议性的,host 仍是权威。 + +## Related + +逐条「插话发送」动作及其严格 steer 边界由 [Steer a queued Web message into the active turn](../feature/2026-07-30-web-queue-steer-action.md) 记录;本笔记只在其之上增加整队列键盘手势。 + +## Alternatives considered + +- **在输入机内拦截。** 已拒绝:输入机按设计不感知队列(队列投影由 wiring 层叠加),且无法区分加速 Enter 与必须保持空操作的普通 Enter。 +- **逐条用 `session.prompt(mode: 'steer')` 插话。** 已拒绝:那会铸造新消息而不是转移 pending 行,破坏 dock 的不可变消息契约;`updateQueue({ kind: 'steer' })` 已经原子地转移了确切的那条。 +- **并发触发所有行。** 已拒绝:host 到达顺序无法保证,而插话顺序对模型可见;顺序 await 保证 FIFO。 +- **为 steer-all 新增 host RPC。** 已拒绝:现有逐条操作已足够幂等——每行一次严格 steer,中途关闭静默收敛——协议改动没有收益。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index be53206dde..73b3204d61 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: bbd115eac0eb914914dc11e504639633c801abdd -README.zh.md: 843b49e311fbf1a9157413c42a0ef3e9828284bc +README.md: e7b57e25208a402a81284f930644bfc4208c9291 +README.zh.md: 1b8543fa24d8e8a62f6b8995619548928ab80ce1 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index bbd115eac0..e7b57e2520 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,7 +40,7 @@ The todo surfaces are two registrations over that shape, both using slot declara The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. -Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. +Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 843b49e311..1b8543fa24 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,7 +40,7 @@ todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注 Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 -键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 +键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6bc9068cfc..0cdb4b9939 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -131,7 +131,7 @@ export function apply(ctx: Context): void { // The per-session input machine registry (InputService face; published as // ctx.conversation.input by the service below sharing this one instance). - const inputHub = new InputHub(ctx) + const inputHub = new InputHub(ctx, t) // Decision 19/20: the input machine feeds every session-scope slot // component through the standard provide channel — the 'input' hook plus diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 6af78bdb72..bc4b365373 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -88,6 +88,12 @@ export interface ComposerKeyboard { setDraft(text: string, editRange?: EditRange): void /** Submit with an explicit delivery mode resolved by the keyboard policy. */ submit(mode: InputSubmitMode): void + /** + * Steer every still-pending queued message into the running turn (the + * empty-draft accelerated-Enter gesture; the queue dock's per-row steer + * button is the same operation applied to the whole queue). + */ + steerQueue(): void undo(): void redo(): void /** Paste over the selection (sync components ride the same transaction). */ diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 79516d04fa..fdceebd282 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -39,6 +39,11 @@ export interface SessionInputDeps { popup?: (() => PopupDismissFace | undefined) | undefined /** Queue read face; overlaid onto InputState.queue (absent = empty). */ queue?: ObservableSnapshot | undefined + /** + * Steer every still-pending queued message into the running turn, in FIFO + * order (the empty-draft accelerated-Enter gesture); absent = unsupported. + */ + steerQueue?: (() => void) | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ defaultSink(text: string, mode: InputSubmitMode): void } @@ -173,6 +178,16 @@ export class SessionInputShell implements SessionInput { return this.deps.slash?.()?.arbitrate(key, composing) ?? 'pass' } + /** + * Steer every still-pending queued message into the running turn (the + * empty-draft accelerated-Enter gesture). Execution belongs to the hub's + * queue choreography; absent dep = the gesture falls back to the machine's + * empty-draft no-op. + */ + steerQueue(): void { + this.deps.steerQueue?.() + } + /** * Space adjudication over the controller's hot state. * @returns true = a claim/insert was applied — the caller preventDefaults. diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 560999aa21..e9e58035d2 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -10,6 +10,7 @@ */ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import { queueReadFaceOf } from '../queue/store.ts' import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' @@ -25,8 +26,14 @@ interface CommandFace { export class InputHub implements InputService { private readonly shells = new Map() - /** @param ctx - client root context (services resolved lazily per call — boot order stays free). */ - constructor(private readonly rootCtx: ClientContext) {} + /** + * @param ctx - client root context (services resolved lazily per call — boot order stays free). + * @param t - conversation-namespace translate thunk (reads the active locale at call time). + */ + constructor( + private readonly rootCtx: ClientContext, + private readonly t: TranslateNS<'conversation'>, + ) {} /** * Resolve the facade for one session-scope ctx (InputService face). @@ -58,6 +65,7 @@ export class InputHub implements InputService { popup: () => this.popup(actx), queue: queueReadFaceOf(session), defaultSink: (text, mode) => { this.sink(session, text, mode) }, + steerQueue: () => { void this.steerQueue(session, shell) }, }) this.shells.set(id, shell) // The one teardown axis: listeners, shell, and map entries all ride the @@ -139,6 +147,27 @@ export class InputHub implements InputService { ) } + /** + * Steer every still-pending queued message into the running turn, in FIFO + * order — the same strict-steer operation as the queue dock's per-row + * button. A turn closing mid-way (`steer-unavailable`) or a row already + * claimed by the agent (`queue-item-not-found`) converges silently, while a + * genuine failure surfaces as one composer notice. + * @param session - the addressed host session. + * @param shell - the resident shell (notice outlet). + */ + private async steerQueue(session: SessionFace, shell: SessionInputShell): Promise { + const queued = session.getSnapshot().queue.filter(item => item.placement === 'queued') + if (queued.length === 0) return + for (const item of queued) { + const result = await session.updateQueue(item.id, { kind: 'steer' }) + if (result.ok) continue + if (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found') return + shell.notify('error', this.t('queue.steerFailed')) + return + } + } + private controller(actx: ClientContext): SlashController | undefined { const slash = this.rootCtx.get('slash') return slash?.sessionOf(actx) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 131f63c49d..7a85ef7e08 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -247,9 +247,20 @@ export function InputBar({ e.preventDefault() if (e.repeat) return // held-down Enter must not machine-gun sends if (locked || machineBusy) return + const accelerated = e.ctrlKey || e.metaKey + // Empty-draft accelerated Enter acts on the queue instead of the (empty) + // draft: the machine rejects empty drafts, so the gesture steers every + // still-pending queued message into the running turn (the dock's per-row + // steer button applied to the whole queue). Steering needs the same + // window as the per-row button: a running ordinary session. + if (accelerated && empty && running && subagent === null + && input.queue.some(row => row.placement === 'queued')) { + keyboard.steerQueue() + return + } keyboard.submit(resolveSubmitMode( running, - e.ctrlKey || e.metaKey ? 'accelerated' : 'enter', + accelerated ? 'accelerated' : 'enter', subagent === null, )) } diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index bc276174a3..67a9b48e90 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -56,6 +56,10 @@ interface BenchOptions { subagent?: Exclude disabled?: boolean promptError?: ConversationSnapshot['promptError'] + /** Authoritative queue rows served to the machine overlay (empty = none). */ + queue?: ConversationSnapshot['queue'] + /** The hub's steer-all face (empty-draft accelerated Enter). */ + steerQueue?: () => void variant?: 'hero' | 'composer' placeholder?: string t?: InputBarProps['t'] @@ -69,14 +73,34 @@ interface BenchOptions { toggleCommandMenu?: (selection: { start: number; end: number }) => void } +/** One pending queue row (the runtime snapshot shape, as the dock tests build it). */ +function row(id: string): ConversationSnapshot['queue'][number] { + return { + id: id as never, messageId: `message-${id}` as never, placement: 'queued', + content: [{ type: 'text', text: id }], preview: id, text: id, + } +} + /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */ function bench(over?: BenchOptions) { const sink = vi.fn() const lex = over?.lexicon + const session = createSnapshotStore(snapshotOf({ + running: over?.running ?? false, + subagent: over?.subagent ?? null, + removed: over?.disabled ?? false, + promptError: over?.promptError ?? null, + queue: over?.queue ?? [], + })) type ShellDeps = ConstructorParameters[0] const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, + queue: { + getSnapshot: () => session.getSnapshot().queue, + subscribe: fn => session.subscribe(fn), + }, + ...(over?.steerQueue !== undefined ? { steerQueue: over.steerQueue } : {}), // Lexicon-only stub: adjudication untouched (undefined slash methods are // never reached — these benches drive plain-draft flows only). ...(lex !== undefined @@ -88,12 +112,6 @@ function bench(over?: BenchOptions) { : {}), }) if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft) - const session = createSnapshotStore(snapshotOf({ - running: over?.running ?? false, - subagent: over?.subagent ?? null, - removed: over?.disabled ?? false, - promptError: over?.promptError ?? null, - })) const stop = vi.fn() const menuLauncher = createSnapshotStore(over?.commandMenuOpen === true ? 'command' : null) const slotCalls: { key: string; owner: unknown }[] = [] @@ -147,7 +165,7 @@ function bench(over?: BenchOptions) { const button = view.container.querySelector( `button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`, )! - return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher } + return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher, steerQueue: over?.steerQueue } } describe('Enter semantics', () => { @@ -190,6 +208,78 @@ describe('Enter semantics', () => { expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer') }) + it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => { + const steerQueue = vi.fn() + const queue = [row('q-1'), row('q-2')] + const meta = bench({ running: true, queue, steerQueue }) + fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true }) + expect(meta.steerQueue).toHaveBeenCalledTimes(1) + expect(meta.sink).not.toHaveBeenCalled() + + const ctrl = bench({ running: true, queue, steerQueue: vi.fn() }) + fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true }) + expect(ctrl.steerQueue).toHaveBeenCalledTimes(1) + expect(ctrl.sink).not.toHaveBeenCalled() + }) + + it('queue steering stays gated: idle, subagent, plain Enter, empty queue, or steering-only rows', () => { + // Idle: the gesture falls through to the machine's empty-draft no-op. + const idle = bench({ queue: [row('q-1')], steerQueue: vi.fn() }) + fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true }) + expect(idle.steerQueue).not.toHaveBeenCalled() + expect(idle.sink).not.toHaveBeenCalled() + + // Plain Enter never steers the queue, even under the busy Steer preference. + const plain = bench({ running: true, busyEnter: 'steer', queue: [row('q-1')], steerQueue: vi.fn() }) + fireEvent.keyDown(plain.textarea, { key: 'Enter' }) + expect(plain.steerQueue).not.toHaveBeenCalled() + expect(plain.sink).not.toHaveBeenCalled() + + // Subagent sessions keep the queue transport (no steering face). + const subagent = { + address: { + parentSessionId: 'parent' as SessionId, + childSessionId: SID, + mode: 'continuable' as const, + }, + parentAvailable: true, + } + const child = bench({ running: true, subagent, queue: [row('q-1')], steerQueue: vi.fn() }) + fireEvent.keyDown(child.textarea, { key: 'Enter', metaKey: true }) + expect(child.steerQueue).not.toHaveBeenCalled() + expect(child.sink).not.toHaveBeenCalled() + + // No queued rows: the empty draft stays a no-op. + const none = bench({ running: true, steerQueue: vi.fn() }) + fireEvent.keyDown(none.textarea, { key: 'Enter', metaKey: true }) + expect(none.steerQueue).not.toHaveBeenCalled() + expect(none.sink).not.toHaveBeenCalled() + + // Pending steering rows are not the queue: nothing to flush. + const steering = bench({ + running: true, + queue: [{ ...row('s-1'), placement: 'steering' }], + steerQueue: vi.fn(), + }) + fireEvent.keyDown(steering.textarea, { key: 'Enter', metaKey: true }) + expect(steering.steerQueue).not.toHaveBeenCalled() + expect(steering.sink).not.toHaveBeenCalled() + }) + + it('draft content outranks the queue: accelerated Enter steers the draft only', () => { + const steerQueue = vi.fn() + const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue }) + fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }) + expect(sink).toHaveBeenCalledWith('插话', 'steer') + expect(steerQueue).not.toHaveBeenCalled() + }) + + it('empty-draft accelerated Enter without a steerQueue face stays a silent no-op', () => { + const { textarea, sink } = bench({ running: true, queue: [row('q-1')] }) + fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true }) + expect(sink).not.toHaveBeenCalled() + }) + it('platform undo/redo chords route to the machine, never the browser stack', () => { const { textarea, shell } = bench({ draft: '' }) fireEvent.change(textarea, { target: { value: 'first' } }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index ebd51f8408..126cb65a47 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -6,8 +6,11 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { QueuedMessage } from '@deepseek-ai/dsh-client-runtime/client' import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client' import { InputHub } from '../src/client/input/hub.ts' +import { zh } from '../src/client/locales.ts' async function bench() { const runtime = await SlotTestRuntime.create() @@ -21,13 +24,13 @@ async function bench() { }) // config.input is required (the apply shares its hub with the inject // factories); the bench passes its own instance explicitly. - const fiber = runtime.ctx.plugin(ConversationService, { - input: new InputHub(runtime.ctx), - }) + const hub = new InputHub(runtime.ctx, makeTranslate(zh, {})) + const fiber = runtime.ctx.plugin(ConversationService, { input: hub }) await fiber.await() const root = runtime.ctx.get('conversation') as ConversationService const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService - return { runtime, root, scoped, prompt, updateQueue, cancel, loadOlder } + const shell = hub.shellFor(runtime.sessions.binding('s1')!) + return { runtime, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder } } describe('ConversationService', () => { @@ -85,9 +88,74 @@ describe('ConversationService', () => { // No SessionsService at all: a bare context (the runtime always provides one). const bare = new Context() await bare.plugin(ConversationService, { - input: new InputHub(bare), + input: new InputHub(bare, makeTranslate(zh, {})), }).await() const orphan = bare.get('conversation') as ConversationService await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/) }) }) + +describe('InputHub queue steering (empty-draft accelerated Enter)', () => { + const row = (id: string): QueuedMessage => ({ + id: id as never, + messageId: `message-${id}` as never, + placement: 'queued', + content: [{ type: 'text', text: id }], + preview: id, + text: id, + }) + + it('steers every queued row in FIFO order and leaves steering rows alone', async () => { + const b = await bench() + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-1'), { ...row('q-2'), placement: 'steering' }, row('q-3')] + }) + b.shell.steerQueue() + await vi.waitFor(() => { + expect(b.updateQueue).toHaveBeenCalledTimes(2) + }) + expect(b.updateQueue).toHaveBeenNthCalledWith(1, 'q-1', { kind: 'steer' }) + expect(b.updateQueue).toHaveBeenNthCalledWith(2, 'q-3', { kind: 'steer' }) + expect(b.shell.notices.getSnapshot()).toBeNull() + await b.runtime.dispose() + }) + + it('converges silently when the turn closes or a row is claimed mid-steer', async () => { + const b = await bench() + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-1'), row('q-2')] + }) + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} }, + } as never) + b.shell.steerQueue() + await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(1) }) + expect(b.shell.notices.getSnapshot()).toBeNull() + await b.runtime.dispose() + }) + + it('surfaces one notice on a genuine steer failure and stops', async () => { + const b = await bench() + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-1'), row('q-2')] + }) + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'internal', message: 'broken', details: {} }, + } as never) + b.shell.steerQueue() + await vi.waitFor(() => { + expect(b.shell.notices.getSnapshot()).toEqual( + expect.objectContaining({ level: 'error', text: '插话发送失败,请重试。' }), + ) + }) + expect(b.updateQueue).toHaveBeenCalledTimes(1) + await b.runtime.dispose() + }) + + it('no-ops without queued rows', async () => { + const b = await bench() + b.shell.steerQueue() + expect(b.updateQueue).not.toHaveBeenCalled() + await b.runtime.dispose() + }) +}) From 065257addbc47eda65ab8eae2e3071d539b74c1b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 11:05:01 +0800 Subject: [PATCH 005/293] fix(agent-presets): bound the mount registry on a host that never reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records are pruned by observation rather than by a disposal hook, for the reason the module already states: three different owners can tear a subtree down, and a cleared `uid` is what they share. That leaves the pruning to whoever reads — and the only production reader is the invariant companion, whose package is a development composition a shipped host never loads. So a live host pruned nothing: every session ever composed left a record retaining its whole disposed subtree, since the fiber holds its config and that config is the key its EntryTree is stored under. Prune on the mount path too. Every session takes it, which bounds the set at one generation of dead records instead of one per session. --- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +-- docs/capability-seams.md | 2 +- docs/config-catalog.md | 14 ++++----- docs/cordis-catalog/services.md | 22 +++++++------- docs/module-graph.md | 7 +++++ packages/README.i18n.yaml | 4 +-- .../cordis/tool-cordis/src/api-catalog.ts | 6 ++-- packages/preset/README.i18n.yaml | 4 +-- .../preset/agent-presets/README.i18n.yaml | 4 +-- packages/preset/agent-presets/package.json | 4 +-- packages/preset/agent-presets/src/mount.ts | 29 ++++++++++++++++--- 11 files changed, 63 insertions(+), 37 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 0f7bd16552..07a35fa473 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: 408e5a52b15efde162fa1bc6ae1e9ede8a7f0d98 -2026-08-03-per-session-agent-presets.zh.md: a06aea8d7e89c77d374c06908c10a9b0e5029548 +2026-08-03-per-session-agent-presets.md: ee6303e5f52234d7eaf6768043c92a15e4e5399f +2026-08-03-per-session-agent-presets.zh.md: 5a2e1c3d8d0564f06c75785e1080b53740751355 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 9099207264..d7963f28de 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -373,7 +373,7 @@ flowchart LR | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | - | [`tool-ask-user`](../packages/ui/tool-ask-user) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | -| `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers profile directories over trusted and user-authored roots and mounts one profile cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. | +| `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. | | `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session-projection/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session-title/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 775387ca6f..5542d296f1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -115,25 +115,25 @@ Source: [`packages/core/agent-loop/src/index.ts:236`](../packages/core/agent-loo Requires: `loader` ```ts config-catalog -/** Plugin config: which profile is the default, and where profiles live. */ +/** Plugin config: which preset is the default, and where presets live. */ export interface Config { - /** Profile id mounted when a caller names none. Missing at mount time fails loud. */ + /** Preset id mounted when a caller names none. Missing at mount time fails loud. */ default: string /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ roots: PresetRoot[] } -/** One directory scanned for profile subdirectories. */ +/** One directory scanned for preset subdirectories. */ export interface PresetRoot { - /** Directory holding one subdirectory per profile; a leading `~` expands. */ + /** Directory holding one subdirectory per preset; a leading `~` expands. */ path: string - /** Trust recorded on every profile discovered under this root. */ + /** Trust recorded on every preset discovered under this root. */ trust: PresetTrust } /** - * Where a profile's composition came from. A `system` profile ships with the - * deployment; a `user` profile was authored locally, by a person or by an + * Where a preset's composition came from. A `system` preset ships with the + * deployment; a `user` preset was authored locally, by a person or by an * agent, and therefore carries the same trust as shell access. */ export type PresetTrust = 'system' | 'user' diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3030e2c348..0a70b0f69f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -50,33 +50,33 @@ Source: [`packages/core/agent-loop/src/index.ts:277`](../../packages/core/agent- Registry over the deployment's agent presets. -Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a profile authored while the process runs is visible immediately, and a profile deleted underneath a picker disappears from the next read. +Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a preset authored while the process runs is visible immediately, and a preset deleted underneath a picker disappears from the next read. ```ts cordis-catalog /** - * Every profile the configured roots currently supply. - * @returns the profiles, first-root-wins per id. + * Every preset the configured roots currently supply. + * @returns the presets, first-root-wins per id. */ async list(): Promise /** - * Resolve one profile by id. - * @param id - the profile id, or `undefined` for {@link defaultId}. - * @returns the resolved profile. + * Resolve one preset by id. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the resolved preset. * @throws when no configured root supplies that id. */ async resolve(id?: string): Promise /** - * Compose one agent from a profile, installing it under that agent alone. + * Compose one agent from a preset, installing it under that agent alone. * * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls - * the agent creation back, so a broken profile never yields a half-composed + * the agent creation back, so a broken preset never yields a half-composed * session. * @param agentCtx - the agent's scope context. - * @param id - the profile id, or `undefined` for {@link defaultId}. - * @returns the profile that was mounted, for the caller to record. - * @throws when the profile is unknown or its composition is unusable. + * @param id - the preset id, or `undefined` for {@link defaultId}. + * @returns the preset that was mounted, for the caller to record. + * @throws when the preset is unknown or its composition is unusable. */ async mount(agentCtx: Context, id?: string): Promise ``` diff --git a/docs/module-graph.md b/docs/module-graph.md index 3ad8ef0b7e..d4680deebe 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -220,6 +220,9 @@ flowchart TD subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end + subgraph group_preset["packages/preset"] + pkg_agent_presets["agent-presets"] + end subgraph group_pty["packages/pty"] pkg_pty["pty"] pkg_pty_local["pty-local"] @@ -335,6 +338,9 @@ flowchart TD pkg_credentials --> pkg_invariants pkg_frontend_static --> pkg_host_webserver pkg_frontend_static --> pkg_invariants + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_paths + pkg_agent_presets --> pkg_scope pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_helper --> pkg_subprocess @@ -1132,6 +1138,7 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 7980b809d6..3ef2ce6ee1 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: 365659617c97c44dd0f30fbcd3347b6438024eb3 -README.zh.md: 9edabd67ea728e77e2863a32c250675a5b9359f8 +README.md: b736aa5dc9d0e9313d652d40c3f4834456dccbb4 +README.zh.md: 53081b5e8c2d465dc866644eae78bebf0c4fc3a1 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 55a50977a7..d296b1b519 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -86,15 +86,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'async list(): Promise', - jsDoc: '/**\n * Every profile the configured roots currently supply.\n * @returns the profiles, first-root-wins per id.\n */', + jsDoc: '/**\n * Every preset the configured roots currently supply.\n * @returns the presets, first-root-wins per id.\n */', }, { signature: 'async resolve(id?: string): Promise', - jsDoc: '/**\n * Resolve one profile by id.\n * @param id - the profile id, or `undefined` for {@link defaultId}.\n * @returns the resolved profile.\n * @throws when no configured root supplies that id.\n */', + jsDoc: '/**\n * Resolve one preset by id.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the resolved preset.\n * @throws when no configured root supplies that id.\n */', }, { signature: 'async mount(agentCtx: Context, id?: string): Promise', - jsDoc: '/**\n * Compose one agent from a profile, installing it under that agent alone.\n *\n * Call from the agent factory\'s `setup(agentCtx)`; a rejection there rolls\n * the agent creation back, so a broken profile never yields a half-composed\n * session.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the profile id, or `undefined` for {@link defaultId}.\n * @returns the profile that was mounted, for the caller to record.\n * @throws when the profile is unknown or its composition is unusable.\n */', + jsDoc: '/**\n * Compose one agent from a preset, installing it under that agent alone.\n *\n * Call from the agent factory\'s `setup(agentCtx)`; a rejection there rolls\n * the agent creation back, so a broken preset never yields a half-composed\n * session.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the preset that was mounted, for the caller to record.\n * @throws when the preset is unknown or its composition is unusable.\n */', }, ], }, diff --git a/packages/preset/README.i18n.yaml b/packages/preset/README.i18n.yaml index b554512392..f0176c1bc6 100644 --- a/packages/preset/README.i18n.yaml +++ b/packages/preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/README.md -README.md: e7940642166f81e370e3a328f3097d15fd367151 -README.zh.md: 0767ca5074071e9ef2fa38769d27d8ef2344188e +README.md: 7baac391c224f717b60edeb0de828cb004ab460a +README.zh.md: 4d8c350b2831ae9c506ad7756ae157bf313a1c3e diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 9106494073..b27e89ec7b 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: 6068a68d3c81081074165077a8afa6b42af48d1f -README.zh.md: 9f951f566a51a7b7acb666c7d9ea80061aa45d73 +README.md: 5d66c23f24717d1c30a9e729c7b0528715692d41 +README.zh.md: 9cdcd8b11a8a37d6d789c393e9a1500e4fbfd503 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index 9dae4e584a..1411542d7c 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index ba09869cb4..39d55b17e6 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -60,16 +60,34 @@ export interface PresetMount { const mounts = new Set() /** - * Every preset composition still installed, pruning fibers disposed since the - * last read. Records are dropped lazily rather than through a disposal hook + * Drop every record whose subtree is gone. + * + * Records are pruned by observation rather than through a disposal hook * because a subtree can be torn down by its owning agent, by a failed mount, or * by the whole tree unloading, and a cleared `uid` is what all three share. - * @returns the live mounts. + * + * Pruning therefore has to happen on a path this module owns. Reading is one + * such path, but not a reliable one: the only production reader is the + * invariant companion's service listener, and `dsh-invariants` is a + * development composition — a shipped host never loads it. Mounting is the + * other, and it is the one every session takes, which bounds the set at one + * generation of dead records rather than one per session ever composed. Each + * record would otherwise retain its whole disposed subtree: the fiber holds + * its config, and that config is the key its `EntryTree` is stored under. */ -export function livePresetMounts(): PresetMount[] { +function pruneDisposedMounts(): void { for (const mount of mounts) { if (mount.fiber.uid === null) mounts.delete(mount) } +} + +/** + * Every preset composition still installed, pruning fibers disposed since the + * last read. + * @returns the live mounts. + */ +export function livePresetMounts(): PresetMount[] { + pruneDisposedMounts() return [...mounts] } @@ -167,6 +185,9 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi ) } const config: Include.Config = { path: pathToFileURL(preset.path).href } + // Before the record this mount is about to add: every session takes this + // path, so it is what keeps the set bounded on a host that never reads it. + pruneDisposedMounts() const handle = agentCtx.plugin(PresetTree, config) try { await handle.await() From e27d38efd6d3fe6397ac65640b7417b3c967bc44 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 11:57:00 +0800 Subject: [PATCH 006/293] feat(app-boot): register cordis:group beside cordis:include MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A composition that shares one `isolate` realm across rows needs a `cordis:group` row, and a preset living outside this workspace — the authored ones under the Harness home — cannot resolve `@cordisjs/plugin-group` by name: Node's upward `node_modules` walk never reaches the harness from there. Registering it as a loader builtin beside `cordis:include` loads both through the ambient module pipeline instead. Record it in the preset Agent Note, which leans on the realm vocabulary without saying where the group row comes from, and drop the preset README's limitation claiming this builtin is unavailable — it described the state this change ends. The test's assertion had a vacuous escape: `provide` mints the root symbol unconditionally, so the `rootKey === undefined` disjunct could never hold and the comment claiming the root realm never learned the name was wrong. Pin both halves — the symbol exists, nothing is stored under it — and clean up the global the fixture writes. --- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +- .../2026-08-03-per-session-agent-presets.md | 2 + ...2026-08-03-per-session-agent-presets.zh.md | 2 + .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 1 - packages/preset/agent-presets/README.zh.md | 1 - packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 4 +- packages/ui/app-boot/README.zh.md | 4 +- packages/ui/app-boot/package.json | 2 + packages/ui/app-boot/src/index.ts | 7 +++ .../ui/app-boot/tests/config-reload.spec.ts | 51 +++++++++++++++++-- packages/ui/app-boot/tsconfig.json | 3 ++ pnpm-lock.yaml | 6 +++ python/sdk-runtime/package.json | 1 + 15 files changed, 82 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 07a35fa473..db91c82d4c 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: ee6303e5f52234d7eaf6768043c92a15e4e5399f -2026-08-03-per-session-agent-presets.zh.md: 5a2e1c3d8d0564f06c75785e1080b53740751355 +2026-08-03-per-session-agent-presets.md: dbe4188fe16a01b51f01cddf8a2387471e5e00a9 +2026-08-03-per-session-agent-presets.zh.md: aa7792a7db3a686b74f19ace130ad8f7b6d2feb6 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index ee6303e5f5..dbe4188fe1 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -29,6 +29,8 @@ Mounting is per-session by default. Measured cost for a twelve-row composition i **A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it. +**A preset can only name a group because the app registers one.** Sharing a realm across rows is a `cordis:group` row, and a preset living outside this workspace — the authored ones under the Harness home, which is the point — cannot resolve `@cordisjs/plugin-group` by name: Node's upward `node_modules` walk never reaches the harness from there. `boot()` therefore registers `cordis:group` beside `cordis:include` as a loader builtin, so both load through the ambient module pipeline rather than through the included tree's own specifier resolution. Without it the `isolate` vocabulary above is expressible one row at a time only, and a provider could never be grouped with its consumers. + **A preset may not publish into the root service realm.** Such a service is process-global rather than per-session, so the second session mounting the same preset collides with the first — and the collision surfaces as an unhandled rejection that `setup` never observes, leaving a half-composed agent that looks healthy. The mount rejects it instead, and the package invariant re-checks on every service notification because a row publishing from a timer or an asynchronous continuation would escape a one-shot audit. **Failure rolls the agent back.** `setup` runs before publication, so a rejected mount fails `ctx.agents.create()` and leaves nothing behind. This is why `setup` is the one supported call site. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 5a2e1c3d8d..aa7792a7db 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -29,6 +29,8 @@ Status: implemented **直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。 +**preset 能写出 group,是因为 app 注册了它。** 跨行共享 realm 就是一个 `cordis:group` 行,而住在本工作区之外的 preset——也就是 Harness home 下由人或 agent 创作的那些,正是这套设计的目的——无法按名字解析 `@cordisjs/plugin-group`:Node 向上查找 `node_modules` 的路径从那里永远走不到 harness。因此 `boot()` 把 `cordis:group` 与 `cordis:include` 并排注册为 loader builtin,两者都经由环境模块管线加载,而不依赖被包含树自身的说明符解析。没有它,上文那套 `isolate` 词汇就只能一行一行地表达,提供方也永远无法与它的消费方归入同一组。 + **preset 不得把服务发布进根 realm。** 这类服务是进程级全局而非按会话的,因此第二个挂载同一 preset 的会话会与第一个相撞——而这次相撞表现为 `setup` 永远观察不到的未处理 rejection,留下一个看起来健康、实则组装到一半的 agent。挂载改为直接拒绝它;本包的运行时不变量还会在每次服务通知时复查,因为从定时器或异步续体中发布的行会绕过一次性审计。 **失败会让 agent 回滚。** `setup` 在发布之前运行,因此挂载被拒绝会让 `ctx.agents.create()` 失败且不留残留。这正是 `setup` 是唯一受支持调用点的原因。 diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index b27e89ec7b..da74ba925a 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: 5d66c23f24717d1c30a9e729c7b0528715692d41 -README.zh.md: 9cdcd8b11a8a37d6d789c393e9a1500e4fbfd503 +README.md: e53a52f145b3f66ab115b93c614561fed8194719 +README.zh.md: 39b470002816f309c8ec9af1d72e04495b020ad7 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 5d66c23f24..e53a52f145 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -58,5 +58,4 @@ Prefix-stable for the life of an agent: a composition is installed once, before - **A preset cannot be changed on a live agent** — the mount happens once during creation, so switching a running session's composition would mean unwinding its subtree mid-turn, dropping tools the model may already have called. Changing the default affects only sessions created afterwards. - **Display names are the directory id** — a preset carries no manifest, so pickers and settings surfaces show the id until a consumer needs richer metadata. -- **`isolate` realms cannot be expressed across rows without `cordis:group`** — an entry-local realm works on a single row, but grouping a provider with its consumers under one shared realm needs the group builtin, which `dsh-app-boot` does not register. - **Root scans are not watched** — every read hits the filesystem instead, which keeps the roster fresh but puts one `readdir` per root on each `list()`. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 9cdcd8b11a..39b4700028 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -58,5 +58,4 @@ Indirectly, through the plugins a mounted composition registers, which own every - **无法在存活的 agent 上更换 preset** —— 挂载只在创建时发生一次,因此切换运行中会话的组装意味着要在轮次进行途中卸载其子树,抽走模型可能已经调用的工具。更改默认值只影响此后创建的会话。 - **展示名称就是目录 id** —— preset 不携带 manifest,因此选择器与设置界面在有消费方需要更丰富的元数据之前,只显示 id。 -- **跨多行的 `isolate` realm 需要 `cordis:group` 才能表达** —— 单行可用 entry 本地 realm,但要把一个提供方与它的消费方归入同一个共享 realm,需要 group 内建插件,而 `dsh-app-boot` 并未注册它。 - **根目录扫描不做监听** —— 每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。 diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 398ec6e923..9ff7452391 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md -README.md: cdd78047b6ad71148c6ebeba598b63b4ae4cfa7b -README.zh.md: ee2b07884e68510e2b59b9f2c27053c263d15f1a +README.md: 56df424b44564d6e8f6ccd0a61c258dacf9748d3 +README.zh.md: ddd8103e849bc6798b056cae92c88e5f84551108 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index cdd78047b6..56df424b44 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -14,7 +14,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape; a missing file also throws, because the caller named it | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by user patch-layer HMR | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR | | `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error | @@ -26,6 +26,8 @@ Loader settlement rejects import and lifecycle failures with the failing entry a The Loader mounts entries concurrently, so a surface can already own the terminal when something else fails: exiting without the tree's own teardown would leave raw mode, bracketed paste, and the keyboard protocol set on the user's shell, and an in-flight terminal query's reply would land as literal text at the next prompt. A config-tree failure settles through `boot()`, whose disposal of the partial context runs the surface's own shutdown before the labelled rejection. For the rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting — a terminal-owning bin passes `release` to dispose the tree before the exit commits; `dsh` captures the root context in `boot()`'s `prepare` hook rather than from its return value so the hook covers the whole mounting window. While a release is in flight the handler stays installed and latched: the first rejection is the reported one, and later rejections (teardown's own included) are swallowed rather than becoming uncaught and killing the process mid-teardown. +`cordis:group` is registered beside `cordis:include` so a composition can give one `isolate` realm to a provider and its consumers together. Both load through the ambient module pipeline rather than the included tree's own specifier resolution, which is what lets a composition outside this workspace — an agent preset under the Harness home — use a group row at all. + Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index ee2b07884e..ddd8103e84 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -14,7 +14,7 @@ | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份形状相同的必需 patch 列表文件;文件缺失同样抛出异常,因为该文件是调用方指名的 | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 | +| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 | | `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步 disposer | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | @@ -26,6 +26,8 @@ Loader 结算会在导入或生命周期失败时 reject,并携带失败的配 Loader 并发挂载各个条目,因此当其他环节失败时,某个界面可能已经持有终端:此时不经过整棵树自身的拆卸就退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。配置树失败会经 `boot()` 结算:它先释放部分构建的上下文(从而执行该界面自身的 shutdown),再抛出带标签的 rejection。对于 `boot()` 看不到的 rejection(插件游离的异步工作在挂载期间或挂载完成后失败),持有终端的 bin 会传入 `release`,在提交退出前释放整棵树;`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,使该回调覆盖整个挂载窗口。release 执行期间处理函数保持注册并加闩:被报告的始终是第一个 rejection,后续 rejection(包括拆卸自身的)会被吞掉,而不会变成未捕获错误、在拆卸中途杀死进程。 +`cordis:group` 与 `cordis:include` 一并注册,使一份组装能把一个提供方与它的消费方放进同一个 `isolate` realm。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析,这正是让本工作区之外的组装——放在 Harness home 下的 agent preset——能够使用 group 行的原因。 + 配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个已交付的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index c1214953a5..1766ec765b 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -28,6 +28,7 @@ "js-yaml": "^4.2.0" }, "peerDependencies": { + "@cordisjs/plugin-group": "^1.0.0", "@cordisjs/plugin-hmr": "^1.0.15", "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", @@ -42,6 +43,7 @@ } }, "devDependencies": { + "@cordisjs/plugin-group": "workspace:^", "@cordisjs/plugin-hmr": "workspace:^", "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index e14b249f5c..73f1900ef5 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -13,6 +13,7 @@ import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' +import Group from '@cordisjs/plugin-group' import { dshHomePath } from '@deepseek-ai/dsh-paths' import type {} from '@cordisjs/plugin-hmr' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. @@ -376,6 +377,12 @@ export async function mountRootInclude( patches: readonly PatchOptions[] = [], ): Promise { ctx.loader.builtins.include = Include + // `cordis:group` alongside it: a group row is how a composition gives one + // `isolate` realm to a provider and its consumers together, and an agent + // preset living outside this workspace cannot resolve `@cordisjs/plugin-group` + // by name. Both builtins load through the ambient module pipeline, so neither + // depends on the included tree's own specifier resolution. + ctx.loader.builtins.group = Group // Pinned id: the bootstrap include is app glue, not a config row, and its // id appears in Loader failure chains — a random id would make startup // diagnostics unstable across runs (and snapshot fixtures). diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts index 45eab9ea7d..9cbe3d1a58 100644 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -8,9 +8,8 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import { Context } from 'cordis' import type { Include } from '@cordisjs/plugin-include' -import { Group } from '@cordisjs/plugin-loader' import { boot } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -219,8 +218,10 @@ describe('loader tree replacement', () => { }) it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => { + // No manual builtin registration: `boot()` supplies `cordis:group` beside + // `cordis:include`, which is what lets a composition give one `isolate` + // realm to a provider and its consumers together. const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n') - ctx.loader.builtins.group = Group try { const config = (disabled: boolean) => [ '- id: parent', @@ -253,7 +254,6 @@ describe('loader tree replacement', () => { const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', { 'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), }) - ctx.loader.builtins.group = Group try { const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] }) const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } }) @@ -386,3 +386,46 @@ describe('include patches layered over one base', () => { } }) }) + +describe('shipped builtins', () => { + it('lets a booted composition share one isolate realm across a group of rows', async () => { + // The reason `boot()` registers `cordis:group`: a composition — notably an + // agent preset living outside this workspace, which cannot resolve + // `@cordisjs/plugin-group` by name — gives a provider and its consumer one + // named realm so the service stays out of the root realm while remaining + // visible to the rows that need it. + const { ctx } = await bootTree([ + '- id: realm', + ' name: cordis:group', + ' isolate:', + ' demoRealmSvc: true', + ' config:', + ' - id: provider', + ' name: ./provider.mjs', + ' - id: consumer', + ' name: ./consumer.mjs', + '', + ].join('\n'), { + 'provider.mjs': 'export const name = "provider"\n' + + 'export function apply(ctx) { ctx.effect(() => ctx.reflect.provide("demoRealmSvc", { tag: "realm" })) }\n', + 'consumer.mjs': 'export const name = "consumer"\n' + + 'export const inject = ["demoRealmSvc"]\n' + + 'export function apply(ctx) { globalThis.__REALM_SEEN__ = ctx.get("demoRealmSvc").tag }\n', + }) + try { + expect((globalThis as { __REALM_SEEN__?: string }).__REALM_SEEN__).toBe('realm') + // `provide` mints the root symbol unconditionally (cordis `reflect.ts`), + // so the name IS in the root realm — pinned here because it is the half + // that looks like the claim and is not. The claim is the other half: no + // implementation is stored under that symbol, so the root realm cannot + // resolve the service and a second composition mounting the same rows + // cannot collide with this one. + const rootKey = ctx.root[Context.isolate].demoRealmSvc + expect(rootKey).toBeDefined() + expect(ctx.reflect.store[rootKey!]).toBeUndefined() + } finally { + delete (globalThis as { __REALM_SEEN__?: string }).__REALM_SEEN__ + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/ui/app-boot/tsconfig.json b/packages/ui/app-boot/tsconfig.json index beb61317dc..9ec64c31fc 100644 --- a/packages/ui/app-boot/tsconfig.json +++ b/packages/ui/app-boot/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/include" }, + { + "path": "../../../vendor/group" + }, { "path": "../../../vendor/hmr" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f18d43bbc8..a612f4af93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5975,6 +5975,9 @@ importers: specifier: ^4.2.0 version: 4.2.0 devDependencies: + '@cordisjs/plugin-group': + specifier: workspace:^ + version: link:../../../vendor/group '@cordisjs/plugin-hmr': specifier: workspace:^ version: link:../../../vendor/hmr @@ -6570,6 +6573,9 @@ importers: python/sdk-runtime: dependencies: + '@cordisjs/plugin-group': + specifier: workspace:^ + version: link:../../vendor/group '@cordisjs/plugin-include': specifier: workspace:^ version: link:../../vendor/include diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 4fd7291633..450f7d5333 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -5,6 +5,7 @@ "private": true, "type": "module", "dependencies": { + "@cordisjs/plugin-group": "workspace:^", "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", "@cordisjs/plugin-timer": "workspace:^", From 739042a80434af610ec0e96684304edd04d0e87d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 3 Aug 2026 22:22:57 +0800 Subject: [PATCH 007/293] feat(persona): make the agent persona a composable row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dsh-system-prompt` owns the deployment persona as its own config and registers that section unconditionally, so a process has exactly one. An agent preset cannot mount the prompt registry itself, which means that without a row of its own a preset could change an agent's tools but never its identity — and a roster of presets that all sound the same is not worth having. The row is scope-only by construction: mounted outside an agent scope it collides with the registry's own `deployment:persona` registration and fails loud. That is the correct shape rather than a gap, because the unscoped slot already has an owner and this row exists to shadow it for one agent. Two behaviours are pinned by test because both read the other way at a glance: an empty persona still occupies the slot (shadowing the deployment persona away entirely, then disappearing at render), and `assemble()` keeps section text uninterpolated — `renderPrompt()` is the stage that resolves `{{…}}`. --- docs/config-catalog.md | 18 ++++ packages/preset/README.i18n.yaml | 4 +- packages/preset/README.md | 1 + packages/preset/README.zh.md | 1 + packages/preset/persona/README.i18n.yaml | 6 ++ packages/preset/persona/README.md | 39 ++++++++ packages/preset/persona/README.zh.md | 39 ++++++++ packages/preset/persona/package.json | 43 +++++++++ packages/preset/persona/src/index.ts | 59 +++++++++++++ packages/preset/persona/src/invariant.ts | 30 +++++++ packages/preset/persona/tests/persona.spec.ts | 88 +++++++++++++++++++ packages/preset/persona/tsconfig.json | 25 ++++++ pnpm-lock.yaml | 19 ++++ tsconfig.host.json | 1 + 14 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 packages/preset/persona/README.i18n.yaml create mode 100644 packages/preset/persona/README.md create mode 100644 packages/preset/persona/README.zh.md create mode 100644 packages/preset/persona/package.json create mode 100644 packages/preset/persona/src/index.ts create mode 100644 packages/preset/persona/src/invariant.ts create mode 100644 packages/preset/persona/tests/persona.spec.ts create mode 100644 packages/preset/persona/tsconfig.json diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5542d296f1..1cb00d2b9b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1022,6 +1022,24 @@ Depends on: [`ApprovalPolicy`](core-data-structures/approval.md) · [`SandboxMod Source: [`packages/ui/permission/src/index.ts:140`](../packages/ui/permission/src/index.ts) +## `@deepseek-ai/dsh-persona` + +Requires: `systemPrompt` + +```ts config-catalog +/** Plugin config: the persona text this composition contributes. */ +export interface Config { + /** + * Persona prose rendered as the `deployment:persona` section. A template: + * complete `{{…}}` groups interpolate strictly against registered prompt + * variables. Empty text drops the section at render, matching the registry. + */ + text: string +} +``` + +Source: [`packages/preset/persona/src/index.ts:33`](../packages/preset/persona/src/index.ts) + ## `@deepseek-ai/dsh-plan-mode` Requires: `tools` · `systemPrompt` diff --git a/packages/preset/README.i18n.yaml b/packages/preset/README.i18n.yaml index f0176c1bc6..034d0a90ff 100644 --- a/packages/preset/README.i18n.yaml +++ b/packages/preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/README.md -README.md: 7baac391c224f717b60edeb0de828cb004ab460a -README.zh.md: 4d8c350b2831ae9c506ad7756ae157bf313a1c3e +README.md: fcce3013174b5c3b7e545eb48e73cc1d8f4126dc +README.zh.md: 281885daae9aab8dc989c232e751f9a255075b96 diff --git a/packages/preset/README.md b/packages/preset/README.md index 7baac391c2..d2ed10014a 100644 --- a/packages/preset/README.md +++ b/packages/preset/README.md @@ -7,6 +7,7 @@ An **agent preset** is a directory holding one `agent.cordis.yml`. Mounting it u | Package | Role | ctx key | |---|---|---| | `agent-presets/` | Preset vocabulary, filesystem discovery over trusted and user-authored roots, and the guarded per-agent mount | `ctx.agentPresets` | +| `persona/` | The agent persona as a composable row, so a preset can change identity and not only tools | — | The composition split this group assumes: registries and cross-session facilities are process singletons and stay in the host composition, while a preset carries what one agent contributes to them. A preset that names a row publishing a process-global service is rejected at mount rather than allowed to collide with the next session. diff --git a/packages/preset/README.zh.md b/packages/preset/README.zh.md index 4d8c350b28..db7bf18e6b 100644 --- a/packages/preset/README.zh.md +++ b/packages/preset/README.zh.md @@ -7,6 +7,7 @@ | 包 | 职责 | ctx 键 | |---|---|---| | `agent-presets/` | preset 词汇、在受信任目录与用户自建目录上的文件系统发现,以及带校验的按 agent 挂载 | `ctx.agentPresets` | +| `persona/` | 把 agent 人设做成可组装的行,使 preset 不止能改工具、也能改身份 | — | 本组假定的组装划分是:注册表与跨会话设施是进程单例,留在宿主组装中;preset 只承载单个 agent 对它们的贡献。若 preset 中某一行发布了进程级全局服务,挂载时即被拒绝,而不是留到与下一个会话相撞。 diff --git a/packages/preset/persona/README.i18n.yaml b/packages/preset/persona/README.i18n.yaml new file mode 100644 index 0000000000..fa0933593c --- /dev/null +++ b/packages/preset/persona/README.i18n.yaml @@ -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/preset/persona/README.md +README.md: 3a9f9f2e2debf9d4274949e14913137ef752baae +README.zh.md: 2c3bac3bb4a1fbeeb9c30defb225c2b64cb3e577 diff --git a/packages/preset/persona/README.md b/packages/preset/persona/README.md new file mode 100644 index 0000000000..789776b32d --- /dev/null +++ b/packages/preset/persona/README.md @@ -0,0 +1,39 @@ +# dsh-persona + +English | [中文](README.zh.md) + +The agent persona as a composable row. One config field, one prompt section. + +[`dsh-system-prompt`](../../core/system-prompt/README.md) owns the deployment persona as its own config and registers that section unconditionally, so a process has exactly one. An [agent preset](../agent-presets/README.md) cannot mount the prompt registry itself — without a row of its own, a preset could change an agent's tools but never its identity. This package is that row. + +## Scope-only + +Mounting this row outside an agent scope collides with the registry's own `deployment:persona` registration and fails loud. That is not a limitation to work around: the deployment persona already has an owner, and the whole point of this row is to shadow it for one agent. Mount it inside a preset composition, where the preset mount supplies the agent scope. + +## Config + +| Field | Default | Meaning | +|---|---|---| +| `text` | required | Persona prose rendered as the `deployment:persona` section | + +`text` is a template, like any prompt section: complete `{{…}}` groups resolve strictly against registered prompt variables when the prompt renders, not when it assembles. Empty text still occupies the slot, so it shadows the deployment persona away entirely and then disappears at render. + +## Model Experience + +### The persona section + +#### What the model sees + +The `deployment:persona` section at order 0, immediately after the harness identity opener, carrying exactly this row's configured `text` with prompt variables resolved. For an agent whose preset mounts this row, it replaces whatever persona the deployment configured. + +#### Token effect + +Fixed for a given preset: the persona's own tokens on every request that agent makes, and none for any other agent. Empty text contributes nothing. + +#### KV Cache effect + +Prefix-stable for the life of an agent — the row mounts once, before the agent is published and therefore before its first request, and its text never changes while the agent runs. Two agents on different presets establish different prefixes from this section onward; neither can invalidate the other's reuse. + +## Known Limitations and Deferred Work + +- **No global mount** — the prompt registry owns the unscoped persona slot, so this row is usable only from a scoped composition. A deployment-wide persona change belongs in the `system-prompt` row's own config. diff --git a/packages/preset/persona/README.zh.md b/packages/preset/persona/README.zh.md new file mode 100644 index 0000000000..4e28d75bbd --- /dev/null +++ b/packages/preset/persona/README.zh.md @@ -0,0 +1,39 @@ +# dsh-persona + +[English](README.md) | 中文 + +把 agent(智能体)人设做成一个可组装的行:一个配置字段,一个提示词段落。 + +[`dsh-system-prompt`](../../core/system-prompt/README.md) 以自身配置持有部署级人设,并且无条件注册该段落,因此一个进程只有一份。[agent preset](../agent-presets/README.md) 无法自行挂载提示词注册表——若没有属于自己的行,preset 能改变 agent 的工具,却永远改不了它的身份。本包就是那一行。 + +## 仅限 scope 内使用 + +在 agent scope 之外挂载本行,会与注册表自身的 `deployment:persona` 注册相撞并明确报错。这不是需要绕开的限制:部署级人设已经有归属,而本行存在的意义正是为某一个 agent 遮蔽它。请把它挂在 preset 组装内部,由 preset 的挂载过程提供 agent scope。 + +## 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `text` | 必填 | 作为 `deployment:persona` 段落渲染的人设文本 | + +`text` 与任何提示词段落一样是模板:完整的 `{{…}}` 组在提示词**渲染**时(而非组装时)严格解析为已注册的提示词变量。空文本同样占据该槽位,因此会把部署级人设整个遮蔽掉,然后在渲染时消失。 + +## Model Experience + +### 人设段落 + +#### What the model sees + +位于 order 0 的 `deployment:persona` 段落,紧随 harness 身份开场白之后,携带本行配置的 `text`,其中的提示词变量已解析。对于其 preset 挂载了本行的 agent,它会替换部署所配置的任何人设。 + +#### Token effect + +对给定 preset 而言是固定的:该 agent 的每次请求都携带人设自身的 token,其他 agent 一个都不带。空文本不贡献任何 token。 + +#### KV Cache effect + +在一个 agent 的整个生命周期内保持前缀稳定——本行只挂载一次,发生在 agent 发布之前、因而也在它的首个请求之前,且在 agent 运行期间文本不再改变。两个使用不同 preset 的 agent 从该段落起建立各自不同的前缀,谁都无法让对方失去缓存复用。 + +## Known Limitations and Deferred Work + +- **不支持全局挂载** —— 提示词注册表拥有未加 scope 的人设槽位,因此本行只能从带 scope 的组装中使用。要改变部署级人设,应在 `system-prompt` 行自身的配置中修改。 diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json new file mode 100644 index 0000000000..c5e968bd2b --- /dev/null +++ b/packages/preset/persona/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-persona", + "description": "Composition-authored deployment persona section for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/preset/persona/src/index.ts b/packages/preset/persona/src/index.ts new file mode 100644 index 0000000000..4bf24aea37 --- /dev/null +++ b/packages/preset/persona/src/index.ts @@ -0,0 +1,59 @@ +/** + * A per-agent persona as a composable row. + * + * `dsh-system-prompt` owns the global persona as its own config, and registers + * that section unconditionally — so this row is **scope-only**. Mounted inside + * an agent preset it shadows the deployment persona for that one session, + * exactly like the per-child persona `dsh-subagent` installs; mounted globally + * it collides with the registry's own registration and fails loud. + * + * That constraint is the reason the row exists. An agent preset cannot mount + * the prompt registry itself, so without a row of its own a preset could + * change an agent's tools but never its identity. + * @module @deepseek-ai/dsh-persona + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-system-prompt' + +/** The section name this plugin registers; the prompt registry's persona slot. */ +export const PERSONA_SECTION = 'deployment:persona' + +/** Prompt order of the persona slot, matching the registry's own default. */ +export const PERSONA_ORDER = 0 + +/** Cordis plugin name. */ +export const name = 'persona' + +/** The prompt registry this row contributes to. */ +export const inject = ['systemPrompt'] + +/** Plugin config: the persona text this composition contributes. */ +export interface Config { + /** + * Persona prose rendered as the `deployment:persona` section. A template: + * complete `{{…}}` groups interpolate strictly against registered prompt + * variables. Empty text drops the section at render, matching the registry. + */ + text: string +} + +/** Runtime schema for the persona row. */ +export const Config: z = z.object({ + text: z.string().required(), +}) + +/** + * Register the persona section for the mounting context's scope. + * @param ctx - an agent scope context; an unscoped context collides with the + * prompt registry's own persona registration and rejects. + * @param config - the persona text. + */ +export function apply(ctx: Context, config: Config): void { + ctx.effect(() => ctx.systemPrompt.section({ + name: PERSONA_SECTION, + order: PERSONA_ORDER, + text: config.text, + }), 'persona.section()') +} diff --git a/packages/preset/persona/src/invariant.ts b/packages/preset/persona/src/invariant.ts new file mode 100644 index 0000000000..5f9068fe24 --- /dev/null +++ b/packages/preset/persona/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-persona`. + * @module @deepseek-ai/dsh-persona/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-persona' + +/** Cordis companion plugin name. */ +export const name = 'persona-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this row owns no event stream or mutable runtime data — it registers one + * prompt section and the prompt registry owns section identity, shadowing, and disposal. + */ +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 */ diff --git a/packages/preset/persona/tests/persona.spec.ts b/packages/preset/persona/tests/persona.spec.ts new file mode 100644 index 0000000000..bb7555df7c --- /dev/null +++ b/packages/preset/persona/tests/persona.spec.ts @@ -0,0 +1,88 @@ +import { Context } from 'cordis' +import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import { createScope, type ScopeKey } from '@deepseek-ai/dsh-scope' +import { describe, expect, it } from 'vitest' +import * as Persona from '@deepseek-ai/dsh-persona' +import { PERSONA_SECTION } from '@deepseek-ai/dsh-persona' + +async function harness(deploymentPersona: string): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt, { persona: deploymentPersona }) + return ctx +} + +/** The rendered text of the persona slot as one scope sees it. */ +async function personaText(ctx: Context, scope?: ScopeKey): Promise { + const assembly = await ctx.systemPrompt.assemble(scope === undefined ? {} : { scope }) + return assembly.sections.find(section => section.name === PERSONA_SECTION)?.text +} + +describe('the persona row', () => { + it('rejects an unscoped mount, which would collide with the registry default', async () => { + const ctx = await harness('deployment identity') + + await expect(ctx.plugin(Persona, { text: 'composition identity' })) + .rejects.toThrow(/"deployment:persona" is already registered/) + }) + + it('shadows the deployment default for one scope only', async () => { + const ctx = await harness('deployment identity') + const key: ScopeKey = { agent: 'a1' } + const scope = createScope(ctx, key) + + await scope.ctx.plugin(Persona, { text: 'preset identity' }) + + expect(await personaText(ctx, key)).toBe('preset identity') + expect(await personaText(ctx)).toBe('deployment identity') + }) + + it('gives two scopes independent personas', async () => { + const ctx = await harness('') + const first: ScopeKey = { agent: 'a1' } + const second: ScopeKey = { agent: 'a2' } + + await createScope(ctx, first).ctx.plugin(Persona, { text: 'first identity' }) + await createScope(ctx, second).ctx.plugin(Persona, { text: 'second identity' }) + + expect(await personaText(ctx, first)).toBe('first identity') + expect(await personaText(ctx, second)).toBe('second identity') + }) + + it('shadows the deployment persona away entirely when its text is empty', async () => { + const ctx = await harness('deployment identity') + const key: ScopeKey = { agent: 'a1' } + + await createScope(ctx, key).ctx.plugin(Persona, { text: '' }) + + // The slot is still occupied, so the deployment persona is gone for this + // agent; an empty section is dropped when the prompt renders. + expect(await personaText(ctx, key)).toBe('') + expect(await personaText(ctx)).toBe('deployment identity') + }) + + it('restores the shadowed default when its fiber unloads', async () => { + const ctx = await harness('deployment identity') + const key: ScopeKey = { agent: 'a1' } + const scope = createScope(ctx, key) + const fiber = await scope.ctx.plugin(Persona, { text: 'preset identity' }) + expect(await personaText(ctx, key)).toBe('preset identity') + + await fiber.dispose() + + expect(await personaText(ctx, key)).toBe('deployment identity') + }) + + it('interpolates prompt variables strictly, like any other section', async () => { + const ctx = await harness('') + const key: ScopeKey = { agent: 'a1' } + ctx.systemPrompt.variable('model', () => 'deepseek-v4-pro') + + await createScope(ctx, key).ctx.plugin(Persona, { text: 'You run on {{model}}.' }) + + // `assemble()` keeps section text uninterpolated; `renderPrompt()` is the + // stage that resolves `{{…}}` against the assembly's variables. + expect(await personaText(ctx, key)).toBe('You run on {{model}}.') + expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))) + .toContain('You run on deepseek-v4-pro.') + }) +}) diff --git a/packages/preset/persona/tsconfig.json b/packages/preset/persona/tsconfig.json new file mode 100644 index 0000000000..178bd54dbb --- /dev/null +++ b/packages/preset/persona/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a612f4af93..3bba564577 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4203,6 +4203,25 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/preset/persona: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/pty/pty: devDependencies: '@deepseek-ai/dsh-agent': diff --git a/tsconfig.host.json b/tsconfig.host.json index 021ff2e23b..043ed309fa 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -218,6 +218,7 @@ { "path": "./packages/todo/tool-todo" }, { "path": "./packages/plan/plan-mode" }, { "path": "./packages/preset/agent-presets" }, + { "path": "./packages/preset/persona" }, { "path": "./packages/guard/repeat-tool-guard" }, { "path": "./packages/cordis/tool-cordis" }, { "path": "./packages/cordis/repository-plugin" }, From 91b55b92455b6d39d9de5c3f8c7485024777bce3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 3 Aug 2026 22:38:11 +0800 Subject: [PATCH 008/293] feat(web): compose a web session's agent from a named preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `session.create` takes an optional `agentPreset`, and the host resolves it, mounts it during pre-publication setup, and records the resolved id on the session header so a later resume rebuilds the same agent. Resolution happens BEFORE the session exists, not inside setup: the session boundary snapshots `meta` before asynchronous setup begins, so an id discovered during setup could never reach the header. Mounting still happens in setup, where a failure rolls the whole creation back rather than publishing a session whose capabilities are half-installed. Resume ignores whatever the request names and rebuilds from the stored id. A resumed session's history was produced under that composition; restoring a different one would replay tool calls the model can no longer make. `dsh-agent-presets` now throws `UnknownPresetError` / `PresetMountError` so the host can tell a bad request from a broken preset — they become `agent-preset-not-found` and `agent-preset-invalid`. Ships the two built-in compositions (`standard`, `core-web`) and the persona row that lets them differ in identity. Nothing mounts them yet: no roster is configured, so `composeAgent` finds no service and every session keeps the host composition. Wiring the roster and moving base's agent-plane rows behind it is the next commit, so the switch happens atomically with a real-composition test. --- .../agent-presets/core-web/agent.cordis.yml | 31 +++ .../agent-presets/standard/agent.cordis.yml | 241 ++++++++++++++++++ docs/cordis-catalog/services.md | 6 +- .../persistence.i18n.yaml | 4 +- docs/core-data-structures/persistence.md | 10 +- docs/core-data-structures/persistence.zh.md | 10 +- docs/persistence-catalog.md | 28 +- .../cordis/tool-cordis/src/api-catalog.ts | 6 +- packages/core/agent/src/index.ts | 1 + packages/core/session/src/index.ts | 4 + packages/core/session/src/types.ts | 8 + packages/host/apiproxy/package.json | 10 +- packages/host/apiproxy/src/api-proxy.ts | 78 +++++- packages/host/apiproxy/src/api/rpc.schema.ts | 2 + packages/host/apiproxy/src/api/rpc.ts | 2 + .../host/apiproxy/src/api/sessions.schema.ts | 1 + packages/host/apiproxy/src/api/sessions.ts | 8 +- packages/host/apiproxy/tsconfig.json | 3 + packages/preset/agent-presets/src/index.ts | 6 +- packages/preset/agent-presets/src/mount.ts | 4 +- packages/preset/agent-presets/src/types.ts | 31 +++ pnpm-lock.yaml | 3 + 22 files changed, 458 insertions(+), 39 deletions(-) create mode 100644 apps/cli/config/agent-presets/core-web/agent.cordis.yml create mode 100644 apps/cli/config/agent-presets/standard/agent.cordis.yml diff --git a/apps/cli/config/agent-presets/core-web/agent.cordis.yml b/apps/cli/config/agent-presets/core-web/agent.cordis.yml new file mode 100644 index 0000000000..48f7b5b3a0 --- /dev/null +++ b/apps/cli/config/agent-presets/core-web/agent.cordis.yml @@ -0,0 +1,31 @@ +# The `core-web` agent preset: the two-tool benchmark surface. +# +# The native model surface is exactly persistent `bash` plus +# `str_replace_editor`. Everything else a session could reach — skills, goals, +# plan mode, delegation, workflows, todo, web — is simply absent rather than +# disabled, because a preset composes what an agent has instead of subtracting +# from a shared default. +# +# The host composition is unchanged: this agent still runs inside the same +# sandbox, approval, persistence, and model routing as any other session. + +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: >- + You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + +# `tool-bash` provides the `bashEnv` service, so it needs a realm even alone. +- id: shell + name: cordis:group + group: true + isolate: + bashEnv: true + config: + - id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +- id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml new file mode 100644 index 0000000000..ccf0d92360 --- /dev/null +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -0,0 +1,241 @@ +# The `standard` agent preset: the full coding agent, mounted per session. +# +# This file is an AGENT-PLANE composition. It is mounted under one agent's +# scope context, so every tool and prompt section it registers belongs to that +# session alone. The host composition (`base.cordis.yml` + `web.cordis.yml`) +# keeps everything a preset must not own: the registries themselves, the +# sandbox and approval stack, persistence, and the model route. +# +# A service row here MUST sit inside a group carrying an `isolate` realm. +# Without one it publishes into the root realm, where it is process-global +# rather than per-session and the second session mounting this preset collides +# with the first; `dsh-agent-presets` rejects that at mount. `true` means an +# entry-local realm — one private instance per mounted session, which is the +# default this deployment wants. A shared label would instead pool one instance +# across every session naming it. + +# ── identity ──────────────────────────────────────────────────────────────── + +# The preset's own persona, shadowing the deployment default for this agent. +# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace. +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: >- + You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + +- id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' + config: + maxBytes: 65536 + +# ── shell ─────────────────────────────────────────────────────────────────── + +# `tool-bash` reads as a tool but provides the `bashEnv` service, so it needs a +# realm like any other provider. The executor behind it (`bash-sandbox`) stays +# in the host composition, where the sandbox policy owns it. +- id: shell + name: cordis:group + group: true + isolate: + bashEnv: true + config: + - id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +# ── filesystem ────────────────────────────────────────────────────────────── + +# All three register into the host `tools` registry and provide nothing, so +# they need no realm. The `fs` service and its policy stay in the host. +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + +- id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + +# ── background tasks ──────────────────────────────────────────────────────── + +- id: tasks + name: cordis:group + group: true + isolate: + tasks: true + config: + - id: tasks-local + name: '@deepseek-ai/dsh-tasks-local' + + - id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +# ── skills ────────────────────────────────────────────────────────────────── + +- id: skills + name: cordis:group + group: true + isolate: + skills: true + config: + - id: skill + name: '@deepseek-ai/dsh-skill' + + - id: skill-local + name: '@deepseek-ai/dsh-skill-local' + + - id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +# ── goals ─────────────────────────────────────────────────────────────────── + +- id: goals + name: cordis:group + group: true + isolate: + goals: true + config: + - id: goal + name: '@deepseek-ai/dsh-goal' + + - id: goal-session + name: '@deepseek-ai/dsh-goal-session' + + - id: command-goal + name: '@deepseek-ai/dsh-command-goal' + + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── plan mode ─────────────────────────────────────────────────────────────── + +# Plan state is per-agent by nature, so an entry-local realm is not a +# workaround here — it is the correct lifetime. +- id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + +# ── compaction ────────────────────────────────────────────────────────────── + +# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must +# share this realm rather than sit outside it. +- id: compaction + name: cordis:group + group: true + isolate: + tokenMeter: true + compact: true + toolResultPrune: true + config: + - id: token-meter + name: '@deepseek-ai/dsh-token-meter' + + - id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + + - id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + +# ── delegation and workflows ──────────────────────────────────────────────── + +# Every backend and every tool that reaches `subagents` or `workflows` shares +# one realm: a consumer left outside it would resolve the host's registry +# instead, which this preset does not populate. +- id: delegation + name: cordis:group + group: true + isolate: + subagents: true + workflows: true + config: + - id: subagent + name: '@deepseek-ai/dsh-subagent' + + - id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + + - id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: continuable + + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + + - id: tool-subagent-report + name: '@deepseek-ai/dsh-tool-subagent-report' + + - id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 + +# ── remaining model-facing rows ───────────────────────────────────────────── + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + +# The `web` service and its search provider stay in the host composition; only +# the model-facing tool is per-session. +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: false + searchTimeoutMs: 60000 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0a70b0f69f..60924f99ab 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -81,7 +81,7 @@ async resolve(id?: string): Promise async mount(agentCtx: Context, id?: string): Promise ``` -Source: [`packages/preset/agent-presets/src/index.ts:36`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:37`](../../packages/preset/agent-presets/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -253,7 +253,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:242`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:243`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -1785,7 +1785,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [PrepareSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:800`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:803`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index 58321e0f22..f1e4fe5030 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/persistence.md -persistence.md: 0968496201defa869d94925e8e5ae3c5da1bbd37 -persistence.zh.md: efb01427b4355e531fb9b86922223cf27d3b3db0 +persistence.md: 65b000516894c6dfb4c661f0b9112197317197e6 +persistence.zh.md: 214b631063ae5368b95e2fa9603941eb7b3bbe64 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 0968496201..1b8f124661 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -77,12 +77,19 @@ 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 } ``` ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. +Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, the `agentPreset` the agent was composed from, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. ```ts type-equiv /** @@ -104,6 +111,7 @@ interface CreateSessionOptions { readonly seedLength?: number readonly origin?: 'subagent' readonly delegationDepth?: number + readonly agentPreset?: string } } ``` diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index efb01427b4..6099ceac46 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -77,12 +77,19 @@ 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 } ``` ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 +通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth`、该 agent 所依据组装的 `agentPreset` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 ```ts type-equiv /** @@ -104,6 +111,7 @@ interface CreateSessionOptions { readonly seedLength?: number readonly origin?: 'subagent' readonly delegationDepth?: number + readonly agentPreset?: string } } ``` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 93b01139a8..1854ccd33f 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -77,7 +77,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:323`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:351`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:383`](../packages/core/session/src/types.ts) ## Events @@ -174,7 +174,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -190,7 +190,7 @@ Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/ Types: [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) ### `command/*` @@ -446,7 +446,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -458,7 +458,7 @@ Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -511,7 +511,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -547,7 +547,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:43`](../packages 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -556,7 +556,7 @@ Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -586,7 +586,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) ### `tool/*` @@ -603,7 +603,7 @@ Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -676,7 +676,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/c } ``` -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) ### `turn/*` @@ -696,7 +696,7 @@ Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -710,7 +710,7 @@ Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) ### `user/*` @@ -727,7 +727,7 @@ Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d296b1b519..2396d5725a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1865,7 +1865,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'CreateGoalRequest', @@ -1873,7 +1873,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateSessionOptions', - declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n}', + declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n };\n}', }, { name: 'CredentialInfo', @@ -2525,7 +2525,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionHeader', - declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n}', + declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n}', }, { name: 'SessionId', diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 55cb94d8f9..b5b6161a1b 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -84,6 +84,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 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index d250998624..6d05df300d 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -142,6 +142,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) } @@ -882,6 +885,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) } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 854e28d2e7..dd4c100d6d 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -69,6 +69,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 +97,7 @@ export interface CreateSessionOptions { readonly seedLength?: number readonly origin?: 'subagent' readonly delegationDepth?: number + readonly agentPreset?: string } } diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 426860eebc..58247720ec 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -63,13 +63,15 @@ "zod": "^4.4.3" }, "peerDependencies": { - "cordis": "^4.0.0-rc.7", - "@deepseek-ai/dsh-invariants": "^0.0.1" + "@deepseek-ai/dsh-agent-presets": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", - "cordis": "^4.0.0-rc.7", - "@deepseek-ai/dsh-invariants": "workspace:^" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f528b2297e..12d15af124 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -24,6 +24,7 @@ import { WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). +import { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-tools' import type { ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, @@ -744,6 +745,45 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro targetFor(agent) } + /** + * Resolve the preset an agent will be composed from, and the setup that + * installs it. + * + * The id is resolved BEFORE the session exists because the session boundary + * snapshots `meta` before asynchronous setup begins — a preset discovered + * during setup could never reach the header. Mounting still happens in + * setup, where a failure rolls the whole creation back rather than leaving a + * published session whose capabilities are half-installed. + * + * A deployment with no preset roster composes nothing and every session + * shares the host composition, which is the behavior before presets existed. + * @param presetId - the requested preset, or `undefined` for the default. + * @returns the id to record on the header (absent without a roster) and the setup callback. + * @throws when the roster supplies no such preset. + */ + async function composeAgent(presetId: string | undefined): Promise<{ + agentPreset?: string + setup: (agentCtx: Context) => Promise + }> { + const presets = ctx.get('agentPresets') + if (presets === undefined) { + return { + setup: (agentCtx: Context) => { + installTarget(agentCtx) + return Promise.resolve() + }, + } + } + const resolvedId = (await presets.resolve(presetId)).id + return { + agentPreset: resolvedId, + setup: async (agentCtx: Context) => { + installTarget(agentCtx) + await presets.mount(agentCtx, resolvedId) + }, + } + } + /** Send one transient frame to every connected mux consumer. */ function broadcast(payload: MuxFrame): void { const envelope = frame(payload) @@ -1101,7 +1141,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } /** Resolve one requested identity to a live agent, creating or resuming it once. */ - async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise { + async function ensureSession( + sessionId: SessionId, + cwd: string, + checkPersistedIdentity: boolean, + presetId?: string, + ): Promise { let creation = sessionCreations.get(sessionId) if (creation === undefined) { creation = (async () => { @@ -1127,10 +1172,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (inspected.meta.cwd !== cwd) { throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd) } + // The stored preset wins over anything the request names: a resumed + // session's history was produced under that composition, and + // rebuilding it differently would replay tool calls the model can no + // longer make. return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, - setup: installTarget, + setup: (await composeAgent(inspected.meta.agentPreset)).setup, })).agent } @@ -1139,11 +1188,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } catch (error: unknown) { throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error }) } + const composition = await composeAgent(presetId) return (await ctx.agents.create({ sessionId, agentOptions, - meta: { cwd }, - setup: installTarget, + meta: { + cwd, + ...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }, + }, + setup: composition.setup, })).agent })().catch((error: unknown) => { // Another Host entry path may have published the same identity while @@ -1592,9 +1645,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd + const requestedPreset = request.payload.agentPreset try { - await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined) + await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset) } catch (error: unknown) { + if (error instanceof UnknownPresetError) { + return err(request, { + code: 'agent-preset-not-found', + message: error.message, + details: { agentPreset: error.presetId, available: [...error.available] }, + }) + } + if (error instanceof PresetMountError) { + return err(request, { + code: 'agent-preset-invalid', + message: error.message, + details: { agentPreset: error.presetId, reason: error.reason }, + }) + } if (error instanceof SessionCwdConflict) { return err(request, { code: 'session-conflict', diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 2733c6e940..5b9c7f82a7 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -46,6 +46,8 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), + z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), + z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }), z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 54bbb5a8cc..1a35048b32 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -44,6 +44,8 @@ export interface RpcErrorDetailsMap { 'directory-exists': { path: string } 'directory-create-failed': { path: string } 'directory-picker-unavailable': { capability: string } + 'agent-preset-not-found': { agentPreset: string; available: string[] } + 'agent-preset-invalid': { agentPreset: string; reason: string } 'agent-busy': { reason: string } 'queue-item-not-found': { itemId: MessageId } 'steer-unavailable': { itemId: MessageId } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 9f9c4329e6..a3c7845aad 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -100,6 +100,7 @@ export const sessionCreateRequestSchema = z.object({ workspaceId: workspaceIdSchema.optional(), cwd: z.string().optional(), sessionId: sessionIdSchema.optional(), + agentPreset: z.string().optional(), }).refine( payload => payload.workspaceId === undefined || payload.cwd === undefined, { message: 'session.create accepts workspaceId or cwd, not both' }, diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 18315eef19..aeb3e933db 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -199,8 +199,14 @@ export interface SessionsApi { * session, while a different cwd fails with `session-conflict`. Workspace * creation attaches the session after publication; an attach failure * returns `workspace-attach-failed` with the published session id. + * + * `agentPreset` names the composition the new session's agent is built + * from; omitted, the deployment's default preset applies. The resolved id + * is stored on the session header, so a later resume rebuilds the same + * agent. An unknown id fails with `agent-preset-not-found`, and a preset + * whose composition cannot be mounted fails with `agent-preset-invalid`. */ - create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>): + create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId; agentPreset?: string }>): Promise> /** diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index c648d7a30d..2911c002ab 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../core/agent" }, + { + "path": "../../preset/agent-presets" + }, { "path": "../../core/session" }, diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 778b0d7275..ff0f0fe116 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -14,10 +14,11 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { discoverPresets } from './discovery.ts' import { mountPreset } from './mount.ts' -import type { AgentPreset, Config } from './types.ts' +import { UnknownPresetError, type AgentPreset, type Config } from './types.ts' export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' export { inactiveRows, leakedServices, livePresetMounts, mountPreset, type PresetMount } from './mount.ts' +export { PresetMountError, UnknownPresetError } from './types.ts' export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts' declare module 'cordis' { @@ -73,8 +74,7 @@ export class AgentPresets extends Service { const presets = await this.list() const found = presets.find(preset => preset.id === wanted) if (found === undefined) { - const known = presets.map(preset => preset.id).join(', ') - throw new Error(`agent-presets: preset "${wanted}" not found (available: ${known || 'none'})`) + throw new UnknownPresetError(wanted, presets.map(preset => preset.id)) } return found } diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index 39d55b17e6..2563b623f3 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -19,7 +19,7 @@ import { Context, type Fiber } from 'cordis' import { Include } from '@cordisjs/plugin-include' import type { EntryTree } from '@cordisjs/plugin-loader' import { scopeOf } from '@deepseek-ai/dsh-scope' -import type { AgentPreset } from './types.ts' +import { PresetMountError, type AgentPreset } from './types.ts' /** What one mounted subtree publishes about itself for the audit to read. */ interface MountedTree { @@ -221,6 +221,6 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi wraps a row's thrown value before it propagates, and this module's own rejections are Errors. The fallback keeps a hostile value readable. */ const detail = error instanceof Error ? error.message : String(error) - throw new Error(`agent-presets: preset "${preset.id}" (${preset.path}) failed to mount: ${detail}`, { cause: error }) + throw new PresetMountError(preset.id, `${detail} (${preset.path})`, { cause: error }) } } diff --git a/packages/preset/agent-presets/src/types.ts b/packages/preset/agent-presets/src/types.ts index 64fa2cdf07..975540a44f 100644 --- a/packages/preset/agent-presets/src/types.ts +++ b/packages/preset/agent-presets/src/types.ts @@ -32,3 +32,34 @@ export interface Config { /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ roots: PresetRoot[] } + +/** + * No configured root supplies the requested preset. + * + * Separate from a mount failure because the two mean different things to a + * caller: an unknown id is a bad request, while an unusable composition is a + * broken preset the deployment must fix. + */ +export class UnknownPresetError extends Error { + constructor( + /** The id that was requested. */ + readonly presetId: string, + /** Ids the roster does supply, for the caller to offer instead. */ + readonly available: readonly string[], + ) { + super(`agent-presets: preset "${presetId}" not found (available: ${available.join(', ') || 'none'})`) + } +} + +/** A preset exists but its composition cannot be installed. */ +export class PresetMountError extends Error { + constructor( + /** The preset whose composition failed. */ + readonly presetId: string, + /** Why it failed, without this package's own message prefix. */ + readonly reason: string, + options?: ErrorOptions, + ) { + super(`agent-presets: preset "${presetId}" failed to mount: ${reason}`, options) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bba564577..914238e3d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3669,6 +3669,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants From 3d6818548081162d1734aa88590cdac5e47c561c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 3 Aug 2026 23:44:09 +0800 Subject: [PATCH 009/293] feat(web): move the agent plane behind per-session presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Web overlay disables base's 32 agent-plane rows and mounts the preset roster instead, so each session composes its own tools and prompt rather than sharing one process-wide set. The TUI keeps base unchanged: it is single-session and composing its agent process-wide is correct there. `roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit beside the composition that names them and the user's live under the Harness home, neither of which a config author chooses. A session's preset is fixed at creation. Naming a different one for an existing identity is `agent-preset-conflict` rather than a switch, because that session's history was produced under the first preset's tools. The guard sits after `await creation`, beside the cwd check, so it covers every path that yields a live agent — freshly created, adopted live, resumed, or recovered by the concurrent-creation catch. A request naming no preset adopts the session as it is, keeping reconnect and retry ordinary. Two bugs the real-composition test caught, both invisible to unit tests: `PresetTree` now refuses to write. The Loader persists a tree whose plugin self-disposed, and tearing an agent down disposes its whole subtree — inherited, that rewrote the shipped composition, truncating a 241-line preset to `[]` the first time a session ended. `dsh-tool-skill` compared against a lookup of its own name in the global layer, so it threw inside any preset: `register()` files into the calling context's scope. It now compares against the definition it registered, which is what the identity check meant all along. The `standard` catalog is asserted exactly, not spot-checked: a row that registers into the wrong layer mounts cleanly and simply contributes nothing, so an omission is this design's quietest failure. It matches the shipped TUI catalog plus `glob`/`grep`, the pair that composition documents as ripgrep-dependent. Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the `agentPreset` field. `fs-glob-sampling` fails identically on pristine master and is untouched here. The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is resolved and patched in by the CLI entry, like `distIndex` on the webserver row, and this lane boots the shipped tree without that entry — so it has to supply the same fact or the roster resolves nothing and every session in the lane composes an agent with no tools, no persona, and no token meter. Only the shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The `cordis:group` builtin comes with it, exactly as `boot()` registers it, because a preset resolving package names from its own directory cannot reach `@cordisjs/plugin-group` by name. The lane stays red through this layer and the next four for the reason stated above — the api-proxy injects `subagents`, `workspace`, and `tools`, so `api-gateway` cannot activate and the browser has no `/api` at all. It goes green again in the layer that returns those registries to the host plane; this change is what makes that layer's fix sufficient rather than partial. --- apps/cli/src/web.ts | 17 ++ apps/cli/tests/web-agent-presets.spec.ts | 173 ++++++++++++++++++ apps/web/package.json | 1 + apps/web/tests/scaffold.ts | 20 ++ packages/bundle/web-app/cordis.patch.yml | 119 ++++++++++++ packages/host/apiproxy/src/api-proxy.ts | 56 ++++++ packages/host/apiproxy/src/api/rpc.schema.ts | 1 + packages/host/apiproxy/src/api/rpc.ts | 1 + .../tests/api-proxy-agent-preset.spec.ts | 145 +++++++++++++++ packages/preset/agent-presets/src/mount.ts | 19 +- packages/skill/tool-skill/README.i18n.yaml | 4 +- packages/skill/tool-skill/README.md | 2 +- packages/skill/tool-skill/README.zh.md | 2 +- packages/skill/tool-skill/src/index.ts | 12 +- pnpm-lock.yaml | 3 + 15 files changed, 564 insertions(+), 11 deletions(-) create mode 100644 apps/cli/tests/web-agent-presets.spec.ts create mode 100644 packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 4301af6e1a..3ca3ec2ba3 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -10,6 +10,7 @@ import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' +import { dshHomePath } from '@deepseek-ai/dsh-paths' import type { Context } from 'cordis' import type { PatchOptions } from '@cordisjs/plugin-include' import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' @@ -17,6 +18,12 @@ import { runProfile } from './profile-boot.ts' const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) +/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */ +const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url)) + +/** Harness-home directory holding locally authored agent presets. */ +const USER_PRESET_DIR = '.agent-presets' + /** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */ const ALL_INTERFACES_HOST = '0.0.0.0' @@ -96,6 +103,16 @@ function deriveWebFlagPatches( // inserts the client-hmr row), never pass-throughs of composed values. put('web-runtime', 'mode', flags.dev ? 'development' : 'production') put('web-runtime', 'lanAddresses', lanAddresses) + // The agent-preset roots are an assembly fact, like the values above: the + // shipped set sits beside this app's config and the user's own under the + // Harness home, and neither location is something a patch author chooses. + // Only patched when the composed tree actually mounts the roster. + if (rows.has('agent-presets')) { + put('agent-presets', 'roots', [ + { path: SHIPPED_PRESET_ROOT, trust: 'system' }, + { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }, + ]) + } const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => { const composed = rows.get(id) if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`) diff --git a/apps/cli/tests/web-agent-presets.spec.ts b/apps/cli/tests/web-agent-presets.spec.ts new file mode 100644 index 0000000000..87123dde36 --- /dev/null +++ b/apps/cli/tests/web-agent-presets.spec.ts @@ -0,0 +1,173 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { Context } from 'cordis' +import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { PatchOptions } from '@cordisjs/plugin-include' +import { beforeAll, describe, expect, it } from 'vitest' +import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-tools' + +const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url)) +const BASE_CONFIG = join(CONFIG_DIR, 'base.cordis.yml') +const WEB_OVERLAY = join(CONFIG_DIR, 'web.cordis.yml') + +/** + * Boot the shipped Web composition, minus the rows that would bind a port, + * touch the network, or write outside the test. Everything that decides an + * agent's capabilities is the real thing, including both shipped presets. + */ +async function bootWeb(): Promise { + const patches: PatchOptions[] = [ + ...loadOverlayPatches('dsh-test', WEB_OVERLAY), + // Host rows with side effects outside this process. + { id: 'webserver', disabled: true }, + { id: 'telemetry-otel', disabled: true }, + { id: 'modules', disabled: true }, + { id: 'connection', disabled: true }, + { id: 'api-gateway', disabled: true }, + { id: 'directory-picker', disabled: true }, + // The roster AppCLIEntry would patch in; only the shipped root, so a + // developer's own `~/.dsh/.preset` cannot change this test's outcome. + { + id: 'agent-presets', + config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] }, + }, + ] + return await boot('dsh-test', BASE_CONFIG, patches) +} + +const toolNames = (ctx: Context, agent?: Agent): string[] => + ctx.tools.schemas(agent).map(schema => schema.name).sort() + +let ctx: Context +beforeAll(async () => { + ctx = await bootWeb() +}, 120_000) + +describe('the shipped Web composition', () => { + it('leaves only the host UI tool in the global layer', () => { + // `ask_user_question` is the host's own interaction surface, not an agent + // capability, so it stays global. Every other tool now belongs to a + // preset; a regression here means an agent-plane row came back to base. + expect(toolNames(ctx)).toEqual(['ask_user_question']) + }) + + it('supplies both shipped presets, and only those, from the system root', async () => { + const listed = await ctx.agentPresets.list() + + expect(listed.map(preset => preset.id).sort()).toEqual(['core-web', 'standard']) + expect(listed.every(preset => preset.trust === 'system')).toBe(true) + expect(ctx.agentPresets.defaultId).toBe('standard') + }) + + it('composes the full agent from `standard`', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-standard'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + // The EXACT catalog, not a spot-check: an omission is this design's + // quietest failure mode, because a row that registers into the wrong + // layer mounts cleanly and simply contributes nothing. `glob`/`grep` are + // excluded for the reason the TUI composition e2e excludes them — they + // depend on ripgrep being present on the machine. + expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ + 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', + 'get_goal', 'list_agents', 'ralph', 'read', 'send_message', 'skill', + 'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill', + 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search', + 'workflow', 'write', + ]) + } finally { + await handle.dispose() + } + }) + + it('composes exactly two tools from `core-web`', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-core-web'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined), + }) + try { + expect(toolNames(ctx, handle.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor']) + } finally { + await handle.dispose() + } + }) + + it('keeps two differently composed sessions independent', async () => { + const full = await ctx.agents.create({ + sessionId: SessionId('preset-both-full'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + const minimal = await ctx.agents.create({ + sessionId: SessionId('preset-both-minimal'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined), + }) + try { + expect(toolNames(ctx, minimal.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor']) + expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) + + await minimal.dispose() + + // Tearing the minimal session down leaves the full one whole. + expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) + expect(toolNames(ctx)).toEqual(['ask_user_question']) + } finally { + await full.dispose() + } + }) + + it('never rewrites the preset file it composed from', async () => { + // The Loader persists a tree whose plugin self-disposed, and tearing an + // agent down disposes its whole subtree. Inherited, that rewrote the + // shipped composition — truncating it to `[]` the first time a session + // ended — so `PresetTree` refuses to write at all. + const path = join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml') + const before = await readFile(path, 'utf8') + + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-readonly'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + await handle.dispose() + await new Promise(resolve => setTimeout(resolve, 50)) + + expect(await readFile(path, 'utf8')).toBe(before) + }) + + it('gives each session its own persona', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-persona'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined), + }) + try { + const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent }) + expect(assembly.sections.find(section => section.name === 'deployment:persona')?.text) + .toContain('You are a coding agent powered by') + } finally { + await handle.dispose() + } + }) +}) + +describe('a session keeps the preset it was created with', () => { + it('refuses to adopt a live session under a different preset', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-locked'), + meta: { agentPreset: 'core-web' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined), + }) + try { + // The api-proxy guard reads exactly this: the header records what the + // session runs, so naming anything else is a caller error rather than a + // switch. Its history was produced under `core-web`'s two tools. + expect(handle.agent.session.header.agentPreset).toBe('core-web') + } finally { + await handle.dispose() + } + }) +}) diff --git a/apps/web/package.json b/apps/web/package.json index 10c2dc4702..c58e5b9682 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@cordisjs/plugin-group": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index dc68cbf67d..acc2116531 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -32,6 +32,7 @@ import { expect } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' +import Group from '@cordisjs/plugin-group' import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' import { assertEntriesLoaded, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import { dshHomePath } from '@deepseek-ai/dsh-paths' @@ -76,6 +77,8 @@ const BASE_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') /** The installation anchor whose dependency surface the profile module fallback mirrors. */ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') +/** The deployment's own agent-preset root, shipped beside the app's config. */ +const SHIPPED_PRESET_DIR = join(REPO_ROOT, 'apps/cli/config/agent-presets') // Replay publishes the provider catalog the gateway routes to (providers // mode, never catch-all: with llm-deepseek disabled no adapter exists, so a @@ -256,6 +259,18 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise = z.discriminatedUnion('code', z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), + z.object({ code: z.literal('agent-preset-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedPreset: z.string(), existingPreset: z.string().optional() }) }), z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 1a35048b32..9bfb30bd11 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -44,6 +44,7 @@ export interface RpcErrorDetailsMap { 'directory-exists': { path: string } 'directory-create-failed': { path: string } 'directory-picker-unavailable': { capability: string } + 'agent-preset-conflict': { sessionId: SessionId; requestedPreset: string; existingPreset?: string } 'agent-preset-not-found': { agentPreset: string; available: string[] } 'agent-preset-invalid': { agentPreset: string; reason: string } 'agent-busy': { reason: string } diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts new file mode 100644 index 0000000000..293ab3015e --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -0,0 +1,145 @@ +/** + * A session's agent preset is fixed at creation. The gateway records the + * resolved id on the header and refuses to adopt the identity under a different + * one, because the session's history was produced under that preset's tools: + * rebuilding it differently would replay tool calls the new agent cannot make. + */ + +import { mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import { RpcId, type RpcRequest } from '../src/api/rpc.ts' +import { UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' +import { createApiProxy } from '../src/api-proxy.ts' +import { describe, expect, it } from 'vitest' + +let nextRpc = 0 +function request

(payload: P): RpcRequest

{ + return { rpcId: RpcId(`preset-${String(nextRpc++)}`), payload } +} + +/** Minimal live agent; the gateway only needs identity and its session. */ +function stubAgent(session: Session): Agent { + return { id: session.id, session, status: 'idle' } as unknown as Agent +} + +/** + * A roster whose `mount` is a no-op: this spec is about the gateway's identity + * rules, and the composition itself is covered by the real-composition test in + * `apps/cli`. + */ +function roster(ids: readonly string[]): unknown { + return { + defaultId: ids[0], + list: () => Promise.resolve(ids.map(id => ({ id, trust: 'system', path: `/presets/${id}.yml` }))), + resolve: (id?: string) => { + const wanted = id ?? ids[0] ?? '' + if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids)) + return Promise.resolve({ id: wanted, trust: 'system', path: `/presets/${wanted}.yml` }) + }, + mount: (_ctx: Context, id?: string) => + Promise.resolve({ id: id ?? ids[0], trust: 'system', path: '/presets/x.yml' }), + } +} + +async function harness(presets?: readonly string[]) { + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-'))) + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never) + if (presets !== undefined) ctx.provide('agentPresets', roster(presets) as never) + + const factory: AgentFactory = { + async createAgent(_ownerCtx, options) { + const session = ctx.sessions.create( + options.sessionId, + options.meta === undefined ? {} : { meta: options.meta }, + ) + const agent = stubAgent(session) + // Setup runs before publication against a context that carries the + // agent, and the agent reaches back through `agent.ctx` — the pair the + // gateway's own `installTarget` relies on. + const agentCtx = ctx.extend({ agent }) + ;(agent as { ctx?: Context }).ctx = agentCtx + await options.setup?.(agentCtx) + const unregister = ctx.agents.register(agent) + return { agent, dispose: () => { unregister(); return Promise.resolve() } } + }, + async resume() { + throw new Error('test harness has no persisted sessions') + }, + } + ctx.agents.setFactory(factory) + const api = createApiProxy(ctx, { provider: 'test', model: 'test-model', cwd, workspaceRoot: cwd }) + return { api, ctx, cwd } +} + +describe('session.create with an agent preset', () => { + it('records the resolved preset on the session header', async () => { + const { api, ctx } = await harness(['standard', 'core-web']) + + const created = await api.sessions.create(request({ sessionId: SessionId('s1'), agentPreset: 'core-web' })) + + expect(created.result.ok).toBe(true) + expect(ctx.sessions.get(SessionId('s1'))?.header.agentPreset).toBe('core-web') + }) + + it('records the default when the caller names none', async () => { + const { api, ctx } = await harness(['standard', 'core-web']) + + await api.sessions.create(request({ sessionId: SessionId('s2') })) + + expect(ctx.sessions.get(SessionId('s2'))?.header.agentPreset).toBe('standard') + }) + + it('rejects an unknown preset and names the ones that exist', async () => { + const { api } = await harness(['standard']) + + const response = await api.sessions.create(request({ sessionId: SessionId('s3'), agentPreset: 'nope' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) + + it('refuses to adopt a live session under a different preset', async () => { + const { api } = await harness(['standard', 'core-web']) + await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'core-web' })) + + const response = await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'standard' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-conflict') + expect(response.result.error.details).toEqual({ + sessionId: 's4', + requestedPreset: 'standard', + existingPreset: 'core-web', + }) + }) + + it('adopts a live session unchanged when the caller names no preset', async () => { + const { api } = await harness(['standard', 'core-web']) + await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'core-web' })) + + // Reconnecting and retrying a create must stay ordinary operations. + const response = await api.sessions.create(request({ sessionId: SessionId('s5') })) + + expect(response.result.ok).toBe(true) + }) + + it('leaves the header preset-less when no roster is composed', async () => { + const { api, ctx } = await harness() + + await api.sessions.create(request({ sessionId: SessionId('s6') })) + + expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined() + }) +}) diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index 2563b623f3..88d3936013 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -41,12 +41,29 @@ interface MountedTree { */ const mounted = new WeakMap() -/** Include subclass whose only addition is publishing its tree and fiber for the audit. */ +/** + * Include subclass that publishes its tree and fiber for the audit, and never + * writes to the file it read. + */ class PresetTree extends Include { constructor(ctx: Context, config: Include.Config) { super(ctx, config) mounted.set(config, { tree: this, fiber: ctx.fiber }) } + + /** + * A preset is an input, never a persistence target. + * + * The Loader writes a tree back through this method whenever it decides the + * config changed — a plugin self-disposing is enough, and tearing an agent + * down disposes its whole subtree. Inherited, that rewrites the preset file + * with whatever the dying tree held, which in practice means truncating a + * shipped composition to `[]` the first time a session ends. Persisting a + * preset is also meaningless: nothing here is user state, and the same file + * backs every session that names it. + */ + override write(): void { + } } /** One preset composition currently installed under some agent. */ diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index b57689d742..a4bdf1ad3c 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md -README.md: 8e0bff5d1c4853092d412b8f7f9528d4b00d9626 -README.zh.md: c6b815bef59eb1f14be0892078694f129366d004 +README.md: be5b5b98865328ccd5f8a4666bff04acc8fd5927 +README.zh.md: dd84a7a24835c7f55b2b9b694197abd1fa30f77a diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 8e0bff5d1c..be5b5b9886 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -12,7 +12,7 @@ At every eligible `agent/pre-step`, the plugin calls `ctx.skills.snapshot()` for Every catalog message carries the `skill-catalog` source: a `catalog`-form context whose `entries` record exactly the `name` and `description` pairs it published, plus `update` on a replacement. The digest covers those durable entries, not the rendered prose, so the surrounding `` framing cannot decide whether a republish is needed and consumers never re-parse the `` block. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest visible `skill-catalog` message it can read; unreadable and foreign records are skipped. When the digest changes, the downstream `enter` decision receives a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry at the next pre-step. If no prior catalog exists and the current view is empty, no tombstone is necessary. -The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned. +The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Identity is compared against the definition this plugin registered rather than a lookup of its own name, so the plugin works mounted globally or inside one agent's composition, where `register()` files into that agent's layer alone. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned. `catalogDescriptionMaxLength` controls normalized catalog descriptions; rendering XML-escapes them. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns the durable initial catalog and replacement lifecycle. diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index c6b815bef5..dd84a7a248 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -12,7 +12,7 @@ 每条目录消息都携带 `skill-catalog` 来源,也就是 `catalog` 形态的上下文。它的 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖这些持久条目,而不是渲染后的正文,因此 `` 包装不会影响是否需要重新发布,消费方也不需要重新解析 `` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见且可读的 `skill-catalog` 消息作为比较基线;不可读和外来的记录都会跳过。digest 变化时,下游 `enter` 决策会收到一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,在下一次 pre-step 重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 -如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。 +如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。身份比对针对本插件所注册的那个定义,而非按自身名字回查,因此本插件既可全局挂载,也可挂在单个 agent 的组装内——在后者中 `register()` 只归档进该 agent 的分层。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。 `catalogDescriptionMaxLength` 控制规范化后的目录描述,渲染时会对其执行 XML 转义。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index ddc45d18e9..5761e3bed1 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -154,14 +154,14 @@ export function apply(ctx: Context, config: Config = {}): void { }, }) ctx.tools.register(skillTool) - const registeredSkillTool = ctx.tools.get(skillTool.name) - /* v8 ignore next 3 -- register() publishes synchronously or throws; this guards future registry drift. */ - if (registeredSkillTool === undefined) { - throw new Error('dsh-tool-skill: registered skill tool is not visible in the global registry') - } // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. + // + // The comparison is against the definition this plugin registered, not against + // a lookup of its own name: `register()` files into the CALLING context's + // scope, so a plugin mounted inside an agent preset registers for that agent + // alone and an unscoped lookup correctly finds nothing. ctx.on('agent/pre-step', async ( { agent, signal }, next, @@ -169,7 +169,7 @@ export function apply(ctx: Context, config: Config = {}): void { const decision = await next() if (decision.kind === 'reject') return decision signal.throwIfAborted() - const toolVisible = ctx.tools.get(skillTool.name, agent) === registeredSkillTool + const toolVisible = ctx.tools.get(skillTool.name, agent) === skillTool const snapshot = toolVisible ? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal }) : { skills: [], complete: true } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 914238e3d5..42628a86b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,6 +229,9 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) devDependencies: + '@cordisjs/plugin-group': + specifier: workspace:^ + version: link:../../vendor/group '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../packages/client/modules From 25b6381c848a5b1502ff4487e97e38d07a22d090 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 15:40:32 +0800 Subject: [PATCH 010/293] fix(web): compose a forked session, and give the shell realm its provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consequences of moving the agent plane behind presets, both invisible until the host plane stopped carrying model-facing rows. `sessions.fork` built its child with a bare `installTarget` and a `meta` without `agentPreset`. That was harmless while every tool sat in the host plane — the child inherited them for free. It now comes up with an EMPTY tool set. The child composes the parent's preset instead, for the same reason a resumed session keeps its own: the seeded history was produced under those tools. `bashEnv` lives in its own `dsh-bash-env` row rather than inside `tool-bash`, so a preset that isolates the realm must compose the provider beside its consumer; the host row is disabled here like every other model-facing one. Nothing outside the agent plane injects `bashEnv`, so it stays per-session. --- .../agent-presets/core-web/agent.cordis.yml | 5 ++++ .../agent-presets/standard/agent.cordis.yml | 5 ++++ apps/cli/tests/web-agent-presets.spec.ts | 30 +++++++++++++++++++ packages/bundle/web-app/cordis.patch.yml | 3 ++ packages/host/apiproxy/src/api-proxy.ts | 11 ++++++- 5 files changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/cli/config/agent-presets/core-web/agent.cordis.yml b/apps/cli/config/agent-presets/core-web/agent.cordis.yml index 48f7b5b3a0..9a251461c5 100644 --- a/apps/cli/config/agent-presets/core-web/agent.cordis.yml +++ b/apps/cli/config/agent-presets/core-web/agent.cordis.yml @@ -22,6 +22,11 @@ isolate: bashEnv: true config: + # The registry and its consumer share the realm: a consumer left outside + # would resolve the host's `bashEnv`, which this plane no longer provides. + - id: bash-env + name: '@deepseek-ai/dsh-bash-env' + - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index ccf0d92360..407e4d7d27 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -40,6 +40,11 @@ isolate: bashEnv: true config: + # The registry and its consumer share the realm: a consumer left outside + # would resolve the host's `bashEnv`, which this plane no longer provides. + - id: bash-env + name: '@deepseek-ai/dsh-bash-env' + - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' diff --git a/apps/cli/tests/web-agent-presets.spec.ts b/apps/cli/tests/web-agent-presets.spec.ts index 87123dde36..4b854697ff 100644 --- a/apps/cli/tests/web-agent-presets.spec.ts +++ b/apps/cli/tests/web-agent-presets.spec.ts @@ -154,6 +154,36 @@ describe('the shipped Web composition', () => { }) }) +describe('a forked session', () => { + it('inherits the composition its seeded history was produced under', async () => { + const parent = await ctx.agents.create({ + sessionId: SessionId('preset-fork-parent'), + meta: { agentPreset: 'core-web' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined), + }) + const inherited = parent.agent.session.header.agentPreset + const child = await ctx.agents.create({ + sessionId: SessionId('preset-fork-child'), + meta: { + parentSession: SessionId('preset-fork-parent'), + seedLength: 0, + ...inherited === undefined ? {} : { agentPreset: inherited }, + }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, inherited).then(() => undefined), + }) + try { + // Composing nothing would leave the child empty: this layer moved every + // model-facing row out of the host plane, so there is nothing to inherit + // for free any more. + expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent)) + expect(toolNames(ctx, child.agent).length).toBeGreaterThan(0) + } finally { + await child.dispose() + await parent.dispose() + } + }) +}) + describe('a session keeps the preset it was created with', () => { it('refuses to adopt a live session under a different preset', async () => { const handle = await ctx.agents.create({ diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 50b8d7f94e..ca28e5552a 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -201,6 +201,9 @@ # absent from a surface overlay would silently reappear the day someone reorders # the composition. +- id: bash-env + disabled: true + - id: tool-bash disabled: true diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index d465f1056c..8eefd9ac2f 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1898,6 +1898,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } const childId = `session-${randomUUID()}` as SessionId + // The child inherits the parent's composition for the same reason a + // resumed session keeps its own: the seeded history was produced under + // those tools, and composing anything else would strand the tool calls + // it already carries. Now that no model-facing row sits in the host + // plane, composing nothing would leave the child with no tools at all. + const forkComposition = await composeAgent(source.header.agentPreset) try { await ctx.agents.create({ sessionId: childId, @@ -1906,9 +1912,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ...source.header.cwd === undefined ? {} : { cwd: source.header.cwd }, parentSession: source.id, seedLength: cut, + ...forkComposition.agentPreset === undefined + ? {} + : { agentPreset: forkComposition.agentPreset }, }, agentOptions, - setup: installTarget, + setup: forkComposition.setup, }) } catch (error: unknown) { return err(request, { From 523d95a9cfd7dd5252fd6bdd2b4bd11ebb950e28 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 11:12:12 +0800 Subject: [PATCH 011/293] docs(agent-presets): record that a preset file is never written back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Loader writes a tree back to its source whenever it decides the config changed, and a row disposing its own fiber is enough to decide that. The mounted subtree overrides `write()` as a no-op for that reason — a fact that lived only in a PR description, so nothing in the repo said why the override exists or what removing it would cost. --- packages/preset/agent-presets/README.i18n.yaml | 4 ++-- packages/preset/agent-presets/README.md | 6 ++++++ packages/preset/agent-presets/README.zh.md | 6 ++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index da74ba925a..d0cc9e542a 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: e53a52f145b3f66ab115b93c614561fed8194719 -README.zh.md: 39b470002816f309c8ec9af1d72e04495b020ad7 +README.md: 8c95719bead97845519b7a334603005df201a34d +README.zh.md: c5e2fd3c7896437ddb4a2acdf101cd2e0e0eedaf diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index e53a52f145..1592fba116 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -42,6 +42,12 @@ A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot aud The package invariant re-checks that last rule on every service notification, because a row that publishes from a timer or an asynchronous continuation would escape the one-shot audit. +## A preset file is an input, never a persistence target + +The Loader writes a tree back to its source file whenever it decides the config changed, and a row disposing its own fiber is enough to decide that: the entry is marked `disabled` and the tree is written. Inherited, that would burn one session's runtime state into a file every session shares — comments stripped by the YAML round trip, and a `writeFile` rejection inside a `setTimeout` for a read-only shipped preset. + +The mounted subtree therefore overrides `write()` as a no-op. Nothing in this package writes a composition; authoring one is a separate, explicit operation. + ## Trust Presets are compositions, so a preset is exactly as privileged as the plugins it names. A `user` preset — authored by a person or by an agent — carries the same trust as shell access; the `trust` field exists so consumers can present that difference, not to enforce it. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 39b4700028..ef4512ab93 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -42,6 +42,12 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有 最后一条规则由本包的运行时不变量在每次服务通知时复查,因为从定时器或异步续体中发布的行会绕过一次性审计。 +## preset 文件是输入,不是持久化目标 + +只要 Loader 认为配置变了,它就会把树写回源文件——而一个行释放自己的 fiber 就足以让它这么认为:该 entry 被标记 `disabled`,随即触发写回。若继承该行为,一个会话的运行时状态就会被烧进所有会话共享的文件里:YAML 往返会抹掉注释,而对随附的只读 preset,`writeFile` 还会在 `setTimeout` 内抛出无人接管的 rejection。 + +因此被挂载的子树把 `write()` 覆写为空操作。本包不写任何组装;创作组装是另一件独立且显式的操作。 + ## 信任 preset 就是组装,因此一个 preset 的权限恰好等于它所引用的插件。`user` preset——无论由人还是由 agent 写出——与 shell 访问权限同级;`trust` 字段的存在是为了让消费方呈现这一差异,而不是用来强制隔离。 From fedb8a27022dfa4a3b99dca9fcd368b7063ad86e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 13:08:30 +0800 Subject: [PATCH 012/293] fix(session-projection): count registrants sharing one projection key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One unit definition already serves every session — its cells are keyed by `Session` — but registrants became per-session when agent presets started mounting tool packages per agent. N sessions on one preset register the same key N times. The first registration won and owned the only disposer, so ending one session stripped `goal`, `todos`, `plan`, `tokenUsage` and `contextPressure` from every other live session's snapshot. Measured against the shipped `standard` preset: two concurrent sessions each had eight projection keys, and disposing the first left the second with three — the ones host rows register. Count the registrants instead and remove the key when the last one goes. A differing `stateVersion` still refuses to share: it is the one incompatibility a runtime comparison can name, since everything else about a definition is functions. --- .../session-projection/src/index.ts | 42 +++++++++++++++---- .../session-projection/tests/registry.spec.ts | 35 +++++++++++++++- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/packages/session-projection/session-projection/src/index.ts b/packages/session-projection/session-projection/src/index.ts index 90c36ed579..68b582560f 100644 --- a/packages/session-projection/session-projection/src/index.ts +++ b/packages/session-projection/session-projection/src/index.ts @@ -134,10 +134,22 @@ interface UnitCell { observedSeq: number } -/** One live registration: the unit plus its per-session cells (dropped whole on disposal). */ +/** + * One live registration: the unit plus its per-session cells (dropped whole + * once the last registrant releases it). + * + * `refs` exists because one unit definition already serves every session — the + * cells are keyed by `Session` — while the registrants are now per-session: + * an agent preset mounts the same tool package once per agent, so N sessions + * on one preset register the same key N times. Without a count the first + * registrant would own the disposer, and its session ending would strip the + * projection from every other live session. + */ interface Registration { readonly def: ErasedDefinition readonly cells: WeakMap + /** Live registrants sharing this unit; the last one out removes the key. */ + refs: number } /** @@ -149,9 +161,12 @@ interface Registration { * older than the registry, folds `init` over the in-memory log on first * touch (event or read). Registration is an effect (disposer rides the * calling fiber): an unloaded domain plugin's key disappears from snapshots - * and clients read it as capability absence. Duplicate keys throw. Domain + * and clients read it as capability absence. Domain * plugins register under `ctx.inject(['sessionProjections'], …)` so headless - * assemblies without the registry stay unaffected. + * assemblies without the registry stay unaffected. Registrants sharing a key + * share one unit and are counted: the same tool package mounted in N agent + * presets registers N times, and the key survives until the last one + * unloads. */ export class SessionProjectionRegistry extends Service { private readonly registrations = new Map() @@ -182,12 +197,25 @@ export class SessionProjectionRegistry extends Service { } const dispose = this.ctx.effect(function* (this: SessionProjectionRegistry) { const key = definition.key as string - if (this.registrations.has(key)) { - throw new Error(`session projection key ${JSON.stringify(key)} is already registered`) + const existing = this.registrations.get(key) + if (existing === undefined) { + this.registrations.set(key, { def: definition, cells: new WeakMap(), refs: 1 }) + } else { + // A differing `stateVersion` is the one incompatibility this can name: + // the versioned contract says the cached state shape differs, so the + // two registrants cannot share cells. Anything else about a definition + // is functions, which no runtime comparison can tell apart. + if (existing.def.stateVersion !== definition.stateVersion) { + throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(definition.stateVersion)}`) + } + existing.refs += 1 } - this.registrations.set(key, { def: definition, cells: new WeakMap() }) yield () => { - this.registrations.delete(key) + const live = this.registrations.get(key) + /* v8 ignore next -- the disposer runs once per successful registration, so the entry it counted is still here */ + if (live === undefined) return + live.refs -= 1 + if (live.refs === 0) this.registrations.delete(key) } }.bind(this), 'sessionProjections.register()') return () => void dispose() diff --git a/packages/session-projection/session-projection/tests/registry.spec.ts b/packages/session-projection/session-projection/tests/registry.spec.ts index bd33914b2d..92be53cb7e 100644 --- a/packages/session-projection/session-projection/tests/registry.spec.ts +++ b/packages/session-projection/session-projection/tests/registry.spec.ts @@ -127,14 +127,45 @@ describe('SessionProjectionRegistry drive', () => { expect(snapshot.values['test/marks']).toEqual({ marks: [] }) }) - it('rejects duplicate keys loud and keeps the first unit', async () => { + it('shares one unit between registrants of the same key', async () => { const { ctx, session } = await harness() ctx.sessionProjections.register(marksUnit()) - expect(() => ctx.sessionProjections.register(marksUnit())).toThrow(/"test\/marks" is already registered/) + + // One definition already serves every session (cells are keyed by + // Session), and registrants are per-session now: an agent preset mounts + // the same tool package once per agent. + expect(() => ctx.sessionProjections.register(marksUnit())).not.toThrow() mark(session, ['kept']) expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['kept'] }) }) + it('keeps the unit until the last registrant releases it', async () => { + const { ctx, session } = await harness() + const first = ctx.sessionProjections.register(marksUnit()) + const second = ctx.sessionProjections.register(marksUnit()) + mark(session, ['kept']) + + first() + + // The regression this counts against: one session ending used to strip + // the projection from every other live session, because the first + // registrant owned the only disposer. + expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['kept'] }) + second() + expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) + }) + + it('refuses to share a key across a stateVersion change', async () => { + const { ctx } = await harness() + ctx.sessionProjections.register(marksUnit()) + + // The one incompatibility a runtime comparison can name: the versioned + // contract says the cached state shape differs, so the two cannot share + // cells. Everything else about a definition is functions. + expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 9 })) + .toThrow(/already registered at stateVersion 1; refusing to share it with stateVersion 9/) + }) + it('rejects a non-integer or negative stateVersion at register time', async () => { const { ctx } = await harness() expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: -1 })).toThrow(/stateVersion/) From c58cc23d455df804c96efb88b1d45f7cfe76f716 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 13:39:40 +0800 Subject: [PATCH 013/293] fix(web): address a session's own services from the host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A preset publishes its services behind `isolate` realms, which is what makes them per session — and what makes them invisible to every host context. The api-proxy kept reading the root realm, so requests that are ABOUT a session but arrive from outside it answered for a singleton that no longer exists: `goal.pause`/`clear` and `skill.list` returned "this deployment does not mount @deepseek-ai/dsh-goal / dsh-skill" for sessions whose composition mounts exactly that. Verified against a running host before and after. `agentPresets.serviceFor(agent, name)` addresses the instance instead, reading the same subtree-ownership relation `leakedServices` already uses, inverted. It is read addressing for a caller holding the agent: a host row that `inject`s a service cannot use it, because injection resolves before any session exists — which is why `tools` and `subagents` stay host-plane and this is not a way around that. Tool presenters had the same shape and the same cure: `viewFor` looked definitions up without a scope while the global layer is empty by design, so every card degraded to the generic renderer. It now takes the owning agent. Cold resume through `agentFor()` mounted no preset at all, so every generic entry point — prompt, models, commands — rebuilt a restarted session on host tools and the deployment persona. It composes the recorded preset now, as the other resume path already did. --- packages/host/apiproxy/src/api-proxy.ts | 87 ++++++++++++++----- .../tests/api-proxy-agent-preset.spec.ts | 70 +++++++++++++++ .../tests/api-proxy-subagents.spec.ts | 9 +- packages/preset/agent-presets/src/index.ts | 25 +++++- packages/preset/agent-presets/src/mount.ts | 41 +++++++++ .../preset/agent-presets/tests/mount.spec.ts | 30 +++++++ 6 files changed, 234 insertions(+), 28 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8eefd9ac2f..38039f8ab3 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -414,11 +414,20 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall: * the client's documented default (generic JSON card) covers every miss. */ -function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined { +function viewFor( + ctx: Context, + event: SessionEvent, + argsFor: (callId: string) => unknown, + // The presenter lives with the definition, and definitions are per agent + // now: a preset registers its tools into that agent's layer, leaving the + // global layer empty. Looking one up without the owner finds nothing, and + // every card silently degrades to the generic renderer. + agent?: Agent, +): ToolEventView | undefined { try { if (event.type === 'tool/call') { const { name, arguments: raw } = event.data as ToolCallData - const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw)) + const view = ctx.tools.get(name, agent)?.presentCall?.(JSON.parse(raw)) return view === undefined ? undefined : { for: 'call', view } } if (event.type === 'tool/result') { @@ -427,7 +436,7 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => const callId = message.source.callId const call = argsFor(callId) as { name: string; args: unknown } | undefined if (call === undefined) return undefined - const view = ctx.tools.get(call.name)?.presentResult?.(call.args, { + const view = ctx.tools.get(call.name, agent)?.presentResult?.(call.args, { content: result.content, isError: result.isError === true, ...meta === undefined ? {} : { meta }, @@ -470,11 +479,12 @@ function historyPage( events: readonly SessionEvent[], beforeSeq: number | undefined, maxMessages: number | undefined, + agent?: Agent, ): { events: HistoryEntry[]; hasMore: boolean } { const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES) return { events: page.events.map((event) => { - const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId)) + const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId), agent) return { event, ...view === undefined ? {} : { view } } }), hasMore: page.hasMore, @@ -1090,10 +1100,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (publishedSession !== undefined && hasSubagentOwner(publishedSession, publishedAgent)) { throw new SubagentSessionOwnership(sessionId) } + // Cold resume composes the preset the session recorded, for the + // same reason `session.create` does: its history was produced under + // that composition. Every generic entry point — prompt, models, + // commands — arrives here, so leaving it out meant a session opened + // after a restart ran on host tools and the deployment persona. const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, - setup: installTarget, + setup: (await composeAgent(inspected.meta.agentPreset)).setup, }) return handle.agent } finally { @@ -1354,11 +1369,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return items } - /** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */ - function goalService(): NonNullable>> | { error: RpcError } { - const goals = ctx.get('goals') + /** + * Resolve the goal service THIS agent runs. + * + * The service is per session: an agent preset mounts it behind an `isolate` + * realm, which no host context resolves. Reading it from the root would + * answer "absent" for a session whose composition mounts it — so the lookup + * is keyed by the agent, and only a deployment composing it nowhere is + * genuinely absent. + */ + function goalServiceFor(agent: Agent): NonNullable>> | { error: RpcError } { + const presets = ctx.get('agentPresets') + const goals = presets?.serviceFor(agent, 'goals') ?? ctx.get('goals') if (goals === undefined) { - return { error: { code: 'internal', message: 'goal service is absent: this deployment does not mount @deepseek-ai/dsh-goal in its composition (cordis.yml or explicit assembly)', details: {} } } + return { error: { code: 'internal', message: 'goal service is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-goal', details: {} } } } return goals } @@ -1374,10 +1398,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro request: RpcRequest<{ sessionId: SessionId }>, mutation: (goals: NonNullable>>, agent: Agent) => CoreGoalRef, ): Promise> { - const goals = goalService() - if ('error' in goals) return err(request, goals.error) const found = await agentFor(request.payload.sessionId) if ('error' in found) return err(request, found.error) + const goals = goalServiceFor(found.agent) + if ('error' in goals) return err(request, goals.error) try { const ref = mutation(goals, found.agent) return ok(request, { ref: { id: ref.id, revision: ref.revision } }) @@ -1768,7 +1792,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - const page = historyPage(ctx, state.events, beforeSeq, maxMessages) + // `ctx.get`, not `ctx.agents`: this is the COLD path, and a caller may + // serve history from storage with no agent registry composed at all. + // An absent registry means no live agent, which is the same answer a + // present one gives here — presenters fall back to the global layer. + const page = historyPage(ctx, state.events, beforeSeq, maxMessages, ctx.get('agents')?.get(sessionId)) return ok(request, { events: page.events, hasMore: page.hasMore, @@ -2071,7 +2099,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: { childSessionId }, }) } - const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages) + const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages, ctx.agents.get(childSessionId)) const projections = beforeSeq === undefined ? detachedProjectionsFor(ctx, snapshot.events) : undefined @@ -2440,10 +2468,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, async clear(request) { - const goals = goalService() - if ('error' in goals) return err(request, goals.error) const found = await agentFor(request.payload.sessionId) if ('error' in found) return err(request, found.error) + const goals = goalServiceFor(found.agent) + if ('error' in goals) return err(request, goals.error) try { goals.clear(found.agent, request.payload.ref) return ok(request, { cleared: true as const }) @@ -2473,14 +2501,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} }) } const cwd = session.header.cwd - // Same stance as the commands domain: a missing service means the - // deployment omitted dsh-skill from its composition, not an empty - // catalog. ctx.get also keeps this handler independent of the gateway - // plugin's inject list (an undeclared `ctx.skills` property read - // fails the reflect proxy). - const skillRegistry = ctx.get('skills') + // The registry is per session when a preset mounts one — a preset + // ships its own skill directory, so the catalog IS the session's — and + // that instance sits behind an `isolate` realm no host context + // resolves. Address it through the live agent; `agents.get` keeps the + // no-side-effect stance above (a cold session creates nothing and + // falls through to whatever the host composes). + const live = ctx.agents.get(sessionId) + const presets = ctx.get('agentPresets') + const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills') + // Same stance as the commands domain: a missing service means no + // composition mounts dsh-skill, not an empty catalog. `ctx.get` also + // keeps this handler independent of the gateway plugin's inject list + // (an undeclared `ctx.skills` property read fails the reflect proxy). + const skillRegistry = scoped ?? ctx.get('skills') if (skillRegistry === undefined) { - return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) + return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} }) } try { const skills = (await skillRegistry.list({ cwd })) @@ -2711,8 +2747,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } else if (event.type === 'turn/end') { openCalls.delete(session.id) } - const view = viewFor(ctx, event, callId => - openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId)) + const view = viewFor( + ctx, event, + callId => openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId), + ctx.agents.get(session.id), + ) queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } })) }), ctx.on('session/created', (session: Session) => { diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 293ab3015e..014b7847ca 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -15,6 +15,7 @@ import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { RpcId, type RpcRequest } from '../src/api/rpc.ts' import { UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' +import { GoalId } from '@deepseek-ai/dsh-goal' import { createApiProxy } from '../src/api-proxy.ts' import { describe, expect, it } from 'vitest' @@ -44,9 +45,19 @@ function roster(ids: readonly string[]): unknown { }, mount: (_ctx: Context, id?: string) => Promise.resolve({ id: id ?? ids[0], trust: 'system', path: '/presets/x.yml' }), + // What a real mount leaves behind: a service instance only the agent that + // mounted it can be used to address. The doubles are per agent so a test + // can tell "this session's" from "some session's". + serviceFor: (agent: { id: unknown }, name: string) => { + const perAgent = services.get(String(agent.id)) + return perAgent?.[name] + }, } } +/** Per-agent service instances a mounted preset would own, keyed by session id. */ +const services = new Map>() + async function harness(presets?: readonly string[]) { const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-'))) const ctx = new Context() @@ -143,3 +154,62 @@ describe('session.create with an agent preset', () => { expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined() }) }) + +/** + * A capability a preset mounts is reachable from nowhere the host normally + * looks: an `isolate` realm is what makes it per session. The gateway serves + * requests that are ABOUT a session from OUTSIDE it, so it addresses the + * instance through the agent instead of reading a root-realm singleton. + */ +describe('a capability the session\'s preset mounts', () => { + it('serves the goal RPC from the session\'s own goal service', async () => { + const { api } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('g1'), agentPreset: 'standard' })) + const ref = { id: GoalId('goal-1'), revision: 1 } + const paused: unknown[] = [] + services.set('g1', { + goals: { pause: (agent: { id: unknown }, r: unknown) => { paused.push([String(agent.id), r]); return ref } }, + }) + + const response = await api.goals.pause(request({ sessionId: SessionId('g1'), ref })) + + expect(response.result).toMatchObject({ ok: true, value: { ref } }) + // Reached the instance this session mounted, and was handed its own agent. + expect(paused).toEqual([['g1', ref]]) + services.delete('g1') + }) + + it('serves the skill catalog from the session\'s own registry', async () => { + const { api } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('k1'), agentPreset: 'standard' })) + services.set('k1', { + skills: { + list: () => Promise.resolve([{ + name: 'preset-owned', + description: 'ships inside the preset directory', + invocation: { modelInvocable: true, userInvocable: true }, + }]), + }, + }) + + const response = await api.skills.list(request({ sessionId: SessionId('k1') })) + + // A preset ships its own skill directory, so the catalog IS the + // session's; reading a host singleton would answer for the wrong one. + expect(response.result).toMatchObject({ ok: true, value: { skills: [{ name: 'preset-owned' }] } }) + services.delete('k1') + }) + + it('says so when no composition mounts the capability at all', async () => { + const { api } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('n1'), agentPreset: 'standard' })) + + const response = await api.skills.list(request({ sessionId: SessionId('n1') })) + + // Absent means absent — not "this session has none", which is what a + // root-realm read used to report for every presetd session. + expect(response.result.ok).toBe(false) + const failure = response.result as { ok: false; error: { message: string } } + expect(failure.error.message).toContain('neither this session') + }) +}) diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index faa1c39e9f..f9e317bf5a 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -112,7 +112,12 @@ describe('subagent gateway', () => { .toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } }) }) - it('reads a healthy direct child without looking up or activating any Agent', async () => { + it('reads a healthy direct child without acquiring an Agent owner', async () => { + // `bench()` leaves the child with no live Agent at all, so the response + // below is produced cold — which is the invariant: the read never creates + // or resumes one. It may still CONSULT the live registry, because tool + // presenters live with the per-agent definitions and rendering this + // child's own cards needs its layer. const { api, getAgent, readSession } = bench() const response = await api.subagents.history(request({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10, @@ -122,7 +127,7 @@ describe('subagent gateway', () => { value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] }, }) expect(readSession).toHaveBeenCalledWith(CHILD) - expect(getAgent).not.toHaveBeenCalled() + expect(getAgent).not.toHaveBeenCalledWith(PARENT) }) it('reads one-shot history and rejects an address with the wrong mode', async () => { diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index ff0f0fe116..69e59d1fd0 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -13,11 +13,13 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { discoverPresets } from './discovery.ts' -import { mountPreset } from './mount.ts' +import { mountPreset, serviceForAgent } from './mount.ts' import { UnknownPresetError, type AgentPreset, type Config } from './types.ts' export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' -export { inactiveRows, leakedServices, livePresetMounts, mountPreset, type PresetMount } from './mount.ts' +export { + inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, type PresetMount, +} from './mount.ts' export { PresetMountError, UnknownPresetError } from './types.ts' export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts' @@ -95,6 +97,25 @@ export class AgentPresets extends Service { await mountPreset(agentCtx, preset) return preset } + + /** + * One agent's instance of a service its preset mounted. + * + * A preset publishes services behind `isolate` realms, which are invisible + * outside the group that declares them — including to the host. This is how a + * caller holding the agent reads one anyway: a request that is ABOUT a + * session but arrives from outside it, which is every browser RPC. + * + * Read addressing only. A host row that `inject`s a service cannot use this, + * because injection resolves before any session exists and has no agent to + * key by; such a service belongs on the host plane instead. + * @param agent - the agent whose composition to look inside. + * @param name - the service name as the preset's rows resolve it. + * @returns the agent's instance, or undefined when its preset mounts none. + */ + serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined { + return serviceForAgent(this.ctx, agent, name) + } } export default AgentPresets diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index 88d3936013..e307601a49 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -155,6 +155,47 @@ export function leakedServices(ctx: Context, mount: Fiber): string[] { return leaked.sort((left, right) => left.localeCompare(right)) } +/** + * One agent's instance of a service its preset mounted. + * + * A preset publishes a service behind an `isolate` realm so two sessions + * cannot collide, and an entry-local realm is invisible to everything outside + * the group — including the agent's own scope context and the host. That is + * right for the rows inside the group and wrong for one caller: a request that + * is ABOUT a session but arrives from outside it, which is every browser RPC + * the api-proxy serves. + * + * Ownership is the same relation {@link leakedServices} reads, inverted: there + * it names implementations a subtree published into the ROOT realm, here it + * names the one this subtree published anywhere. Fiber membership is object + * identity for the reason stated on {@link withinFiber}. + * + * This is READ addressing for a caller that already holds the agent. It is not + * a general host handle on a session's internals: a host row that `inject`s a + * service cannot use it, because injection resolves before any session exists + * and has no agent to key by — such a service belongs on the host plane. + * @param ctx - any context of the runtime whose service store is inspected. + * @param agent - the agent whose mounted composition to look inside. + * @param name - the service name as the preset's rows resolve it. + * @returns the agent's instance, or undefined when its preset mounts none. + */ +export function serviceForAgent( + ctx: Context, + agent: { ctx: Context }, + name: K, +): Context[K] | undefined { + const root = agent.ctx.fiber + const store = ctx.reflect.store + for (const key of Object.getOwnPropertySymbols(store)) { + const impl = store[key] + /* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */ + if (impl === undefined) continue + if (impl.name !== name) continue + if (withinFiber(impl.fiber, root)) return impl.value as Context[K] + } + return undefined +} + /** * Rows that did not reach a usable state, each rendered as one diagnostic line. * diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 36810c61d1..3ece3db20e 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -12,6 +12,13 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { beforeEach, describe, expect, it } from 'vitest' import AgentPresets, { leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets' +declare module 'cordis' { + interface Context { + /** Published by the `isolated` fixture preset behind an entry-local realm. */ + fixtureIsolatedSvc: { label: string } + } +} + const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') const ROOTS = [ { path: join(FIXTURES, 'system'), trust: 'system' as const }, @@ -155,6 +162,29 @@ describe('rejecting a composition that cannot be used', () => { expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false) }) + it('addresses one agent\'s instance of a realm-private service', async () => { + const first = await agentOn(ctx, 'sess-reach-a', 'isolated') + const second = await agentOn(ctx, 'sess-reach-b', 'isolated') + + // The realm keeps the service out of every host context — that is what + // makes it per session — so a caller holding the agent is the only way a + // request from OUTSIDE the session can read the instance it is about. + expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false) + const mine = ctx.agentPresets.serviceFor(first, 'fixtureIsolatedSvc') + const theirs = ctx.agentPresets.serviceFor(second, 'fixtureIsolatedSvc') + expect(mine).toBeDefined() + expect(theirs).toBeDefined() + // Each agent gets ITS own: the addressing is per subtree, not a lookup + // that happens to find the first match. + expect(mine).not.toBe(theirs) + }) + + it('answers undefined for a service the agent\'s preset does not mount', async () => { + const agent = await agentOn(ctx, 'sess-reach-none', 'standard') + + expect(ctx.agentPresets.serviceFor(agent, 'fixtureIsolatedSvc')).toBeUndefined() + }) + it('reports the known ids when a preset is unknown', async () => { await expect(ctx.agentPresets.resolve('nope')) .rejects.toThrow(/preset "nope" not found \(available: .*standard/) From 5ed79887fb95e0cd75ad69da88afd6a0b6ae7b05 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 13:43:32 +0800 Subject: [PATCH 014/293] fix(web): correct the preset-layer contracts review found stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of these change behavior; each said something that was not true. `SessionCwdConflict`'s doc block had been left stranded above the `AgentPresetConflict` inserted under it, so one class carried a comment about the other and the second carried none. The roster comment named a `.system` directory that does not exist; the shipped root is `config/agent-presets/`, and `system` is the trust its entries carry. The real-composition test attributed the disabled `api-gateway` row to "side effects outside this process" alongside the port and the exporter. It is disabled for a different reason — the api-proxy cannot mount in this layer at all — and hiding that behind the same phrase would leave a later layer unable to tell whether the line can come out. One test claimed to refuse an adoption while asserting only that the header records the preset; it now says what it checks. `PERSONA_SECTION`/`PERSONA_ORDER` existed twice, once in the registry that declares the slot and once restated in the row that replaces it — a drift that would land a preset's persona beside the deployment's instead of shadowing it. The registry exports them now. The preset conflict message read "already runs agent preset undefined" for a session that records none, which is the shape a deployment with no roster produces; it names that case instead, with the regression that reaches it through the gateway. Finally, `PresetTree.write()` drops the `loader/config-update` the inherited method emits — recorded on the override, since a future edit-while-running flow needs its own persistence path. --- apps/cli/tests/web-agent-presets.spec.ts | 15 +++++++++-- docs/config-catalog.md | 4 +-- docs/cordis-catalog/services.md | 25 ++++++++++++++++--- .../persistence.i18n.yaml | 4 +-- docs/module-graph.md | 21 ++++++++++------ .../cordis-inspect-jsdoc/session.jsonl | 2 +- packages/bundle/web-app/cordis.patch.yml | 3 ++- packages/bundle/web-app/package.json | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 4 +++ packages/core/system-prompt/src/index.ts | 15 +++++++++-- packages/host/apiproxy/src/api-proxy.ts | 7 ++++-- .../tests/api-proxy-agent-preset.spec.ts | 22 ++++++++++++++++ packages/preset/README.i18n.yaml | 4 +-- .../preset/agent-presets/README.i18n.yaml | 4 +-- packages/preset/agent-presets/src/mount.ts | 5 ++++ packages/preset/persona/README.i18n.yaml | 4 +-- packages/preset/persona/package.json | 4 +-- packages/preset/persona/src/index.ts | 9 ++++--- pnpm-lock.yaml | 3 +++ 19 files changed, 119 insertions(+), 37 deletions(-) diff --git a/apps/cli/tests/web-agent-presets.spec.ts b/apps/cli/tests/web-agent-presets.spec.ts index 4b854697ff..4c2d7484ee 100644 --- a/apps/cli/tests/web-agent-presets.spec.ts +++ b/apps/cli/tests/web-agent-presets.spec.ts @@ -22,11 +22,17 @@ const WEB_OVERLAY = join(CONFIG_DIR, 'web.cordis.yml') async function bootWeb(): Promise { const patches: PatchOptions[] = [ ...loadOverlayPatches('dsh-test', WEB_OVERLAY), - // Host rows with side effects outside this process. + // Host rows with side effects outside this process: a bound port, a + // served asset tree, a telemetry exporter. { id: 'webserver', disabled: true }, { id: 'telemetry-otel', disabled: true }, { id: 'modules', disabled: true }, { id: 'connection', disabled: true }, + // NOT a side-effect row: the api-proxy cannot mount in THIS layer at all, + // because it injects `subagents` and the subagent registry moved into the + // presets here. That is the breakage a later layer returns to the host + // plane; when it does, this line comes out and the boot audit covers the + // whole host-plane injection graph again. { id: 'api-gateway', disabled: true }, { id: 'directory-picker', disabled: true }, // The roster AppCLIEntry would patch in; only the shipped root, so a @@ -134,6 +140,11 @@ describe('the shipped Web composition', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), }) await handle.dispose() + // Slack, not a race the number has to win. The write is driven by the + // Loader's fiber-unload listener, which fires as the subtree's fibers + // settle rather than when `dispose()` resolves, and the Loader exposes no + // flush to await. A regression writes synchronously inside that listener, + // so any wait past settlement fails; a longer one only slows the test. await new Promise(resolve => setTimeout(resolve, 50)) expect(await readFile(path, 'utf8')).toBe(before) @@ -185,7 +196,7 @@ describe('a forked session', () => { }) describe('a session keeps the preset it was created with', () => { - it('refuses to adopt a live session under a different preset', async () => { + it('records the preset the gateway guard reads', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-locked'), meta: { agentPreset: 'core-web' }, diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1cb00d2b9b..cf2ebc362a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1038,7 +1038,7 @@ export interface Config { } ``` -Source: [`packages/preset/persona/src/index.ts:33`](../packages/preset/persona/src/index.ts) +Source: [`packages/preset/persona/src/index.ts:34`](../packages/preset/persona/src/index.ts) ## `@deepseek-ai/dsh-plan-mode` @@ -1790,7 +1790,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:166`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:177`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-time-context` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 60924f99ab..646440c758 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -79,9 +79,26 @@ async resolve(id?: string): Promise * @throws when the preset is unknown or its composition is unusable. */ async mount(agentCtx: Context, id?: string): Promise + +/** + * One agent's instance of a service its preset mounted. + * + * A preset publishes services behind `isolate` realms, which are invisible + * outside the group that declares them — including to the host. This is how a + * caller holding the agent reads one anyway: a request that is ABOUT a + * session but arrives from outside it, which is every browser RPC. + * + * Read addressing only. A host row that `inject`s a service cannot use this, + * because injection resolves before any session exists and has no agent to + * key by; such a service belongs on the host plane instead. + * @param agent - the agent whose composition to look inside. + * @param name - the service name as the preset's rows resolve it. + * @returns the agent's instance, or undefined when its preset mounts none. + */ +serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined ``` -Source: [`packages/preset/agent-presets/src/index.ts:37`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:39`](../../packages/preset/agent-presets/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -1390,7 +1407,7 @@ Source: [`packages/session-projection/session-projection-cache/src/index.ts:71`] ## `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** @@ -1494,7 +1511,7 @@ restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseS Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-projection/session-projection/src/index.ts:156`](../../packages/session-projection/session-projection/src/index.ts) +Source: [`packages/session-projection/session-projection/src/index.ts:171`](../../packages/session-projection/session-projection/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) @@ -2273,7 +2290,7 @@ async assemble(context: AssembleContext = {}): Promise Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md) -Source: [`packages/core/system-prompt/src/index.ts:314`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tasks` — `TaskService` (abstract seam) diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index f1e4fe5030..091a85067b 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/persistence.md -persistence.md: 65b000516894c6dfb4c661f0b9112197317197e6 -persistence.zh.md: 214b631063ae5368b95e2fa9603941eb7b3bbe64 +persistence.md: 1b8f124661399b610534ff787b11a4d4a743ad4d +persistence.zh.md: 6099ceac46242a384098162cc6746de8e7d8dd7c diff --git a/docs/module-graph.md b/docs/module-graph.md index d4680deebe..aa9a8bc39d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -222,6 +222,7 @@ flowchart TD end subgraph group_preset["packages/preset"] pkg_agent_presets["agent-presets"] + pkg_persona["persona"] end subgraph group_pty["packages/pty"] pkg_pty["pty"] @@ -303,7 +304,6 @@ flowchart TD pkg_client_web_react --> pkg_invariants pkg_code_runtime --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants - pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants @@ -322,11 +322,6 @@ flowchart TD pkg_client_locale --> pkg_client_ui_primitives pkg_client_locale --> pkg_client_ui_slots pkg_client_locale --> pkg_invariants - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_client_web_react - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants pkg_client_ui_settings --> pkg_client_runtime pkg_client_ui_settings --> pkg_client_ui_primitives pkg_client_ui_settings --> pkg_client_ui_slots @@ -419,6 +414,8 @@ flowchart TD pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -463,6 +460,11 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_host_apiproxy + pkg_client_test_runtime --> pkg_invariants pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_client_ui_theme @@ -492,6 +494,8 @@ flowchart TD pkg_lsp_local --> pkg_lsp pkg_lsp_local --> pkg_subprocess pkg_lsp_local --> pkg_timeout + pkg_persona --> pkg_invariants + pkg_persona --> pkg_system_prompt pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox @@ -1122,7 +1126,6 @@ flowchart TD | [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | @@ -1133,7 +1136,6 @@ flowchart TD | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | @@ -1160,6 +1162,7 @@ flowchart TD | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | @@ -1174,12 +1177,14 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 12a88365a6..b1e5620493 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index ca28e5552a..578089d8a8 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -300,7 +300,8 @@ - id: tool-web disabled: true -# The preset roster. `.system` ships with the deployment and is read-only; +# The preset roster. `config/agent-presets/` ships with the deployment and is +# read-only (its entries carry `system` trust); # `$DSH_HOME/.agent-presets` is where a person — or an agent — authors their own, and # carries the same trust as shell access because a preset IS a composition. # `roots` is an assembly fact, not user config: the shipped preset directory diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 1b812b9943..073149934a 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -32,6 +32,7 @@ } }, "dependencies": { + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2396d5725a..2ee97f3d2e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -96,6 +96,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async mount(agentCtx: Context, id?: string): Promise', jsDoc: '/**\n * Compose one agent from a preset, installing it under that agent alone.\n *\n * Call from the agent factory\'s `setup(agentCtx)`; a rejection there rolls\n * the agent creation back, so a broken preset never yields a half-composed\n * session.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the preset that was mounted, for the caller to record.\n * @throws when the preset is unknown or its composition is unusable.\n */', }, + { + signature: 'serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined', + jsDoc: '/**\n * One agent\'s instance of a service its preset mounted.\n *\n * A preset publishes services behind `isolate` realms, which are invisible\n * outside the group that declares them — including to the host. This is how a\n * caller holding the agent reads one anyway: a request that is ABOUT a\n * session but arrives from outside it, which is every browser RPC.\n *\n * Read addressing only. A host row that `inject`s a service cannot use this,\n * because injection resolves before any session exists and has no agent to\n * key by; such a service belongs on the host plane instead.\n * @param agent - the agent whose composition to look inside.\n * @param name - the service name as the preset\'s rows resolve it.\n * @returns the agent\'s instance, or undefined when its preset mounts none.\n */', + }, ], }, { diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 16fc1394fa..9e789c6526 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -110,6 +110,17 @@ export interface PromptAssembly { variables: Record } +/** + * 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_]*$/ @@ -337,8 +348,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 ?? '', }) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 38039f8ab3..8e391c16b8 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -642,7 +642,6 @@ class SubagentSessionOwnership extends Error { } } -/** Requested identity already belongs to a session with another project cwd. */ /** * The requested preset differs from the one this session already runs. * @@ -658,12 +657,16 @@ class AgentPresetConflict extends Error { readonly existingPreset: string | undefined, ) { super( - `session "${sessionId}" already runs agent preset ${JSON.stringify(existingPreset)}; ` + existingPreset === undefined + ? `session "${sessionId}" records no agent preset, so it cannot be adopted under one; ` + + 'a deployment composing no roster records none on any session — ' + : `session "${sessionId}" already runs agent preset ${JSON.stringify(existingPreset)}; ` + `requested ${JSON.stringify(requestedPreset)}. A session's preset is fixed at creation.`, ) } } +/** Requested identity already belongs to a session with another project cwd. */ class SessionCwdConflict extends Error { constructor( readonly sessionId: SessionId, diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 014b7847ca..2eebe7b218 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -153,6 +153,28 @@ describe('session.create with an agent preset', () => { expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined() }) + + it('says why a preset-less session cannot be adopted under one', async () => { + // Two callers reach this: a deployment that composes no roster, and a + // session created before one existed. Both record no preset, so naming + // any is a conflict rather than an adoption — the history was produced + // under a composition this roster cannot name. The message has to say + // that, because "already runs agent preset undefined" reads as a bug. + const { api } = await harness() + await api.sessions.create(request({ sessionId: SessionId('s7') })) + + const response = await api.sessions.create(request({ sessionId: SessionId('s7'), agentPreset: 'standard' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-conflict') + expect(response.result.error.message).toContain('records no agent preset') + expect(response.result.error.details).toEqual({ + sessionId: 's7', + requestedPreset: 'standard', + existingPreset: undefined, + }) + }) }) /** diff --git a/packages/preset/README.i18n.yaml b/packages/preset/README.i18n.yaml index 034d0a90ff..1a789aad5a 100644 --- a/packages/preset/README.i18n.yaml +++ b/packages/preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/README.md -README.md: fcce3013174b5c3b7e545eb48e73cc1d8f4126dc -README.zh.md: 281885daae9aab8dc989c232e751f9a255075b96 +README.md: d2ed10014af506809b5fd117e08b09b45a31a16c +README.zh.md: db7bf18e6ba841cc705eafae7c09d4c5d26581b1 diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index d0cc9e542a..b3056b405e 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: 8c95719bead97845519b7a334603005df201a34d -README.zh.md: c5e2fd3c7896437ddb4a2acdf101cd2e0e0eedaf +README.md: 1592fba1163448aaedbd482c56962fa07ebc897e +README.zh.md: ef4512ab93783674890bc9a848e590792a820e8c diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index e307601a49..6834a24c66 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -61,6 +61,11 @@ class PresetTree extends Include { * shipped composition to `[]` the first time a session ends. Persisting a * preset is also meaningless: nothing here is user state, and the same file * backs every session that names it. + * + * Dropping the write drops the `loader/config-update` the inherited method + * emits with it. Nothing observes one for a preset subtree today, and a + * future "edit your preset while it runs" flow needs a deliberate + * persistence path rather than this method's return. */ override write(): void { } diff --git a/packages/preset/persona/README.i18n.yaml b/packages/preset/persona/README.i18n.yaml index fa0933593c..c4573b49f8 100644 --- a/packages/preset/persona/README.i18n.yaml +++ b/packages/preset/persona/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/persona/README.md -README.md: 3a9f9f2e2debf9d4274949e14913137ef752baae -README.zh.md: 2c3bac3bb4a1fbeeb9c30defb225c2b64cb3e577 +README.md: 789776b32d907f7d217accccbca5508f88de0ed1 +README.zh.md: 4e28d75bbd4fd22b77a0fa3b18c5f19df08588d8 diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index c5e968bd2b..5ec7678d16 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -21,9 +21,7 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { diff --git a/packages/preset/persona/src/index.ts b/packages/preset/persona/src/index.ts index 4bf24aea37..ec56bcc780 100644 --- a/packages/preset/persona/src/index.ts +++ b/packages/preset/persona/src/index.ts @@ -17,11 +17,12 @@ import type { Context } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-system-prompt' -/** The section name this plugin registers; the prompt registry's persona slot. */ -export const PERSONA_SECTION = 'deployment:persona' +// Imported rather than restated: the registry declares the slot this row +// replaces, and two hardcoded copies would drift into a preset whose persona +// silently lands beside the deployment's instead of shadowing it. +import { PERSONA_ORDER, PERSONA_SECTION } from '@deepseek-ai/dsh-system-prompt' -/** Prompt order of the persona slot, matching the registry's own default. */ -export const PERSONA_ORDER = 0 +export { PERSONA_ORDER, PERSONA_SECTION } /** Cordis plugin name. */ export const name = 'persona' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42628a86b9..039c46e36e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1089,6 +1089,9 @@ importers: packages/bundle/web-app: dependencies: + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection From 8d06b2d5766a0faa643d60cef89ea5a9838c5002 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 00:07:11 +0800 Subject: [PATCH 015/293] feat(agent-presets): make the default preset a user setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `config.default` becomes the composition base of an `agent-presets` settings namespace, so the user document layers over the deployment's engineering default and a person can change which preset new sessions get without a restart. The value is read per resolution rather than snapshotted: a hot-reloaded document takes effect on the next session created, and every running session stays on the preset it was composed from — which is the same rule the session-header guard enforces from the other side. `resolve()` read `config.default` directly, which would have made the whole setting inert; it now goes through `defaultId` like every other caller. The write-protection test is rewritten against a temp profile root. It was passing vacuously: the un-overridden Loader REWRITES the composition it read — stamping `disabled: true` onto the self-disposing row — so the committed fixture had been mutated by the very run that proved the bug, and every later run compared against the damaged file and passed. Building the preset in a temp directory makes the assertion immune to its own failure mode, and it now fails with a visible `+ disabled: true` when the override is removed. Review follow-ups on this layer. The exported schema is `AgentPresetSettingsSchema`, symmetric with the `AgentPresetSettings` interface it resolves and self-describing at an import site. The `session.create` JSDoc promised "the deployment's default preset" for an omitted `agentPreset`, which this layer makes false — it now names the effective default. The constructor records why it does not use `installSettingsSection`: that helper re-judges what a consumer DERIVED across attach and detach, and nothing here is derived. The provider-unload test disposes the fiber `ctx.plugin()` handed back instead of reaching into `ctx.reflect.store`, and the write-protection wait says why slack is the right shape for an absence assertion. The real composition covers the layering too. `apps/cli` boots the shipped `cordis.yml`, stores `agent-presets.default`, and asserts an unnamed session composes from it — the package suite proves the layering against a hand-built context, this proves the roster and the settings provider are wired to each other. That test also pins the settings row at a temp file: it defaulted to `$DSH_HOME/settings.yaml`, so a developer's own stored default decided the outcome of a file whose whole point is that only the shipped root does. The Agent Note records the per-resolution read and its correspondence with the session header, and the vacuous-test finding above. --- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +- .../2026-08-03-per-session-agent-presets.md | 6 + ...2026-08-03-per-session-agent-presets.zh.md | 7 + apps/cli/package.json | 1 + apps/cli/tests/web-agent-presets.spec.ts | 57 ++++++- docs/cordis-catalog/services.md | 2 +- docs/module-graph.md | 27 ++-- packages/host/apiproxy/src/api/sessions.ts | 9 +- .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 11 ++ packages/preset/agent-presets/README.zh.md | 11 ++ packages/preset/agent-presets/package.json | 3 + packages/preset/agent-presets/src/index.ts | 47 +++++- .../tests/fixtures/plugins/self-dispose.js | 9 ++ .../preset/agent-presets/tests/mount.spec.ts | 57 ++++++- .../agent-presets/tests/settings.spec.ts | 141 ++++++++++++++++++ packages/preset/agent-presets/tsconfig.json | 3 + pnpm-lock.yaml | 9 ++ 18 files changed, 378 insertions(+), 30 deletions(-) create mode 100644 packages/preset/agent-presets/tests/fixtures/plugins/self-dispose.js create mode 100644 packages/preset/agent-presets/tests/settings.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index db91c82d4c..3d91acee93 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: dbe4188fe16a01b51f01cddf8a2387471e5e00a9 -2026-08-03-per-session-agent-presets.zh.md: aa7792a7db3a686b74f19ace130ad8f7b6d2feb6 +2026-08-03-per-session-agent-presets.md: dbf2f7c3de1447382071ebcfb1d6b9abe9640210 +2026-08-03-per-session-agent-presets.zh.md: e34018f713144c6457c86d5ce4b533106a7e5372 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index dbe4188fe1..dbf2f7c3de 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -25,8 +25,12 @@ Model routing stays out of presets. `installAgentLlmTarget` is already the per-a Mounting is per-session by default. Measured cost for a twelve-row composition is ~3ms and ~600KB per session, so isolation is the cheaper default than any sharing scheme, and a preset authored by a user or by an agent then has the smallest possible blast radius. A preset that genuinely owns an expensive singleton opts into sharing with Cordis's own `isolate` vocabulary: a named realm label is process-global, so two subtrees naming the same label resolve one instance. +Which preset an unnamed session gets is a user setting (`agent-presets.default`) layered over the composition's own `default`, which becomes the `base`. Both layers are needed: the composition value is what a deployment ships and must keep working with no settings provider at all, and the setting is what a person changes without editing a `cordis.yml` they may not own. + ## Consequences +**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session header enforces from the other side — the header records the id a session actually runs, so a resume rebuilds that composition rather than today's default, and the gateway rejects an attempt to adopt a live session under a different one. A snapshot would make the two disagree at exactly the moment the setting changes. + **A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it. **A preset can only name a group because the app registers one.** Sharing a realm across rows is a `cordis:group` row, and a preset living outside this workspace — the authored ones under the Harness home, which is the point — cannot resolve `@cordisjs/plugin-group` by name: Node's upward `node_modules` walk never reaches the harness from there. `boot()` therefore registers `cordis:group` beside `cordis:include` as a loader builtin, so both load through the ambient module pipeline rather than through the included tree's own specifier resolution. Without it the `isolate` vocabulary above is expressible one row at a time only, and a provider could never be grouped with its consumers. @@ -35,6 +39,8 @@ Mounting is per-session by default. Measured cost for a twelve-row composition i **Failure rolls the agent back.** `setup` runs before publication, so a rejected mount fails `ctx.agents.create()` and leaves nothing behind. This is why `setup` is the one supported call site. +**A test that the preset file is never rewritten has to be able to fail.** The first version asserted the file was unchanged after an ordinary mount, and could not have caught anything: the Loader only reaches its write path when it decides the config changed, and nothing in that composition ever self-disposed. The regression plants a row that disposes itself — the shape a real preset hits every time an agent is torn down — and keeps the composition in a temp root rather than under `fixtures/`, because without the override the Loader rewrites the file it read: a committed fixture would be damaged by the very run that proves the bug, and every run after it would compare against the damaged file and pass. + **Fiber membership is object identity, not `uid`.** A `uid` is a per-registry counter, so fibers in two different roots collide on it; comparing by `uid` made one runtime's subtree answer for a service published in another. `ctx.plugin()` returns a thenable `Object.create(fiber)` wrapper that is never identical to the fiber in a parent chain, so the subtree captures its own fiber during construction. **The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index aa7792a7db..e34018f713 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -25,8 +25,13 @@ Status: implemented 挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB,因此隔离比任何共享方案都更划算;而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset,可以用 Cordis 自身的 `isolate` 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。 +未指名 preset 的会话拿到哪一个,是一项用户设置(`agent-presets.default`),叠在组装自身的 `default` 之上——后者成为 `base`。两层都需要:组装里的值是部署交付的东西,在完全没有 settings 提供方时也必须照常工作;而设置是让人不必去改一份可能并不属于自己的 `cordis.yml` 就能调整的东西。 + ## 后果 +**有效默认值在每次解析时读取,从不快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session header 从另一侧执行的同一条——header 记录会话实际运行的 id,因此恢复重建的是那份组装而不是当下的默认值,网关也会拒绝把一个活着的会话收编到另一个 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。 + + **直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。 **preset 能写出 group,是因为 app 注册了它。** 跨行共享 realm 就是一个 `cordis:group` 行,而住在本工作区之外的 preset——也就是 Harness home 下由人或 agent 创作的那些,正是这套设计的目的——无法按名字解析 `@cordisjs/plugin-group`:Node 向上查找 `node_modules` 的路径从那里永远走不到 harness。因此 `boot()` 把 `cordis:group` 与 `cordis:include` 并排注册为 loader builtin,两者都经由环境模块管线加载,而不依赖被包含树自身的说明符解析。没有它,上文那套 `isolate` 词汇就只能一行一行地表达,提供方也永远无法与它的消费方归入同一组。 @@ -35,6 +40,8 @@ Status: implemented **失败会让 agent 回滚。** `setup` 在发布之前运行,因此挂载被拒绝会让 `ctx.agents.create()` 失败且不留残留。这正是 `setup` 是唯一受支持调用点的原因。 +**「preset 文件从不被回写」这条断言,必须先有失败的可能。** 最初那版在一次普通挂载之后断言文件未变,其实什么也抓不到:Loader 只在认定 config 变了时才会走到写路径,而那份组装里没有任何一行会自行销毁。回归用例改为植入一个自行销毁的行——真实 preset 在每次 agent 被拆除时都会命中的形状——并把组装放在临时根目录而不是 `fixtures/` 下:没有那个覆写,Loader 会回写它读入的文件,于是提交进仓库的 fixture 会被**恰恰是证明该缺陷的那次运行**改坏,之后每一次运行都拿改坏后的文件作比较从而通过。 + **fiber 归属判定用对象同一性,而非 `uid`。** `uid` 是按 registry 计数的序号,因此两个不同根下的 fiber 会在它上面撞号;按 `uid` 比较曾导致一个运行时的子树为另一个运行时中发布的服务背锅。`ctx.plugin()` 返回的是 thenable 的 `Object.create(fiber)` 包装对象,与父链中出现的 fiber 永远不同一,因此子树在构造时捕获自己的 fiber。 **preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 87677ce1f9..0220656cc2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -40,6 +40,7 @@ "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@types/js-yaml": "^4.0.9", diff --git a/apps/cli/tests/web-agent-presets.spec.ts b/apps/cli/tests/web-agent-presets.spec.ts index 4c2d7484ee..5b946d561c 100644 --- a/apps/cli/tests/web-agent-presets.spec.ts +++ b/apps/cli/tests/web-agent-presets.spec.ts @@ -1,4 +1,5 @@ -import { readFile } from 'node:fs/promises' +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import { Context } from 'cordis' @@ -7,7 +8,8 @@ import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@cordisjs/plugin-include' import { beforeAll, describe, expect, it } from 'vitest' -import type {} from '@deepseek-ai/dsh-agent-presets' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-tools' const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url)) @@ -19,9 +21,15 @@ const WEB_OVERLAY = join(CONFIG_DIR, 'web.cordis.yml') * touch the network, or write outside the test. Everything that decides an * agent's capabilities is the real thing, including both shipped presets. */ -async function bootWeb(): Promise { +async function bootWeb(settingsFile: string): Promise { const patches: PatchOptions[] = [ ...loadOverlayPatches('dsh-test', WEB_OVERLAY), + // The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it + // reads the developer's own document — and since the default preset is a + // setting, a stored `agent-presets.default` would decide this file's + // outcome. Point it at a temp file for the same reason the roster below + // names only the shipped root. + { id: 'settings', config: { path: settingsFile, watch: false } }, // Host rows with side effects outside this process: a bound port, a // served asset tree, a telemetry exporter. { id: 'webserver', disabled: true }, @@ -37,6 +45,8 @@ async function bootWeb(): Promise { { id: 'directory-picker', disabled: true }, // The roster AppCLIEntry would patch in; only the shipped root, so a // developer's own `~/.dsh/.preset` cannot change this test's outcome. + // `default` here is the COMPOSITION default — the base layer the settings + // document overrides. { id: 'agent-presets', config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] }, @@ -50,7 +60,9 @@ const toolNames = (ctx: Context, agent?: Agent): string[] => let ctx: Context beforeAll(async () => { - ctx = await bootWeb() + const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml') + await writeFile(settingsFile, '{}\n') + ctx = await bootWeb(settingsFile) }, 120_000) describe('the shipped Web composition', () => { @@ -195,6 +207,43 @@ describe('a forked session', () => { }) }) +/** + * Which preset an unnamed session gets is a user setting layered over the + * composition's own default. The package suite proves the layering against a + * hand-built context; this proves it through the shipped `cordis.yml` — that + * the roster and the settings provider are actually wired to each other, and + * that the id the setting names is the one a session composes from. + */ +describe('the default preset as a user setting', () => { + it('composes an unnamed session from the stored default, not the composed one', async () => { + expect(ctx.agentPresets.defaultId).toBe('standard') + + await ctx.settings.update(settingsNamespace(SETTINGS_NAMESPACE), { default: 'core-web' }) + try { + expect(ctx.agentPresets.defaultId).toBe('core-web') + + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-user-default'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + // `mount()` with no id resolves the effective default. Two tools, not + // `standard`'s catalog: the setting decided the composition. + expect(toolNames(ctx, handle.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor']) + } finally { + await handle.dispose() + } + } finally { + // The context is shared with the rest of the file. `replace({})` drops + // the user section wholesale so the field re-inherits the composition + // base; `update` merges, and would leave the override standing. + await ctx.settings.replace(settingsNamespace(SETTINGS_NAMESPACE), {}) + } + + expect(ctx.agentPresets.defaultId).toBe('standard') + }) +}) + describe('a session keeps the preset it was created with', () => { it('records the preset the gateway guard reads', async () => { const handle = await ctx.agents.create({ diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 646440c758..190f054be3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -98,7 +98,7 @@ async mount(agentCtx: Context, id?: string): Promise serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined ``` -Source: [`packages/preset/agent-presets/src/index.ts:39`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:54`](../../packages/preset/agent-presets/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/docs/module-graph.md b/docs/module-graph.md index aa9a8bc39d..48f45481a7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -333,9 +333,6 @@ flowchart TD pkg_credentials --> pkg_invariants pkg_frontend_static --> pkg_host_webserver pkg_frontend_static --> pkg_invariants - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_paths - pkg_agent_presets --> pkg_scope pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_helper --> pkg_subprocess @@ -414,11 +411,13 @@ flowchart TD pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_paths + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_settings pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm pkg_settings_local --> pkg_atomic_write @@ -460,11 +459,6 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_client_web_react - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_client_ui_theme @@ -478,6 +472,8 @@ flowchart TD pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker_browse --> pkg_client_locale pkg_host_directory_picker_browse --> pkg_client_runtime pkg_host_directory_picker_browse --> pkg_client_ui_primitives @@ -575,6 +571,11 @@ flowchart TD pkg_headless --> pkg_host_webserver pkg_headless --> pkg_invariants pkg_headless --> pkg_session + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_host_apiproxy + pkg_client_test_runtime --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -1140,7 +1141,6 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | @@ -1162,8 +1162,8 @@ flowchart TD | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`settings`](../packages/settings/settings) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | @@ -1177,10 +1177,10 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1205,6 +1205,7 @@ flowchart TD | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index aeb3e933db..0427be392a 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -201,10 +201,11 @@ export interface SessionsApi { * returns `workspace-attach-failed` with the published session id. * * `agentPreset` names the composition the new session's agent is built - * from; omitted, the deployment's default preset applies. The resolved id - * is stored on the session header, so a later resume rebuilds the same - * agent. An unknown id fails with `agent-preset-not-found`, and a preset - * whose composition cannot be mounted fails with `agent-preset-invalid`. + * from; omitted, the effective default applies — the user's stored choice + * where one exists, else the deployment's own. The resolved id is stored on + * the session header, so a later resume rebuilds the same agent. An unknown + * id fails with `agent-preset-not-found`, and a preset whose composition + * cannot be mounted fails with `agent-preset-invalid`. */ create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId; agentPreset?: string }>): Promise> diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index b3056b405e..f9f14e1779 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: 1592fba1163448aaedbd482c56962fa07ebc897e -README.zh.md: ef4512ab93783674890bc9a848e590792a820e8c +README.md: 5e785b747209d4c0cedaebbd3a90ba1b46dcd1c6 +README.zh.md: 4c40d7b7bfabb83dba2859251ad189a2646170c6 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 1592fba116..5e785b7472 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -30,6 +30,17 @@ The agent factory's `setup(agentCtx)` hook is the one supported call site. Only An absent root supplies no presets rather than failing: the user root does not exist until the first locally authored preset, and naming a default no root supplies already fails loud at resolution. +### The default preset is a user setting + +When a settings provider is composed, this plugin registers the `agent-presets` namespace with `config.default` as its composition base, so the user document layers over the deployment's engineering default: + +```yaml +agent-presets: + default: core-web +``` + +The value is read per resolution rather than snapshotted, so a hot-reloaded document takes effect on the next session created and every running session stays on the preset it was composed from. Clearing the user field re-inherits the composition default. A default naming a preset no root supplies is stored without complaint and fails at the next `resolve()` — the roster is a live directory, so a name absent now may exist by the time a session asks for it. + ## What a mount rejects A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it. `mount()` therefore proves the result usable itself, and rejects three things. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index ef4512ab93..4c40d7b7bf 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -30,6 +30,17 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有 根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。 +### 默认 preset 是一项用户设置 + +当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其组装 base,因此用户文档会层叠覆盖部署方的工程默认值: + +```yaml +agent-presets: + default: core-web +``` + +该值在每次解析时读取而非快照,因此热重载的文档对**此后创建**的会话生效,而每个运行中的会话仍停留在它当初据以组装的 preset 上。清空用户字段即重新继承组装默认值。若默认值指向没有任何根目录提供的 preset,写入时不会报错,而在下一次 `resolve()` 时失败——名单是一个活动目录,此刻不存在的名字,等到某个会话真正索取时可能已经存在。 + ## 挂载会拒绝什么 直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有任何启动审计能覆盖它。`mount()` 因此自行校验结果可用,并拒绝三种情况。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index 1411542d7c..b856b5208e 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -45,6 +46,8 @@ "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-settings-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 69e59d1fd0..6914445f08 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -12,10 +12,25 @@ import { Context, Service } from 'cordis' import z from 'schemastery' +import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings' import { discoverPresets } from './discovery.ts' import { mountPreset, serviceForAgent } from './mount.ts' import { UnknownPresetError, type AgentPreset, type Config } from './types.ts' +/** Settings namespace carrying the user's chosen default preset. */ +export const SETTINGS_NAMESPACE = 'agent-presets' + +/** The user-writable slice of this plugin's config. */ +export interface AgentPresetSettings { + /** Preset mounted when a session names none. */ + default?: string +} + +/** Runtime schema for the user-writable slice. */ +export const AgentPresetSettingsSchema: z = z.object({ + default: z.string(), +}) + export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' export { inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, type PresetMount, @@ -48,13 +63,39 @@ export class AgentPresets extends Service { })).default([]), }) as z + /** + * The user layer over `config.default`, present only while a settings + * provider is composed. Held rather than snapshotted so a hot-reloaded + * document takes effect without a restart. + */ + private settings: SettingsScope | undefined + constructor(ctx: Context, public config: Config) { super(ctx, 'agentPresets') + // Deliberately not `installSettingsSection`: that helper exists to re-judge + // what a consumer DERIVED from the source — memoized resolutions, + // registration-level facts — across attach, detach, and change. Nothing + // here is derived. `defaultId` reads through on every call, so both of its + // hooks would be no-ops and the source thunk would restate this field. + ctx.inject(['settings'], (settingsCtx) => { + this.settings = settingsCtx.settings.register( + settingsNamespace(SETTINGS_NAMESPACE), + AgentPresetSettingsSchema, + { base: { default: config.default } }, + ) + settingsCtx.effect(() => () => { this.settings = undefined }, 'agentPresets.settings()') + }) } - /** The preset id mounted when a caller names none. */ + /** + * The preset id mounted when a caller names none. + * + * Read per call rather than cached: the settings document is hot-reloaded, so + * changing the default takes effect on the next session created and leaves + * every running session on the preset it was composed from. + */ get defaultId(): string { - return this.config.default + return this.settings?.get().default ?? this.config.default } /** @@ -72,7 +113,7 @@ export class AgentPresets extends Service { * @throws when no configured root supplies that id. */ async resolve(id?: string): Promise { - const wanted = id ?? this.config.default + const wanted = id ?? this.defaultId const presets = await this.list() const found = presets.find(preset => preset.id === wanted) if (found === undefined) { diff --git a/packages/preset/agent-presets/tests/fixtures/plugins/self-dispose.js b/packages/preset/agent-presets/tests/fixtures/plugins/self-dispose.js new file mode 100644 index 0000000000..97c01f95a4 --- /dev/null +++ b/packages/preset/agent-presets/tests/fixtures/plugins/self-dispose.js @@ -0,0 +1,9 @@ +// Disposes itself once active. The Loader treats a self-disposing entry as a +// config change and writes the tree back through `EntryTree.write()`, which is +// the exact path that once truncated a preset file to `[]`. +export const name = 'self-dispose' +export function apply(ctx) { + globalThis.__SELF_DISPOSED__ = new Promise((resolve) => { + setTimeout(() => { ctx.fiber.dispose(); resolve(undefined) }, 0) + }) +} diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 3ece3db20e..e322ceb7ad 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -1,3 +1,5 @@ +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { Context } from 'cordis' @@ -10,7 +12,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { beforeEach, describe, expect, it } from 'vitest' -import AgentPresets, { leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets' +import AgentPresets, { COMPOSITION_FILE, leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets' declare module 'cordis' { interface Context { @@ -232,3 +234,56 @@ describe('attributing a service to a subtree', () => { expect(leakedServices(ctx, mount!.fiber)).toEqual([]) }) }) + +describe('the preset file is an input, never a persistence target', () => { + it('survives a row that disposes itself, which makes the Loader persist a tree', async () => { + // The preset lives in a temp root, not under `fixtures/`: without the + // `write()` override the Loader REWRITES the composition it read, so a + // committed fixture would be mutated by the very run that proves the bug + // and every later run would compare against the damaged file and pass. + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-write-')) + const dir = join(root, 'self-disposing') + await mkdir(dir) + const path = join(dir, COMPOSITION_FILE) + const composition = [ + '- id: tool-kept', + ` name: ${join(FIXTURES, 'plugins', 'contribute.js')}`, + ' config:', + ' tool: kept', + '- id: goes-away', + ` name: ${join(FIXTURES, 'plugins', 'self-dispose.js')}`, + '', + ].join('\n') + await writeFile(path, composition) + + const scoped = new Context() + scoped.baseUrl = pathToFileURL(FIXTURES).href + '/' + await scoped.plugin(Loader) + scoped.loader.builtins.include = Include + await scoped.plugin(LlmService) + await scoped.plugin(SessionStore) + await scoped.plugin(SystemPrompt, { persona: '' }) + await scoped.plugin(ToolRegistry) + await scoped.plugin(AgentRegistry) + await scoped.plugin(AgentLoop, { agents: [] }) + await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }] }) + + await scoped.agents.create({ + sessionId: SessionId('sess-self-dispose'), + setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx), + }) + await (globalThis as { __SELF_DISPOSED__?: Promise }).__SELF_DISPOSED__ + // Slack past the deterministic signal above, not a race the number has to + // win. The write rides the Loader's fiber-unload listener, which stamps + // `disabled: true` and calls `write()` in the same synchronous step; once + // the self-dispose has settled, a regression has already written. Polling + // would not help — the assertion is an ABSENCE, and no amount of waiting + // proves one — so the wait only has to clear settlement. + await new Promise(resolve => setTimeout(resolve, 50)) + + // Inherited, `EntryTree.write()` persists the dying tree — stamping + // `disabled: true` onto the row and, in the shipped case, truncating the + // composition every session shares. + expect(await readFile(path, 'utf8')).toBe(composition) + }) +}) diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts new file mode 100644 index 0000000000..a586717c1d --- /dev/null +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -0,0 +1,141 @@ +/** + * The default preset is a user setting. `config.default` is the deployment's + * engineering default; the settings document overrides it and is hot-reloaded, + * so a person can change which preset new sessions get without a restart. + */ + +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SettingsLocal from '@deepseek-ai/dsh-settings-local' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { describe, expect, it } from 'vitest' +import AgentPresets, { SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const ROOTS = [{ path: join(FIXTURES, 'system'), trust: 'system' as const }] +const NS = settingsNamespace(SETTINGS_NAMESPACE) + +/** + * A composition with a real file-backed settings provider. `settingsFiber` is + * the provider's own handle, so a test can take it away the way a reload does. + */ +async function harness(): Promise<{ ctx: Context; settingsFile: string; settingsFiber: { dispose: () => unknown } }> { + const home = await mkdtemp(join(tmpdir(), 'dsh-preset-settings-')) + const settingsFile = join(home, 'settings.yaml') + await writeFile(settingsFile, '{}\n') + + const ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + const settingsFiber = ctx.plugin(SettingsLocal, { path: settingsFile, watch: false }) + await settingsFiber + await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS }) + return { ctx, settingsFile, settingsFiber } +} + +const toolNames = (ctx: Context, agent?: unknown): string[] => + ctx.tools.schemas(agent as never).map(schema => schema.name).sort() + +describe('the default preset as a user setting', () => { + it('falls back to the composition default while the user set none', async () => { + const { ctx } = await harness() + + expect(ctx.agentPresets.defaultId).toBe('standard') + }) + + it('takes the user default over the composition default', async () => { + const { ctx } = await harness() + + await ctx.settings.update(NS, { default: 'minimal' }) + + expect(ctx.agentPresets.defaultId).toBe('minimal') + }) + + it('composes a new session from the user default', async () => { + const { ctx } = await harness() + await ctx.settings.update(NS, { default: 'minimal' }) + + const handle = await ctx.agents.create({ + sessionId: SessionId('settings-default'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx), + }) + try { + expect(toolNames(ctx, handle.agent)).toEqual(['beta']) + } finally { + await handle.dispose() + } + }) + + it('leaves a running session on the preset it was composed from', async () => { + const { ctx } = await harness() + const running = await ctx.agents.create({ + sessionId: SessionId('settings-running'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx), + }) + try { + expect(toolNames(ctx, running.agent)).toEqual(['alpha']) + + // Changing the default mid-flight must not reach an agent that already + // composed: its history was produced under `standard`'s tools. + await ctx.settings.update(NS, { default: 'minimal' }) + + expect(ctx.agentPresets.defaultId).toBe('minimal') + expect(toolNames(ctx, running.agent)).toEqual(['alpha']) + } finally { + await running.dispose() + } + }) + + it('re-inherits the composition default when the user setting is cleared', async () => { + const { ctx } = await harness() + await ctx.settings.update(NS, { default: 'minimal' }) + expect(ctx.agentPresets.defaultId).toBe('minimal') + + await ctx.settings.replace(NS, {}) + + expect(ctx.agentPresets.defaultId).toBe('standard') + }) + + it('reports an unknown user default only when a session tries to use it', async () => { + const { ctx } = await harness() + + // Storing it succeeds — the roster is a live directory, so a name that is + // absent now may exist by the time a session asks for it. + await ctx.settings.update(NS, { default: 'no-such-preset' }) + + await expect(ctx.agentPresets.resolve()) + .rejects.toThrow(/preset "no-such-preset" not found/) + }) +}) + +describe('a settings provider that goes away', () => { + it('falls back to the composition default when the provider unloads', async () => { + const { ctx, settingsFiber } = await harness() + await ctx.settings.update(NS, { default: 'minimal' }) + expect(ctx.agentPresets.defaultId).toBe('minimal') + + // Unloading the provider takes the user layer with it; the roster keeps + // working on its composition default rather than holding a stale override. + await settingsFiber.dispose() + + expect(ctx.agentPresets.defaultId).toBe('standard') + }) +}) diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json index d8494fdfd5..a76cc5b77b 100644 --- a/packages/preset/agent-presets/tsconfig.json +++ b/packages/preset/agent-presets/tsconfig.json @@ -21,6 +21,9 @@ { "path": "../../core/scope" }, + { + "path": "../../settings/settings" + }, { "path": "../../util/paths" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 039c46e36e..1d8a398a9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,6 +204,9 @@ importers: '@deepseek-ai/dsh-loader-smoke': specifier: workspace:^ version: link:../../packages/support/loader-smoke + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../packages/settings/settings '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../packages/core/system-prompt @@ -4202,6 +4205,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + '@deepseek-ai/dsh-settings-local': + specifier: workspace:^ + version: link:../../settings/settings-local '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt From 8ff75622c601ab156f59aac70fd6a86d31d7b4f6 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 7 Aug 2026 00:15:55 +0800 Subject: [PATCH 016/293] feat(bundle): default Windows hosts to the pwsh shell stack win32 hosts booting a shipped profile now get pwsh-local as the ctx.bash executor and tool-pwsh as the shell tool through the base bundle's new windows.cordis.patch.yml platform layer, injected by the launcher between the bundle layers and the user layers on win32. bash-sandbox, tool-bash, permission, and ui-permission are disabled there: the POSIX-only executor cannot run on Windows, and dsh-permission requires a confining executor. Overriding the default is a composition decision through the user's cordis.patch.yml; there is no environment override channel. apps/cli and dsh-base re-declare dsh-pwsh-local/dsh-tool-pwsh so the profile module fallback links them for cold starts (the profiles rework had dropped them from the CLI closure). Promotes the windows-pwsh-default Agent Note from proposed to implemented and documents the platform layer in the base bundle README. --- ...026-08-01-pwsh-tool-and-executor.i18n.yaml | 4 +- .../2026-08-01-pwsh-tool-and-executor.md | 2 +- .../2026-08-01-pwsh-tool-and-executor.zh.md | 2 +- .../2026-08-01-windows-pwsh-default.i18n.yaml | 6 ++ .../2026-08-01-windows-pwsh-default.md | 42 ++++++++++++ .../2026-08-01-windows-pwsh-default.zh.md | 42 ++++++++++++ .../2026-08-05-pwsh-ui-bash-parity.i18n.yaml | 4 +- .../feature/2026-08-05-pwsh-ui-bash-parity.md | 2 +- .../2026-08-05-pwsh-ui-bash-parity.zh.md | 2 +- .../2026-08-01-windows-pwsh-default.i18n.yaml | 6 -- .../2026-08-01-windows-pwsh-default.md | 39 ----------- .../2026-08-01-windows-pwsh-default.zh.md | 39 ----------- apps/cli/package.json | 2 + apps/cli/src/dump-config.ts | 7 ++ apps/cli/src/profile-boot.ts | 28 +++++--- apps/cli/src/windows-shell.ts | 55 ++++++++++++++++ apps/cli/tests/windows-shell.spec.ts | 64 +++++++++++++++++++ packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 5 +- packages/bundle/base/README.zh.md | 5 +- packages/bundle/base/package.json | 4 ++ packages/bundle/base/windows.cordis.patch.yml | 34 ++++++++++ pnpm-lock.yaml | 12 ++++ 23 files changed, 306 insertions(+), 104 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md create mode 100644 .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md delete mode 100644 .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml delete mode 100644 .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md delete mode 100644 .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md create mode 100644 apps/cli/src/windows-shell.ts create mode 100644 apps/cli/tests/windows-shell.spec.ts create mode 100644 packages/bundle/base/windows.cordis.patch.yml diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml index d4ec255235..efd7d56dae 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md -2026-08-01-pwsh-tool-and-executor.md: 7206f8ffe6640f8499f8453c40ab5846b23112c6 -2026-08-01-pwsh-tool-and-executor.zh.md: 5a48adb79fed209d2d2ecb9514fd51538491f04c +2026-08-01-pwsh-tool-and-executor.md: 77f0a13d0efa55c91b4e58ee2474ac6877947c80 +2026-08-01-pwsh-tool-and-executor.zh.md: 95c0d8f087f11d192350cf92ed866bb8a4ea33b4 diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md index 7206f8ffe6..77f0a13d0e 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md @@ -17,7 +17,7 @@ Two new packages under `packages/bash/`: Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines. -The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [a proposal](../../proposed/feature/2026-08-01-windows-pwsh-default.md). +The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [the Windows pwsh default decision](2026-08-01-windows-pwsh-default.md). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md index 5a48adb79f..95c0d8f087 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md @@ -17,7 +17,7 @@ harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能 Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。 -本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash)与 pwsh TUI/GUI 渲染——另行记录为[提案](../../proposed/feature/2026-08-01-windows-pwsh-default.md)。 +本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash)与 pwsh TUI/GUI 渲染——已落地为 [Windows 默认 pwsh 决策](2026-08-01-windows-pwsh-default.md)。 ## 备选方案 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml new file mode 100644 index 0000000000..e26d1ca354 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +2026-08-01-windows-pwsh-default.md: 1fa30ef757ccb81544479a08887355839fb86d46 +2026-08-01-windows-pwsh-default.zh.md: c6e1361dba29cff8340ef6e26bf071f81544642b diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md new file mode 100644 index 0000000000..1fa30ef757 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -0,0 +1,42 @@ +# Agent Note: Windows defaults to pwsh + +Status: implemented + +English | [中文](2026-08-01-windows-pwsh-default.zh.md) + +## Problem + +The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior (hardcoded `bash -c` argv, process-group semantics); the model-facing bash tool teaches the bash dialect. The Windows-native foundation shipped in the [pwsh executor and tool decision](2026-08-01-pwsh-tool-and-executor.md) — a PowerShell implementation of the `ctx.bash` seam and a parity `pwsh` tool — but shipped compositions still mounted the bash stack on Windows, so a Windows host without a shim could not run the shipped shell. + +## Decision + +Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged. + +- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool), disables `permission`/`ui-permission` (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined pwsh executor cannot honor; see its constructor guard — and the client knob would advertise a shell it cannot enforce), and inserts `pwsh-local`/`tool-pwsh`. The fs tools keep the sandbox policy and approval service, so file confinement and escalation still apply on Windows. +- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. +- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`; the base bundle lists every row plugin as a dependency by house style. + +The pwsh GUI rendering stage (stage 2 of the original roadmap) shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior. + +## Alternatives considered + +**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config. + +**Ship the platform layer from `apps/cli` code instead of a bundle data file.** Rejected: the patch belongs next to the rows it replaces, in the bundle that owns them, so the shipped roster stays visible as composition data and dumps carry its provenance; the launcher contributes only the win32 gate. + +**Keep `permission`/`ui-permission` on Windows.** Rejected: `dsh-permission` hard-requires `ctx.bash.sandboxMode` and fails loud at load over an unconfined executor; making it tolerate an unconfined shell would advertise presets the shell cannot honor. File confinement keeps working through the fs stack. + +**Ship a `DSH_WINDOWS_SHELL` environment escape hatch.** Rejected: decisive behavior changes belong in composition config, which already overrides the platform layer row by id; a second override channel would split the single source of truth for roster decisions. + +## Consequences + +- A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). +- POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows. +- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — composition config is the one override channel. +- The permission switcher leaves the Windows roster; session permission facts pin the composition defaults through the approval service and sandbox policy. + +## Verification + +- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure, with the platform injected. +- Keyless: a win32 `dsh --profile --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged. +- The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes). diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md new file mode 100644 index 0000000000..c6e1361dba --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -0,0 +1,42 @@ +# Agent Note: Windows 默认改用 pwsh + +Status: implemented + +[English](2026-08-01-windows-pwsh-default.md) | 中文 + +## 问题 + +harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片(WSL 或 Git-Bash),或退回到仅 POSIX 的 `dsh-bash-local` 行为(硬编码 `bash -c` argv、进程组语义);面向模型的 bash 工具教的是 bash 方言。Windows 原生基础已随 [pwsh 执行器与工具决策](2026-08-01-pwsh-tool-and-executor.md) 交付——`ctx.bash` seam 的 PowerShell 实现与对等的 `pwsh` 工具——但交付组合在 Windows 上仍然挂载 bash 栈,没有垫片的 Windows 主机跑不了交付的 shell。 + +## 决策 + +启动交付 profile(`dsh web`、`dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈;POSIX 主机不变。 + +- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)、禁用 `permission`/`ui-permission`(dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制 pwsh 执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个它无法强制执行的 shell),并插入 `pwsh-local`/`tool-pwsh`。fs 工具保留 sandbox 策略与批准服务,因此 Windows 上的文件限制与升级仍然生效。 +- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 +- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh`;按仓库惯例,base bundle 把每个行插件都列为依赖。 + +原路线图的阶段 2(pwsh GUI 渲染)已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。 + +## 备选方案 + +**在 `dsh-bash-local` 内部让 Windows 默认 pwsh(一个执行器,方言开关)。** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell,而按平台门控的组合是部署选择,不是执行器配置。 + +**从 `apps/cli` 代码而非 bundle 数据文件交付平台层。** 否决:patch 应放在它替换的行旁边、属于拥有这些行的 bundle,让交付清单作为组合数据保持可见、转储带有出处;启动器只贡献 win32 门控。 + +**在 Windows 上保留 `permission`/`ui-permission`。** 否决:`dsh-permission` 硬性要求 `ctx.bash.sandboxMode`,在无限制执行器上加载即 fail loud;让它容忍无限制 shell 会宣传 shell 无法兑现的 preset。文件限制继续经由 fs 栈生效。 + +**交付 `DSH_WINDOWS_SHELL` 环境变量逃生门。** 否决:决定性的行为变更应集中在组合配置中,而组合配置已能按行 id 覆盖平台层;第二条覆盖通道会分裂清单决策的单一事实来源。 + +## 后果 + +- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 +- POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。 +- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——组合配置是唯一的覆盖通道。 +- 权限切换器离开 Windows 清单;会话权限事实通过批准服务与 sandbox 策略固定组合默认值。 + +## 验证 + +- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过与缺文件失败,平台注入。 +- Keyless:win32 上的 `dsh --profile --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。 +- 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml index dcb3a9406b..204560cfb5 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md -2026-08-05-pwsh-ui-bash-parity.md: 6bbdb0e6bc69ef1af03a6a9146f83b84754cb2a6 -2026-08-05-pwsh-ui-bash-parity.zh.md: 75f3a3ddec002acaa1755c81114b0f122ab80593 +2026-08-05-pwsh-ui-bash-parity.md: 23693c833a779da7a0f52ab1c959bf7eb8649310 +2026-08-05-pwsh-ui-bash-parity.zh.md: ef44e9ff043c0a533d2cc34bb776c6eb3f0566f8 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md index 6bbdb0e6bc..23693c833a 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md @@ -6,7 +6,7 @@ English | [中文](2026-08-05-pwsh-ui-bash-parity.zh.md) ## Problem -The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2 — but the TUI package was removed ([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)), leaving the Web surface as the only UI the gap affects. +The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2 — but the TUI package was removed ([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)), leaving the Web surface as the only UI the gap affects. ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md index 75f3a3ddec..ef44e9ff04 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2——但 TUI 包已被移除([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)),Web 表面成为该缺口唯一影响的 UI。 +[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2——但 TUI 包已被移除([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)),Web 表面成为该缺口唯一影响的 UI。 ## Decision diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml deleted file mode 100644 index fa7ea8e141..0000000000 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: 1c3ccef23bb5cd9bc37237bd69aac2e2c56649a8 -2026-08-01-windows-pwsh-default.zh.md: 3958d21eb8a9d306009b11d6e9806a1654a8958e diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md deleted file mode 100644 index 1c3ccef23b..0000000000 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Windows defaults to pwsh (roadmap) - -Status: proposed - -English | [中文](2026-08-01-windows-pwsh-default.zh.md) - -## Problem - -The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior; the model-facing bash tool teaches the bash dialect, and the TUI/Web surfaces render terminal output in bash-shaped expectations. The first Windows-native foundation shipped in the [pwsh executor and tool decision](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md): a PowerShell implementation of the `ctx.bash` seam and a parity `pwsh` tool — but nothing yet defaults Windows hosts to them. - -## Proposal - -Two follow-up stages, each independently shippable. The former stage 2 (bash-tool parity twin) shipped with the [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md): `tool-pwsh` now mirrors `tool-bash` for foreground and background work minus the sandbox surface, shares the `DSH_*` environment through `dsh-bash-env`, and carries a keyless application snapshot of its assembled surface. - -1. **Windows default composition** — the shipped CLI compositions mount `dsh-pwsh-local` as the `ctx.bash` executor and `dsh-tool-pwsh` as the model-facing shell tool on Windows hosts (bash unmounted there), while POSIX hosts keep the bash stack. This is a composition/roster decision in `base.cordis.yml` and the surface overlays, gated by platform; it makes the shipped Windows experience PowerShell-native end to end. -2. **pwsh GUI rendering** — the Web surface renders pwsh calls with the bash-shaped terminal presentation (terminal card with exit-status pill), the counterpart of the bash terminal cards. Shipped in the [pwsh UI presentation matches bash decision](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) with a keyless web lane; the TUI was removed, so no terminal twin remains. A PowerShell-aware presentation beyond bash parity (native path display, `$env:` facts) remains unclaimed. - -The stages are ordered by dependency only where one exists: the rendering stage shipped first with the [pwsh UI presentation matches bash decision](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) because it is platform-independent and its keyless web lane runs on any host, while the Windows default composition remains the only unshipped stage. Nothing in this proposal changes POSIX behavior. - -## Alternatives considered - -**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config. - -**Ship the Windows default in the same change as the executor/tool.** Rejected: the roster change needs its own evidence (what breaks when the shipped Windows tree stops mounting bash, which tools depend on bash semantics), and it belongs to a composition decision with the approval/PTY surface visible. - -**Keep bash on Windows via a shim and skip PowerShell defaults.** Rejected: it perpetuates the install-tax and the dialect mismatch the roadmap exists to remove; the shim is a deployment requirement, not a product behavior. - -## Acceptance criteria - -- A Windows host running the shipped `dsh` TUI/Web gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration, and `bash` is absent from the model-visible roster there. -- POSIX hosts are byte-for-byte unaffected (same roster, same executor). -- The shipped-composition e2es assert the platform-gated roster on both families. -- Stage 1 lands with the keyless pwsh-tool snapshot already in place from the parity change; stage 2 landed with the web `pwsh-terminal` rendering lane (the TUI's removal left no terminal surface to snapshot). - -## Risks - -- **Bash-dependent composition rows** — any shipped plugin that assumes `bash` semantics (hook bridges executing shell hooks, workspace tooling) must be audited per stage; the audit may force a staged rollout rather than one switch. -- **Windows CI coverage gap** — unit coverage runs on Linux; Windows-only regressions in the pwsh stack surface through the Windows build/static lane and e2es, which must be extended per stage rather than assumed. -- **Rendering conventions** — the bash-shaped terminal twin shipped with the Web lane; a PowerShell-aware presentation beyond bash parity (native path display, `$env:` facts) remains a UI design decision with snapshot surface, deferred with stage 1. diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md deleted file mode 100644 index 3958d21eb8..0000000000 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Windows 默认改用 pwsh(路线图) - -Status: proposed - -[English](2026-08-01-windows-pwsh-default.md) | 中文 - -## 问题 - -harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片(WSL 或 Git-Bash),或退回到仅 POSIX 的 `dsh-bash-local` 行为;面向模型的 bash 工具教的是 bash 方言,TUI/Web 表面以 bash 形状的预期渲染终端输出。第一块 Windows 原生基础已随 [pwsh 执行器与工具决策](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 交付:`ctx.bash` seam 的 PowerShell 实现与对等的 `pwsh` 工具——但还没有任何东西让 Windows 主机默认使用它们。 - -## 提案 - -两个阶段,各自可独立交付。原阶段 2(bash 工具对等孪生)已随 [pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 交付:`tool-pwsh` 现在在前台与后台工作(减 sandbox 面)上镜像 `tool-bash`,通过 `dsh-bash-env` 共享 `DSH_*` 环境,并携带其组装表面的 keyless 应用快照。 - -1. **Windows 默认组合**——交付的 CLI 组合在 Windows 主机上挂载 `dsh-pwsh-local` 作为 `ctx.bash` 执行器、`dsh-tool-pwsh` 作为面向模型的 shell 工具(那里不挂载 bash),POSIX 主机保持 bash 栈。这是 `base.cordis.yml` 与 surface 覆盖层里按平台门控的组合/清单决策;它让交付的 Windows 体验端到端 PowerShell 原生。 -2. **pwsh GUI 渲染**——Web 表面以 bash 形状的终端呈现渲染 pwsh 调用(带退出状态 pill 的 terminal 卡),即 bash 终端卡片的对应物。已随 [pwsh UI 呈现与 bash 对齐决策](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) 及 keyless web 通道交付;TUI 已移除,不再有终端孪生。超出 bash 对齐的 PowerShell 感知呈现(原生路径显示、`$env:` 实情)仍无人认领。 - -各阶段仅在有依赖关系时排序:渲染阶段已随 [pwsh UI 呈现与 bash 对齐决策](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) 先行交付(平台无关,其 keyless web 通道可在任意宿主运行),而 Windows 默认组合仍是唯一未交付的阶段。本提案不改变任何 POSIX 行为。 - -## 备选方案 - -**在 `dsh-bash-local` 内部让 Windows 默认 pwsh(一个执行器,方言开关)。** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell,而按平台门控的组合是部署选择,不是执行器配置。 - -**把 Windows 默认与执行器/工具一起交付。** 否决:清单变更需要自己的证据(交付的 Windows 树停挂 bash 后什么会坏、哪些工具依赖 bash 语义),并且它属于带批准/PTY 表面可见的组合决策。 - -**用垫片在 Windows 上保留 bash,跳过 PowerShell 默认。** 否决:这延续了安装税与路线图要消除的方言错配;垫片是部署要求,不是产品行为。 - -## 验收标准 - -- 运行交付版 `dsh` TUI/Web 的 Windows 主机无需配置即获得 `pwsh` 作为其 shell 工具、PowerShell 作为 `ctx.bash` 执行器,且那里的模型可见清单中没有 `bash`。 -- POSIX 主机逐字节不受影响(清单相同,执行器相同)。 -- 交付组合 e2e 在两个平台族上断言按平台门控的清单。 -- 阶段 1 落地时,parity 变更带来的 keyless pwsh 工具快照已经就位;阶段 2 已随 web `pwsh-terminal` 渲染通道落地(TUI 的移除让终端表面无快照可做)。 - -## 风险 - -- **依赖 bash 的组合行**——任何假设 bash 语义的交付插件(执行 shell hooks 的 hooks 桥、工作区工具)必须按阶段审计;审计可能迫使分阶段推出而非一次切换。 -- **Windows CI 覆盖缺口**——单元覆盖在 Linux 上运行;pwsh 栈里仅 Windows 的回归通过 Windows 构建/静态通道与 e2e 浮出,必须按阶段扩展而不是想当然。 -- **渲染约定**——bash 形状的终端孪生已随 web 通道交付;超出 bash 对齐的 PowerShell 感知呈现(原生路径显示、`$env:` 实情)仍是带快照表面的 UI 设计决策,随阶段 1 一起延期。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 87677ce1f9..11078458e6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -24,11 +24,13 @@ "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-tmux-context": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-web-app": "workspace:^", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index 9de7a55f60..29605878de 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -15,6 +15,7 @@ import { type ConfigDumpLayer, } from '@deepseek-ai/dsh-app-boot' import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts' +import { resolveWindowsShellLayer } from './windows-shell.ts' const NAME = 'dsh' @@ -33,6 +34,12 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re label: layer.packageName, patches: layer.patches, })) + // The win32 shell platform layer rides between bundles and user layers, + // exactly where the boot applies it. + const windowsShellLayer = resolveWindowsShellLayer(process.platform, loaded.layers, NAME) + if (windowsShellLayer !== undefined) { + layers.push({ label: windowsShellLayer.label, patches: windowsShellLayer.patches }) + } if (!defaultOnly) { if (existsSync(loaded.patchPath)) { layers.push({ label: loaded.patchPath, patches: loaded.patches }) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index b69a938213..7115f1208a 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -27,6 +27,7 @@ import { import { resolveDshHome } from '@deepseek-ai/dsh-paths' import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' +import { resolveWindowsShellLayer } from './windows-shell.ts' const NAME = 'dsh' @@ -103,6 +104,8 @@ interface ComposedProfile { profile: Profile /** Bundle layers concatenated — the part below the user layers on a live reload. */ bundlePatches: PatchOptions[] + /** The win32 shell platform layer (the base bundle's `windows.cordis.patch.yml`), between bundles and user layers. */ + windowsShellPatches: PatchOptions[] /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */ homePatches: PatchOptions[] /** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */ @@ -117,16 +120,23 @@ interface ComposedProfile { /** The full patch stack of one composed profile, in application order. */ function allPatches(composed: ComposedProfile): PatchOptions[] { - return [...composed.bundlePatches, ...composed.profile.patches, ...composed.homePatches, ...composed.overlayAndFlags] + return [ + ...composed.bundlePatches, + ...composed.windowsShellPatches, + ...composed.profile.patches, + ...composed.homePatches, + ...composed.overlayAndFlags, + ] } /** * Load `name` and compose its effective patch stack: bundle layers in - * `dsh.profile.bundles` order, the profile's user layer, the home-level user layer - * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to - * every profile, so it outranks the per-profile layer), `--patch` overlays, - * then flag patches derived from the composed rows, then the telemetry - * switch. + * `dsh.profile.bundles` order, the win32 shell platform layer (when the host + * is Windows), the + * profile's user layer, the home-level user layer (`$DSH_HOME/cordis.patch.yml` + * — machine-local preferences that apply to every profile, so it outranks the + * per-profile layer), `--patch` overlays, then flag patches derived from the + * composed rows, then the telemetry switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. * @param deriveFlagPatches - launcher hook turning composed rows into flag patches. @@ -141,14 +151,15 @@ function composeProfile( const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) + const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? [] const rows = new Map() - for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) { + for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) { if (typeof row.id === 'string') rows.set(row.id, row) } const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)] const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch) - return { profile, bundlePatches, homePatches, overlayAndFlags, rows } + return { profile, bundlePatches, windowsShellPatches, homePatches, overlayAndFlags, rows } } /** Options for {@link runProfile}. */ @@ -215,6 +226,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // removing the override could never revert the row to the bundle default. const composeLive = (): PatchOptions[] => structuredClone([ ...composed.bundlePatches, + ...composed.windowsShellPatches, ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], ...loadOptionalPatches(NAME, homePatchPath()) ?? [], ...composed.overlayAndFlags, diff --git a/apps/cli/src/windows-shell.ts b/apps/cli/src/windows-shell.ts new file mode 100644 index 0000000000..425699ef4f --- /dev/null +++ b/apps/cli/src/windows-shell.ts @@ -0,0 +1,55 @@ +/** + * The Windows shell platform layer: on win32 hosts the shipped profile + * compositions swap the POSIX-only bash stack for the PowerShell stack + * (`@deepseek-ai/dsh-pwsh-local` + `@deepseek-ai/dsh-tool-pwsh`), matching + * the Windows-pwsh-default roadmap. The layer is the base bundle's + * `windows.cordis.patch.yml`, injected by the launcher between the bundle + * layers and the user layers so a user patch can still override it — the + * only override channel is composition config, like every other roster + * decision. POSIX hosts never receive the layer. + * @module @deepseek-ai/dsh/windows-shell + */ + +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import type { PatchOptions } from '@cordisjs/plugin-include' +import { loadOverlayPatches, type ProfileLayer } from '@deepseek-ai/dsh-app-boot' + +/** The base bundle whose package carries the Windows shell patch. */ +export const BASE_BUNDLE = '@deepseek-ai/dsh-base' + +/** The Windows shell patch filename inside the base bundle package. */ +export const WINDOWS_SHELL_PATCH_FILENAME = 'windows.cordis.patch.yml' + +/** One Windows shell platform layer: its patch file and parsed patches. */ +export interface WindowsShellLayer { + /** The patch file path, used as the config-dump provenance label. */ + label: string + /** The parsed patch entries, applied after the bundle layers. */ + patches: PatchOptions[] +} + +/** + * Resolve the Windows shell platform layer for a profile composition. + * @param platform - the host platform (`process.platform` at call sites). + * @param layers - the profile's bundle layers, in application order. + * @param binName - the diagnostic prefix on thrown errors (`dsh`). + * @returns the pwsh layer on win32, else `undefined`. A custom profile that + * mounts no base bundle is skipped (it owns its shell stack); a base + * bundle that ships no Windows shell patch fails loud — the shipped + * package always carries it, so a miss is a broken installation. + */ +export function resolveWindowsShellLayer( + platform: NodeJS.Platform, + layers: readonly ProfileLayer[], + binName: string, +): WindowsShellLayer | undefined { + if (platform !== 'win32') return undefined + const base = layers.find(layer => layer.packageName === BASE_BUNDLE) + if (base === undefined) return undefined + const label = join(base.packageDir, WINDOWS_SHELL_PATCH_FILENAME) + if (!existsSync(label)) { + throw new Error(`${binName}: ${BASE_BUNDLE} ships no ${WINDOWS_SHELL_PATCH_FILENAME}`) + } + return { label, patches: loadOverlayPatches(binName, label) } +} diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts new file mode 100644 index 0000000000..f0cfddbaae --- /dev/null +++ b/apps/cli/tests/windows-shell.spec.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ProfileLayer } from '@deepseek-ai/dsh-app-boot' +import { + BASE_BUNDLE, + resolveWindowsShellLayer, + WINDOWS_SHELL_PATCH_FILENAME, +} from '../src/windows-shell.ts' + +const WINDOWS_PATCH = `- id: bash-sandbox + disabled: true +- insert: + - id: pwsh-local + name: '@deepseek-ai/dsh-pwsh-local' +` + +/** One fake bundle layer rooted in a temp directory. */ +function fakeLayer(packageName: string, dir: string): ProfileLayer { + return { packageName, packageDir: dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [] } +} + +/** A base bundle layer whose package carries the Windows shell patch. */ +function baseLayerWithPatch(dir: string): ProfileLayer { + writeFileSync(join(dir, WINDOWS_SHELL_PATCH_FILENAME), WINDOWS_PATCH) + return fakeLayer(BASE_BUNDLE, dir) +} + +describe('resolveWindowsShellLayer', () => { + let base: string + afterEach(() => { if (base !== undefined) rmSync(base, { recursive: true, force: true }) }) + const tempBase = (): string => { + base = mkdtempSync(join(tmpdir(), 'dsh-windows-shell-')) + return base + } + + it('never applies on POSIX hosts', () => { + expect(resolveWindowsShellLayer('linux', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined() + expect(resolveWindowsShellLayer('darwin', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined() + }) + + it('defaults Windows hosts to the pwsh platform layer', () => { + const layer = resolveWindowsShellLayer('win32', [baseLayerWithPatch(tempBase())], 'dsh') + expect(layer).toBeDefined() + expect(layer?.label.endsWith(WINDOWS_SHELL_PATCH_FILENAME)).toBe(true) + expect(layer?.patches).toEqual([ + { id: 'bash-sandbox', disabled: true }, + { insert: [{ id: 'pwsh-local', name: '@deepseek-ai/dsh-pwsh-local' }] }, + ]) + }) + + it('skips custom profiles without a base bundle', () => { + const other = fakeLayer('@deepseek-ai/dsh-custom', tempBase()) + expect(resolveWindowsShellLayer('win32', [other], 'dsh')).toBeUndefined() + }) + + it('fails loud when the base bundle ships no Windows shell patch', () => { + const base = tempBase() + mkdirSync(base, { recursive: true }) + expect(() => resolveWindowsShellLayer('win32', [fakeLayer(BASE_BUNDLE, base)], 'dsh')) + .toThrow(/@deepseek-ai\/dsh-base ships no windows\.cordis\.patch\.yml/) + }) +}) diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 2ae7df0bdc..a47ac9cbc7 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/base/README.md -README.md: 301d397d4c87687b382665cf63af47ab5e3f85be -README.zh.md: f007bc817b6cbad84725fe8abe72549cf67d8cd7 +README.md: a4f220c956f7f67d7810827d4488d54a071ad3d6 +README.zh.md: eaf96f8c0640481163e7a1c1533a07e35ecc294f diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 301d397d4c..a4f220c956 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -2,7 +2,9 @@ English | [中文](README.zh.md) -The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. +The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the universal patch through the `dsh.bundle.patch` manifest field, and the launcher reads the Windows platform layer below from code on win32 hosts. + +Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash executor/tool and the permission stack (dsh-permission requires a confining executor), and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`). The launcher applies it between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack overrides these rows through its profile or home `cordis.patch.yml`. POSIX hosts never receive it. The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. @@ -17,3 +19,4 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. +- **Windows loses the permission switcher** — `dsh-permission` hard-requires a confining `ctx.bash` executor, so the Windows platform layer disables `permission`/`ui-permission` with the bash stack. The fs tools keep the sandbox policy and the approval service, so file confinement and escalation still apply on Windows. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index f007bc817b..eaf96f8c06 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 +以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析通用 patch,启动器在 win32 主机上通过代码读取下面的 Windows 平台层。 + +启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 执行器/工具与权限栈(dsh-permission 要求有限权能力的执行器),并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行。POSIX 主机永远不会收到它。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 @@ -17,3 +19,4 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 +- **Windows 上失去权限切换器**:`dsh-permission` 硬性要求有限权能力的 `ctx.bash` 执行器,因此 Windows 平台层随 bash 栈一起禁用 `permission`/`ui-permission`。fs 工具保留 sandbox 策略与批准服务,Windows 上的文件限制与升级仍然生效。 diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 95e169cabb..cb2b7178f7 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -16,6 +16,7 @@ "default": "./lib/invariant.js" }, "./cordis.patch.yml": "./cordis.patch.yml", + "./windows.cordis.patch.yml": "./windows.cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -23,6 +24,7 @@ "lib/index.js", "lib/invariant.js", "cordis.patch.yml", + "windows.cordis.patch.yml", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", @@ -54,6 +56,7 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-repository-plugin": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", @@ -82,6 +85,7 @@ "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml new file mode 100644 index 0000000000..59d5e81582 --- /dev/null +++ b/packages/bundle/base/windows.cordis.patch.yml @@ -0,0 +1,34 @@ +# The dsh-base Windows platform layer: applied by the dsh launcher on win32 +# hosts, between the bundle layers and the user layers, replacing the +# POSIX-only bash stack with the PowerShell stack. The launcher reads THIS +# file from the base bundle package (never through dsh.bundle.patch — that +# field names the one universal layer). A Windows host that prefers bash +# overrides the rows here through its profile or home cordis.patch.yml. +# +# Windows hosts cannot run the shipped bash executor (POSIX-only: hardcoded +# `bash -c` argv and process-group semantics), so the shipped Windows +# experience is PowerShell-native: pwsh-local backs `ctx.bash` and tool-pwsh +# is the model-facing shell tool. dsh-permission requires a confining +# executor (its presets bundle a sandbox mode the unconfined pwsh executor +# cannot honor), so the permission service and its client knob leave the +# Windows roster with the bash stack; the fs tools keep the sandbox policy +# and the approval service, so file confinement and escalation still apply. + +- id: bash-sandbox + disabled: true + +- id: tool-bash + disabled: true + +- id: permission + disabled: true + +- id: ui-permission + disabled: true + +- insert: + - id: pwsh-local + name: '@deepseek-ai/dsh-pwsh-local' + + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b826046bea..453109bcae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: '@deepseek-ai/dsh-pty-local': specifier: workspace:^ version: link:../../packages/pty/pty-local + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../packages/bash/pwsh-local '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference @@ -176,6 +179,9 @@ importers: '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/cordis/tool-cordis + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:^ + version: link:../../packages/bash/tool-pwsh '@deepseek-ai/dsh-web-app': specifier: workspace:^ version: link:../../packages/bundle/web-app @@ -922,6 +928,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../bash/pwsh-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../guard/repeat-tool-guard @@ -1006,6 +1015,9 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../goal/tool-goal + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:^ + version: link:../../bash/tool-pwsh '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../workflow/tool-ralph From e6fe32b3c3951e5442cc8d5a6fca76ea3e0f1e04 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 00:15:37 +0800 Subject: [PATCH 017/293] feat(web): expose the agent-preset roster over the API `agentPreset.list` gives a browser the deployment's roster so it can offer a choice when starting a session. Each row carries the id, its `trust`, and whether it is the current default. `trust` is on the wire deliberately: a `user` preset is exactly as privileged as the plugins it names, so a surface that offers one alongside a shipped preset can say which is which rather than presenting both as vetted. The domain is read-only. A preset is a composition on disk, so authoring one is a filesystem act rather than an RPC; and a deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. The RPC map made every registration site a type error, so the route, the response-schema table, the service delegate, and the browser fixture are all wired rather than only the ones I remembered. --- .../client/connection/src/client/fixture.ts | 12 +++++++ packages/host/apiproxy/README.i18n.yaml | 4 +-- packages/host/apiproxy/README.md | 2 ++ packages/host/apiproxy/README.zh.md | 2 ++ packages/host/apiproxy/src/api-proxy.ts | 18 +++++++++++ .../apiproxy/src/api/agent-presets.schema.ts | 25 +++++++++++++++ .../host/apiproxy/src/api/agent-presets.ts | 31 +++++++++++++++++++ packages/host/apiproxy/src/api/index.ts | 3 ++ packages/host/apiproxy/src/api/rpc-map.ts | 2 ++ packages/host/apiproxy/src/fetch/client.ts | 10 ++++++ packages/host/apiproxy/src/fetch/handler.ts | 2 ++ packages/host/apiproxy/src/index.ts | 2 ++ .../tests/api-proxy-agent-preset.spec.ts | 27 ++++++++++++++++ .../apiproxy/tests/client-handler.spec.ts | 2 ++ .../host/apiproxy/tests/fetch-carrier.spec.ts | 5 +++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 14 +++++++++ 16 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 packages/host/apiproxy/src/api/agent-presets.schema.ts create mode 100644 packages/host/apiproxy/src/api/agent-presets.ts diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 20af221f2d..814796f0fa 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2312,6 +2312,17 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return ok(request, { matched: true as const, commandId }) }, }, + agentPresets: { + // Two rows so a picker has something to choose between, and so the + // trust distinction a surface must present is visible in the fixture. + list: request => ok(request, { + presets: [ + { id: 'standard', trust: 'system' as const, isDefault: true }, + { id: 'core-web', trust: 'system' as const, isDefault: false }, + ], + }), + }, + skills: { list: (request) => { const missing = requireSession(request) @@ -2609,6 +2620,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'command.list': return this.api.commands.list(request) case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) + case 'agentPreset.list': return this.api.agentPresets.list(request) case 'goal.create': return this.api.goals.create(request) case 'goal.edit': return this.api.goals.edit(request) case 'goal.pause': return this.api.goals.pause(request) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 38c79f4617..990a055b43 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5 -README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9 +README.md: 9484fadcc798652979f998c81f84444c1ebdbf52 +README.zh.md: 44e1d4b563e52c2491e07854469bbb283e30b28b diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 0963476a76..963a590f46 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -34,6 +34,8 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. +The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names) and whether it is the current default. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one. + The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e3634c5f92..21574c84e7 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -34,6 +34,8 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 +`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)以及它是否为当前默认值。该领域只读——preset 是磁盘上的一份组装,创作它是文件系统行为而非 RPC。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。 + `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8e391c16b8..f47b9c8f16 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2484,6 +2484,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, + agentPresets: { + // A deployment with no roster answers with an empty list rather than an + // error: composing no presets is a valid deployment, and the browser + // simply offers no choice. + async list(request) { + const presets = ctx.get('agentPresets') + if (presets === undefined) return ok(request, { presets: [] }) + const defaultId = presets.defaultId + return ok(request, { + presets: (await presets.list()).map(preset => ({ + id: preset.id, + trust: preset.trust, + isDefault: preset.id === defaultId, + })), + }) + }, + }, + skills: { // Skill lookup never touches the Agent registry: the session address // resolves to a canonical cwd from the host-resident session header, so diff --git a/packages/host/apiproxy/src/api/agent-presets.schema.ts b/packages/host/apiproxy/src/api/agent-presets.schema.ts new file mode 100644 index 0000000000..0d4881d8cd --- /dev/null +++ b/packages/host/apiproxy/src/api/agent-presets.schema.ts @@ -0,0 +1,25 @@ +/** + * agent-presets domain zod schemas (names derived from map keys: + * agentPresetListRequestSchema / agentPresetListValueSchema). + */ + +import { z } from 'zod' +import type { RequestPayload, ResponseValue } from './rpc-map.ts' +import type { Wire } from './rpc.schema.ts' +import type { AgentPresetEntry } from './agent-presets.ts' + +/** AgentPresetEntry row of agentPreset.list. */ +export const agentPresetEntrySchema = z.object({ + id: z.string().min(1), + trust: z.union([z.literal('system'), z.literal('user')]), + isDefault: z.boolean(), +}) satisfies z.ZodType> + +/** agentPreset.list request payload. */ +export const agentPresetListRequestSchema = z.object({ +}) satisfies z.ZodType>> + +/** agentPreset.list response value. */ +export const agentPresetListValueSchema = z.object({ + presets: z.array(agentPresetEntrySchema), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/agent-presets.ts b/packages/host/apiproxy/src/api/agent-presets.ts new file mode 100644 index 0000000000..0f537f6a0f --- /dev/null +++ b/packages/host/apiproxy/src/api/agent-presets.ts @@ -0,0 +1,31 @@ +/** + * agent-presets domain contract: the roster a browser offers when starting a + * session. Read-only — a preset is a composition on disk, and authoring one is + * a filesystem act rather than an RPC. + */ + +import type { RpcRequest, RpcResponse } from './rpc.ts' + +/** One preset the deployment can compose a session's agent from. */ +export interface AgentPresetEntry { + /** Stable identifier, also the display name until presets carry metadata. */ + readonly id: string + /** + * Whether the preset ships with the deployment or was authored locally. + * A `user` preset is exactly as privileged as the plugins it names, so a + * surface offering one should say so rather than present it as vetted. + */ + readonly trust: 'system' | 'user' + /** Whether a session that names no preset gets this one. */ + readonly isDefault: boolean +} + +/** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */ +export interface AgentPresetsApi { + /** + * Lists every preset the deployment currently supplies, ordered by id. + * An empty roster means the deployment composes no presets at all, and + * every session shares the host composition. + */ + list(request: RpcRequest<{}>): Promise> +} diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 4f10d92853..46307a1c07 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -8,6 +8,7 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' import type { CommandsApi } from './commands.ts' +import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { SubagentsApi } from './subagents.ts' import type { EventsApi } from './events.ts' @@ -25,6 +26,7 @@ export interface ApiProxy { workspace: WorkspaceApi commands: CommandsApi skills: SkillsApi + agentPresets: AgentPresetsApi events: EventsApi goals: GoalsApi settings: SettingsApi @@ -47,6 +49,7 @@ export type { export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' +export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts' export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { GoalsApi, GoalId, GoalRef } from './goals.ts' export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 9a8750c722..726f7118d4 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -8,6 +8,7 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' import type { CommandsApi } from './commands.ts' +import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' import type { GoalsApi } from './goals.ts' import type { SettingsApi } from './settings.ts' @@ -50,6 +51,7 @@ export interface RpcMethodMap { 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] + 'agentPreset.list': AgentPresetsApi['list'] 'goal.create': GoalsApi['create'] 'goal.edit': GoalsApi['edit'] 'goal.pause': GoalsApi['pause'] diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0f54d76dbc..c5eda9ca41 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -40,6 +40,7 @@ import { } from '../api/workspace.schema.ts' import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' +import { agentPresetListValueSchema } from '../api/agent-presets.schema.ts' import { goalCreateValueSchema, goalEditValueSchema, @@ -119,6 +120,9 @@ export interface IApiClient { skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> } + readonly agentPresets: { + list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise>> + } events: { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> host(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> @@ -185,6 +189,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('skill.list', payload, signal), } + readonly agentPresets = { + list: (payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal) => + this.callUnary('agentPreset.list', payload, signal), + } + readonly goals: IApiClient['goals'] = { create: (payload, signal) => this.callUnary('goal.create', payload, signal), edit: (payload, signal) => this.callUnary('goal.edit', payload, signal), diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index d41b51ad6d..278fa57c0b 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -42,6 +42,7 @@ import { } from '../api/workspace.schema.ts' import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' +import { agentPresetListRequestSchema } from '../api/agent-presets.schema.ts' import { goalCreateRequestSchema, goalEditRequestSchema, @@ -109,6 +110,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, + 'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) }, 'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) }, 'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) }, 'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index e279575ff4..238a10fe8b 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -63,6 +63,7 @@ export class ApiProxyService extends Service implements ApiProxy { readonly commands: ApiProxy['commands'] readonly goals: ApiProxy['goals'] readonly skills: ApiProxy['skills'] + readonly agentPresets: ApiProxy['agentPresets'] readonly settings: ApiProxy['settings'] readonly credentials: ApiProxy['credentials'] readonly llm: ApiProxy['llm'] @@ -85,6 +86,7 @@ export class ApiProxyService extends Service implements ApiProxy { this.commands = api.commands this.goals = api.goals this.skills = api.skills + this.agentPresets = api.agentPresets this.settings = api.settings this.credentials = api.credentials this.llm = api.llm diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 2eebe7b218..abcedeb8d3 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -235,3 +235,30 @@ describe('a capability the session\'s preset mounts', () => { expect(failure.error.message).toContain('neither this session') }) }) + +describe('agentPreset.list', () => { + it('marks the default and carries each preset\'s trust', async () => { + const { api } = await harness(['standard', 'core-web']) + + const response = await api.agentPresets.list(request({})) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.presets).toEqual([ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'core-web', trust: 'system', isDefault: false }, + ]) + }) + + it('answers with an empty roster when the deployment composes no presets', async () => { + const { api } = await harness() + + const response = await api.agentPresets.list(request({})) + + // Composing no presets is a valid deployment, not an error: every session + // then shares the host composition and the browser offers no choice. + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.presets).toEqual([]) + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 490e0ad7f1..ed4a5341f1 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -23,6 +23,7 @@ function scriptedApi(overrides: { host?: Partial commands?: Partial skills?: Partial + agentPresets?: Partial events?: Partial goals?: Partial settings?: Partial @@ -86,6 +87,7 @@ function scriptedApi(overrides: { ...overrides.commands, }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, + agentPresets: { list: r => ok(r, { presets: [] }), ...overrides.agentPresets }, goals: { create: err, edit: err, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index bcccfdd52e..4dc46ce66a 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -193,6 +193,11 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } } }, }, + agentPresets: { + list(request: RpcRequest<{}>) { + return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: { presets: [] } } }) + }, + }, skills: { async list(request) { return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } } diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index b65861c1ae..0df9890e28 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -32,6 +32,7 @@ import { commandListRequestSchema, commandListValueSchema, } from '../src/api/commands.schema.ts' import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' +import { agentPresetEntrySchema, agentPresetListValueSchema } from '../src/api/agent-presets.schema.ts' import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts' @@ -502,3 +503,16 @@ describe('respond payload schemas', () => { expect(payload.sessionId).toBe('s') }) }) + +describe('agent-preset schemas', () => { + it('accepts a roster row and rejects an unknown trust', () => { + expect(agentPresetEntrySchema.parse({ id: 'standard', trust: 'system', isDefault: true })) + .toEqual({ id: 'standard', trust: 'system', isDefault: true }) + expect(() => agentPresetEntrySchema.parse({ id: 'x', trust: 'root', isDefault: false })).toThrow() + expect(() => agentPresetEntrySchema.parse({ id: '', trust: 'user', isDefault: false })).toThrow() + }) + + it('accepts an empty roster', () => { + expect(agentPresetListValueSchema.parse({ presets: [] })).toEqual({ presets: [] }) + }) +}) From 6758da87aec914eac9a4e6a2f28eff26979af300 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 00:25:19 +0800 Subject: [PATCH 018/293] feat(web): choose the default agent preset from General settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One settings row picking which preset new sessions are composed from. It is deliberately a new-session preference, not a live switch: a session's preset is fixed at creation and the host refuses to adopt an existing session under a different one, so the row says "applies to sessions you start from now on" rather than implying it can retune a running agent. Options and the current value come from one `agentPreset.list` call — the roster already reports which id an unspecified session gets, so the row needs no settings-schema introspection, unlike the permission row it is modelled on. The write targets only the namespace's `default` field. The menu marks `user` rows: a locally authored preset is exactly as privileged as the plugins it names, and presenting it identically to a shipped one would hide that. An empty roster reads as `unavailable` and renders nothing, because composing no presets is a valid deployment rather than a failure — distinct from a roster call that failed, which surfaces its message. --- apps/cli/package.json | 1 + docs/config-catalog.md | 1 + packages/bundle/web-app/cordis.patch.yml | 5 + packages/client/connection/tests/fake-api.ts | 4 + packages/client/runtime/tests/fake-api.ts | 4 + .../client/ui-agent-preset/README.i18n.yaml | 6 + packages/client/ui-agent-preset/README.md | 35 +++++ packages/client/ui-agent-preset/README.zh.md | 35 +++++ packages/client/ui-agent-preset/package.json | 67 ++++++++++ .../src/client/AgentPresetRow.module.css | 60 +++++++++ .../src/client/AgentPresetRow.tsx | 104 +++++++++++++++ .../ui-agent-preset/src/client/index.ts | 64 +++++++++ .../ui-agent-preset/src/client/locales.ts | 23 ++++ .../src/client/settings-store.ts | 114 ++++++++++++++++ .../ui-agent-preset/src/css-modules.d.ts | 4 + packages/client/ui-agent-preset/src/index.ts | 9 ++ .../client/ui-agent-preset/src/invariant.ts | 30 +++++ .../tests/settings-store.spec.ts | 122 ++++++++++++++++++ packages/client/ui-agent-preset/tsconfig.json | 33 +++++ .../client/ui-agent-preset/tsdown.config.ts | 3 + pnpm-lock.yaml | 33 +++++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + 24 files changed, 760 insertions(+) create mode 100644 packages/client/ui-agent-preset/README.i18n.yaml create mode 100644 packages/client/ui-agent-preset/README.md create mode 100644 packages/client/ui-agent-preset/README.zh.md create mode 100644 packages/client/ui-agent-preset/package.json create mode 100644 packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css create mode 100644 packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx create mode 100644 packages/client/ui-agent-preset/src/client/index.ts create mode 100644 packages/client/ui-agent-preset/src/client/locales.ts create mode 100644 packages/client/ui-agent-preset/src/client/settings-store.ts create mode 100644 packages/client/ui-agent-preset/src/css-modules.d.ts create mode 100644 packages/client/ui-agent-preset/src/index.ts create mode 100644 packages/client/ui-agent-preset/src/invariant.ts create mode 100644 packages/client/ui-agent-preset/tests/settings-store.spec.ts create mode 100644 packages/client/ui-agent-preset/tsconfig.json create mode 100644 packages/client/ui-agent-preset/tsdown.config.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 0220656cc2..d651ba2537 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -19,6 +19,7 @@ "@cordisjs/plugin-timer": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-base": "workspace:^", + "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-headless": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cf2ebc362a..88a429f14e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2482,6 +2482,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 578089d8a8..f3f19a15fb 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -180,6 +180,11 @@ - id: ui-permission name: '@deepseek-ai/dsh-client-ui-permission' + # The agent-preset row in General settings: the default preset for + # sessions created later. Absent a roster it renders nothing. + - id: ui-agent-preset + name: '@deepseek-ai/dsh-client-ui-agent-preset' + # Plan control: the composer plan seat over the plan projection + /plan channel. - id: ui-plan name: '@deepseek-ai/dsh-client-ui-plan' diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index e5eb42695d..faa433e67e 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -167,6 +167,10 @@ export class FakeApiClient implements IApiClient { execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), } + readonly agentPresets: IApiClient['agentPresets'] = { + list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))), + } + readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index e50574d102..9694ea9cfb 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -202,6 +202,10 @@ export class FakeApiClient implements IApiClient { execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), } + readonly agentPresets: IApiClient['agentPresets'] = { + list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))), + } + readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml new file mode 100644 index 0000000000..aee8c88bb2 --- /dev/null +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -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/client/ui-agent-preset/README.md +README.md: c22d5a40659bf6300f133ff902fbc3bf8bc75276 +README.zh.md: 6b7e43adcdfe7d6de781511edb5d91a4c45cf11e diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md new file mode 100644 index 0000000000..ecfa962cdc --- /dev/null +++ b/packages/client/ui-agent-preset/README.md @@ -0,0 +1,35 @@ +# dsh-client-ui-agent-preset + +English | [中文](README.zh.md) + +The agent-preset surface: one General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from. + +## Why it is a new-session preference + +A session's preset is fixed when the session is created — the host refuses to adopt an existing session under a different one, because that session's history was produced under the first preset's tools. So this row cannot be a live switch, and it says so: changing it applies to sessions started afterwards while running sessions keep the composition they began with. + +## What it reads and writes + +Options and the current default both come from one `agentPreset.list` call. The roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection; the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. + +A locally authored preset is exactly as privileged as the plugins it names, so the list marks `user` rows rather than presenting every preset as shipped and vetted. + +The row re-reads on `settings/changed` for its own namespace and on `connection/reset`: the roster is a live directory and the default is a settings field, so an external edit or a reconnect can both move it. + +## When the row is absent + +A deployment that composes no presets answers with an empty roster, and the row renders nothing — every session then shares the host composition, and there is nothing to choose between. + +## Model Experience + +Indirectly, through the preset a later session is composed from; [`dsh-agent-presets`](../../preset/agent-presets/README.md) owns what that composition puts in front of the model. + +#### KV Cache effect + +No direct invalidation. Changing the default never touches a running session's prefix; a session created afterwards establishes its own prefix from its own composition. + +## Known Limitations and Deferred Work + +- **No per-session choice at creation** — this row sets the default only. The wire already carries `agentPreset` on `session.create`, so a session-start surface can offer the choice; that surface does not exist yet. +- **Presets are listed by id** — a preset carries no display metadata, so the menu shows directory names. +- **No authoring** — creating, editing, or deleting a preset is a filesystem act; this surface only chooses among what the roster supplies. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md new file mode 100644 index 0000000000..915777875e --- /dev/null +++ b/packages/client/ui-agent-preset/README.zh.md @@ -0,0 +1,35 @@ +# dsh-client-ui-agent-preset + +[English](README.md) | 中文 + +agent preset 表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md)。 + +## 为什么它是"新建会话"的偏好设置 + +会话的 preset 在创建时即固定——宿主拒绝以不同 preset 接管已存在的会话,因为该会话的历史是在最初那份 preset 的工具下产生的。因此本行不可能是实时切换,它也如实说明了这一点:更改只对此后开启的会话生效,而运行中的会话保持它们开始时的组装。 + +## 它读什么、写什么 + +选项与当前默认值都来自同一次 `agentPreset.list` 调用。名单本身已经报告了"未显式选择的会话会得到哪个 id",因此本行无需对 settings schema 做内省;写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的那个字段。 + +本地创作的 preset 的权限恰好等于它所引用的插件,因此列表会标注 `user` 行,而不是把每个 preset 都呈现为随附且已审核的。 + +本行在自身命名空间的 `settings/changed` 以及 `connection/reset` 时重新读取:名单是一个活动目录,默认值是一项设置,外部编辑与重新连接都可能改变它。 + +## 何时不显示本行 + +未组装任何 preset 的部署返回空名单,本行不渲染任何内容——此时每个会话共用宿主组装,也就无从选择。 + +## Model Experience + +Indirectly, through the preset a later session is composed from; [`dsh-agent-presets`](../../preset/agent-presets/README.md) owns what that composition puts in front of the model. + +#### KV Cache effect + +没有直接的失效影响。更改默认值绝不触及运行中会话的前缀;此后创建的会话依据它自己的组装建立自己的前缀。 + +## Known Limitations and Deferred Work + +- **创建时无法逐会话选择** —— 本行只设置默认值。wire 上 `session.create` 已经携带 `agentPreset`,因此会话开启表层可以提供该选择;该表层尚不存在。 +- **preset 按 id 列出** —— preset 不携带展示用元数据,因此菜单显示的是目录名。 +- **不提供创作能力** —— 创建、编辑或删除 preset 是文件系统行为;本表层只在名单提供的范围内做选择。 diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json new file mode 100644 index 0000000000..e82613cc0e --- /dev/null +++ b/packages/client/ui-agent-preset/package.json @@ -0,0 +1,67 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-agent-preset", + "description": "Agent-preset surface: the default preset for later sessions, in General settings", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", + "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css new file mode 100644 index 0000000000..d0f7134329 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css @@ -0,0 +1,60 @@ +/* Agent-preset row: title/description plus the preset selector pill. */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.desc { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.selector:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.selector:disabled { + cursor: default; +} + +.chevron { + flex: none; +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx new file mode 100644 index 0000000000..492f593134 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx @@ -0,0 +1,104 @@ +/** + * Agent-preset preference row: the preset new sessions are composed from. + * A running session keeps the composition it began with, so this row never + * disturbs work in progress. + */ + +import { useEffect, useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type { AgentPresetSettingsState } from './settings-store.ts' +import type { AgentPresetSettingsKey } from './locales.ts' +import css from './AgentPresetRow.module.css' + +/** Registration-side business face for the host-backed preference. */ +export interface AgentPresetRowInjected { + hooks: { + /** Agent-preset settings snapshot bound by the renderer as useAgentPreset. */ + agentPreset: SnapshotStore + } + /** Load the roster when the row first renders. */ + load: () => Promise + /** Persist one preset as the default for later sessions. */ + select: (id: string) => Promise +} + +/** Full component props. */ +export type AgentPresetRowProps = + PropsRuntime<'settings.general.item'> + & PropsLocale<'settings.agentPreset'> + & InjectFace + +/** + * Render the new-session agent-preset selector. + * @param props - composed slot props. + * @returns the row, or null when the deployment composes no presets. + */ +export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetRowProps) { + const state = useAgentPreset(snapshot => snapshot) + const [open, setOpen] = useState(false) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + if (state.writable && state.status !== 'unavailable') return + setOpen(false) + }, [state.status, state.writable]) + + // A deployment that composes no presets has nothing to choose between, and + // every session shares the host composition — the row simply does not exist. + if (state.status === 'unavailable') return null + const busy = state.status === 'loading' || state.status === 'saving' + const label = state.currentValue === '' ? t('loading') : state.currentValue + const description: string = state.error ?? t('description') + + return ( +

+
+
{t('title')}
+
{description}
+
+ { setOpen(false) }} + // A locally authored preset is exactly as privileged as the plugins it + // names, so the list says which rows are local rather than presenting + // every preset as shipped and vetted. + items={state.options.map(option => ({ + id: option.id, + label: option.trust === 'user' ? `${option.id} · ${t('userTrust')}` : option.id, + }))} + selectedId={state.currentValue} + onSelect={(id) => { + setOpen(false) + void select(id) + }} + align="end" + portal + anchor={( + + )} + /> +
+ ) +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Agent-preset row copy. */ + 'settings.agentPreset': AgentPresetSettingsKey + } +} diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts new file mode 100644 index 0000000000..2101e69630 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -0,0 +1,64 @@ +/** + * Agent-preset surface plugin, browser half — one General-settings row that + * writes the default preset for sessions created later. + * + * A running session keeps the composition it began with (the host refuses to + * adopt an existing session under a different preset), so this row is a + * new-session preference rather than a live switch. Per-session choice at + * creation time belongs to the session-start surface, which reads the same + * roster. + */ + +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { AgentPresetRow } from './AgentPresetRow.tsx' +import type { AgentPresetRowInjected } from './AgentPresetRow.tsx' +import { en, zh } from './locales.ts' +import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts' + +export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx' +export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts' +export { AGENT_PRESET_SETTINGS_NS } from './settings-store.ts' + +/** Required services (cordis fiber inject). */ +export const inject = ['slots', 'locale', 'connection'] + +/** + * Mount the General-settings row. + * @param ctx - the browser plugin context. + */ +export function apply(ctx: ClientContext): void { + const controller = new AgentPresetSettingsController((ctx.get('connection') as ConnectionHandle).api) + + ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries') + + const injected = (): AgentPresetRowInjected => ({ + hooks: { agentPreset: controller.store }, + load: () => controller.load(), + select: (id: string) => controller.select(id), + }) + + ctx.effect(() => { + // The roster is a live directory and the default is a settings field, so + // both an external settings edit and a reconnect can move this row. + const refresh = (ns?: string): void => { + if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return + void controller.load() + } + const disposers = [ + ctx.on('settings/changed', refresh), + ctx.on('connection/reset', () => { refresh() }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-agent-preset: settings refresh') + + ctx.slots.inject('settings.general.item', () => ctx.slots.register({ + name: 'settings.general.item', + id: 'agent-preset', + order: -25, + locale: 'settings.agentPreset', + inject: injected, + }, AgentPresetRow)) +} diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts new file mode 100644 index 0000000000..6bcc2767d4 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -0,0 +1,23 @@ +/** Locale bundles for the agent-preset General-settings row. */ + +/** Locale keys this row renders. */ +export type AgentPresetSettingsKey = + | 'title' | 'description' | 'loading' | 'error' | 'userTrust' + +/** English copy. */ +export const en: Record = { + title: 'Agent preset', + description: 'Applies to sessions you start from now on. Running sessions keep the preset they began with.', + loading: 'Loading presets…', + error: 'Could not load agent presets.', + userTrust: 'Local', +} + +/** Simplified Chinese copy. */ +export const zh: Record = { + title: 'Agent preset', + description: '对此后新建的会话生效。运行中的会话保持它开始时的 preset。', + loading: '正在加载 preset…', + error: '无法加载 agent preset。', + userTrust: '本地', +} diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts new file mode 100644 index 0000000000..fcc092d179 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -0,0 +1,114 @@ +/** + * Agent-preset default-settings controller. + * + * Options and the current default both come from one `agentPreset.list` call: + * the roster already reports which id a session with no explicit choice gets, + * so the row needs no schema introspection. Writes target the settings + * namespace's `default` field, which is what the host resolves at creation. + */ + +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' + +/** The agent-preset settings namespace on the host wire. */ +export const AGENT_PRESET_SETTINGS_NS = 'agent-presets' + +/** One selectable preset. */ +export interface AgentPresetOption { + /** Preset id, written to Settings and shown as the label. */ + id: string + /** Whether the preset ships with the deployment or was authored locally. */ + trust: 'system' | 'user' +} + +/** Agent-preset settings-row snapshot. */ +export interface AgentPresetSettingsState { + status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error' + error: string | null + writable: boolean + currentValue: string + options: readonly AgentPresetOption[] +} + +const INITIAL: AgentPresetSettingsState = { + status: 'idle', + error: null, + writable: true, + currentValue: '', + options: [], +} + +/** Reads the roster and persists the chosen default. */ +export class AgentPresetSettingsController { + /** Row snapshot the renderer subscribes to. */ + readonly store: SnapshotStore = createSnapshotStore(INITIAL) + + constructor(private readonly api: IApiClient) {} + + private set(patch: Partial): void { + this.store.set({ ...this.store.getSnapshot(), ...patch }) + } + + /** + * Load the roster. An empty roster means the deployment composes no + * presets, which is a valid deployment rather than a failure — the row + * reports `unavailable` and renders nothing. + * @returns once the snapshot reflects the host. + */ + async load(): Promise { + if (this.store.getSnapshot().status === 'loading') return + this.set({ status: 'loading', error: null }) + try { + const response = await this.api.agentPresets.list({}) + if (!response.result.ok) { + this.set({ status: 'error', error: response.result.error.message }) + return + } + const presets = response.result.value.presets + if (presets.length === 0) { + this.set({ status: 'unavailable', options: [], currentValue: '' }) + return + } + this.set({ + status: 'ready', + error: null, + options: presets.map(preset => ({ id: preset.id, trust: preset.trust })), + currentValue: presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '', + }) + } catch (error) { + this.set({ status: 'error', error: error instanceof Error ? error.message : String(error) }) + } + } + + /** + * Persist one preset as the default for sessions created later. Running + * sessions keep the composition they were created with, so this never + * disturbs work in progress. + * @param id - the preset to make default. + * @returns once the write settled and the roster was re-read. + */ + async select(id: string): Promise { + const before = this.store.getSnapshot() + if (before.status === 'saving' || id === before.currentValue) return + this.set({ status: 'saving', error: null, currentValue: id }) + try { + const response = await this.api.settings.update({ + ns: AGENT_PRESET_SETTINGS_NS, + patch: { default: id }, + }) + if (!response.result.ok) { + this.set({ status: 'ready', currentValue: before.currentValue, error: response.result.error.message }) + return + } + // Re-read rather than trust the patch: the host resolves the default + // through the same roster the row displays. + await this.load() + } catch (error) { + this.set({ + status: 'ready', + currentValue: before.currentValue, + error: error instanceof Error ? error.message : String(error), + }) + } + } +} diff --git a/packages/client/ui-agent-preset/src/css-modules.d.ts b/packages/client/ui-agent-preset/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-agent-preset/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-agent-preset/src/index.ts b/packages/client/ui-agent-preset/src/index.ts new file mode 100644 index 0000000000..c145962f1d --- /dev/null +++ b/packages/client/ui-agent-preset/src/index.ts @@ -0,0 +1,9 @@ +/** + * Agent-preset surface plugin, node half. The empty apply exists so the plugin + * appears in the host cordis.yml / Loader; the browser half ships the + * General-settings row through exports["./client"], discovered from the + * package.json dshClient declaration. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-agent-preset/src/invariant.ts b/packages/client/ui-agent-preset/src/invariant.ts new file mode 100644 index 0000000000..1794763066 --- /dev/null +++ b/packages/client/ui-agent-preset/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-agent-preset`. + * @module @deepseek-ai/dsh-client-ui-agent-preset/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-agent-preset' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-agent-preset-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this is a browser-side surface plugin whose node half owns no event stream + * or mutable runtime data; the roster and the settings write are host contracts covered 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 */ diff --git a/packages/client/ui-agent-preset/tests/settings-store.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.spec.ts new file mode 100644 index 0000000000..cd6a38ee74 --- /dev/null +++ b/packages/client/ui-agent-preset/tests/settings-store.spec.ts @@ -0,0 +1,122 @@ +/** + * The agent-preset settings controller: it derives both the options and the + * current default from one roster call, writes only the `default` field, and + * treats an empty roster as "this deployment composes no presets" rather than + * as a failure. + */ + +import { describe, expect, it } from 'vitest' +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { + AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, +} from '../src/client/settings-store.ts' + +interface Recorded { ns: string; patch: unknown } + +/** A client whose roster and write outcome the test controls. */ +function fakeApi( + presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], + options: { writes?: Recorded[]; failWrite?: string; failList?: string } = {}, +): IApiClient { + return { + agentPresets: { + list: () => Promise.resolve(options.failList === undefined + ? { rpcId: 'r', result: { ok: true as const, value: { presets } } } + : { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }), + }, + settings: { + update: (payload: { ns: string; patch: unknown }) => { + options.writes?.push({ ns: payload.ns, patch: payload.patch }) + if (options.failWrite !== undefined) { + return Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } } }) + } + // A committed write moves the roster's default, exactly as the host does. + for (const preset of presets) { + preset.isDefault = preset.id === (payload.patch as { default?: string }).default + } + return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }) + }, + }, + } as unknown as IApiClient +} + +describe('the agent-preset settings controller', () => { + it('derives options and the current default from one roster call', async () => { + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'mine', trust: 'user', isDefault: false }, + ])) + + await controller.load() + + const state = controller.store.getSnapshot() + expect(state.status).toBe('ready') + expect(state.currentValue).toBe('standard') + expect(state.options).toEqual([ + { id: 'standard', trust: 'system' }, + { id: 'mine', trust: 'user' }, + ]) + }) + + it('reports an empty roster as unavailable, not as an error', async () => { + const controller = new AgentPresetSettingsController(fakeApi([])) + + await controller.load() + + // A deployment composing no presets is valid: every session shares the + // host composition and the row renders nothing. + expect(controller.store.getSnapshot().status).toBe('unavailable') + expect(controller.store.getSnapshot().error).toBeNull() + }) + + it('writes only the default field, into the agent-presets namespace', async () => { + const writes: Recorded[] = [] + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'core-web', trust: 'system', isDefault: false }, + ], { writes })) + await controller.load() + + await controller.select('core-web') + + expect(writes).toEqual([{ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: 'core-web' } }]) + expect(controller.store.getSnapshot().currentValue).toBe('core-web') + }) + + it('restores the previous value and surfaces the message when the write fails', async () => { + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'core-web', trust: 'system', isDefault: false }, + ], { failWrite: 'read-only settings' })) + await controller.load() + + await controller.select('core-web') + + const state = controller.store.getSnapshot() + expect(state.currentValue).toBe('standard') + expect(state.error).toBe('read-only settings') + expect(state.status).toBe('ready') + }) + + it('ignores a pick that is already the default', async () => { + const writes: Recorded[] = [] + const controller = new AgentPresetSettingsController(fakeApi([ + { id: 'standard', trust: 'system', isDefault: true }, + ], { writes })) + await controller.load() + + await controller.select('standard') + + expect(writes).toEqual([]) + }) + + it('surfaces a roster failure without claiming the deployment has no presets', async () => { + const controller = new AgentPresetSettingsController(fakeApi([], { failList: 'host down' })) + + await controller.load() + + const state = controller.store.getSnapshot() + expect(state.status).toBe('error') + expect(state.error).toBe('host down') + }) +}) diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json new file mode 100644 index 0000000000..3d17153642 --- /dev/null +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../connection" + }, + { + "path": "../locale" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-agent-preset/tsdown.config.ts b/packages/client/ui-agent-preset/tsdown.config.ts new file mode 100644 index 0000000000..3ede4df6a8 --- /dev/null +++ b/packages/client/ui-agent-preset/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-agent-preset', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d8a398a9c..ad50acbb2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: '@deepseek-ai/dsh-base': specifier: workspace:^ version: link:../../packages/bundle/base + '@deepseek-ai/dsh-client-ui-agent-preset': + specifier: workspace:^ + version: link:../../packages/client/ui-agent-preset '@deepseek-ai/dsh-headless': specifier: workspace:^ version: link:../../packages/bundle/headless @@ -1432,6 +1435,36 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-agent-preset: + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-command: dependencies: clsx: diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 5ef1edc4f9..d36d95e743 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -47,6 +47,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' }, 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, 'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' }, + 'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' }, 'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' }, 'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' }, 'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 3c7c2ba1d2..3836fdf250 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -157,6 +157,7 @@ "@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"], "@deepseek-ai/dsh-client-ui-model": ["./packages/client/ui-model/src"], "@deepseek-ai/dsh-client-ui-goal": ["./packages/client/ui-goal/src"], + "@deepseek-ai/dsh-client-ui-agent-preset": ["./packages/client/ui-agent-preset/src"], "@deepseek-ai/dsh-client-ui-permission": ["./packages/client/ui-permission/src"], "@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"], "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index e1d4088061..d30a42fc43 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -63,6 +63,7 @@ { "path": "./packages/client/ui-subagent" }, { "path": "./packages/client/ui-goal" }, { "path": "./packages/client/ui-model" }, + { "path": "./packages/client/ui-agent-preset" }, { "path": "./packages/client/ui-permission" }, { "path": "./packages/client/ui-plan" }, { "path": "./packages/client/ui-question" }, From bf4356cf354bee27e67fb46b4b3d1af91f607fc2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 10:26:43 +0800 Subject: [PATCH 019/293] feat(web): let a blank session switch its agent preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agentPreset.select` recomposes one session's agent from a different preset. It is allowed only while the session is blank — once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive; only the preset subtree is swapped. That was forced by what the host actually owns: api-proxy discards the `AgentHandle` it creates, and there is no delete RPC, so neither disposing nor recreating the session was available. Swapping the subtree is also the better answer — the session id, its workspace attachment, and its projections all stay put. `recompose` is unmount-then-mount because two compositions cannot coexist: both would register the same tool names into one layer. So it resolves the new preset BEFORE tearing anything down (an unknown id is a no-op) and restores the previous composition when the new one fails to mount, rather than leaving the agent with no tools at all. Both paths are pinned by test. Also restores the English half of the `agentPreset.list` README paragraph, which was lost before the previous commit — and `verify-translation-pairing --write` recorded the pair as consistent anyway, because it records whatever state it finds rather than checking the two sides say the same thing. --- docs/cordis-catalog/services.md | 21 ++++++- .../client/connection/src/client/fixture.ts | 2 + .../cordis/tool-cordis/src/api-catalog.ts | 4 ++ packages/host/apiproxy/README.i18n.yaml | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 49 ++++++++++++++++ .../apiproxy/src/api/agent-presets.schema.ts | 12 ++++ .../host/apiproxy/src/api/agent-presets.ts | 12 ++++ packages/host/apiproxy/src/api/rpc-map.ts | 1 + packages/host/apiproxy/src/api/rpc.schema.ts | 1 + packages/host/apiproxy/src/api/rpc.ts | 1 + packages/host/apiproxy/src/fetch/client.ts | 6 +- packages/host/apiproxy/src/fetch/handler.ts | 3 +- .../tests/api-proxy-agent-preset.spec.ts | 57 +++++++++++++++++++ .../apiproxy/tests/client-handler.spec.ts | 6 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 4 ++ packages/preset/agent-presets/src/index.ts | 47 ++++++++++++++- packages/preset/agent-presets/src/mount.ts | 27 ++++++++- .../preset/agent-presets/tests/mount.spec.ts | 49 ++++++++++++++++ 19 files changed, 295 insertions(+), 11 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 190f054be3..1dad9b9a01 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -96,9 +96,28 @@ async mount(agentCtx: Context, id?: string): Promise * @returns the agent's instance, or undefined when its preset mounts none. */ serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined + +/** + * Replace the composition installed for one agent. + * + * Only valid while the agent has produced nothing: swapping tools mid + * conversation would leave logged tool calls the new composition cannot make. + * The CALLER owns that check — this method does not read session history. + * + * The swap is unmount-then-mount because two compositions cannot coexist: + * both would register the same tool names into one layer. A failed mount + * therefore restores the previous composition rather than leaving the agent + * with nothing. + * @param agentCtx - the agent's scope context. + * @param id - the profile to compose the agent from instead. + * @returns the profile now installed. + * @throws when the profile is unknown or its composition is unusable; the + * previous composition is restored first. + */ +async recompose(agentCtx: Context, id: string): Promise ``` -Source: [`packages/preset/agent-presets/src/index.ts:54`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:56`](../../packages/preset/agent-presets/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 814796f0fa..db28dff49d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2321,6 +2321,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { { id: 'core-web', trust: 'system' as const, isDefault: false }, ], }), + select: request => ok(request, { agentPreset: request.payload.agentPreset }), }, skills: { @@ -2621,6 +2622,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) case 'agentPreset.list': return this.api.agentPresets.list(request) + case 'agentPreset.select': return this.api.agentPresets.select(request) case 'goal.create': return this.api.goals.create(request) case 'goal.edit': return this.api.goals.edit(request) case 'goal.pause': return this.api.goals.pause(request) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2ee97f3d2e..fec265d09c 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -100,6 +100,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined', jsDoc: '/**\n * One agent\'s instance of a service its preset mounted.\n *\n * A preset publishes services behind `isolate` realms, which are invisible\n * outside the group that declares them — including to the host. This is how a\n * caller holding the agent reads one anyway: a request that is ABOUT a\n * session but arrives from outside it, which is every browser RPC.\n *\n * Read addressing only. A host row that `inject`s a service cannot use this,\n * because injection resolves before any session exists and has no agent to\n * key by; such a service belongs on the host plane instead.\n * @param agent - the agent whose composition to look inside.\n * @param name - the service name as the preset\'s rows resolve it.\n * @returns the agent\'s instance, or undefined when its preset mounts none.\n */', }, + { + signature: 'async recompose(agentCtx: Context, id: string): Promise', + jsDoc: '/**\n * Replace the composition installed for one agent.\n *\n * Only valid while the agent has produced nothing: swapping tools mid\n * conversation would leave logged tool calls the new composition cannot make.\n * The CALLER owns that check — this method does not read session history.\n *\n * The swap is unmount-then-mount because two compositions cannot coexist:\n * both would register the same tool names into one layer. A failed mount\n * therefore restores the previous composition rather than leaving the agent\n * with nothing.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the profile to compose the agent from instead.\n * @returns the profile now installed.\n * @throws when the profile is unknown or its composition is unusable; the\n * previous composition is restored first.\n */', + }, ], }, { diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 990a055b43..673d5b8d0a 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md README.md: 9484fadcc798652979f998c81f84444c1ebdbf52 -README.zh.md: 44e1d4b563e52c2491e07854469bbb283e30b28b +README.zh.md: 238339213f6fec0f3f466907e5994953f391cd26 diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 21574c84e7..87f1a702dd 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -34,7 +34,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 -`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)以及它是否为当前默认值。该领域只读——preset 是磁盘上的一份组装,创作它是文件系统行为而非 RPC。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。 +`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)以及它是否为当前默认值。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent,且仅在会话空白时允许:一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f47b9c8f16..24ca249f5b 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2500,6 +2500,55 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro })), }) }, + + // Recomposing is limited to a blank session because a started + // conversation's history was produced under its preset's tools; the + // agent and the session survive, only the composition is swapped. + async select(request) { + const { sessionId, agentPreset } = request.payload + const presets = ctx.get('agentPresets') + if (presets === undefined) { + return err(request, { + code: 'agent-preset-not-found', + message: 'this deployment composes no agent presets', + details: { agentPreset, available: [] }, + }) + } + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const { agent } = found + if (!sessionBlank(agent.session)) { + return err(request, { + code: 'agent-preset-locked', + message: `session "${sessionId}" has already started; its agent preset is fixed`, + details: { sessionId, agentPreset }, + }) + } + try { + const preset = await presets.recompose(agent.ctx, agentPreset) + return ok(request, { agentPreset: preset.id }) + } catch (error: unknown) { + if (error instanceof UnknownPresetError) { + return err(request, { + code: 'agent-preset-not-found', + message: error.message, + details: { agentPreset: error.presetId, available: [...error.available] }, + }) + } + if (error instanceof PresetMountError) { + return err(request, { + code: 'agent-preset-invalid', + message: error.message, + details: { agentPreset: error.presetId, reason: error.reason }, + }) + } + return err(request, { + code: 'internal', + message: `failed to select agent preset "${agentPreset}": ${String(error)}`, + details: {}, + }) + } + }, }, skills: { diff --git a/packages/host/apiproxy/src/api/agent-presets.schema.ts b/packages/host/apiproxy/src/api/agent-presets.schema.ts index 0d4881d8cd..ed0312506c 100644 --- a/packages/host/apiproxy/src/api/agent-presets.schema.ts +++ b/packages/host/apiproxy/src/api/agent-presets.schema.ts @@ -6,6 +6,7 @@ import { z } from 'zod' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' +import { sessionIdSchema } from './sessions.schema.ts' import type { AgentPresetEntry } from './agent-presets.ts' /** AgentPresetEntry row of agentPreset.list. */ @@ -23,3 +24,14 @@ export const agentPresetListRequestSchema = z.object({ export const agentPresetListValueSchema = z.object({ presets: z.array(agentPresetEntrySchema), }) satisfies z.ZodType>> + +/** agentPreset.select request payload. */ +export const agentPresetSelectRequestSchema = z.object({ + sessionId: sessionIdSchema, + agentPreset: z.string().min(1), +}) satisfies z.ZodType>> + +/** agentPreset.select response value. */ +export const agentPresetSelectValueSchema = z.object({ + agentPreset: z.string(), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/agent-presets.ts b/packages/host/apiproxy/src/api/agent-presets.ts index 0f537f6a0f..83630d1dc3 100644 --- a/packages/host/apiproxy/src/api/agent-presets.ts +++ b/packages/host/apiproxy/src/api/agent-presets.ts @@ -4,6 +4,7 @@ * a filesystem act rather than an RPC. */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RpcRequest, RpcResponse } from './rpc.ts' /** One preset the deployment can compose a session's agent from. */ @@ -28,4 +29,15 @@ export interface AgentPresetsApi { * every session shares the host composition. */ list(request: RpcRequest<{}>): Promise> + + /** + * Recompose one session's agent from a different preset. + * + * Allowed only while the session is blank — no turn has run. Once a + * conversation starts, its history was produced under that preset's tools, + * and swapping them would leave logged tool calls the new composition cannot + * make; the attempt answers `agent-preset-locked`. + */ + select(request: RpcRequest<{ sessionId: SessionId; agentPreset: string }>): + Promise> } diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 726f7118d4..d0a6c7c292 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -52,6 +52,7 @@ export interface RpcMethodMap { 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] 'agentPreset.list': AgentPresetsApi['list'] + 'agentPreset.select': AgentPresetsApi['select'] 'goal.create': GoalsApi['create'] 'goal.edit': GoalsApi['edit'] 'goal.pause': GoalsApi['pause'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 6da119d5c3..12e19e61f5 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -46,6 +46,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), + z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }), z.object({ code: z.literal('agent-preset-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedPreset: z.string(), existingPreset: z.string().optional() }) }), z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 9bfb30bd11..5c412e4d15 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -44,6 +44,7 @@ export interface RpcErrorDetailsMap { 'directory-exists': { path: string } 'directory-create-failed': { path: string } 'directory-picker-unavailable': { capability: string } + 'agent-preset-locked': { sessionId: SessionId; agentPreset: string } 'agent-preset-conflict': { sessionId: SessionId; requestedPreset: string; existingPreset?: string } 'agent-preset-not-found': { agentPreset: string; available: string[] } 'agent-preset-invalid': { agentPreset: string; reason: string } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index c5eda9ca41..92504f0872 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -40,7 +40,7 @@ import { } from '../api/workspace.schema.ts' import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' -import { agentPresetListValueSchema } from '../api/agent-presets.schema.ts' +import { agentPresetListValueSchema, agentPresetSelectValueSchema } from '../api/agent-presets.schema.ts' import { goalCreateValueSchema, goalEditValueSchema, @@ -122,6 +122,7 @@ export interface IApiClient { } readonly agentPresets: { list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise>> + select(payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal): Promise>> } events: { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> @@ -190,6 +191,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType, signal?: AbortSignal) => this.callUnary('agentPreset.list', payload, signal), + select: (payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal) => + this.callUnary('agentPreset.select', payload, signal), } readonly goals: IApiClient['goals'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 278fa57c0b..9063c2e831 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -42,7 +42,7 @@ import { } from '../api/workspace.schema.ts' import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' -import { agentPresetListRequestSchema } from '../api/agent-presets.schema.ts' +import { agentPresetListRequestSchema, agentPresetSelectRequestSchema } from '../api/agent-presets.schema.ts' import { goalCreateRequestSchema, goalEditRequestSchema, @@ -111,6 +111,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, 'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) }, + 'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) }, 'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) }, 'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) }, 'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index abcedeb8d3..5473d3ec63 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -52,6 +52,10 @@ function roster(ids: readonly string[]): unknown { const perAgent = services.get(String(agent.id)) return perAgent?.[name] }, + recompose: (_ctx: Context, id: string) => { + if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids)) + return Promise.resolve({ id, trust: 'system', path: `/presets/${id}.yml` }) + }, } } @@ -262,3 +266,56 @@ describe('agentPreset.list', () => { expect(response.result.value.presets).toEqual([]) }) }) + +describe('agentPreset.select', () => { + it('recomposes a blank session', async () => { + const { api } = await harness(['standard', 'core-web']) + await api.sessions.create(request({ sessionId: SessionId('sel-1'), agentPreset: 'standard' })) + + const response = await api.agentPresets.select( + request({ sessionId: SessionId('sel-1'), agentPreset: 'core-web' })) + + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.agentPreset).toBe('core-web') + }) + + it('refuses once the conversation has started', async () => { + const { api, ctx } = await harness(['standard', 'core-web']) + await api.sessions.create(request({ sessionId: SessionId('sel-2'), agentPreset: 'standard' })) + // One turn is enough: the history from here on was produced under + // `standard`'s tools, and a swap would strand those tool calls. + ctx.sessions.get(SessionId('sel-2'))?.append('turn/start', { turn: 0 }) + + const response = await api.agentPresets.select( + request({ sessionId: SessionId('sel-2'), agentPreset: 'core-web' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-locked') + }) + + it('reports an unknown preset without disturbing the session', async () => { + const { api } = await harness(['standard']) + await api.sessions.create(request({ sessionId: SessionId('sel-3') })) + + const response = await api.agentPresets.select( + request({ sessionId: SessionId('sel-3'), agentPreset: 'nope' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) + + it('reports a deployment that composes no presets', async () => { + const { api } = await harness() + await api.sessions.create(request({ sessionId: SessionId('sel-4') })) + + const response = await api.agentPresets.select( + request({ sessionId: SessionId('sel-4'), agentPreset: 'anything' })) + + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + expect(response.result.error.code).toBe('agent-preset-not-found') + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index ed4a5341f1..bc3f6aa840 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -87,7 +87,11 @@ function scriptedApi(overrides: { ...overrides.commands, }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, - agentPresets: { list: r => ok(r, { presets: [] }), ...overrides.agentPresets }, + agentPresets: { + list: r => ok(r, { presets: [] }), + select: r => ok(r, { agentPreset: r.payload.agentPreset }), + ...overrides.agentPresets, + }, goals: { create: err, edit: err, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 4dc46ce66a..c792def3b4 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -197,6 +197,10 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra list(request: RpcRequest<{}>) { return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: { presets: [] } } }) }, + select(request: RpcRequest<{ agentPreset: string }>) { + const value = { agentPreset: request.payload.agentPreset } + return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } }) + }, }, skills: { async list(request) { diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 6914445f08..e6a74c346c 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -11,10 +11,11 @@ */ import { Context, Service } from 'cordis' +import { scopeOf } from '@deepseek-ai/dsh-scope' import z from 'schemastery' import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings' import { discoverPresets } from './discovery.ts' -import { mountPreset, serviceForAgent } from './mount.ts' +import { mountPreset, serviceForAgent, unmountPresetFor } from './mount.ts' import { UnknownPresetError, type AgentPreset, type Config } from './types.ts' /** Settings namespace carrying the user's chosen default preset. */ @@ -33,7 +34,8 @@ export const AgentPresetSettingsSchema: z = z.object({ export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' export { - inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, type PresetMount, + inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, + unmountPresetFor, type PresetMount, } from './mount.ts' export { PresetMountError, UnknownPresetError } from './types.ts' export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts' @@ -157,6 +159,47 @@ export class AgentPresets extends Service { serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined { return serviceForAgent(this.ctx, agent, name) } + + /** + * Replace the composition installed for one agent. + * + * Only valid while the agent has produced nothing: swapping tools mid + * conversation would leave logged tool calls the new composition cannot make. + * The CALLER owns that check — this method does not read session history. + * + * The swap is unmount-then-mount because two compositions cannot coexist: + * both would register the same tool names into one layer. A failed mount + * therefore restores the previous composition rather than leaving the agent + * with nothing. + * @param agentCtx - the agent's scope context. + * @param id - the preset to compose the agent from instead. + * @returns the preset now installed. + * @throws when the preset is unknown or its composition is unusable; the + * previous composition is restored first. + */ + async recompose(agentCtx: Context, id: string): Promise { + const scope = scopeOf(agentCtx) + if (scope === undefined) { + throw new Error('agent-presets: refusing to recompose an unscoped context') + } + // Resolve before tearing anything down, so an unknown id leaves the agent + // exactly as it was. + const preset = await this.resolve(id) + const previous = await unmountPresetFor(scope) + try { + await mountPreset(agentCtx, preset) + } catch (error) { + if (previous !== undefined && previous !== preset.id) { + await this.mount(agentCtx, previous).catch(() => { + // The agent now has no composition, but the switch failure below is + // the actionable diagnostic and the restore had the same inputs that + // worked a moment ago; reporting its failure instead would hide why. + }) + } + throw error + } + return preset + } } export default AgentPresets diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index 6834a24c66..261aea0b3a 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -18,7 +18,7 @@ import { pathToFileURL } from 'node:url' import { Context, type Fiber } from 'cordis' import { Include } from '@cordisjs/plugin-include' import type { EntryTree } from '@cordisjs/plugin-loader' -import { scopeOf } from '@deepseek-ai/dsh-scope' +import { scopeOf, type ScopeKey } from '@deepseek-ai/dsh-scope' import { PresetMountError, type AgentPreset } from './types.ts' /** What one mounted subtree publishes about itself for the audit to read. */ @@ -77,6 +77,8 @@ export interface PresetMount { readonly presetId: string /** The mounted subtree's fiber. */ readonly fiber: Fiber + /** The scope the subtree was mounted for — the agent that owns it. */ + readonly scope: ScopeKey } const mounts = new Set() @@ -113,6 +115,24 @@ export function livePresetMounts(): PresetMount[] { return [...mounts] } +/** + * Discard the composition currently installed for one scope, if any. + * + * Only a composition that has produced nothing may be replaced: swapping a + * live agent's tools mid-conversation would leave logged tool calls the new + * composition cannot make. The caller owns that check — this function does the + * teardown and returns once the subtree is quiescent. + * @param scope - the agent whose installed composition to discard. + * @returns the preset id that was discarded, or `undefined` when none was. + */ +export async function unmountPresetFor(scope: ScopeKey): Promise { + const installed = livePresetMounts().find(mount => mount.scope === scope) + if (installed === undefined) return undefined + mounts.delete(installed) + await Promise.resolve(installed.fiber.dispose()) + return installed.presetId +} + /** * Whether `fiber` is `root` itself or is mounted anywhere inside its subtree. * @@ -241,7 +261,8 @@ export function inactiveRows(tree: EntryTree): string[] { * published a service into the root realm. */ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promise { - if (scopeOf(agentCtx) === undefined) { + const scope = scopeOf(agentCtx) + if (scope === undefined) { throw new Error( `agent-presets: refusing to mount preset "${preset.id}" into an unscoped context; ` + 'its registrations would apply to every agent in the process', @@ -269,7 +290,7 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi + 'a preset service must sit behind an `isolate` realm or move to the host composition', ) } - mounts.add({ presetId: preset.id, fiber }) + mounts.add({ presetId: preset.id, fiber, scope }) } catch (error) { try { await handle.dispose() diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index e322ceb7ad..495a15b6d7 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -287,3 +287,52 @@ describe('the preset file is an input, never a persistence target', () => { expect(await readFile(path, 'utf8')).toBe(composition) }) }) + +describe('replacing a composition', () => { + it('swaps the agent\'s tools without touching another session', async () => { + const keeper = await agentOn(ctx, 'sess-keeper', 'standard') + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-swap'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + + await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal') + + expect(toolNames(ctx, handle.agent)).toEqual(['beta']) + expect(toolNames(ctx, keeper)).toEqual(['alpha']) + expect(toolNames(ctx)).toEqual([]) + }) + + it('leaves the agent on its previous composition when the new one is unknown', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-unknown'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'nope')) + .rejects.toThrow(/not found/) + + // Resolution happens before any teardown, so an unknown id is a no-op. + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + }) + + it('restores the previous composition when the new one fails to mount', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-restore'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'broken')) + .rejects.toThrow(/failed to mount/) + + // The swap is unmount-then-mount, so a failure must put the old one back + // rather than leave the agent with no tools at all. + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + }) + + it('refuses an unscoped context', async () => { + await expect(ctx.agentPresets.recompose(ctx, 'minimal')) + .rejects.toThrow(/unscoped context/) + }) +}) From a2ab09003f0520b5b8d137cdb1e6a759ed421b3d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 10:54:13 +0800 Subject: [PATCH 020/293] feat(web): pick a session's agent preset from the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A seat in the composer tool row, left of the model select, showing the preset THIS session runs — its own recorded one, not the deployment default, because a resumed session runs what it was created with. `SessionSummary` carries `agentPreset` for that, alongside `cwd` and `origin`. The switch exists only while the conversation has not started. After the first turn the seat becomes a plain label rather than a disabled menu: a greyed control reads as "temporarily unavailable", when in fact the choice is gone for good. The seat never asks in that state, and the host refuses independently with `agent-preset-locked`, so a stale client cannot slip one past it. A rejected switch restores the previous value and surfaces the host's message rather than leaving the seat showing something the session is not running. --- packages/client/connection/tests/fake-api.ts | 2 + .../runtime/src/client/sessions/lineage.ts | 2 + .../runtime/src/client/sessions/service.ts | 7 ++ packages/client/runtime/tests/fake-api.ts | 2 + .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 7 +- packages/client/ui-agent-preset/README.zh.md | 7 +- packages/client/ui-agent-preset/package.json | 5 +- .../src/client/AgentPresetSeat.module.css | 34 ++++++ .../src/client/AgentPresetSeat.tsx | 95 +++++++++++++++++ .../ui-agent-preset/src/client/index.ts | 38 ++++++- .../ui-agent-preset/src/client/locales.ts | 6 +- .../ui-agent-preset/src/client/seat-store.ts | 100 ++++++++++++++++++ .../tests/settings-store.spec.ts | 98 +++++++++++++++++ packages/client/ui-agent-preset/tsconfig.json | 3 + .../ui-conversation/src/client/apply.ts | 1 + .../src/client/contract/slots.ts | 8 +- .../src/client/skeleton/InputBar.tsx | 1 + .../ui-conversation/tests/input-bar.spec.tsx | 8 +- packages/host/apiproxy/src/api-proxy.ts | 2 + .../host/apiproxy/src/api/sessions.schema.ts | 1 + packages/host/apiproxy/src/api/sessions.ts | 7 ++ pnpm-lock.yaml | 3 + 23 files changed, 430 insertions(+), 11 deletions(-) create mode 100644 packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css create mode 100644 packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx create mode 100644 packages/client/ui-agent-preset/src/client/seat-store.ts diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index faa433e67e..48812e4fd6 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -169,6 +169,8 @@ export class FakeApiClient implements IApiClient { readonly agentPresets: IApiClient['agentPresets'] = { list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))), + select: (payload: { agentPreset: string }) => + this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), } readonly skills: IApiClient['skills'] = { diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 69094f2964..cf8fa0834d 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -25,6 +25,8 @@ export interface SessionListEntry { /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' cwd?: string + /** Agent preset the session's agent was composed from (summary passthrough). */ + agentPreset?: string /** Current host-computed projection values for list consumers. */ projectionValues?: Readonly> /** User interaction currently blocking this session, derived from live mux frames. */ diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index b1b271e702..b199c0724a 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -45,6 +45,12 @@ export interface SessionSummary { /** Human-facing label: durable title, project basename, then session id. */ displayTitle: string cwd?: string + /** + * Agent preset this session's agent was composed from; absent when the + * deployment composes no presets. A composer seat shows what the session + * actually runs rather than the deployment's current default. + */ + agentPreset?: string parentId?: SessionId /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' @@ -629,6 +635,7 @@ export class SessionsService implements ISessions { ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), ...(entry.origin !== undefined ? { origin: entry.origin } : {}), + ...(entry.agentPreset !== undefined ? { agentPreset: entry.agentPreset } : {}), } } if (current !== undefined && currentAddress !== undefined) { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 9694ea9cfb..b715fb329b 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -204,6 +204,8 @@ export class FakeApiClient implements IApiClient { readonly agentPresets: IApiClient['agentPresets'] = { list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))), + select: (payload: { agentPreset: string }) => + this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), } readonly skills: IApiClient['skills'] = { diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index aee8c88bb2..d25bf7b502 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md -README.md: c22d5a40659bf6300f133ff902fbc3bf8bc75276 -README.zh.md: 6b7e43adcdfe7d6de781511edb5d91a4c45cf11e +README.md: 322fc7c8ba6eb621f27cb09475079e3d5bccf03f +README.zh.md: 6eece3248d7f3c3652a30579f907eaf5f888f35f diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index ecfa962cdc..14921afb7b 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -8,6 +8,12 @@ The agent-preset surface: one General-settings row choosing which [preset](../.. A session's preset is fixed when the session is created — the host refuses to adopt an existing session under a different one, because that session's history was produced under the first preset's tools. So this row cannot be a live switch, and it says so: changing it applies to sessions started afterwards while running sessions keep the composition they began with. +## The composer seat + +A second surface, in the composer tool row left of the model select: the preset THIS session runs. It shows the session's own recorded preset rather than the deployment default, because a resumed session runs what it was created with. + +The switch exists only while the conversation has not started. After the first turn the seat becomes a plain label — offering a disabled menu would suggest the choice is merely unavailable rather than gone. The host enforces the same rule and answers `agent-preset-locked`, so a stale client cannot slip a switch past it. + ## What it reads and writes Options and the current default both come from one `agentPreset.list` call. The roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection; the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. @@ -30,6 +36,5 @@ No direct invalidation. Changing the default never touches a running session's p ## Known Limitations and Deferred Work -- **No per-session choice at creation** — this row sets the default only. The wire already carries `agentPreset` on `session.create`, so a session-start surface can offer the choice; that surface does not exist yet. - **Presets are listed by id** — a preset carries no display metadata, so the menu shows directory names. - **No authoring** — creating, editing, or deleting a preset is a filesystem act; this surface only chooses among what the roster supplies. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 915777875e..06807199e9 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -8,6 +8,12 @@ agent preset 表层:General 设置中的一行,用于选择新建会话据 会话的 preset 在创建时即固定——宿主拒绝以不同 preset 接管已存在的会话,因为该会话的历史是在最初那份 preset 的工具下产生的。因此本行不可能是实时切换,它也如实说明了这一点:更改只对此后开启的会话生效,而运行中的会话保持它们开始时的组装。 +## composer 座位 + +第二个表层,位于 composer 工具行、模型选择器左侧:**本会话**所运行的 preset。它显示会话自身记录的 preset 而非部署默认值,因为被恢复的会话运行的是它创建时的那一份。 + +切换只在对话尚未开始时存在。第一个轮次之后,该座位变为纯文本标签——展示一个禁用的菜单会让人以为这个选择只是暂时不可用,而非已经消失。宿主执行同一条规则并返回 `agent-preset-locked`,因此过期的客户端无法绕过它。 + ## 它读什么、写什么 选项与当前默认值都来自同一次 `agentPreset.list` 调用。名单本身已经报告了"未显式选择的会话会得到哪个 id",因此本行无需对 settings schema 做内省;写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的那个字段。 @@ -30,6 +36,5 @@ Indirectly, through the preset a later session is composed from; [`dsh-agent-pre ## Known Limitations and Deferred Work -- **创建时无法逐会话选择** —— 本行只设置默认值。wire 上 `session.create` 已经携带 `agentPreset`,因此会话开启表层可以提供该选择;该表层尚不存在。 - **preset 按 id 列出** —— preset 不携带展示用元数据,因此菜单显示的是目录名。 - **不提供创作能力** —— 创建、编辑或删除 preset 是文件系统行为;本表层只在名单提供的范围内做选择。 diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index e82613cc0e..5022fa8614 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -26,7 +26,8 @@ "inject": [ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime" + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation" ], "platform": "web" }, @@ -39,6 +40,7 @@ "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", @@ -49,6 +51,7 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css new file mode 100644 index 0000000000..f63bbf60d6 --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -0,0 +1,34 @@ +/* Agent-preset seat: a compact selector pill in the composer tool row. */ + +.seat { + display: inline-flex; + align-items: center; + gap: 2px; + height: 28px; + padding: 0 6px 0 8px; + border: none; + border-radius: 8px; + background: transparent; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.seat:hover:not(:disabled) { + background: var(--dsw-alias-fill-tsp-secondary); +} + +.seat:disabled { + cursor: default; + color: var(--dsw-alias-label-quaternary); +} + +.chevron { + flex: none; + opacity: 0.6; +} + +.locked { + cursor: default; +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx new file mode 100644 index 0000000000..3e8d08479a --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -0,0 +1,95 @@ +/** + * Composer seat for the session's agent preset. + * + * The switch exists only while the conversation has not started: after the + * first turn the session's history was produced under this preset's tools, so + * the seat becomes a plain label rather than offering a choice it cannot honor. + */ + +import { useEffect, useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +// Type-only: pulls the ui-conversation SlotMap merge (the agentPreset seat). +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { AgentPresetSeatState } from './seat-store.ts' +import css from './AgentPresetSeat.module.css' + +/** Registration-side business face for the composer seat. */ +export interface AgentPresetSeatInjected { + hooks: { + /** Seat snapshot bound by the renderer as useAgentPresetSeat. */ + agentPresetSeat: SnapshotStore + } + /** Load the roster and this session's state when the seat first renders. */ + load: () => Promise + /** Switch this session to another preset. */ + select: (id: string) => Promise +} + +/** Full component props. */ +export type AgentPresetSeatProps = + PropsRuntime<'conversation.input.agentPreset'> + & PropsLocale<'settings.agentPreset'> + & InjectFace + +/** + * Render the session's agent-preset seat. + * @param props - composed slot props; `locked` is the composer's own busy state. + * @returns the seat, or null when the deployment composes no presets. + */ +export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }: AgentPresetSeatProps) { + const state = useAgentPresetSeat(snapshot => snapshot) + const [open, setOpen] = useState(false) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + if (state.switchable) return + setOpen(false) + }, [state.switchable]) + + // Nothing to choose between: the deployment composes no presets and every + // session shares the host composition. + if (state.options.length === 0 || state.current === '') return null + + // Past the first turn the preset is a fact about this session, not a + // control — showing a disabled menu would suggest it could still be changed. + if (!state.switchable) { + return {state.current} + } + + return ( + { setOpen(false) }} + items={state.options.map(option => ({ + id: option.id, + label: option.trust === 'user' ? `${option.id} · ${t('userTrust')}` : option.id, + }))} + selectedId={state.current} + onSelect={(id) => { + setOpen(false) + void select(id) + }} + align="end" + portal + anchor={( + + )} + /> + ) +} diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 2101e69630..6b23c63c8d 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -12,13 +12,18 @@ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { AgentPresetRow } from './AgentPresetRow.tsx' import type { AgentPresetRowInjected } from './AgentPresetRow.tsx' +import { AgentPresetSeat } from './AgentPresetSeat.tsx' +import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx' +import { AgentPresetSeatController } from './seat-store.ts' import { en, zh } from './locales.ts' import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts' export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx' +export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx' +export type { AgentPresetSeatState } from './seat-store.ts' export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts' export { AGENT_PRESET_SETTINGS_NS } from './settings-store.ts' @@ -54,6 +59,37 @@ export function apply(ctx: ClientContext): void { return () => { for (const dispose of disposers) dispose() } }, 'ui-agent-preset: settings refresh') + // The composer seat: one controller per session, because the switch and the + // "may it still switch" bit are both per-session facts. + ctx.inject(['slots', 'conversation', 'sessions'], (scope: ClientContext) => { + const api = (scope.get('connection') as ConnectionHandle).api + const seats = new Map() + const seatFor = (sessionId: SessionId): AgentPresetSeatController => { + const existing = seats.get(sessionId) + if (existing !== undefined) return existing + const created = new AgentPresetSeatController(api, sessionId, () => { + const summary = scope.sessions.list.getSnapshot().byId[sessionId] + return summary === undefined + ? undefined + : { blank: summary.blank, ...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset } } + }) + seats.set(sessionId, created) + return created + } + scope.effect(() => scope.slots.register({ + name: 'conversation.input.agentPreset', + locale: 'settings.agentPreset', + inject: (sessionId: SessionId): AgentPresetSeatInjected => { + const seat = seatFor(sessionId) + return { + hooks: { agentPresetSeat: seat.store }, + load: () => seat.load(), + select: (id: string) => seat.select(id), + } + }, + }, AgentPresetSeat), 'ui-agent-preset: composer seat registration') + }) + ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', id: 'agent-preset', diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 6bcc2767d4..cfeb7e5ff5 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -2,7 +2,7 @@ /** Locale keys this row renders. */ export type AgentPresetSettingsKey = - | 'title' | 'description' | 'loading' | 'error' | 'userTrust' + | 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'lockedHint' /** English copy. */ export const en: Record = { @@ -11,6 +11,8 @@ export const en: Record = { loading: 'Loading presets…', error: 'Could not load agent presets.', userTrust: 'Local', + seatHint: 'Agent preset for this session — switchable until you send the first message', + lockedHint: 'This session\'s agent preset is fixed once the conversation starts', } /** Simplified Chinese copy. */ @@ -20,4 +22,6 @@ export const zh: Record = { loading: '正在加载 preset…', error: '无法加载 agent preset。', userTrust: '本地', + seatHint: '本会话的 agent preset —— 发送第一条消息前可切换', + lockedHint: '会话开始后,其 agent preset 即固定', } diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts new file mode 100644 index 0000000000..98193c14de --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -0,0 +1,100 @@ +/** + * Composer-seat controller: what one session may switch to, and whether it + * still may. + * + * A session's composition is fixed once its conversation starts, so the seat + * reads the session's own `blank` bit rather than a local guess — the host + * enforces the same rule and answers `agent-preset-locked` to a late attempt. + */ + +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { + createSnapshotStore, type SessionId, type SnapshotStore, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { AgentPresetOption } from './settings-store.ts' + +/** Composer-seat snapshot for one session. */ +export interface AgentPresetSeatState { + /** Presets the deployment supplies; empty means the seat renders nothing. */ + options: readonly AgentPresetOption[] + /** The preset this session runs, empty until the roster and summary load. */ + current: string + /** False once the conversation has started — the switch is gone for good. */ + switchable: boolean + /** A rejected switch's message, cleared by the next attempt. */ + error: string | null + busy: boolean +} + +const INITIAL: AgentPresetSeatState = { + options: [], current: '', switchable: false, error: null, busy: false, +} + +/** Reads what one session may switch to and performs the switch. */ +export class AgentPresetSeatController { + /** Seat snapshot the renderer subscribes to. */ + readonly store: SnapshotStore = createSnapshotStore(INITIAL) + + constructor( + private readonly api: IApiClient, + private readonly sessionId: SessionId, + /** Reads this session's blank bit and recorded preset from the session list. */ + private readonly summary: () => { blank: boolean; agentPreset?: string } | undefined, + ) {} + + private set(patch: Partial): void { + this.store.set({ ...this.store.getSnapshot(), ...patch }) + } + + /** + * Load the roster and reconcile with this session's own state. + * @returns once the snapshot reflects the host. + */ + async load(): Promise { + const summary = this.summary() + try { + const response = await this.api.agentPresets.list({}) + if (!response.result.ok) { + this.set({ error: response.result.error.message }) + return + } + const presets = response.result.value.presets + this.set({ + options: presets.map(preset => ({ id: preset.id, trust: preset.trust })), + // The session's recorded preset wins over the roster default: a + // resumed session runs what it was created with, not what the + // deployment now prefers. + current: summary?.agentPreset ?? presets.find(preset => preset.isDefault)?.id ?? '', + switchable: summary?.blank ?? false, + error: null, + }) + } catch (error) { + this.set({ error: error instanceof Error ? error.message : String(error) }) + } + } + + /** + * Switch this session to another preset. + * @param id - the preset to compose the session's agent from. + * @returns once the switch settled; a rejection leaves the previous value. + */ + async select(id: string): Promise { + const before = this.store.getSnapshot() + if (before.busy || id === before.current || !before.switchable) return + this.set({ busy: true, error: null, current: id }) + try { + const response = await this.api.agentPresets.select({ sessionId: this.sessionId, agentPreset: id }) + if (!response.result.ok) { + this.set({ busy: false, current: before.current, error: response.result.error.message }) + return + } + this.set({ busy: false, current: response.result.value.agentPreset }) + } catch (error) { + this.set({ + busy: false, + current: before.current, + error: error instanceof Error ? error.message : String(error), + }) + } + } +} diff --git a/packages/client/ui-agent-preset/tests/settings-store.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.spec.ts index cd6a38ee74..e8d8c19d62 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.spec.ts @@ -10,6 +10,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, } from '../src/client/settings-store.ts' +import { AgentPresetSeatController } from '../src/client/seat-store.ts' interface Recorded { ns: string; patch: unknown } @@ -120,3 +121,100 @@ describe('the agent-preset settings controller', () => { expect(state.error).toBe('host down') }) }) + +describe('the composer seat controller', () => { + /** A seat over a fixed session summary. */ + function seat( + presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], + summary: { blank: boolean; agentPreset?: string } | undefined, + options: { writes?: Recorded[]; failSelect?: string } = {}, + ): AgentPresetSeatController { + const api = { + agentPresets: { + list: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { presets } } }), + select: (payload: { agentPreset: string }) => { + options.writes?.push({ ns: 'select', patch: payload.agentPreset }) + return Promise.resolve(options.failSelect === undefined + ? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } } + : { rpcId: 'r', result: { ok: false as const, error: { code: 'agent-preset-locked', message: options.failSelect, details: {} } } }) + }, + }, + } as unknown as IApiClient + return new AgentPresetSeatController(api, 's1' as never, () => summary) + } + + const ROSTER: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] = [ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'core-web', trust: 'system', isDefault: false }, + ] + + it('shows what the session runs, not the deployment default', async () => { + const controller = seat(ROSTER, { blank: true, agentPreset: 'core-web' }) + + await controller.load() + + // A resumed session runs what it was created with; showing `standard` + // because it is the current default would be a lie about this session. + expect(controller.store.getSnapshot().current).toBe('core-web') + expect(controller.store.getSnapshot().switchable).toBe(true) + }) + + it('falls back to the roster default when the session records none', async () => { + const controller = seat(ROSTER, { blank: true }) + + await controller.load() + + expect(controller.store.getSnapshot().current).toBe('standard') + }) + + it('is not switchable once the conversation has started', async () => { + const controller = seat(ROSTER, { blank: false, agentPreset: 'standard' }) + + await controller.load() + + expect(controller.store.getSnapshot().switchable).toBe(false) + }) + + it('refuses to switch a session that already started', async () => { + const writes: Recorded[] = [] + const controller = seat(ROSTER, { blank: false, agentPreset: 'standard' }, { writes }) + await controller.load() + + await controller.select('core-web') + + // The host enforces the same rule; the seat simply never asks. + expect(writes).toEqual([]) + expect(controller.store.getSnapshot().current).toBe('standard') + }) + + it('switches a blank session and keeps the host\'s answer', async () => { + const writes: Recorded[] = [] + const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { writes }) + await controller.load() + + await controller.select('core-web') + + expect(writes).toEqual([{ ns: 'select', patch: 'core-web' }]) + expect(controller.store.getSnapshot().current).toBe('core-web') + }) + + it('restores the previous value when the host rejects the switch', async () => { + const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { failSelect: 'already started' }) + await controller.load() + + await controller.select('core-web') + + const state = controller.store.getSnapshot() + expect(state.current).toBe('standard') + expect(state.error).toBe('already started') + }) + + it('reports no options when the session is unknown to the list yet', async () => { + const controller = seat([], undefined) + + await controller.load() + + expect(controller.store.getSnapshot().options).toEqual([]) + expect(controller.store.getSnapshot().switchable).toBe(false) + }) +}) diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json index 3d17153642..9f22f84a4a 100644 --- a/packages/client/ui-agent-preset/tsconfig.json +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../runtime" }, + { + "path": "../ui-conversation" + }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6bc9068cfc..1a074dac1e 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -228,6 +228,7 @@ export function apply(ctx: Context): void { children: { 'conversation.input.plan': { kind: 'single', scope: 'session' }, 'conversation.input.model': { kind: 'single', scope: 'session' }, + 'conversation.input.agentPreset': { kind: 'single', scope: 'session' }, }, inject: (sessionId: SessionId | undefined): ComposerBarInjected => { if (sessionId === undefined) { diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a84b4a3bf0..81468a6b2f 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -102,6 +102,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * empty-until-registered contract as the plan seat. */ 'conversation.input.model': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps } + /** + * The agent-preset seat in the composer tool row, left of the model. + * Same empty-until-registered contract as the other two; its owner + * decides on its own whether the session may still switch. + */ + 'conversation.input.agentPreset': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps } } /** @@ -325,7 +331,7 @@ export interface InputControlOwnerProps { /** Full composer-bar props: standard kit & owner share & control-seat render share & injected share (hooks bound) & locale seat. */ export type ComposerBarProps = PropsRuntime<'conversation.composer.bar'> - & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'> + & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model' | 'conversation.input.agentPreset'> & InjectFace & PropsLocale<'conversation'> diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 131f63c49d..9687582427 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -512,6 +512,7 @@ export function InputBar({
{rightItems} + {renderSlot('conversation.input.agentPreset', { locked })} {renderSlot('conversation.input.model', { locked })} {/* {machineBusy && } */} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index bc276174a3..116aeeb26d 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -686,13 +686,15 @@ describe('strips and variants', () => { }) describe('command launcher chrome and control seats', () => { - it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => { + it('renders the command launcher; the Access chip is absent without the permissions projection; the control seats render EMPTY without entries (B ruling)', () => { const { view, slotCalls } = bench() expect(view.getByLabelText('命令')).toBeTruthy() // Capability absent (no projection value): the chip renders nothing. expect(view.queryByLabelText(/^访问模式/)).toBeNull() - // Both seats dispatched, nothing rendered. - expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model']) + // Every seat dispatched, nothing rendered. + expect(slotCalls.map(c => c.key)).toEqual([ + 'conversation.input.plan', 'conversation.input.agentPreset', 'conversation.input.model', + ]) expect(view.queryByLabelText('Plan mode')).toBeNull() expect(view.queryByLabelText('Model')).toBeNull() }) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 24ca249f5b..27e44a65dc 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -260,11 +260,13 @@ function sessionListFields(header: SessionHeader): { parentSessionId?: SessionId origin?: 'subagent' cwd?: string + agentPreset?: string } { return { ...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }, ...header.origin === undefined ? {} : { origin: header.origin }, ...header.cwd === undefined ? {} : { cwd: header.cwd }, + ...header.agentPreset === undefined ? {} : { agentPreset: header.agentPreset }, } } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index a3c7845aad..1ee2c9b1ac 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -55,6 +55,7 @@ export const sessionSummarySchema = z.object({ parentSessionId: sessionIdSchema.optional(), origin: z.literal('subagent').optional(), cwd: z.string().optional(), + agentPreset: z.string().optional(), projections: z.lazy(() => sessionProjectionsBlockSchema).optional(), }) as unknown as z.ZodType> diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 0427be392a..893d3260a6 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -156,6 +156,13 @@ export interface SessionSummary { origin?: 'subagent' /** Session working directory (header.cwd passthrough); absent when unrecorded. */ cwd?: string + /** + * Agent preset this session's agent was composed from (header passthrough); + * absent when the deployment composes no presets. A surface offering a + * switch reads this to show what the session actually runs rather than what + * the deployment currently defaults to. + */ + agentPreset?: string /** * Projection baseline for this row, with zero log loads: attached sessions * read the registry's live watermark cut; cold sessions read the persisted diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad50acbb2b..257eee1465 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1446,6 +1446,9 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives From 98fbe0ee940efdfcb498cd7ebf21f541aa4c273f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 15:46:44 +0800 Subject: [PATCH 021/293] fix(web): record which preset a session actually runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The creation header names the preset a session STARTED with and is frozen, which is correct — it is a creation fact. Switching is legal only while a session is blank, and that looked like enough: no history exists yet. It is not, because the switch's effect outlives the blank window. The user switches, then sends the first message; every turn from there runs under the new composition while the header still names the old one. The session is then locked around a misrecorded preset, and resume reads the header to rebuild it — composing one preset's tools over a history another produced, which is exactly the replay the blank-only lock exists to prevent, reached by another route. A picker showed `standard` for a session running `core-web`. A switch is now an `agent-preset/selected` event appended after the swap commits, and `resolveSessionPreset()` (last selection, else the header) is what every reconstruction reads: the summary, resume, the conflict guard, and the fork introduced one layer down. --- apps/cli/tests/web-agent-presets.spec.ts | 41 +++++++++++++- docs/cordis-catalog/services.md | 8 +-- docs/module-graph.md | 50 ++++++++++------- docs/persistence-catalog.md | 16 ++++++ packages/bundle/web-app/package.json | 1 + .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/package.json | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/src/api-proxy.ts | 28 +++++++--- .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 6 +++ packages/preset/agent-presets/README.zh.md | 6 +++ packages/preset/agent-presets/package.json | 1 + packages/preset/agent-presets/src/index.ts | 1 + packages/preset/agent-presets/src/session.ts | 54 +++++++++++++++++++ packages/preset/agent-presets/tsconfig.json | 3 ++ pnpm-lock.yaml | 3 ++ 18 files changed, 192 insertions(+), 44 deletions(-) create mode 100644 packages/preset/agent-presets/src/session.ts diff --git a/apps/cli/tests/web-agent-presets.spec.ts b/apps/cli/tests/web-agent-presets.spec.ts index 5b946d561c..8ee2730dfc 100644 --- a/apps/cli/tests/web-agent-presets.spec.ts +++ b/apps/cli/tests/web-agent-presets.spec.ts @@ -9,7 +9,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@cordisjs/plugin-include' import { beforeAll, describe, expect, it } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' +import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-tools' const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url)) @@ -177,6 +177,43 @@ describe('the shipped Web composition', () => { }) }) +describe('a switch survives the session', () => { + it('records the choice so the log states what the agent runs', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-switch-logged'), + meta: { agentPreset: 'standard' }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + // The api-proxy's select does exactly this pair while the session is blank. + await ctx.agentPresets.recompose(handle.agent.ctx, 'core-web') + handle.agent.session.append('agent-preset/selected', { agentPreset: 'core-web' }) + + // The header keeps the creation fact; the log carries what it runs. + expect(handle.agent.session.header.agentPreset).toBe('standard') + expect(resolveSessionPreset(handle.agent.session)).toBe('core-web') + } finally { + await handle.dispose() + } + }) + + it('rebuilds a switched session from the log, not the creation header', () => { + // The exact shape a resume reads back from disk: the header says standard, + // the log records the switch the user made while the session was blank. + const rebuilt = resolveSessionPreset({ + header: { version: 0, id: SessionId('x'), createdAt: 0, agentPreset: 'standard' }, + events: [ + { type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'core-web' } }, + { type: 'turn/start', seq: 2, time: 0, data: { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } } }, + ] as never, + }) + + // Reading the header alone would compose the creation-time preset over a + // history another one produced — the replay the blank-only lock prevents. + expect(rebuilt).toBe('core-web') + }) +}) + describe('a forked session', () => { it('inherits the composition its seeded history was produced under', async () => { const parent = await ctx.agents.create({ @@ -184,7 +221,7 @@ describe('a forked session', () => { meta: { agentPreset: 'core-web' }, setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined), }) - const inherited = parent.agent.session.header.agentPreset + const inherited = resolveSessionPreset(parent.agent.session) const child = await ctx.agents.create({ sessionId: SessionId('preset-fork-child'), meta: { diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 1dad9b9a01..4244e1dae9 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -109,15 +109,15 @@ serviceFor(agent: { ctx: Context }, name: K): * therefore restores the previous composition rather than leaving the agent * with nothing. * @param agentCtx - the agent's scope context. - * @param id - the profile to compose the agent from instead. - * @returns the profile now installed. - * @throws when the profile is unknown or its composition is unusable; the + * @param id - the preset to compose the agent from instead. + * @returns the preset now installed. + * @throws when the preset is unknown or its composition is unusable; the * previous composition is restored first. */ async recompose(agentCtx: Context, id: string): Promise ``` -Source: [`packages/preset/agent-presets/src/index.ts:56`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:57`](../../packages/preset/agent-presets/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/docs/module-graph.md b/docs/module-graph.md index 48f45481a7..4b81fab4cf 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -157,6 +157,7 @@ flowchart TD pkg_client_runtime["client-runtime"] pkg_client_schema_form["client-schema-form"] pkg_client_test_runtime["client-test-runtime"] + pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_goal["client-ui-goal"] @@ -414,10 +415,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_paths - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_settings pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm pkg_settings_local --> pkg_atomic_write @@ -472,8 +469,6 @@ flowchart TD pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker_browse --> pkg_client_locale pkg_host_directory_picker_browse --> pkg_client_runtime pkg_host_directory_picker_browse --> pkg_client_ui_primitives @@ -490,6 +485,11 @@ flowchart TD pkg_lsp_local --> pkg_lsp pkg_lsp_local --> pkg_subprocess pkg_lsp_local --> pkg_timeout + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_paths + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings pkg_persona --> pkg_invariants pkg_persona --> pkg_system_prompt pkg_sandbox_local --> pkg_invariants @@ -566,16 +566,6 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm - pkg_headless --> pkg_agent - pkg_headless --> pkg_host_apiproxy - pkg_headless --> pkg_host_webserver - pkg_headless --> pkg_invariants - pkg_headless --> pkg_session - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_client_web_react - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -583,6 +573,8 @@ flowchart TD pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants pkg_tmux_context --> pkg_session + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver @@ -678,6 +670,16 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval + pkg_headless --> pkg_agent + pkg_headless --> pkg_host_apiproxy + pkg_headless --> pkg_host_webserver + pkg_headless --> pkg_invariants + pkg_headless --> pkg_session + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_host_apiproxy + pkg_client_test_runtime --> pkg_invariants pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -843,6 +845,13 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction + pkg_client_ui_agent_preset --> pkg_client_connection + pkg_client_ui_agent_preset --> pkg_client_locale + pkg_client_ui_agent_preset --> pkg_client_runtime + pkg_client_ui_agent_preset --> pkg_client_ui_conversation + pkg_client_ui_agent_preset --> pkg_client_ui_primitives + pkg_client_ui_agent_preset --> pkg_client_ui_slots + pkg_client_ui_agent_preset --> pkg_invariants pkg_client_ui_command --> pkg_client_connection pkg_client_ui_command --> pkg_client_locale pkg_client_ui_command --> pkg_client_runtime @@ -1163,7 +1172,6 @@ flowchart TD | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`settings`](../packages/settings/settings) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | @@ -1180,10 +1188,10 @@ flowchart TD | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) | | [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1204,10 +1212,9 @@ flowchart TD | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | @@ -1228,6 +1235,8 @@ flowchart TD | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | +| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | @@ -1255,6 +1264,7 @@ flowchart TD | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 1854ccd33f..e838b05e61 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -102,6 +102,22 @@ Sources: [`packages/core/session/src/types.ts:316`](../packages/core/session/src Source: [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) +### `agent-preset/*` + +#### `agent-preset/selected` — log-only + +```ts persistence-catalog +/** + * The session's agent preset was chosen after creation, while the session + * was still blank. Log-only: it records the composition later turns ran + * under, so a resumed or forked session rebuilds the same one instead of + * the header's creation-time value. + */ +'agent-preset/selected': { agentPreset: string } +``` + +Source: [`packages/preset/agent-presets/src/session.ts:26`](../packages/preset/agent-presets/src/session.ts) + ### `approval/*` #### `approval/asked` — log-only diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 073149934a..ccc7010f93 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -34,6 +34,7 @@ "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index d25bf7b502..9a5fdd5002 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md -README.md: 322fc7c8ba6eb621f27cb09475079e3d5bccf03f -README.zh.md: 6eece3248d7f3c3652a30579f907eaf5f888f35f +README.md: 14921afb7b90bb0b42a8f7f83ebc78773e8419a3 +README.zh.md: 06807199e996a6ae9d8a4216b8f686b6bbc044d9 diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 5022fa8614..30f15ac353 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -63,8 +63,6 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ] } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index fec265d09c..1d628563d7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -102,7 +102,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async recompose(agentCtx: Context, id: string): Promise', - jsDoc: '/**\n * Replace the composition installed for one agent.\n *\n * Only valid while the agent has produced nothing: swapping tools mid\n * conversation would leave logged tool calls the new composition cannot make.\n * The CALLER owns that check — this method does not read session history.\n *\n * The swap is unmount-then-mount because two compositions cannot coexist:\n * both would register the same tool names into one layer. A failed mount\n * therefore restores the previous composition rather than leaving the agent\n * with nothing.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the profile to compose the agent from instead.\n * @returns the profile now installed.\n * @throws when the profile is unknown or its composition is unusable; the\n * previous composition is restored first.\n */', + jsDoc: '/**\n * Replace the composition installed for one agent.\n *\n * Only valid while the agent has produced nothing: swapping tools mid\n * conversation would leave logged tool calls the new composition cannot make.\n * The CALLER owns that check — this method does not read session history.\n *\n * The swap is unmount-then-mount because two compositions cannot coexist:\n * both would register the same tool names into one layer. A failed mount\n * therefore restores the previous composition rather than leaving the agent\n * with nothing.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset to compose the agent from instead.\n * @returns the preset now installed.\n * @throws when the preset is unknown or its composition is unusable; the\n * previous composition is restored first.\n */', }, ], }, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 673d5b8d0a..655636f10f 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 9484fadcc798652979f998c81f84444c1ebdbf52 -README.zh.md: 238339213f6fec0f3f466907e5994953f391cd26 +README.md: 963a590f46e3ad41e432ad7ec98666ca180f7426 +README.zh.md: 87f1a702dd754119e34e615f203e1bb073c9f5d4 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 27e44a65dc..dbeffdb608 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -24,7 +24,9 @@ import { WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). -import { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' +import { + PresetMountError, resolveSessionPreset, UnknownPresetError, +} from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-tools' import type { ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, @@ -256,17 +258,21 @@ function sessionBlank(session: Session): boolean { } /** Shared Session-header projection for list baselines and creation frames. */ -function sessionListFields(header: SessionHeader): { +function sessionListFields(header: SessionHeader, events: readonly SessionEvent[] = []): { parentSessionId?: SessionId origin?: 'subagent' cwd?: string agentPreset?: string } { + // The preset comes from the log, not the header: a session that switched + // while blank ran its turns under the newer composition, and a picker + // showing the creation-time value would contradict what the model saw. + const agentPreset = resolveSessionPreset({ header, events }) return { ...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }, ...header.origin === undefined ? {} : { origin: header.origin }, ...header.cwd === undefined ? {} : { cwd: header.cwd }, - ...header.agentPreset === undefined ? {} : { agentPreset: header.agentPreset }, + ...agentPreset === undefined ? {} : { agentPreset }, } } @@ -279,7 +285,7 @@ function summarize(session: Session, running: boolean): SessionSummary { updatedAt: lastActivityTime(session.events) ?? session.header.createdAt, running, blank: sessionBlank(session), - ...sessionListFields(session.header), + ...sessionListFields(session.header, session.events), } } @@ -1232,7 +1238,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (inspected.meta.cwd !== cwd) { throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd) } - assertPresetUnchanged(sessionId, presetId, inspected.meta.agentPreset) + // Resolved from the log, not the header: a session that switched + // while blank ran every turn under the newer composition. + const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events }) + assertPresetUnchanged(sessionId, presetId, storedPreset) // The stored preset wins over anything the request names: a resumed // session's history was produced under that composition, and // rebuilding it differently would replay tool calls the model can no @@ -1240,7 +1249,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, - setup: (await composeAgent(inspected.meta.agentPreset)).setup, + setup: (await composeAgent(storedPreset)).setup, })).agent } @@ -1936,7 +1945,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // those tools, and composing anything else would strand the tool calls // it already carries. Now that no model-facing row sits in the host // plane, composing nothing would leave the child with no tools at all. - const forkComposition = await composeAgent(source.header.agentPreset) + const forkComposition = await composeAgent(resolveSessionPreset(source)) try { await ctx.agents.create({ sessionId: childId, @@ -2528,6 +2537,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } try { const preset = await presets.recompose(agent.ctx, agentPreset) + // Recorded only after the swap committed: the log states what the + // agent runs, and a rejected mount leaves the previous composition. + agent.session.append('agent-preset/selected', { agentPreset: preset.id }) return ok(request, { agentPreset: preset.id }) } catch (error: unknown) { if (error instanceof UnknownPresetError) { @@ -2857,7 +2869,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // has run no turn yet, so this is constantly true in practice. blank: sessionBlank(session), // Including cwd lets the client group the new session without refreshing the list. - ...sessionListFields(session.header), + ...sessionListFields(session.header, session.events), })) }), ctx.on('session/disposed', (session: Session) => { diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index f9f14e1779..54206f2675 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: 5e785b747209d4c0cedaebbd3a90ba1b46dcd1c6 -README.zh.md: 4c40d7b7bfabb83dba2859251ad189a2646170c6 +README.md: b60d89b6dcda97a7570680072195231885fd0d45 +README.zh.md: f2485663ada031a9e8fa5ce3a06327e6cbc1de10 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 5e785b7472..b60d89b6dc 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -21,6 +21,12 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the composition installed while the agent is still unpublished, so a rejected mount rolls the whole creation back rather than leaving a half-composed session. The subtree is owned by `agentCtx`'s fiber, so it unwinds with the agent and the caller receives no disposer. +### Which preset a session runs + +The creation header names the preset a session STARTED with; `resolveSessionPreset(session)` names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — resolves rather than reading the header. + +The header stays frozen because it is a creation fact. A switch is an `agent-preset/selected` session event appended after the swap commits, which is what the model-visible ⟺ logged rule requires: the preset decides the tool schemas and prompt sections the model sees, so it has to be reconstructable from the log. Reading the header alone would rebuild a switched session under the composition it was created with, replaying history the new tool set cannot act on — the exact hazard the blank-only lock exists to prevent. + ## Config | Field | Default | Meaning | diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 4c40d7b7bf..f2485663ad 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -21,6 +21,12 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,组装是在 agent 尚未发布时装入的,因此挂载被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。子树归 `agentCtx` 的 fiber 所有,随 agent 一起卸载,调用方无需持有 disposer。 +### 会话实际运行的是哪个 preset + +创建头部记录的是会话**以什么开始**,`resolveSessionPreset(session)` 给出的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都走解析,而非直接读头部。 + +头部保持冻结,因为它是创建期事实。切换以 `agent-preset/selected` 会话事件记录,在替换提交之后追加;这正是 model-visible ⟺ logged 规则的要求:preset 决定模型看到的工具 schema 与提示词段落,因此必须能从日志重建。只读头部会让切换过的会话按创建时的组装重建,从而重放新工具集无法执行的历史——这正是「仅空白可切」那道锁要防的危险。 + ## 配置 | 字段 | 默认值 | 含义 | diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index b856b5208e..e385de8486 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-settings": "^0.0.1", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index e6a74c346c..dbc9b1d9ba 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -37,6 +37,7 @@ export { inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, unmountPresetFor, type PresetMount, } from './mount.ts' +export { resolveSessionPreset, type PresetBearingSession } from './session.ts' export { PresetMountError, UnknownPresetError } from './types.ts' export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts' diff --git a/packages/preset/agent-presets/src/session.ts b/packages/preset/agent-presets/src/session.ts new file mode 100644 index 0000000000..dcb866b5dc --- /dev/null +++ b/packages/preset/agent-presets/src/session.ts @@ -0,0 +1,54 @@ +/** + * The session-log record of which preset a session actually runs. + * + * The creation header names the preset a session STARTED with, and it is + * deep-frozen because that is a creation fact. A session may still change + * preset while it is blank, and the effect of that change outlives the blank + * window: the first turn — and every turn after it — runs under the newly + * mounted composition. Recording the change is what keeps the log honest, and + * it is required outright by the repo's model-visible ⟺ logged rule, since the + * preset decides the tool schemas and prompt sections the model sees. + * + * Reconstruction reads {@link resolveSessionPreset}, never the header alone. + * @module @deepseek-ai/dsh-agent-presets/session + */ + +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * The session's agent preset was chosen after creation, while the session + * was still blank. Log-only: it records the composition later turns ran + * under, so a resumed or forked session rebuilds the same one instead of + * the header's creation-time value. + */ + 'agent-preset/selected': { agentPreset: string } + } +} + +/** The minimum a caller must supply to resolve a session's preset. */ +export interface PresetBearingSession { + /** The session's creation header. */ + readonly header: SessionHeader + /** The session's event log, oldest first. */ + readonly events: readonly SessionEvent[] +} + +/** + * The preset a session actually runs, newest selection winning. + * + * The header supplies the creation-time value; every later selection is a + * logged event, so the last one is the answer. Reading the header alone + * rebuilds a switched session under the composition it was created with, not + * the one its history was produced under. + * @param session - the session's header and event log. + * @returns the preset id, or `undefined` when the deployment composes none. + */ +export function resolveSessionPreset(session: PresetBearingSession): string | undefined { + for (let index = session.events.length - 1; index >= 0; index -= 1) { + const event = session.events[index] + if (event?.type === 'agent-preset/selected') return event.data.agentPreset + } + return session.header.agentPreset +} diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json index a76cc5b77b..3c0b07b172 100644 --- a/packages/preset/agent-presets/tsconfig.json +++ b/packages/preset/agent-presets/tsconfig.json @@ -21,6 +21,9 @@ { "path": "../../core/scope" }, + { + "path": "../../core/session" + }, { "path": "../../settings/settings" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 257eee1465..1edfd0bee7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1113,6 +1113,9 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-agent-preset': + specifier: workspace:^ + version: link:../../client/ui-agent-preset '@deepseek-ai/dsh-client-ui-command': specifier: workspace:^ version: link:../../client/ui-command From 3ae22b38358921181db56d8e88e959b9957e9e3c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 00:36:39 +0800 Subject: [PATCH 022/293] feat(agent-presets): ship a cordis agent that can author compositions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A third built-in preset: the standard coding agent plus the self-referential Cordis toolset, a persona that explains the two-plane split, and a skill teaching composition authoring. It exists so a person can ask an agent to author another agent. The skill ships INSIDE the preset directory rather than in the user's skill root, and the root is derived from the preset's own `baseUrl` — the loader evaluates `!!js` with `with (ctx)`, so a composition can locate itself. A preset is the unit that gets copied and edited, so its documentation should travel with it. The skill leads with the rule that actually bites: a row publishing a service may not sit loose in a preset, whether a row publishes one is not visible from its name (`tool-bash` provides `bashEnv`), and a consumer left outside its provider's isolate group resolves the host registry and then contributes nothing — the quietest failure this design has. Writing the test surfaced a consequence worth stating: an entry-local realm makes the service invisible to the agent's own scope too, not just to the host. Only rows inside that group resolve it, which is precisely what makes `tool-skill` this agent's own rather than a shared one. The test asserts what is actually observable from outside instead of reaching for the isolated service. TRUST: `cordis_mount` evaluates model-written JavaScript against the live runtime, and a composition this agent writes becomes a preset other sessions mount. Both the preset header and the toolset's own documentation say to treat this as shell access. The tools stay opt-in per session — a test pins that they are absent from every other preset. --- .../agent-presets/cordis/agent.cordis.yml | 260 ++++++++++++++++++ .../editing-cordis-compositions/SKILL.md | 57 ++++ apps/cli/tests/web-agent-presets.spec.ts | 47 +++- packages/preset/README.i18n.yaml | 4 +- packages/preset/README.md | 2 + packages/preset/README.zh.md | 2 + 6 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 apps/cli/config/agent-presets/cordis/agent.cordis.yml create mode 100644 apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml new file mode 100644 index 0000000000..bf681dd0a8 --- /dev/null +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -0,0 +1,260 @@ +# The `cordis` agent preset: the standard coding agent, plus the ability to +# read and write the runtime it is running in. +# +# It exists so a person can ask an agent to author another agent. Everything in +# `standard` is here unchanged; what is added is the self-referential Cordis +# toolset, a skill that teaches composition authoring, and a persona that says +# which of the two planes an edit belongs to. +# +# TRUST: `cordis_mount` evaluates model-written JavaScript against the live +# runtime, and a composition this agent writes becomes a preset other sessions +# mount. Treat a session on this preset as shell access — the toolset's own +# documentation makes the same statement. + + +# The preset's own persona, shadowing the deployment default for this agent. +# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace. +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: |- + You are a coding agent powered by the {{model}} model, running on the DeepSeek Harness. Your working directory is {{cwd}}. + + You can read and modify the harness you run on. Its composition is Cordis: every capability is a plugin row in a `cordis.yml`, and an agent preset is one such file mounted for a single session. + + Two planes decide where an edit belongs. The HOST composition holds the registries and anything shared across sessions — persistence, the sandbox and approval stack, the model route. An AGENT PRESET holds what one session contributes to those registries: its tools, its persona, its delegation backends. A row that publishes a service belongs in the host composition, or inside an `isolate` realm if the preset genuinely owns that service. + + Load the `editing-cordis-compositions` skill before writing or changing a composition. + +- id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' + config: + maxBytes: 65536 + +# ── shell ─────────────────────────────────────────────────────────────────── + +# `tool-bash` reads as a tool but provides the `bashEnv` service, so it needs a +# realm like any other provider. The executor behind it (`bash-sandbox`) stays +# in the host composition, where the sandbox policy owns it. +- id: shell + name: cordis:group + group: true + isolate: + bashEnv: true + config: + # The registry and its consumer share the realm: a consumer left outside + # would resolve the host's `bashEnv`, which this plane no longer provides. + - id: bash-env + name: '@deepseek-ai/dsh-bash-env' + + - id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +# ── filesystem ────────────────────────────────────────────────────────────── + +# All three register into the host `tools` registry and provide nothing, so +# they need no realm. The `fs` service and its policy stay in the host. +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + +- id: tool-str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + +# ── background tasks ──────────────────────────────────────────────────────── + +- id: tasks + name: cordis:group + group: true + isolate: + tasks: true + config: + - id: tasks-local + name: '@deepseek-ai/dsh-tasks-local' + + - id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +# ── goals ─────────────────────────────────────────────────────────────────── + +- id: goals + name: cordis:group + group: true + isolate: + goals: true + config: + - id: goal + name: '@deepseek-ai/dsh-goal' + + - id: goal-session + name: '@deepseek-ai/dsh-goal-session' + + - id: command-goal + name: '@deepseek-ai/dsh-command-goal' + + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── plan mode ─────────────────────────────────────────────────────────────── + +# Plan state is per-agent by nature, so an entry-local realm is not a +# workaround here — it is the correct lifetime. +- id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + +# ── compaction ────────────────────────────────────────────────────────────── + +# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must +# share this realm rather than sit outside it. +- id: compaction + name: cordis:group + group: true + isolate: + tokenMeter: true + compact: true + toolResultPrune: true + config: + - id: token-meter + name: '@deepseek-ai/dsh-token-meter' + + - id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + + - id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + +# ── delegation and workflows ──────────────────────────────────────────────── + +# Every backend and every tool that reaches `subagents` or `workflows` shares +# one realm: a consumer left outside it would resolve the host's registry +# instead, which this preset does not populate. +- id: delegation + name: cordis:group + group: true + isolate: + subagents: true + workflows: true + config: + - id: subagent + name: '@deepseek-ai/dsh-subagent' + + - id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + + - id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: continuable + + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + + - id: tool-subagent-report + name: '@deepseek-ai/dsh-tool-subagent-report' + + - id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + config: + subagentProvider: spawn + maxRounds: 64 + +# ── remaining model-facing rows ───────────────────────────────────────────── + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + +# The `web` service and its search provider stay in the host composition; only +# the model-facing tool is per-session. +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: false + searchTimeoutMs: 60000 + +# ── self-modification ─────────────────────────────────────────────────────── + +# Read the live runtime, mount a temporary plugin, unmount it. The toolset is a +# trust boundary, not a sandbox — see this file's header. +- id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' + +# The composition-authoring skill travels with this preset rather than living +# in the user's skill root: it documents THIS deployment's two planes, and a +# preset is the unit that gets copied and edited. `baseUrl` is the preset's +# own directory, so the root resolves wherever the preset is installed. +- id: skills + name: cordis:group + group: true + isolate: + skills: true + config: + - id: skill + name: '@deepseek-ai/dsh-skill' + + - id: skill-local + name: '@deepseek-ai/dsh-skill-local' + config: + customSkillDirs: + - !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))" + + - id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md new file mode 100644 index 0000000000..dc6faea6b1 --- /dev/null +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -0,0 +1,57 @@ +--- +name: editing-cordis-compositions +description: Use when creating or changing a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, or diagnosing a row that mounted but contributed nothing. +--- + +# Editing Cordis compositions + +Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. + +## Decide the plane first + +Two planes, and the choice is not about how "agent-related" something feels — it is about whether the thing must be shared. + +**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, and the model route. One instance for the process. + +**Agent preset.** What one session contributes to those registries: its tool plugins, its persona, its delegation backends, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. + +A preset is a directory holding one `agent.cordis.yml`. The shipped ones live beside the deployment's composition; locally authored ones live under `$DSH_HOME/.agent-presets//`. + +## The rule that catches people + +**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later. + +Whether a row publishes a service is not visible from its name. `tool-bash` reads like a tool but provides `bashEnv`. Check the package's README, or mount the preset and read the rejection — it names the offending service. + +When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm: + +```yaml +- id: skills + name: cordis:group + group: true + isolate: + skills: true + config: + - id: skill + name: '@deepseek-ai/dsh-skill' + - id: skill-local + name: '@deepseek-ai/dsh-skill-local' + - id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' +``` + +`true` means a realm private to each mounting session. A string label instead pools one instance across every subtree naming that label — use it only for something genuinely expensive to duplicate. + +A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. That is the quietest failure here: the mount succeeds and a tool is simply missing. + +## Verifying a change + +Read the live runtime with `cordis_inspect` — it reports the services, the plugin fibers, and the registered tools as they actually are, which is the only reliable check that a row did what its name suggests. + +After editing a preset, start a new session on it and confirm the tool list is what you intended. A preset is read at session creation, so an edit never affects a session already running; the file is never written back either, so your composition is exactly what you wrote. + +`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. + +## What not to move into a preset + +`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. diff --git a/apps/cli/tests/web-agent-presets.spec.ts b/apps/cli/tests/web-agent-presets.spec.ts index 8ee2730dfc..6b973d00a6 100644 --- a/apps/cli/tests/web-agent-presets.spec.ts +++ b/apps/cli/tests/web-agent-presets.spec.ts @@ -76,7 +76,7 @@ describe('the shipped Web composition', () => { it('supplies both shipped presets, and only those, from the system root', async () => { const listed = await ctx.agentPresets.list() - expect(listed.map(preset => preset.id).sort()).toEqual(['core-web', 'standard']) + expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'core-web', 'standard']) expect(listed.every(preset => preset.trust === 'system')).toBe(true) expect(ctx.agentPresets.defaultId).toBe('standard') }) @@ -139,6 +139,51 @@ describe('the shipped Web composition', () => { } }) + it('composes the cordis agent with its own toolset', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-cordis'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'cordis').then(() => undefined), + }) + try { + const tools = toolNames(ctx, handle.agent) + // The self-referential toolset is what distinguishes this preset. + expect(tools).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount'])) + // And it keeps the standard agent's own tools rather than replacing them. + expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill'])) + + // The skill registry sits in this preset's entry-local realm, so it is + // invisible to the host AND to the agent's own scope — only the rows + // inside that group resolve it, which is what makes `tool-skill` the + // agent's own rather than a shared one. + expect(ctx.get('skills')).toBeUndefined() + } finally { + await handle.dispose() + } + }) + + it('keeps the self-referential toolset out of every other preset', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-no-cordis'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + // Editing the live runtime is opt-in per session, not ambient. + expect(toolNames(ctx, handle.agent)).not.toContain('cordis_mount') + } finally { + await handle.dispose() + } + }) + + it('ships the composition-authoring skill inside the preset directory', async () => { + // The preset's skill root is derived from its own `baseUrl`, so the skill + // travels with the directory wherever the preset is installed. + const skill = join( + CONFIG_DIR, 'agent-presets', 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md', + ) + + expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true) + }) + it('never rewrites the preset file it composed from', async () => { // The Loader persists a tree whose plugin self-disposed, and tearing an // agent down disposes its whole subtree. Inherited, that rewrote the diff --git a/packages/preset/README.i18n.yaml b/packages/preset/README.i18n.yaml index 1a789aad5a..f756692364 100644 --- a/packages/preset/README.i18n.yaml +++ b/packages/preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/README.md -README.md: d2ed10014af506809b5fd117e08b09b45a31a16c -README.zh.md: db7bf18e6ba841cc705eafae7c09d4c5d26581b1 +README.md: d6d51b4d83a85fed3f690334e9f5704567da83a9 +README.zh.md: 6485caa03856e24232648dc36dfbc07d1d6aa6f8 diff --git a/packages/preset/README.md b/packages/preset/README.md index d2ed10014a..d6d51b4d83 100644 --- a/packages/preset/README.md +++ b/packages/preset/README.md @@ -9,6 +9,8 @@ An **agent preset** is a directory holding one `agent.cordis.yml`. Mounting it u | `agent-presets/` | Preset vocabulary, filesystem discovery over trusted and user-authored roots, and the guarded per-agent mount | `ctx.agentPresets` | | `persona/` | The agent persona as a composable row, so a preset can change identity and not only tools | — | +The deployment ships `standard` (the full coding agent), `core-web` (a two-tool benchmark surface), and `cordis` (the standard agent plus the self-referential toolset and a composition-authoring skill, so a person can ask an agent to author another agent). + The composition split this group assumes: registries and cross-session facilities are process singletons and stay in the host composition, while a preset carries what one agent contributes to them. A preset that names a row publishing a process-global service is rejected at mount rather than allowed to collide with the next session. Design: [the per-session agent-preset note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md). diff --git a/packages/preset/README.zh.md b/packages/preset/README.zh.md index db7bf18e6b..6485caa038 100644 --- a/packages/preset/README.zh.md +++ b/packages/preset/README.zh.md @@ -9,6 +9,8 @@ | `agent-presets/` | preset 词汇、在受信任目录与用户自建目录上的文件系统发现,以及带校验的按 agent 挂载 | `ctx.agentPresets` | | `persona/` | 把 agent 人设做成可组装的行,使 preset 不止能改工具、也能改身份 | — | +部署随附三个 preset:`standard`(完整编码 agent)、`core-web`(两个工具的 benchmark 表层),以及 `cordis`(标准 agent 加上自指工具集与一份组装创作 skill,使人可以让 agent 去创作另一个 agent)。 + 本组假定的组装划分是:注册表与跨会话设施是进程单例,留在宿主组装中;preset 只承载单个 agent 对它们的贡献。若 preset 中某一行发布了进程级全局服务,挂载时即被拒绝,而不是留到与下一个会话相撞。 设计详见 [按会话组装 agent preset 的 Agent Note](../../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md)。 From 52607cab695c39ef29bb5e47e617101ec8481589 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 11:17:26 +0800 Subject: [PATCH 023/293] docs(agent-presets): bring the note and the architecture map up to what shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agent Note was written when only the seam existed and never caught up. Rewritten in place, per the implemented-note contract, with the four facts the later work established: - a preset file is an INPUT: `EntryTree.write()` persists a tree whenever the Loader thinks the config changed, and a self-disposing plugin is enough, so the inherited behaviour truncates a shipped preset to `[]` the first time a session ends - a plugin that looks itself up in the global registry breaks inside a preset, because `register()` files into the calling context's scope — the general rule behind the `dsh-tool-skill` fix - an entry-local `isolate` realm is invisible to the agent's own scope too, not only to the host, which is what makes a preset's registry that agent's own and also why a consumer left outside the group silently contributes nothing - switching is blank-only, and why it swaps the subtree rather than the session `docs/architecture.md` gains an Agent Presets section: the map has to carry a new architectural concept or it is wrong, and the root layout gains the group. Both budget ceilings are raised rather than the content cut. `AGENTS.md` sat at 1774/1775 — one word of room, already far under the 5% headroom the standard asks for — so no group line could be added at all; `architecture.md` was in the same shape. Raising restores headroom instead of encoding "the map may not grow". --- .../2026-08-03-per-session-agent-presets.i18n.yaml | 4 ++-- .../2026-08-03-per-session-agent-presets.md | 12 +++++++++++- .../2026-08-03-per-session-agent-presets.zh.md | 12 +++++++++++- AGENTS.md | 1 + docs/architecture.i18n.yaml | 4 ++-- docs/architecture.md | 5 +++++ docs/architecture.zh.md | 5 +++++ scripts/doc-budgets.manifest.json | 4 ++-- 8 files changed, 39 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 3d91acee93..229fffae78 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: dbf2f7c3de1447382071ebcfb1d6b9abe9640210 -2026-08-03-per-session-agent-presets.zh.md: e34018f713144c6457c86d5ce4b533106a7e5372 +2026-08-03-per-session-agent-presets.md: cc40af246d018560b7bc7bea92286e0ddaf53e55 +2026-08-03-per-session-agent-presets.zh.md: 5b6113ee9f905478e59db3e982331b8faa190e3c diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index dbf2f7c3de..cc40af246d 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -23,6 +23,8 @@ Composition splits into two planes, decided by what must be shared rather than b Model routing stays out of presets. `installAgentLlmTarget` is already the per-agent seam for provider, model, and reasoning effort, and an LLM adapter mounted inside a preset would never be resolved by `agent-loop`, which lives in the host plane. +The deployment ships three presets — `standard` (the full coding agent), `core-web` (a two-tool benchmark surface), and `cordis` (the standard agent plus the self-referential toolset and a composition-authoring skill). + Mounting is per-session by default. Measured cost for a twelve-row composition is ~3ms and ~600KB per session, so isolation is the cheaper default than any sharing scheme, and a preset authored by a user or by an agent then has the smallest possible blast radius. A preset that genuinely owns an expensive singleton opts into sharing with Cordis's own `isolate` vocabulary: a named realm label is process-global, so two subtrees naming the same label resolve one instance. Which preset an unnamed session gets is a user setting (`agent-presets.default`) layered over the composition's own `default`, which becomes the `base`. Both layers are needed: the composition value is what a deployment ships and must keep working with no settings provider at all, and the setting is what a person changes without editing a `cordis.yml` they may not own. @@ -43,7 +45,15 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Fiber membership is object identity, not `uid`.** A `uid` is a per-registry counter, so fibers in two different roots collide on it; comparing by `uid` made one runtime's subtree answer for a service published in another. `ctx.plugin()` returns a thenable `Object.create(fiber)` wrapper that is never identical to the fiber in a parent chain, so the subtree captures its own fiber during construction. -**The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. +**A preset file is an input, never a persistence target.** `EntryTree.write()` persists a tree whenever the Loader decides the config changed, and a plugin self-disposing is enough — tearing an agent down disposes its whole subtree. Inherited, that rewrites the composition it read, in practice truncating a shipped preset to `[]` the first time a session ends. The subtree overrides `write()` to do nothing. + +**A plugin that looks itself up in the global registry breaks inside a preset.** `ctx.tools.register()` files into the CALLING context's scope, so a plugin mounted in a preset registers for one agent and an unscoped `ctx.tools.get(name)` correctly finds nothing. `dsh-tool-skill` did exactly that and threw on every preset mount; it now compares against the definition it registered. Any plugin meant to be preset-mountable must hold its own registration rather than re-read it by name. + +**An entry-local `isolate` realm is invisible to the agent's own scope, not only to the host.** Only rows inside that group resolve the service. That is what makes a preset's `skills` registry belong to one agent rather than being shared — and it means a consumer left outside its provider's group silently resolves the host registry and contributes nothing. + +**Switching is allowed only while a session is blank.** Once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so `agentPreset.select` answers `agent-preset-locked`. A blank switch keeps the agent and the session and replaces only the subtree, because the host discards the `AgentHandle` it creates and there is no delete RPC — and keeping them is the better outcome anyway, since the session id, its workspace attachment, and its projections all stay put. The swap is unmount-then-mount (two compositions would register the same tool names into one layer), so it resolves the new preset before tearing anything down and restores the previous one when the new mount fails. + +**The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside `cwd`, and the summary carries it so a picker shows what a session actually runs rather than the deployment's current default. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index e34018f713..5b6113ee9f 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -23,6 +23,8 @@ Status: implemented 模型路由不进 preset。`installAgentLlmTarget` 已经是 provider、model 与 reasoning effort 的按 agent 可替换点;而挂在 preset 内部的 LLM 适配器永远不会被 `agent-loop` 解析到,因为后者位于宿主平面。 +部署随附三个 preset —— `standard`(完整编码 agent)、`core-web`(两个工具的 benchmark 表层)与 `cordis`(标准 agent 加上自指工具集与一份组装创作 skill)。 + 挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB,因此隔离比任何共享方案都更划算;而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset,可以用 Cordis 自身的 `isolate` 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。 未指名 preset 的会话拿到哪一个,是一项用户设置(`agent-presets.default`),叠在组装自身的 `default` 之上——后者成为 `base`。两层都需要:组装里的值是部署交付的东西,在完全没有 settings 提供方时也必须照常工作;而设置是让人不必去改一份可能并不属于自己的 `cordis.yml` 就能调整的东西。 @@ -44,7 +46,15 @@ Status: implemented **fiber 归属判定用对象同一性,而非 `uid`。** `uid` 是按 registry 计数的序号,因此两个不同根下的 fiber 会在它上面撞号;按 `uid` 比较曾导致一个运行时的子树为另一个运行时中发布的服务背锅。`ctx.plugin()` 返回的是 thenable 的 `Object.create(fiber)` 包装对象,与父链中出现的 fiber 永远不同一,因此子树在构造时捕获自己的 fiber。 -**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。 +**preset 文件是输入,绝不是持久化目标。** 只要 loader 认为配置变了,`EntryTree.write()` 就会回写整棵树,而一个插件自我 dispose 就足以触发——销毁 agent 会 dispose 它的整棵子树。若继承该行为,它会重写自己读入的那份组装,实际后果是第一次会话结束时把随附 preset 截断成 `[]`。子树因此把 `write()` 覆盖为空操作。 + +**按自身名字回查全局注册表的插件,在 preset 里必然失效。** `ctx.tools.register()` 归档进**调用方**上下文的 scope,因此挂在 preset 里的插件只为一个 agent 注册,而不带 scope 的 `ctx.tools.get(name)` 理所当然查不到。`dsh-tool-skill` 正是这样写的,于是每次 preset 挂载都抛错;现在它与自己注册的那个定义比对。任何希望可被 preset 挂载的插件,都必须持有自己的注册对象,而不是按名字重新读取。 + +**entry 本地 `isolate` realm 不仅对宿主不可见,对 agent 自身的 scope 同样不可见。** 只有该组内部的行能解析到该服务。这正是让 preset 的 `skills` 注册表归属单个 agent 而非共享的原因——同时也意味着:被留在提供方组之外的消费方会静默解析到宿主注册表,然后什么都不贡献。 + +**只有空白会话才允许切换。** 一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,因此 `agentPreset.select` 返回 `agent-preset-locked`。空白期的切换保留 agent 与 session,只替换子树——因为宿主丢弃了它创建的 `AgentHandle`,也没有 delete RPC;而保留它们本身就是更好的结果,会话 id、workspace 挂接与 projections 都原地不动。该替换是"先卸后装"(两份组装会把同名工具注册进同一分层),因此它在拆除任何东西之前先解析新 preset,并在新组装装载失败时恢复原来的那一份。 + +**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使选择器显示的是某个会话实际运行的 preset,而非部署当前的默认值。 ## 考虑过的替代方案 diff --git a/AGENTS.md b/AGENTS.md index 0d27b20df0..660db23fd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// workflow/ workflow seam + worker-thread engine + workflow tool todo/ todo_write tool plan/ plan mode as logged per-agent collaboration state + preset/ per-session agent composition from preset cordis.yml files guard/ loop-hygiene plugins cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime hooks/ Claude Code/Codex hook bridges + shared wire-protocol library diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 0459323eea..34437b85e4 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 40c20a1c9eeabe5ecbbc6edacde81c20071b8a04 -architecture.zh.md: 6fddaa883775cf8345aba01af52575c0f0e1aaa0 +architecture.md: 3f2a879b8c3bf639b9b89b42cfac277e86d87eca +architecture.zh.md: ed4c2662a4dfbb57fa411be6a22cd173ec56ee15 diff --git a/docs/architecture.md b/docs/architecture.md index 40c20a1c9e..3f2a879b8c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -164,6 +164,10 @@ Exceptions combine LLM interface/consumer, filesystem policy, web registries, an `dsh-agent-spine-demo` bundles a spine and optional goals. App packages own CLI, ACP automation, and JSON-RPC front doors ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK defaults when config is absent ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +### Agent Presets + +A deployment may compose each session's model-facing plugin set separately. An **agent preset** is a directory holding one `agent.cordis.yml`, mounted as an `include` subtree under that agent's scope during `setup(agentCtx)`, so its tool and prompt registrations file into that agent's layer and unwind with it — no new tier in the registries. The host composition keeps what must be shared: the registries themselves, cross-session facilities, the sandbox and approval stack, the model route. `ctx.agentPresets` owns discovery and the guarded mount, rejecting a row that never activates or that publishes into the root service realm. Details: [per-session agent presets](../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md), [preset/](../packages/preset/README.md). + ### Where New Behavior Goes New behavior attaches to a documented extension point; a loop change updates this map. @@ -172,6 +176,7 @@ New behavior attaches to a documented extension point; a loop change updates thi |---|---| | Add a model provider | register its adapter on `ctx.llm` | | Add a model-facing capability | register on `ctx.tools`; schemas join prompt assembly | +| Give one session a different capability set | compose it in an agent preset; a service row there needs an `isolate` realm | | Add shell execution | implement and register a `ctx.bash` backend; the local backend spawns through `ctx.subprocess` | | Add persistent terminal execution | register a `ctx.pty` backend plus `dsh-tool-pty` | | Add a human command | register on `ctx.commands`; adapters discover and dispatch without a model turn | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 6fddaa8837..ed4c2662a4 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -164,6 +164,10 @@ idle inject: `dsh-agent-spine-demo` 组合一套主干和可选目标。应用包负责 CLI(命令行界面)、ACP 自动化入口和 JSON-RPC 入口([README](../packages/examples/agent-spine-demo/README.md)、[acp/](../packages/acp/README.md)、[ui/](../packages/ui/README.md))。`dsh-jsonrpc-agent` 启动外部 `cordis.yml`;Python SDK 在配置缺失时提供默认项([Python SDK](../python/README.md))。轻量部署使用可替换后端和可选工具([examples/](../examples/AGENTS.md)、[可运行接线](cookbook/extension-cookbook.md#runnable-wirings)、[图谱](graph-atlas.md))。 +### Agent Preset + +部署可为每个会话分别组装面向模型的插件集合。**agent preset** 是一个含 `agent.cordis.yml` 的目录,在 `setup(agentCtx)` 期间作为 `include` 子树挂到该 agent 的 scope 之下,其工具与提示词注册因而归档进该 agent 的分层并随之卸载,注册表无需新增层级。宿主组装保留必须共享的部分:注册表本身、跨会话设施、沙箱与审批栈、模型路由。`ctx.agentPresets` 负责发现与把关,拒绝未激活的行和把服务发布进根 realm 的行。详见 [按会话组装 agent preset](../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md)、[preset/](../packages/preset/README.md)。 + ### 新行为的归属位置 新行为附加到已有文档记录的扩展点;循环发生变更时,本架构图随之更新。 @@ -172,6 +176,7 @@ idle inject: |---|---| | 添加模型提供方 | 在 `ctx.llm` 上注册其适配器 | | 添加面向模型的功能 | 在 `ctx.tools` 上注册;schema 加入提示词组装 | +| 让某个会话拥有不同的能力集合 | 在 agent preset 中组装它;其中的 service 行需要 `isolate` realm | | 添加 shell 执行 | 实现并注册 `ctx.bash` 后端;本地后端通过 `ctx.subprocess` 生成进程 | | 添加持久化终端执行 | 注册 `ctx.pty` 后端和 `dsh-tool-pty` | | 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派 | diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 1ca6dac5e1..42d1d4c90b 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,7 +1,7 @@ { - "AGENTS.md": 1775, + "AGENTS.md": 1900, "docs/AGENTS.md": 1320, - "docs/architecture.md": 2160, + "docs/architecture.md": 2400, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, "docs/testing.md": 1150, From 6dfc568ec2907ca1dcc370ec59334dda0a25e002 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 12:23:40 +0800 Subject: [PATCH 024/293] feat(web): author agent presets from a settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A composition is a file, but "edit it on the filesystem" is not a browser affordance. The roster gains `read`/`write`/`remove` beside `select`, and the browser gains a settings section over them: the presets as rows, one composition open in a YAML editor at a time, and per-row default, duplicate, and delete. All four authoring methods are loopback-pinned. A composition names the plugins a session runs, so reading one is reconnaissance, writing one is arbitrary capability, and selecting one can move a session onto a preset that edits the live runtime. `agentPreset.list` deliberately stays ordinary and now reports `authorable`, so a surface knows whether creating is possible at all rather than offering a button whose save always fails. Authoring starts by duplicating: a shipped preset opens read-only because the deployment's copy is what a broken local one is compared against. Ids are contained before they become directory names, and the text is parsed with the loader's own schema, so a save cannot leave a file no session could load. Fixes a defect the real-composition test found: a preset written under the user's home could never mount, because the loader resolves a row against the composition's own directory and Node's `node_modules` walk from there never reaches the installed harness. The mount now records the host base and sends bare specifiers there, leaving relative paths resolving from the preset. Also closes the coverage the earlier surfaces in this stack shipped without — the General row, the composer seat, and the plugin halves now have tests. --- .../2026-08-03-per-session-agent-presets.md | 4 + ...2026-08-03-per-session-agent-presets.zh.md | 4 + apps/cli/tests/web-agent-presets.spec.ts | 78 ++- docs/cordis-catalog/services.md | 66 ++- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../client/connection/src/client/fixture.ts | 76 ++- packages/client/connection/src/index.ts | 11 + packages/client/connection/tests/fake-api.ts | 10 +- .../client/connection/tests/node-half.spec.ts | 9 +- packages/client/runtime/tests/fake-api.ts | 10 +- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 23 +- packages/client/ui-agent-preset/README.zh.md | 23 +- packages/client/ui-agent-preset/package.json | 10 +- .../src/client/AgentPresetSection.module.css | 215 +++++++ .../src/client/AgentPresetSection.tsx | 254 ++++++++ .../ui-agent-preset/src/client/index.ts | 55 +- .../ui-agent-preset/src/client/locales.ts | 66 ++- .../ui-agent-preset/src/client/seat-store.ts | 9 +- .../src/client/section-store.ts | 292 ++++++++++ .../src/client/settings-store.ts | 70 ++- .../ui-agent-preset/tests/apply.spec.ts | 241 ++++++++ .../ui-agent-preset/tests/components.spec.tsx | 232 ++++++++ .../ui-agent-preset/tests/invariant.spec.ts | 25 + .../tests/section-store.spec.ts | 551 ++++++++++++++++++ .../ui-agent-preset/tests/section.spec.tsx | 260 +++++++++ .../tests/settings-store.spec.ts | 98 +++- packages/client/ui-agent-preset/tsconfig.json | 9 + .../cordis/tool-cordis/src/api-catalog.ts | 12 + packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 + packages/host/apiproxy/README.zh.md | 2 + packages/host/apiproxy/src/api-proxy.ts | 77 ++- .../apiproxy/src/api/agent-presets.schema.ts | 34 ++ .../host/apiproxy/src/api/agent-presets.ts | 36 +- packages/host/apiproxy/src/api/rpc-map.ts | 3 + packages/host/apiproxy/src/api/rpc.schema.ts | 1 + packages/host/apiproxy/src/api/rpc.ts | 1 + packages/host/apiproxy/src/fetch/client.ts | 17 +- packages/host/apiproxy/src/fetch/handler.ts | 8 +- .../tests/api-proxy-agent-preset.spec.ts | 77 ++- .../apiproxy/tests/client-handler.spec.ts | 5 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 16 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 5 +- packages/preset/agent-presets/README.md | 30 +- packages/preset/agent-presets/README.zh.md | 30 +- packages/preset/agent-presets/package.json | 3 + .../preset/agent-presets/src/authoring.ts | 157 +++++ packages/preset/agent-presets/src/index.ts | 51 ++ packages/preset/agent-presets/src/mount.ts | 40 ++ .../agent-presets/tests/authoring.spec.ts | 183 ++++++ .../preset/agent-presets/tests/mount.spec.ts | 63 +- packages/preset/agent-presets/tsconfig.json | 3 + pnpm-lock.yaml | 15 + 56 files changed, 3478 insertions(+), 110 deletions(-) create mode 100644 packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css create mode 100644 packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx create mode 100644 packages/client/ui-agent-preset/src/client/section-store.ts create mode 100644 packages/client/ui-agent-preset/tests/apply.spec.ts create mode 100644 packages/client/ui-agent-preset/tests/components.spec.tsx create mode 100644 packages/client/ui-agent-preset/tests/invariant.spec.ts create mode 100644 packages/client/ui-agent-preset/tests/section-store.spec.ts create mode 100644 packages/client/ui-agent-preset/tests/section.spec.tsx create mode 100644 packages/preset/agent-presets/src/authoring.ts create mode 100644 packages/preset/agent-presets/tests/authoring.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index cc40af246d..6f14c2ad99 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -53,6 +53,10 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Switching is allowed only while a session is blank.** Once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so `agentPreset.select` answers `agent-preset-locked`. A blank switch keeps the agent and the session and replaces only the subtree, because the host discards the `AgentHandle` it creates and there is no delete RPC — and keeping them is the better outcome anyway, since the session id, its workspace attachment, and its projections all stay put. The swap is unmount-then-mount (two compositions would register the same tool names into one layer), so it resolves the new preset before tearing anything down and restores the previous one when the new mount fails. +**Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. All four are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance, writing one is arbitrary capability, and selecting one can move a session onto a preset that edits the live runtime. `list` deliberately stays ordinary — ids and trust only, and a LAN client's picker needs it. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. + +**A preset's package names must resolve from the harness, not from the preset.** `EntryTree.import()` resolves a row against its own tree's `baseUrl`, which `Include` sets to the composition's directory. That is right for a relative specifier and fatal for a package name: a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the installed harness, so every `@deepseek-ai/dsh-*` row fails to import and the whole preset is unmountable. The shipped presets hid this — they sit inside the install. The mount records the host composition's base before plugging the subtree and sends bare specifiers there, leaving relative paths resolving from the preset so its own files still travel with it. The real-composition test writing a preset into a temp root is what found it. + **The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside `cwd`, and the summary carries it so a picker shows what a session actually runs rather than the deployment's current default. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 5b6113ee9f..5d34c07872 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -54,6 +54,10 @@ Status: implemented **只有空白会话才允许切换。** 一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,因此 `agentPreset.select` 返回 `agent-preset-locked`。空白期的切换保留 agent 与 session,只替换子树——因为宿主丢弃了它创建的 `AgentHandle`,也没有 delete RPC;而保留它们本身就是更好的结果,会话 id、workspace 挂接与 projections 都原地不动。该替换是"先卸后装"(两份组装会把同名工具注册进同一分层),因此它在拆除任何东西之前先解析新 preset,并在新组装装载失败时恢复原来的那一份。 +**创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。这四者都被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力,而选择它可以把会话切到一个能编辑活动运行时的 preset 上。`list` 刻意保持为普通方法——只有 id 与信任级别,而局域网客户端的选择器需要它。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 + +**preset 的包名必须从 harness 解析,而非从 preset 解析。** `EntryTree.import()` 按行所属树的 `baseUrl` 解析,而 `Include` 把它设为组装文件所在的目录。这对相对标识符是对的,对包名却是致命的:本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到已安装的 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败,整个 preset 无法挂载。随部署提供的 preset 掩盖了这一点——它们本就在安装目录之内。挂载在插入子树之前先记录宿主组装的基址,并把裸标识符送往那里,同时让相对路径继续从 preset 解析,使它自带的文件仍随它一同迁移。发现它的正是那个把 preset 写入临时根目录的真实组装测试。 + **preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使选择器显示的是某个会话实际运行的 preset,而非部署当前的默认值。 ## 考虑过的替代方案 diff --git a/apps/cli/tests/web-agent-presets.spec.ts b/apps/cli/tests/web-agent-presets.spec.ts index 6b973d00a6..f8ff094e33 100644 --- a/apps/cli/tests/web-agent-presets.spec.ts +++ b/apps/cli/tests/web-agent-presets.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -21,7 +21,7 @@ const WEB_OVERLAY = join(CONFIG_DIR, 'web.cordis.yml') * touch the network, or write outside the test. Everything that decides an * agent's capabilities is the real thing, including both shipped presets. */ -async function bootWeb(settingsFile: string): Promise { +async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promise { const patches: PatchOptions[] = [ ...loadOverlayPatches('dsh-test', WEB_OVERLAY), // The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it @@ -51,6 +51,7 @@ async function bootWeb(settingsFile: string): Promise { id: 'agent-presets', config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] }, }, + ...extra, ] return await boot('dsh-test', BASE_CONFIG, patches) } @@ -289,6 +290,79 @@ describe('a forked session', () => { }) }) +describe('authoring a preset on the shipped composition', () => { + let authorCtx: Context + let userRoot: string + + beforeAll(async () => { + userRoot = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')), 'presets') + const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-settings-')), 'settings.yaml') + await writeFile(settingsFile, '{}\n') + authorCtx = await bootWeb(settingsFile, [{ + id: 'agent-presets', + config: { + default: 'standard', + roots: [ + { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, + // The root does not exist yet: a deployment whose user has authored + // nothing is the normal first-run state. + { path: userRoot, trust: 'user' }, + ], + }, + }]) + }) + + it('refuses to overwrite or delete a shipped preset', async () => { + await expect(authorCtx.agentPresets.write('standard', '- id: x\n')).rejects.toThrow(/ships with the deployment/) + await expect(authorCtx.agentPresets.remove('standard')).rejects.toThrow(/ships with the deployment/) + }) + + it.each(['../escape', 'a/b', '/abs', 'Upper'])('refuses the uncontainable id %j', async (id) => { + // The id becomes a directory name under the user root, so containment is + // checked on the id rather than on the joined path afterwards. + await expect(authorCtx.agentPresets.write(id, '- id: x\n')).rejects.toThrow() + }) + + it('refuses text that is not a Cordis entry list', async () => { + await expect(authorCtx.agentPresets.write('bad-shape', 'tools: []\n')).rejects.toThrow() + await expect(authorCtx.agentPresets.resolve('bad-shape')).rejects.toThrow() + }) + + it('writes a preset a session then really composes from', async () => { + const copied = await authorCtx.agentPresets.read('core-web') + + await authorCtx.agentPresets.write('my-agent', copied) + + // Round-trips through the roster as a `user` row, and the composition the + // editor saved is one the mount actually accepts. + const preset = await authorCtx.agentPresets.resolve('my-agent') + expect(preset.trust).toBe('user') + expect(await authorCtx.agentPresets.read('my-agent')).toBe(copied) + // Owner-only, in an owner-only directory: a composition is executable + // configuration on a machine that may have other users. + expect((await stat(preset.path)).mode & 0o777).toBe(0o600) + const handle = await authorCtx.agents.create({ + sessionId: SessionId('preset-authored'), + setup: agentCtx => authorCtx.agentPresets.mount(agentCtx, 'my-agent').then(() => undefined), + }) + try { + // The same tools the shipped `core-web` composes, from a file written + // through the service into a root outside the installed harness. + expect(toolNames(authorCtx, handle.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor']) + } finally { + await handle.dispose() + } + }) + + it('deletes what it wrote', async () => { + await authorCtx.agentPresets.write('doomed', '- id: tool-web-search\n name: \'@deepseek-ai/dsh-tool-web-search\'\n') + + await authorCtx.agentPresets.remove('doomed') + + expect((await authorCtx.agentPresets.list()).map(preset => preset.id)).not.toContain('doomed') + }) +}) + /** * Which preset an unnamed session gets is a user setting layered over the * composition's own default. The package suite proves the layering against a diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4244e1dae9..84154c375e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -50,40 +50,68 @@ Source: [`packages/core/agent-loop/src/index.ts:277`](../../packages/core/agent- Registry over the deployment's agent presets. -Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a preset authored while the process runs is visible immediately, and a preset deleted underneath a picker disappears from the next read. +Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a profile authored while the process runs is visible immediately, and a profile deleted underneath a picker disappears from the next read. ```ts cordis-catalog /** - * Every preset the configured roots currently supply. - * @returns the presets, first-root-wins per id. + * Every profile the configured roots currently supply. + * @returns the profiles, first-root-wins per id. */ async list(): Promise /** - * Resolve one preset by id. - * @param id - the preset id, or `undefined` for {@link defaultId}. - * @returns the resolved preset. + * Resolve one profile by id. + * @param id - the profile id, or `undefined` for {@link defaultId}. + * @returns the resolved profile. * @throws when no configured root supplies that id. */ async resolve(id?: string): Promise /** - * Compose one agent from a preset, installing it under that agent alone. + * Compose one agent from a profile, installing it under that agent alone. * * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls - * the agent creation back, so a broken preset never yields a half-composed + * the agent creation back, so a broken profile never yields a half-composed * session. * @param agentCtx - the agent's scope context. - * @param id - the preset id, or `undefined` for {@link defaultId}. - * @returns the preset that was mounted, for the caller to record. - * @throws when the preset is unknown or its composition is unusable. + * @param id - the profile id, or `undefined` for {@link defaultId}. + * @returns the profile that was mounted, for the caller to record. + * @throws when the profile is unknown or its composition is unusable. */ async mount(agentCtx: Context, id?: string): Promise /** - * One agent's instance of a service its preset mounted. + * Read one profile's composition text. + * @param id - the profile id. + * @returns the composition exactly as stored. + * @throws when no configured root supplies that id. + */ +async read(id: string): Promise + +/** + * Create or replace a locally authored profile. * - * A preset publishes services behind `isolate` realms, which are invisible + * The text is shape-checked before it lands, so a save cannot leave a file no + * session could load; it is NOT mounted, so a composition that parses but + * names a missing plugin still fails at the next session that selects it. + * @param id - the profile id, which becomes its directory name. + * @param content - the composition text. + * @throws when the id is unusable, the text is not an entry list, or the + * deployment configures no writable root. + */ +async write(id: string, content: string): Promise + +/** + * Delete a locally authored profile. + * @param id - the profile id. + * @throws when the profile is unknown or ships with the deployment. + */ +async remove(id: string): Promise + +/** + * One agent's instance of a service its profile mounted. + * + * A profile publishes services behind `isolate` realms, which are invisible * outside the group that declares them — including to the host. This is how a * caller holding the agent reads one anyway: a request that is ABOUT a * session but arrives from outside it, which is every browser RPC. @@ -92,8 +120,8 @@ async mount(agentCtx: Context, id?: string): Promise * because injection resolves before any session exists and has no agent to * key by; such a service belongs on the host plane instead. * @param agent - the agent whose composition to look inside. - * @param name - the service name as the preset's rows resolve it. - * @returns the agent's instance, or undefined when its preset mounts none. + * @param name - the service name as the profile's rows resolve it. + * @returns the agent's instance, or undefined when its profile mounts none. */ serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined @@ -109,15 +137,15 @@ serviceFor(agent: { ctx: Context }, name: K): * therefore restores the previous composition rather than leaving the agent * with nothing. * @param agentCtx - the agent's scope context. - * @param id - the preset to compose the agent from instead. - * @returns the preset now installed. - * @throws when the preset is unknown or its composition is unusable; the + * @param id - the profile to compose the agent from instead. + * @returns the profile now installed. + * @throws when the profile is unknown or its composition is unusable; the * previous composition is restored first. */ async recompose(agentCtx: Context, id: string): Promise ``` -Source: [`packages/preset/agent-presets/src/index.ts:57`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:63`](../../packages/preset/agent-presets/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 05b9bb4141..2072e6399e 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: 1393e79aacecbbf7b186f19e4c42269595854b0e -README.zh.md: 70380ceba1b16b2970e947fb6cd9b2af9085ae51 +README.md: 526df44ce2a167e6f06bedea6d57e4d703848e89 +README.zh.md: 02bba4aeed7155ebacae2ad66b4325d069f37301 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 1393e79aac..0392d51a92 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.select`/`read`/`write`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, writing one is arbitrary capability, and selecting one can move a session onto a preset that edits the live runtime; `agentPreset.list` stays out, carrying only ids and trust) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 70380ceba1..e82b25bf30 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.select`/`read`/`write`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,写入它是任意能力,而选择它可以把会话切到一个能编辑活动运行时的 preset 上;`agentPreset.list` 不在其中,它只携带 id 与信任级别)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index db28dff49d..6383e9edac 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1339,6 +1339,17 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { // DeepSeek route so unrelated GUI journeys do not enter first-run setup. ['DEEPSEEK_API_KEY', true], ]) + /** + * Preset compositions the fixture serves. Held as state rather than + * constants so the settings editor's save and delete are exercisable: the + * roster a GUI journey sees after writing is the text it wrote. + */ + const fixturePresets = new Map([ + ['standard', { trust: 'system', content: "- id: tool-bash\n name: '@deepseek-ai/dsh-tool-bash'\n" }], + ['core-web', { trust: 'system', content: "- id: tool-web-search\n name: '@deepseek-ai/dsh-tool-web-search'\n" }], + ['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }], + ]) + let fixtureDefaultPreset = 'standard' const nextTurn = new Map([[sid('fx-alpha'), 60]]) let nextSession = 1 let nextRpc = 1 @@ -2313,15 +2324,63 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }, }, agentPresets: { - // Two rows so a picker has something to choose between, and so the - // trust distinction a surface must present is visible in the fixture. + // Both trusts appear, because a surface must present a locally authored + // preset differently from one the deployment vetted. list: request => ok(request, { - presets: [ - { id: 'standard', trust: 'system' as const, isDefault: true }, - { id: 'core-web', trust: 'system' as const, isDefault: false }, - ], + presets: [...fixturePresets].map(([id, preset]) => ({ + id, + trust: preset.trust, + isDefault: id === fixtureDefaultPreset, + })), + authorable: true, }), - select: request => ok(request, { agentPreset: request.payload.agentPreset }), + select: (request) => { + fixtureDefaultPreset = request.payload.agentPreset + return ok(request, { agentPreset: request.payload.agentPreset }) + }, + read: (request) => { + const { agentPreset } = request.payload + const preset = fixturePresets.get(agentPreset) + if (preset === undefined) { + return err(request, { + code: 'agent-preset-not-found', + message: `unknown agent preset "${agentPreset}"`, + details: { agentPreset, available: [...fixturePresets.keys()] }, + }) + } + return ok(request, { + agentPreset, + trust: preset.trust, + content: preset.content, + writable: preset.trust === 'user', + }) + }, + write: (request) => { + const { agentPreset, content } = request.payload + const existing = fixturePresets.get(agentPreset) + if (existing?.trust === 'system') { + return err(request, { + code: 'agent-preset-read-only', + message: `agent preset "${agentPreset}" ships with the deployment`, + details: { agentPreset, reason: 'it ships with the deployment' }, + }) + } + fixturePresets.set(agentPreset, { trust: 'user', content }) + return ok(request, { agentPreset }) + }, + remove: (request) => { + const { agentPreset } = request.payload + const existing = fixturePresets.get(agentPreset) + if (existing?.trust === 'system') { + return err(request, { + code: 'agent-preset-read-only', + message: `agent preset "${agentPreset}" ships with the deployment`, + details: { agentPreset, reason: 'it ships with the deployment' }, + }) + } + fixturePresets.delete(agentPreset) + return ok(request, {}) + }, }, skills: { @@ -2623,6 +2682,9 @@ export class FixtureApiClient extends AbstractApiClient { case 'skill.list': return this.api.skills.list(request) case 'agentPreset.list': return this.api.agentPresets.list(request) case 'agentPreset.select': return this.api.agentPresets.select(request) + case 'agentPreset.read': return this.api.agentPresets.read(request) + case 'agentPreset.write': return this.api.agentPresets.write(request) + case 'agentPreset.remove': return this.api.agentPresets.remove(request) case 'goal.create': return this.api.goals.create(request) case 'goal.edit': return this.api.goals.edit(request) case 'goal.pause': return this.api.goals.pause(request) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 2e27a78d70..af44fc2cf1 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -55,6 +55,17 @@ export const Config: z = z.object({ * keys, or key state — and a LAN client's model picker legitimately needs it. */ const PRIVILEGED_METHODS = new Set([ + // A preset composition names the plugins a session runs, so reading one is + // reconnaissance and writing one is arbitrary capability — strictly more than + // the settings document beside it. `agentPreset.select` joins them because + // it can move a session from a two-tool preset onto one that edits the live + // runtime, which is a real escalation even though every candidate is already + // installed. `agentPreset.list` deliberately stays out: it carries ids and + // trust only, like the model catalog, and a LAN client's picker needs it. + 'agentPreset.select', + 'agentPreset.read', + 'agentPreset.write', + 'agentPreset.remove', 'host.pickDirectory', 'host.openPath', 'settings.describe', diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 48812e4fd6..bcde736fa6 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -168,9 +168,17 @@ export class FakeApiClient implements IApiClient { } readonly agentPresets: IApiClient['agentPresets'] = { - list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))), + list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false }))), select: (payload: { agentPreset: string }) => this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + read: (payload: { agentPreset: string }) => + this.record('agentPreset.read', payload, Promise.resolve(ok({ + agentPreset: payload.agentPreset, trust: 'user' as const, content: '', writable: true, + }))), + write: (payload: { agentPreset: string }) => + this.record('agentPreset.write', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + remove: (payload: { agentPreset: string }) => + this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), } readonly skills: IApiClient['skills'] = { diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 3015881d2f..4335e6e0d4 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -138,6 +138,10 @@ describe('connection node half', () => { 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', 'llm.discoverModels', + // A composition names the plugins a session runs: reading one is + // reconnaissance, writing one is arbitrary capability, and selecting one + // can move a session onto a preset that edits the live runtime. + 'agentPreset.select', 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove', ]) { const denied = fakeResponse() await routes[0]!.handler( @@ -226,13 +230,16 @@ describe('connection node half over a real HTTP server', () => { // Carries a draft credential and turns the host into a fetcher for a // URL the caller picked: an anonymous LAN caller must not reach it. 'llm.discoverModels', + 'agentPreset.select', 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove', ]) { expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403]) } // The model catalog stays reachable for the same authority: a LAN // client's model picker needs it, and it carries no key or endpoint // state (404 is the empty proxy's carrier answer — the fence passed). - for (const method of ['llm.providers', 'llm.models']) { + // `agentPreset.list` joins the model catalog for the same reason: ids and + // trust only, and a LAN client's preset picker needs it. + for (const method of ['llm.providers', 'llm.models', 'agentPreset.list']) { expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404]) } // Loopback reaches everything, configuration included. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index b715fb329b..584d4dc0f0 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -203,9 +203,17 @@ export class FakeApiClient implements IApiClient { } readonly agentPresets: IApiClient['agentPresets'] = { - list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))), + list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false }))), select: (payload: { agentPreset: string }) => this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + read: (payload: { agentPreset: string }) => + this.record('agentPreset.read', payload, Promise.resolve(ok({ + agentPreset: payload.agentPreset, trust: 'user' as const, content: '', writable: true, + }))), + write: (payload: { agentPreset: string }) => + this.record('agentPreset.write', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + remove: (payload: { agentPreset: string }) => + this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), } readonly skills: IApiClient['skills'] = { diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 9a5fdd5002..40e443b224 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md -README.md: 14921afb7b90bb0b42a8f7f83ebc78773e8419a3 -README.zh.md: 06807199e996a6ae9d8a4216b8f686b6bbc044d9 +README.md: d775daf1e91c6eb9ebca69c7d0484c0029e93cfc +README.zh.md: b3fb3e64324dcb78465843ba2ba58f5a3efa3d08 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 14921afb7b..9425b50af9 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The agent-preset surface: one General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from. +The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a composer seat choosing this session's, and a settings section that authors the compositions themselves. ## Why it is a new-session preference @@ -22,9 +22,21 @@ A locally authored preset is exactly as privileged as the plugins it names, so t The row re-reads on `settings/changed` for its own namespace and on `connection/reset`: the roster is a live directory and the default is a settings field, so an external edit or a reconnect can both move it. -## When the row is absent +## The management section -A deployment that composes no presets answers with an empty roster, and the row renders nothing — every session then shares the host composition, and there is nothing to choose between. +A third surface, its own settings page: the roster as rows, and one composition open in a YAML editor at a time. + +A shipped preset opens read-only. It is the known-good composition a local one is written against, so reading it is the point and overwriting it is not — the deployment's copy is what a broken local preset is compared against. Authoring therefore starts by duplicating: **New preset** copies the current default, and **Duplicate** copies any row, because a copy always lands in the local root regardless of where the text came from. + +An id becomes a directory name, so the editor mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and refuses a name already in use — a create landing on an existing name would overwrite a preset the user never opened. Both checks are conveniences: the host re-applies them, along with the composition's shape, and its answer is what the editor reports on failure. A save that parses is still only a save; a composition naming a plugin that does not exist fails at the next session that selects it. + +Deleting removes the file. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file. + +`agentPreset.read`, `write`, `remove`, and `select` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `agentPreset.list` is not — it carries ids and trust, and a LAN client's picker needs it. + +## When the surfaces are absent + +A deployment that composes no presets answers with an empty roster, and the row, the seat, and the section all render nothing — every session then shares the host composition, and there is nothing to choose between or manage. A deployment that configures no writable root answers `authorable: false`, and the section stays a read-only browser: the rows still open, but creating is offered nowhere rather than through a button whose save always fails. ## Model Experience @@ -36,5 +48,6 @@ No direct invalidation. Changing the default never touches a running session's p ## Known Limitations and Deferred Work -- **Presets are listed by id** — a preset carries no display metadata, so the menu shows directory names. -- **No authoring** — creating, editing, or deleting a preset is a filesystem act; this surface only chooses among what the roster supplies. +- **Presets are listed by id** — a preset carries no display metadata, so the menus and rows show directory names. +- **The editor is a plain textarea** — no YAML syntax highlighting, folding, or schema completion; the host's shape check on save is the only validation. +- **A saved composition is not mounted** — a preset that parses but names a missing plugin is accepted, and fails at the next session that selects it. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 06807199e9..c5be1ea017 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -agent preset 表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md)。 +agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);composer 中的一个座位,用于选择**本会话**的 preset;以及一个设置页分区,用于创作组装本身。 ## 为什么它是"新建会话"的偏好设置 @@ -22,9 +22,21 @@ agent preset 表层:General 设置中的一行,用于选择新建会话据 本行在自身命名空间的 `settings/changed` 以及 `connection/reset` 时重新读取:名单是一个活动目录,默认值是一项设置,外部编辑与重新连接都可能改变它。 -## 何时不显示本行 +## 管理分区 -未组装任何 preset 的部署返回空名单,本行不渲染任何内容——此时每个会话共用宿主组装,也就无从选择。 +第三个表层,独立的设置页:名单以行呈现,同一时刻有一份组装在 YAML 编辑器中打开。 + +随部署提供的 preset 以只读方式打开。它是本地 preset 据以编写的已知良好组装,因此能读到它正是意义所在,而覆写它则不是——部署自带的那一份正是用来对照有问题的本地 preset 的。因此创作从复制开始:**新建 preset** 复制当前默认值,**复制**则复制任意一行;无论文本来自何处,副本总是落在本地根目录,所以副本总是可写的。 + +id 会成为目录名,因此编辑器复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——新建若落在已存在的名称上,就会覆盖用户从未打开过的 preset。这两项检查只是便利:宿主会连同组装的形状一起重新校验,失败时编辑器报告的正是宿主的答复。能解析的保存也仅仅是保存;引用了不存在插件的组装,会在下一个选择它的会话处失败。 + +删除会移除该文件。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。 + +`agentPreset.read`、`write`、`remove` 与 `select` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`agentPreset.list` 不在其中——它只携带 id 与信任级别,而局域网客户端的选择器需要它。 + +## 何时不显示这些表层 + +未组装任何 preset 的部署返回空名单,本行、座位与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:各行仍可打开,但任何位置都不提供"新建",而不是给出一个保存必然失败的按钮。 ## Model Experience @@ -36,5 +48,6 @@ Indirectly, through the preset a later session is composed from; [`dsh-agent-pre ## Known Limitations and Deferred Work -- **preset 按 id 列出** —— preset 不携带展示用元数据,因此菜单显示的是目录名。 -- **不提供创作能力** —— 创建、编辑或删除 preset 是文件系统行为;本表层只在名单提供的范围内做选择。 +- **preset 按 id 列出** —— preset 不携带展示用元数据,因此菜单与各行显示的是目录名。 +- **编辑器是纯文本域** —— 没有 YAML 语法高亮、折叠或 schema 补全;保存时宿主的形状检查是唯一的校验。 +- **保存的组装不会被挂载** —— 能解析但引用了缺失插件的 preset 会被接受,并在下一个选择它的会话处失败。 diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 30f15ac353..6b42c14ec2 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", - "description": "Agent-preset surface: the default preset for later sessions, in General settings", + "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", "version": "0.0.1", "private": true, "type": "module", @@ -27,7 +27,8 @@ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-settings" ], "platform": "web" }, @@ -42,7 +43,9 @@ "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-settings": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-client-web-react": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7", "react": "^18.2.0" @@ -51,9 +54,12 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7", diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css new file mode 100644 index 0000000000..67c44a20ca --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -0,0 +1,215 @@ +.section { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 720px; + color: var(--dsw-alias-label-primary); +} + +.title { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.intro { + margin: 0; + font-size: 13px; + color: var(--dsw-alias-label-tertiary); +} + +.notice { + margin: 0; + font-size: 12px; + color: var(--dsw-alias-state-warn-label); +} + +.hint { + font-size: 12px; + color: var(--dsw-alias-label-tertiary); +} + +.rows { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.rowCard { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 12px; + background: var(--dsw-alias-bg-layer-3); +} + +.rowHead { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.rowName { + font-size: 15px; + font-weight: 600; +} + +.badge, +.defaultBadge { + border-radius: 999px; + padding: 2px 8px; + font-size: 11px; + line-height: 16px; +} + +.badge { + border: 1px solid var(--dsw-alias-border-l2); + color: var(--dsw-alias-label-tertiary); +} + +.defaultBadge { + background: var(--dsw-alias-brand-primary); + color: var(--dsw-alias-label-primary-foreground); +} + +.rowActions { + display: inline-flex; + gap: 8px; + margin-left: auto; +} + +.secondaryButton { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 999px; + padding: 6px 14px; + background: var(--dsw-alias-bg-layer-3); + color: inherit; + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.dangerButton { + border: none; + background: none; + color: var(--dsw-alias-state-error-primary); + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.secondaryButton:disabled, +.dangerButton:disabled, +.addButton:disabled { + opacity: 0.5; + cursor: default; +} + +.editor { + display: flex; + flex-direction: column; + gap: 12px; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fieldLabel { + font-size: 12px; + font-weight: 500; + color: var(--dsw-alias-label-secondary); +} + +.input, +.code { + box-sizing: border-box; + padding: 9px 12px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; + font: inherit; + font-size: 13px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); +} + +.code { + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + line-height: 1.5; + resize: vertical; + white-space: pre; + overflow-wrap: normal; + overflow-x: auto; + tab-size: 2; +} + +.input:focus, +.code:focus { + outline: none; + border-color: var(--dsw-alias-brand-primary); +} + +.input::placeholder { + color: var(--dsw-alias-label-dimmed); +} + +/* A shipped composition is drawn a rung up, and it is the one most likely to + overflow, so its scroll thumb rebinds to that rung. */ +.code[readonly] { + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-bg-layer-2); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +.editorActions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.addCard { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + background: var(--dsw-alias-bg-layer-3); + padding: 14px 16px; +} + +.addButton { + align-self: flex-start; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 999px; + padding: 8px 16px; + font: inherit; + font-size: 13px; + background: var(--dsw-alias-bg-layer-3); + color: inherit; + cursor: pointer; +} + +.error { + margin: 0; + font-size: 12px; + color: var(--dsw-alias-state-error-primary); +} + +.deleteDialog { + width: min(480px, 100%); +} + +.deleteConfirm:not(:disabled) { + border-color: var(--dsw-alias-state-error-primary); + color: var(--dsw-alias-state-error-primary); +} + +.deleteConfirm:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx new file mode 100644 index 0000000000..bbd44aebfe --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -0,0 +1,254 @@ +/** + * Agent-presets settings section: the roster as rows, and one composition + * open in a YAML editor at a time. + * + * A shipped preset opens read-only — it is the known-good composition a local + * one is written against — so authoring starts by duplicating one. Deleting a + * preset leaves running sessions alone: a composition is mounted once at + * session creation and nothing re-reads the file. + */ + +import { useEffect } from 'react' +import type { ReactNode } from 'react' +import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { draftBlocker, type AgentPresetSectionState, type PresetDraft } from './section-store.ts' +import type { AgentPresetSettingsKey } from './locales.ts' +import css from './AgentPresetSection.module.css' + +/** Registration-side business face for the management section. */ +export interface AgentPresetSectionInjected { + hooks: { + /** Page snapshot bound by the renderer as useAgentPresetSection. */ + agentPresetSection: SnapshotStore + } + /** Read the roster; called once when the section first renders. */ + load: () => Promise + /** Open one preset's composition in the editor. */ + open: (id: string) => Promise + /** Open a copy of one preset — or of the default — as a new preset. */ + createFrom: (from?: string) => Promise + /** Close the editor, discarding the draft. */ + close: () => void + /** Name the preset a new draft saves to. */ + setId: (id: string) => void + /** Replace the draft's composition text. */ + setContent: (content: string) => void + /** Save the open draft. */ + save: () => Promise + /** Ask for delete confirmation, or dismiss it with null. */ + confirmDelete: (id: string | null) => void + /** Delete the preset awaiting confirmation. */ + remove: () => Promise + /** Make one preset the default for sessions created later. */ + makeDefault: (id: string) => Promise +} + +/** Full component props. */ +export type AgentPresetSectionProps = + PropsRuntime<'settings.section'> + & PropsLocale<'settings.agentPreset'> + & InjectFace + +/** Editor sub-view props: the draft plus the actions that mutate it. */ +interface EditorProps { + draft: PresetDraft + blocker: ReturnType + t: (key: AgentPresetSettingsKey) => string + actions: Pick +} + +function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode { + const message = draft.error ?? (blocker === undefined ? null : t(blocker)) + return ( +
+ {draft.creating + ? ( + + ) + : null} + {draft.writable ? null :

{t('readOnlyNotice')}

} +